Compare commits
20 Commits
feat/man-1
...
feat/whats
| Author | SHA1 | Date | |
|---|---|---|---|
| 400c147a74 | |||
| 08c6d62498 | |||
| 75cce50bd5 | |||
| a0963dd57a | |||
| 77dc592963 | |||
| 623e45cbea | |||
| 455bc4b7d8 | |||
| 3f55180b73 | |||
| ea630328c9 | |||
| 1425d7aaa1 | |||
| 62a19147ad | |||
| 90632ada88 | |||
| ce90cedb18 | |||
| a7d72f3756 | |||
| 0e0724c1cc | |||
| eca261ceba | |||
| ad29ce54d8 | |||
| 0d1ca40c11 | |||
| 484ac77ce7 | |||
| 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/
|
||||
|
||||
@@ -31,9 +31,13 @@ Hooks live in `.husky/` and auto-install on any plain `npm install` via the `pre
|
||||
|
||||
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
||||
|
||||
## Design Principles: DRY & YAGNI
|
||||
|
||||
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse a piece of logic as soon as it is genuinely duplicated in more than one place — but not before. **YAGNI**: do not add abstraction, configurability, or layers speculatively; add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement. Duplication that appears once is not yet a reason to abstract — wait for a second real occurrence before generalizing. This repo already encodes YAGNI in `openspec/config.yaml` (no generic repositories / `BaseService`); keep that spirit in new code. Avoid over-engineering and avoid premature extraction.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced, but changed behavior must have regression coverage.
|
||||
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Tests are verification, not a design driver — write them to cover behavior you've already implemented, matching the layer you changed. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced; add regression tests where a bug was fixed or behavior is non-obvious, without making tests a front-loaded design ceremony.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ If the import above did not load, read `AGENTS.md` at the repo root now — it i
|
||||
- Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`.
|
||||
- Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`).
|
||||
|
||||
## Design principles: DRY & YAGNI (not TDD)
|
||||
|
||||
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse only once logic is genuinely duplicated in more than one place. **YAGNI**: no speculative abstraction, configurability, or layers — add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement; avoid over-engineering and premature extraction. Tests are verification, not a design driver: write them to cover behavior already implemented, not as a front-loaded TDD ceremony.
|
||||
|
||||
## Environment note
|
||||
|
||||
PHP and Composer are **not on PATH** in this environment, and `vendor/` and `node_modules/` are absent. Every `composer …` / `php artisan …` command in `AGENTS.md` and `README.md` assumes a PHP 8.4+ runtime with Composer 2 installed. Verify the toolchain before promising a command ran.
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Application\Data;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final readonly class PageMeta
|
||||
@@ -84,6 +85,43 @@ final readonly class PageMeta
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $jsonLd
|
||||
*/
|
||||
public static function forPackage(
|
||||
WeddingPackage $package,
|
||||
string $canonical,
|
||||
SiteSetting $settings,
|
||||
?array $jsonLd = null,
|
||||
): self {
|
||||
$title = filled($package->meta_title)
|
||||
? (string) $package->meta_title
|
||||
: (filled($package->title_line) ? (string) $package->title_line : (string) $package->name);
|
||||
|
||||
$description = filled($package->meta_description)
|
||||
? (string) $package->meta_description
|
||||
: (filled($package->summary) ? (string) $package->summary : self::defaultDescription($settings));
|
||||
|
||||
$ogImageUrl = filled($package->hero_image_path)
|
||||
? url(Storage::disk('public')->url((string) $package->hero_image_path))
|
||||
: self::defaultOgImageUrl($settings);
|
||||
|
||||
$ogImageAlt = filled($package->hero_image_alt)
|
||||
? (string) $package->hero_image_alt
|
||||
: $settings->default_og_image_alt;
|
||||
|
||||
return new self(
|
||||
title: $title,
|
||||
description: $description,
|
||||
canonical: $canonical,
|
||||
ogType: 'article',
|
||||
ogImageUrl: $ogImageUrl,
|
||||
ogImageAlt: $ogImageAlt,
|
||||
jsonLd: $jsonLd,
|
||||
siteName: (string) $settings->brand_name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata for branded error pages.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
final class FindPublishedWeddingPackageBySlug
|
||||
{
|
||||
public function __invoke(string $slug): ?WeddingPackage
|
||||
{
|
||||
return WeddingPackage::query()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class GetSitemapEntries
|
||||
@@ -38,6 +39,21 @@ final class GetSitemapEntries
|
||||
];
|
||||
}
|
||||
|
||||
$packages = WeddingPackage::query()
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(['slug', 'updated_at']);
|
||||
|
||||
foreach ($packages as $package) {
|
||||
/** @var Carbon|null $updatedAt */
|
||||
$updatedAt = $package->updated_at;
|
||||
|
||||
$entries[] = [
|
||||
'loc' => route('packages.show', $package->slug),
|
||||
'lastmod' => $updatedAt?->toAtomString(),
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
}
|
||||
|
||||
49
app/Domain/Marketing/PackageIconCatalog.php
Normal file
49
app/Domain/Marketing/PackageIconCatalog.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Marketing;
|
||||
|
||||
/**
|
||||
* Closed catalog of icon keys available for wedding modality detail
|
||||
* sections (benefits strip and included items).
|
||||
*
|
||||
* Keys are stable kebab-case identifiers rendered as inline SVGs by the
|
||||
* public Blade components. Admin selection is constrained to this catalog
|
||||
* so arbitrary SVG markup can never reach the public page.
|
||||
*/
|
||||
final class PackageIconCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, string> map of icon_key => pt-BR label
|
||||
*/
|
||||
public static function labels(): array
|
||||
{
|
||||
return [
|
||||
'calendar' => 'Calendário',
|
||||
'checklist' => 'Checklist',
|
||||
'users' => 'Casais',
|
||||
'map' => 'Mapa e fornecedores',
|
||||
'heart' => 'Cuidado',
|
||||
'spark' => 'Detalhes',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::labels());
|
||||
}
|
||||
|
||||
public static function isValid(string $iconKey): bool
|
||||
{
|
||||
return isset(self::labels()[$iconKey]);
|
||||
}
|
||||
|
||||
public static function labelFor(string $iconKey): ?string
|
||||
{
|
||||
return self::labels()[$iconKey] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -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,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Schemas;
|
||||
|
||||
use App\Domain\Marketing\PackageIconCatalog;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class WeddingPackageForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Identificação')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Nome da modalidade')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||
if (blank($get('slug'))) {
|
||||
$set('slug', str($state)->slug()->toString());
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('level')
|
||||
->label('Número / tipo (ex.: 01 / COMPLETA)')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('tag')
|
||||
->label('Etiqueta curta (ex.: Assessoria completa)')
|
||||
->maxLength(255),
|
||||
TextInput::make('subtitle')
|
||||
->label('Frase de apoio')
|
||||
->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),
|
||||
Textarea::make('whatsapp_message')
|
||||
->label('Mensagem do WhatsApp (deixe vazio para usar o padrão)')
|
||||
->helperText('Texto enviado ao cliente ao tocar no botão de contato. O nome da modalidade é citado automaticamente se vazio.')
|
||||
->rows(3),
|
||||
TextInput::make('compare_heading')
|
||||
->label('Comparação — título (leitura rápida)')
|
||||
->maxLength(255),
|
||||
TextInput::make('compare_summary')
|
||||
->label('Comparação — resumo (leitura rápida)')
|
||||
->maxLength(255),
|
||||
TextInput::make('sort_order')
|
||||
->label('Ordem')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
DateTimePicker::make('published_at')
|
||||
->label('Publicado em')
|
||||
->seconds(false),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Hero da página')
|
||||
->description('Dados de abertura da página da modalidade.')
|
||||
->schema([
|
||||
TextInput::make('eyebrow')
|
||||
->label('Eyebrow (ex.: Assessoria)')
|
||||
->maxLength(255),
|
||||
TextInput::make('title_line')
|
||||
->label('Título (linha principal)')
|
||||
->maxLength(255),
|
||||
TextInput::make('title_emphasis')
|
||||
->label('Título (ênfase em itálico)')
|
||||
->maxLength(255),
|
||||
Textarea::make('hero_lead')
|
||||
->label('Texto de abertura')
|
||||
->rows(3),
|
||||
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero'),
|
||||
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Diferenciais')
|
||||
->description('Faixa escura com os valores da modalidade.')
|
||||
->schema([
|
||||
Repeater::make('benefits')
|
||||
->label('Diferenciais')
|
||||
->schema([
|
||||
Select::make('icon_key')
|
||||
->label('Ícone')
|
||||
->options(PackageIconCatalog::labels())
|
||||
->required(),
|
||||
TextInput::make('label')
|
||||
->label('Rótulo')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar diferencial'),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('O que está incluso')
|
||||
->schema([
|
||||
Repeater::make('included_items')
|
||||
->label('Itens inclusos')
|
||||
->schema([
|
||||
Select::make('icon_key')
|
||||
->label('Ícone')
|
||||
->options(PackageIconCatalog::labels())
|
||||
->required(),
|
||||
TextInput::make('title')
|
||||
->label('Título')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Textarea::make('description')
|
||||
->label('Descrição')
|
||||
->rows(2)
|
||||
->maxLength(500),
|
||||
])
|
||||
->defaultItems(6)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar item'),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('Para quem é este pacote')
|
||||
->schema([
|
||||
TextInput::make('audience_heading')
|
||||
->label('Título da seção')
|
||||
->maxLength(255),
|
||||
Textarea::make('audience_intro')
|
||||
->label('Texto de introdução')
|
||||
->rows(3),
|
||||
Repeater::make('audience_points')
|
||||
->label('Público')
|
||||
->schema([
|
||||
TextInput::make('point')
|
||||
->label('Ponto')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar ponto'),
|
||||
PublicImageUploadRules::fileUpload('audience_image_path', 'Imagem da seção'),
|
||||
PublicImageUploadRules::altTextField('audience_image_alt', 'audience_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('CTA final')
|
||||
->schema([
|
||||
TextInput::make('final_cta_heading')
|
||||
->label('Título do CTA final')
|
||||
->maxLength(255),
|
||||
Textarea::make('final_cta_body')
|
||||
->label('Texto do CTA final')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('SEO')
|
||||
->schema([
|
||||
TextInput::make('meta_title')
|
||||
->label('Meta title')
|
||||
->maxLength(255),
|
||||
Textarea::make('meta_description')
|
||||
->label('Meta description')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?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('slug')
|
||||
->label('Slug')
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Http/Controllers/PublicSite/PackageController.php
Normal file
46
app/Http/Controllers/PublicSite/PackageController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\FindPublishedWeddingPackageBySlug;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class PackageController extends Controller
|
||||
{
|
||||
public function show(string $slug, FindPublishedWeddingPackageBySlug $findPublishedWeddingPackageBySlug): View|Response
|
||||
{
|
||||
$package = $findPublishedWeddingPackageBySlug($slug);
|
||||
|
||||
if ($package === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$settings = SiteSetting::instance();
|
||||
$canonical = route('packages.show', $package->slug);
|
||||
|
||||
return view('pages.packages.show', [
|
||||
'package' => $package,
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPackage(
|
||||
package: $package,
|
||||
canonical: $canonical,
|
||||
settings: $settings,
|
||||
jsonLd: [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => $package->name,
|
||||
'description' => $package->summary,
|
||||
'url' => $canonical,
|
||||
'datePublished' => $package->published_at?->toAtomString(),
|
||||
'dateModified' => $package->updated_at?->toAtomString(),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
110
app/Models/WeddingPackage.php
Normal file
110
app/Models/WeddingPackage.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?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;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
* @property string|null $slug
|
||||
* @property string $level
|
||||
* @property string|null $tag
|
||||
* @property string|null $subtitle
|
||||
* @property string $summary
|
||||
* @property list<string> $scope_items
|
||||
* @property string $cta_label
|
||||
* @property string|null $whatsapp_message
|
||||
* @property string|null $compare_heading
|
||||
* @property string|null $compare_summary
|
||||
* @property string|null $eyebrow
|
||||
* @property string|null $title_line
|
||||
* @property string|null $title_emphasis
|
||||
* @property string|null $hero_lead
|
||||
* @property string|null $hero_image_path
|
||||
* @property string|null $hero_image_alt
|
||||
* @property list<array{icon_key: string, label: string}>|null $benefits
|
||||
* @property list<array{icon_key: string, title: string, description: string}>|null $included_items
|
||||
* @property string|null $audience_heading
|
||||
* @property string|null $audience_intro
|
||||
* @property list<string>|null $audience_points
|
||||
* @property string|null $audience_image_path
|
||||
* @property string|null $audience_image_alt
|
||||
* @property string|null $final_cta_heading
|
||||
* @property string|null $final_cta_body
|
||||
* @property string|null $meta_title
|
||||
* @property string|null $meta_description
|
||||
* @property int $sort_order
|
||||
* @property Carbon|null $published_at
|
||||
*/
|
||||
#[Fillable([
|
||||
'name',
|
||||
'slug',
|
||||
'level',
|
||||
'tag',
|
||||
'subtitle',
|
||||
'summary',
|
||||
'scope_items',
|
||||
'cta_label',
|
||||
'whatsapp_message',
|
||||
'compare_heading',
|
||||
'compare_summary',
|
||||
'sort_order',
|
||||
'published_at',
|
||||
'eyebrow',
|
||||
'title_line',
|
||||
'title_emphasis',
|
||||
'hero_lead',
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'benefits',
|
||||
'included_items',
|
||||
'audience_heading',
|
||||
'audience_intro',
|
||||
'audience_points',
|
||||
'audience_image_path',
|
||||
'audience_image_alt',
|
||||
'final_cta_heading',
|
||||
'final_cta_body',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
])]
|
||||
#[UsePolicy(WeddingPackagePolicy::class)]
|
||||
class WeddingPackage extends Model
|
||||
{
|
||||
/** @use HasFactory<WeddingPackageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasPublication;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (WeddingPackage $weddingPackage): void {
|
||||
if (blank($weddingPackage->slug) && filled($weddingPackage->name)) {
|
||||
$weddingPackage->slug = Str::slug($weddingPackage->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, string|class-string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scope_items' => 'array',
|
||||
'benefits' => 'array',
|
||||
'included_items' => 'array',
|
||||
'audience_points' => '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
|
||||
|
||||
58
app/Support/PackageContactLink.php
Normal file
58
app/Support/PackageContactLink.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
|
||||
final class PackageContactLink
|
||||
{
|
||||
/**
|
||||
* Resolve the contextual contact link for a wedding package modality.
|
||||
*
|
||||
* Uses the package-specific WhatsApp message when provided; otherwise
|
||||
* falls back to the default template with the modality name.
|
||||
*
|
||||
* @return array{href: string, isWhatsapp: bool}
|
||||
*/
|
||||
public static function for(SiteSetting $settings, string $packageName, ?string $customMessage = null): array
|
||||
{
|
||||
$message = filled($customMessage)
|
||||
? $customMessage
|
||||
: 'Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.';
|
||||
|
||||
return self::build($settings, $message, $packageName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a generic contact link (no specific modality), used by the
|
||||
* "Conversar com a Amare" guidance band.
|
||||
*
|
||||
* @return array{href: string, isWhatsapp: bool}
|
||||
*/
|
||||
public static function generic(SiteSetting $settings): array
|
||||
{
|
||||
return self::build(
|
||||
$settings,
|
||||
'Olá, ainda estou decidindo a modalidade ideal para o meu casamento. Podemos conversar?',
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{href: string, isWhatsapp: bool}
|
||||
*/
|
||||
private static function build(SiteSetting $settings, string $message, ?string $packageName): array
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
|
||||
$isWhatsapp = strlen($digits) >= 10;
|
||||
|
||||
return [
|
||||
'href' => $isWhatsapp
|
||||
? 'https://wa.me/'.$digits.'?text='.rawurlencode($message)
|
||||
: route('briefing', $packageName !== null ? ['servico_interesse' => $packageName] : []),
|
||||
'isWhatsapp' => $isWhatsapp,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
database/factories/WeddingPackageFactory.php
Normal file
56
database/factories/WeddingPackageFactory.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @extends Factory<WeddingPackage> */
|
||||
class WeddingPackageFactory extends Factory
|
||||
{
|
||||
protected $model = WeddingPackage::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->words(2, true);
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'level' => 'Assessoria',
|
||||
'tag' => fake()->words(2, true),
|
||||
'subtitle' => fake()->sentence(),
|
||||
'summary' => fake()->sentence(),
|
||||
'scope_items' => [fake()->sentence()],
|
||||
'cta_label' => 'Conversar sobre esta modalidade',
|
||||
'compare_heading' => fake()->words(3, true),
|
||||
'compare_summary' => fake()->sentence(),
|
||||
'sort_order' => 0,
|
||||
'published_at' => null,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => fake()->words(2, true),
|
||||
'title_emphasis' => fake()->word(),
|
||||
'hero_lead' => fake()->paragraph(),
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => fake()->word()],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => fake()->word(), 'description' => fake()->sentence()],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => fake()->paragraph(),
|
||||
'audience_points' => [fake()->sentence()],
|
||||
'final_cta_heading' => 'Vamos conversar?',
|
||||
'final_cta_body' => fake()->paragraph(),
|
||||
];
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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('wedding_packages', function (Blueprint $table): void {
|
||||
$table->string('tag')->nullable();
|
||||
$table->string('subtitle')->nullable();
|
||||
$table->string('compare_heading')->nullable();
|
||||
$table->string('compare_summary')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropColumn(['tag', 'subtitle', 'compare_heading', 'compare_summary']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->string('slug')->nullable()->unique()->after('name');
|
||||
$table->string('eyebrow')->nullable()->after('level');
|
||||
$table->string('title_line')->nullable()->after('eyebrow');
|
||||
$table->string('title_emphasis')->nullable()->after('title_line');
|
||||
$table->text('hero_lead')->nullable()->after('title_emphasis');
|
||||
$table->string('hero_image_path')->nullable()->after('hero_lead');
|
||||
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
|
||||
$table->jsonb('benefits')->nullable()->after('hero_image_alt');
|
||||
$table->jsonb('included_items')->nullable()->after('benefits');
|
||||
$table->string('audience_heading')->nullable()->after('included_items');
|
||||
$table->text('audience_intro')->nullable()->after('audience_heading');
|
||||
$table->jsonb('audience_points')->nullable()->after('audience_intro');
|
||||
$table->string('audience_image_path')->nullable()->after('audience_points');
|
||||
$table->string('audience_image_alt')->nullable()->after('audience_image_path');
|
||||
$table->string('final_cta_heading')->nullable()->after('audience_image_alt');
|
||||
$table->text('final_cta_body')->nullable()->after('final_cta_heading');
|
||||
$table->string('meta_title')->nullable()->after('final_cta_body');
|
||||
$table->text('meta_description')->nullable()->after('meta_title');
|
||||
});
|
||||
|
||||
$this->backfillSlugs();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropUnique(['slug']);
|
||||
$table->dropColumn([
|
||||
'slug',
|
||||
'eyebrow',
|
||||
'title_line',
|
||||
'title_emphasis',
|
||||
'hero_lead',
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'benefits',
|
||||
'included_items',
|
||||
'audience_heading',
|
||||
'audience_intro',
|
||||
'audience_points',
|
||||
'audience_image_path',
|
||||
'audience_image_alt',
|
||||
'final_cta_heading',
|
||||
'final_cta_body',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillSlugs(): void
|
||||
{
|
||||
$rows = DB::table('wedding_packages')->whereNull('slug')->get(['id', 'name']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = Str::slug($row->name);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
|
||||
while (DB::table('wedding_packages')->where('slug', $candidate)->exists()) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
DB::table('wedding_packages')->where('id', $row->id)->update(['slug' => $candidate]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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('wedding_packages', function (Blueprint $table): void {
|
||||
$table->text('whatsapp_message')->nullable()->after('cta_label');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropColumn('whatsapp_message');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
175
database/seeders/WeddingPackagesSeeder.php
Normal file
175
database/seeders/WeddingPackagesSeeder.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?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)
|
||||
* and the services page. Copy is approved from the structural model preview
|
||||
* (preview(2).html) and remains editable via the Filament resource. Detail
|
||||
* copy follows the complete-assessoria preview
|
||||
* (amare-assessoria-completa.html) adapted to each modality.
|
||||
*/
|
||||
class WeddingPackagesSeeder extends Seeder
|
||||
{
|
||||
private const PUBLISHED_AT = '2026-08-11 00:00:00';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$packages = [
|
||||
[
|
||||
'name' => 'Essenza',
|
||||
'slug' => 'essenza',
|
||||
'level' => '01 / COMPLETA',
|
||||
'tag' => 'Assessoria completa',
|
||||
'subtitle' => 'Do planejamento ao grande dia.',
|
||||
'summary' => 'Para casais que desejam contar com a Amare desde o início ou ainda estão estruturando etapas importantes.',
|
||||
'scope_items' => [
|
||||
'Estruturação do planejamento',
|
||||
'Cronogramas, prazos e prioridades',
|
||||
'Orientação e gestão de fornecedores',
|
||||
'Alinhamento entre os envolvidos',
|
||||
'Coordenação da execução',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Essenza',
|
||||
'compare_heading' => 'Começar com a Amare',
|
||||
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
|
||||
'sort_order' => 1,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Completa',
|
||||
'hero_lead' => 'Do planejamento ao grande dia, com calma, precisão e cuidado.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'checklist', 'label' => 'Planejamento personalizado'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'heart', 'title' => 'Escuta e briefing', 'description' => 'Cada casal é único; começamos por ouvir.'],
|
||||
['icon_key' => 'checklist', 'title' => 'Planejamento completo', 'description' => 'Cada etapa mapeada com clareza.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
|
||||
['icon_key' => 'spark', 'title' => 'Produção e logística', 'description' => 'Dia a dia do evento organizado nos detalhes.'],
|
||||
['icon_key' => 'users', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
|
||||
'audience_points' => [
|
||||
'Casamentos',
|
||||
'Aniversários',
|
||||
'Eventos sociais e celebrações',
|
||||
'Eventos corporativos',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para começar?',
|
||||
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
|
||||
],
|
||||
[
|
||||
'name' => 'Conduzione',
|
||||
'slug' => 'conduzione',
|
||||
'level' => '02 / PARCIAL',
|
||||
'tag' => 'Assessoria parcial',
|
||||
'subtitle' => 'Para quem já começou e quer seguir acompanhado.',
|
||||
'summary' => 'Para casais que já tomaram decisões e contrataram parte dos fornecedores, mas precisam estruturar o que falta.',
|
||||
'scope_items' => [
|
||||
'Diagnóstico do planejamento existente',
|
||||
'Organização das pendências',
|
||||
'Gestão dos fornecedores contratados',
|
||||
'Orientação para próximas decisões',
|
||||
'Coordenação da execução',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Conduzione',
|
||||
'compare_heading' => 'Trazer a Amare para o caminho',
|
||||
'compare_summary' => 'O planejamento existe, mas ainda há decisões e gestão pela frente.',
|
||||
'sort_order' => 2,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Parcial',
|
||||
'hero_lead' => 'Para retomar o planejamento, organizar o que falta e seguir com acompanhamento profissional.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => 'Diagnóstico completo'],
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Diagnóstico do planejamento', 'description' => 'Um olhar profissional sobre o que já existe.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Organização das pendências', 'description' => 'Prioridades claras, etapa a etapa.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'spark', 'title' => 'Gestão dos próximos passos', 'description' => 'Um plano para chegar tranquilo ao grande dia.'],
|
||||
['icon_key' => 'users', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
|
||||
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem já começou a planejar e quer retomar as rédeas com acompanhamento profissional.',
|
||||
'audience_points' => [
|
||||
'Casamentos em andamento',
|
||||
'Casais com pouco tempo disponível',
|
||||
'Quem precisa priorizar pendências',
|
||||
'Quem busca fornecedores confiáveis',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para retomar?',
|
||||
'final_cta_body' => 'Conte onde seu planejamento está e receba uma proposta.',
|
||||
],
|
||||
[
|
||||
'name' => 'Grand Jour',
|
||||
'slug' => 'grand-jour',
|
||||
'level' => '03 / FINAL',
|
||||
'tag' => 'Assessoria final',
|
||||
'subtitle' => 'O planejamento está pronto. Agora é hora de viver.',
|
||||
'summary' => 'Para casais na reta final que precisam de uma equipe para assumir alinhamentos, cronograma e operação.',
|
||||
'scope_items' => [
|
||||
'Imersão no planejamento existente',
|
||||
'Conferência das informações',
|
||||
'Alinhamento dos fornecedores',
|
||||
'Organização do cronograma final',
|
||||
'Gestão da operação do evento',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Grand Jour',
|
||||
'compare_heading' => 'Entregar a operação para a Amare',
|
||||
'compare_summary' => 'O projeto está pronto e o foco passa a ser alinhamento e execução.',
|
||||
'sort_order' => 3,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Final',
|
||||
'hero_lead' => 'Para assumir os alinhamentos e a operação quando o grande dia está próximo.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => 'Imersão no que já existe'],
|
||||
['icon_key' => 'users', 'label' => 'Alinhamento de fornecedores'],
|
||||
['icon_key' => 'calendar', 'label' => 'Cronograma final'],
|
||||
['icon_key' => 'heart', 'label' => 'Operação com leveza'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Imersão no planejamento', 'description' => 'Entendemos cada decisão já tomada.'],
|
||||
['icon_key' => 'map', 'title' => 'Alinhamento de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Cronograma final', 'description' => 'Cada etapa posicionada na reta final.'],
|
||||
['icon_key' => 'spark', 'title' => 'Ensaios e detalhes', 'description' => 'Produção e logística nos detalhes.'],
|
||||
['icon_key' => 'users', 'title' => 'Operação do evento', 'description' => 'Gestão da equipe e do dia a dia.'],
|
||||
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para casais já planejados que querem chegar ao grande dia com equipe e operação no lugar.',
|
||||
'audience_points' => [
|
||||
'Casamentos já planejados',
|
||||
'Casais na reta final',
|
||||
'Quem precisa de operação no dia',
|
||||
'Quem quer viver o momento com leveza',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para o grande dia?',
|
||||
'final_cta_body' => 'Conte a data do seu evento e receba uma proposta.',
|
||||
],
|
||||
];
|
||||
|
||||
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.").
|
||||
2
openspec/changes/package-detail-pages/.openspec.yaml
Normal file
2
openspec/changes/package-detail-pages/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-12
|
||||
96
openspec/changes/package-detail-pages/design.md
Normal file
96
openspec/changes/package-detail-pages/design.md
Normal file
@@ -0,0 +1,96 @@
|
||||
## Context
|
||||
|
||||
`WeddingPackage` já cobre cards ordenados + CTA WhatsApp/briefing (change `restructure-home-weddings-corporate`, capability ainda só no delta completo). Campos atuais: `name`, `level`, `summary`, `scope_items` (json), `cta_label`, `sort_order`, `published_at`. Sem slug, sem corpo de detalhe, sem imagens. Portfólio (`portfolio.show`, `FindPublishedPortfolioCaseBySlug`, PageMeta, sitemap) é o padrão de detalhe a espelhar. Mock visual: `/home/manoelfreitas/Downloads/amare-assessoria-completa.html`. Tokens: `DESIGN.md` / `resources/css/tokens.css` (Heritage Editorial) — não Cormorant/Inter do HTML estático.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Detalhe CMS-driven `/pacotes/{slug}` para as 3 modalidades, publish-gated.
|
||||
- Composição mock → tokens do projeto (olive accent, EB Garamond via stack existente, bg cream).
|
||||
- CTA final = mesmo canal contextual dos cards (`wa.me` ou `/briefing?servico_interesse=`).
|
||||
- SEO mínimo: meta por registro, canonical, OG image (hero ou default), entrada no sitemap.
|
||||
- Admin Filament editável sem deploy de copy.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Rota índice `/pacotes`; substituir cards da home; WhatsApp Business API; preços; multi-idioma; Livewire público; gallery multi-imagem além de hero + audience.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Estender `WeddingPackage`, não criar modelo novo
|
||||
|
||||
Uma entidade comercial já existe e é o que home/serviços já consomem. Detalhe é projeção do mesmo agregado. Modelo paralelo geraria sync de publicação/slug e viola YAGNI.
|
||||
|
||||
### Slug único + publicação por `published_at`
|
||||
|
||||
Igual portfólio: URL estável, scope `published()`, query Application `FindPublishedWeddingPackageBySlug`. Draft/inexistente → 404 HTTP, sem vazar campos internos.
|
||||
|
||||
### Campos de detalhe (additive)
|
||||
|
||||
| Campo | Uso |
|
||||
|-------|-----|
|
||||
| `slug` | string unique |
|
||||
| `eyebrow` | ex. "Assessoria" |
|
||||
| `title_line` | parte romana do H1 |
|
||||
| `title_emphasis` | parte itálica do H1 |
|
||||
| `hero_lead` | parágrafo do hero |
|
||||
| `hero_image_path` / `hero_image_alt` | foto hero |
|
||||
| `benefits` | jsonb `[{icon_key,label}]` — faixa escura |
|
||||
| `included_items` | jsonb `[{icon_key,title,description}]` — grid 3 col |
|
||||
| `audience_heading` | default editorial se vazio ok |
|
||||
| `audience_intro` | texto |
|
||||
| `audience_points` | jsonb `string[]` checklist |
|
||||
| `audience_image_path` / `audience_image_alt` | foto 50/50 |
|
||||
| `final_cta_heading` / `final_cta_body` | bloco final |
|
||||
| `meta_title` / `meta_description` | SEO opcional |
|
||||
|
||||
Manter `summary` + `scope_items` para cards. Não migrar cards para `included_items` neste ciclo.
|
||||
|
||||
### Catálogo fechado de ícones (`PackageIconCatalog`)
|
||||
|
||||
Select Filament → `icon_key` string. Map PHP/Blade para SVGs inline (ou partials). Sem upload de SVG arbitrário (XSS/ops). Chaves mínimas cobrem mock (ex.: calendar, checklist, users, map, heart, spark — nomes estáveis kebab).
|
||||
|
||||
### Hero de pacote vs `photo-hero` genérico
|
||||
|
||||
Mock exige H1 bipartido (linha + ênfase itálica), divisor vertical, CTA outline no hero e foto. Preferir componente dedicado `x-public.package-hero` (ou estender `photo-hero` só se API ficar genérica sem branching feio). Seções: `package-benefits`, `package-included`, `package-audience`, reutilizar `final-cta` se encaixar.
|
||||
|
||||
### CTA WhatsApp
|
||||
|
||||
Reutilizar VO/helper já usado nos cards (digits de `SiteSetting.whatsapp_number`, mensagem com nome da modalidade). Mesma regra de fallback briefing. Não duplicar lógica de normalização.
|
||||
|
||||
### Links a partir dos cards
|
||||
|
||||
Card: título/área → `route('packages.show', $package)` quando slug presente; botão CTA continua WhatsApp/briefing. Sem quebrar testes de CTA existentes.
|
||||
|
||||
### Imagens
|
||||
|
||||
`PublicImageUploadRules` + disk público existente + `x-media.image`. Paths nullable; se hero ausente, layout degrada (omitir slot de imagem).
|
||||
|
||||
### Camada Application
|
||||
|
||||
- `FindPublishedWeddingPackageBySlug`
|
||||
- Estender `GetSitemapEntries` com slugs de pacotes publicados
|
||||
- PageMeta no controller (espelhar PortfolioCaseController)
|
||||
|
||||
Sem repository genérico / BaseAction.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Capability `wedding-packages` ainda não está em `openspec/specs/`] → delta ADDED nesta change; ao arquivar, consolidar com requirements de cards do change completo ou arquivar ambos em ordem.
|
||||
- [Copy das 3 modalidades incompleta] → seeder: Essenza = mock; Conduzione/Grand Jour estrutura paralela adaptada; admin pode editar.
|
||||
- [Ícones insuficientes no catálogo] → adicionar chave no map + opção Filament; sem free-text SVG.
|
||||
- [Cards sem slug durante migrate] → migration backfill slug a partir do name; seeder garante os 3 oficiais.
|
||||
- [Duplicação visual mock vs tokens] → testes de tokens existentes + asserts de classes/roles; não copiar hex do HTML.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy migration additive + código + assets Blade juntos.
|
||||
2. Rodar seeder/update das 3 modalidades (slug + detalhe).
|
||||
3. Verificar `/pacotes/essenza` (etc.), 404 draft, sitemap, CTA WA.
|
||||
4. Rollback: remover rota/views; colunas additive podem permanecer; unpublish pacotes se necessário.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Nenhuma bloqueante (decisões de produto já confirmadas: detalhe-only, extend model, 3 modalidades, WA CTA).
|
||||
- WEB-ID dedicado a package detail: não existe no SPEC; rastrear via WEB-02 + padrão WEB-03 até SPEC ganhar ID explícito.
|
||||
32
openspec/changes/package-detail-pages/proposal.md
Normal file
32
openspec/changes/package-detail-pages/proposal.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
Modalidades de casamento (Essenza, Conduzione, Grand Jour) existem no CMS e na home/serviços só como cards com CTA WhatsApp. Não há página de detalhe: o visitante não consegue ler o que está incluso, para quem é o pacote ou prova visual editorial antes de converter. O mock aprovado (`amare-assessoria-completa.html`) define a jornada; falta rota CMS-driven `/pacotes/{slug}` alinhada a WEB-02/WEB-03 e ao padrão de detalhe do portfólio.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Estender `WeddingPackage` com slug estável, campos editoriais de detalhe (hero, benefícios, itens inclusos, audiência, imagens, SEO opcional) sem remover `summary`/`scope_items` dos cards.
|
||||
- Expor rota pública `packages.show` em `/pacotes/{slug}`: só publicados; rascunho → 404 (mesmo contrato de `portfolio.show`).
|
||||
- Renderizar página de detalhe Heritage Editorial espelhando o mock (hero split, faixa de benefícios, grid “O que está incluso”, bloco audiência, CTA final WhatsApp contextual com fallback briefing).
|
||||
- Incluir slugs publicados no sitemap; meta title/description/canonical/OG por pacote.
|
||||
- Filament: seções de detalhe + catálogo fechado de ícones SVG; seeder preenche as 3 modalidades.
|
||||
- Cards na home e em `/servicos` passam a linkar para o detalhe (navegação secundária); CTA primário permanece WhatsApp/briefing.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `wedding-packages`: modalidades publicáveis com detalhe por slug (baseline ainda não arquivada em `openspec/specs/`; esta change define o contrato de detalhe e reafirma cards/CTA já entregues).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `public-site-pages`: incluir rota `packages.show` (`/pacotes/{slug}`) e regra de 404 para modalidade não publicada; cards de modalidade podem apontar ao detalhe.
|
||||
- `public-seo`: sitemap e metadados de página para modalidades publicadas.
|
||||
- `service-catalog`: listagem de serviços/casamentos referencia o detalhe da modalidade quando existir slug publicado.
|
||||
|
||||
## Impact
|
||||
|
||||
Migration/model/factory/seeder `WeddingPackage`; query `FindPublishedWeddingPackageBySlug`; controller + rota + view Blade; componentes de seção; `GetSitemapEntries` / PageMeta; Filament `WeddingPackageResource`; links em partials de packages (home/serviços); testes feature (200/404, sitemap, CTA, conteúdo). Atende extensão de WEB-01/WEB-02 e o padrão de detalhe de WEB-03. Sem page builder, sem índice `/pacotes`, sem integração oficial WhatsApp, sem CRM (SPEC.md §4.2).
|
||||
|
||||
## Não objetivos
|
||||
|
||||
- Índice de pacotes, substituir `/servicos`, builder genérico, preços dinâmicos, checkout, multi-idioma, Livewire no site público, inventar prova ou cases Corporate (SPEC.md §4.2).
|
||||
@@ -0,0 +1,84 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Every public page emits title, description and canonical
|
||||
|
||||
The system SHALL render a unique `<title>`, a `<meta name="description">` and a `<link rel="canonical">` on every public route (SPEC §6.6, §19). Portfolio cases and wedding modalities MUST use `meta_title`/`meta_description` when filled and fall back to title/name and summary/hero lead otherwise. Pages without page-level metadata MUST fall back to `default_meta_title` and `default_meta_description` from `site_settings`.
|
||||
|
||||
#### Scenario: Page-level metadata overrides defaults
|
||||
|
||||
- **GIVEN** a published case with `meta_title` and `meta_description` filled
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the rendered title and description MUST use the case values
|
||||
|
||||
#### Scenario: Wedding modality metadata overrides defaults
|
||||
|
||||
- **GIVEN** a published wedding modality with `meta_title` and `meta_description` filled
|
||||
- **WHEN** a visitor loads `/pacotes/{slug}`
|
||||
- **THEN** the rendered title and description MUST use the modality values
|
||||
|
||||
#### Scenario: Missing metadata falls back to site defaults
|
||||
|
||||
- **GIVEN** a published case without `meta_title`
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the rendered title MUST be derived from the case title
|
||||
- **AND** the description MUST fall back to the case summary or the site default
|
||||
|
||||
#### Scenario: Canonical points to the absolute route URL
|
||||
|
||||
- **WHEN** any public page is rendered
|
||||
- **THEN** the canonical URL MUST be the absolute URL of that route without query parameters
|
||||
|
||||
### Requirement: Open Graph metadata is emitted for sharing
|
||||
|
||||
The system SHALL emit Open Graph tags (`og:title`, `og:description`, `og:type`, `og:url`, `og:image`) on public pages. The image MUST use the page cover/hero image when available and `default_og_image_path` from `site_settings` otherwise.
|
||||
|
||||
#### Scenario: Case detail uses its cover as OG image
|
||||
|
||||
- **GIVEN** a published case with a cover image
|
||||
- **WHEN** the case detail is rendered
|
||||
- **THEN** `og:image` MUST reference the case cover image URL
|
||||
|
||||
#### Scenario: Wedding modality detail uses hero as OG image
|
||||
|
||||
- **GIVEN** a published wedding modality with a hero image
|
||||
- **WHEN** `/pacotes/{slug}` is rendered
|
||||
- **THEN** `og:image` MUST reference the modality hero image URL
|
||||
|
||||
#### Scenario: Pages without cover use the default OG image
|
||||
|
||||
- **WHEN** a page without its own image is rendered
|
||||
- **THEN** `og:image` MUST reference `default_og_image_path`
|
||||
|
||||
### Requirement: Sitemap and robots are served by the application
|
||||
|
||||
The system SHALL serve `/sitemap.xml` listing the home, institutional routes, the services listing, the portfolio listing, every published case slug, and every published wedding modality slug with its last modification date. `/robots.txt` MUST be served by an application route referencing the sitemap URL.
|
||||
|
||||
#### Scenario: Sitemap contains only published slugs
|
||||
|
||||
- **GIVEN** one published case and one draft case
|
||||
- **WHEN** `/sitemap.xml` is requested
|
||||
- **THEN** the response MUST include the published slug
|
||||
- **AND** MUST NOT include the draft slug
|
||||
|
||||
#### Scenario: Sitemap contains published wedding modality slugs
|
||||
|
||||
- **GIVEN** one published wedding modality and one draft wedding modality
|
||||
- **WHEN** `/sitemap.xml` is requested
|
||||
- **THEN** the response MUST include `/pacotes/{published-slug}`
|
||||
- **AND** MUST NOT include the draft modality slug
|
||||
|
||||
#### Scenario: Newly published case enters the sitemap
|
||||
|
||||
- **WHEN** an admin publishes a case
|
||||
- **THEN** the case slug MUST appear in `/sitemap.xml` on the next request
|
||||
|
||||
#### Scenario: Newly published wedding modality enters the sitemap
|
||||
|
||||
- **WHEN** an admin publishes a wedding modality
|
||||
- **THEN** the modality package URL MUST appear in `/sitemap.xml` on the next request
|
||||
|
||||
#### Scenario: Robots references the sitemap
|
||||
|
||||
- **WHEN** `/robots.txt` is requested
|
||||
- **THEN** the response MUST be `text/plain`
|
||||
- **AND** MUST contain the absolute `/sitemap.xml` URL
|
||||
@@ -0,0 +1,57 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public routes serve published content without authentication
|
||||
|
||||
The system SHALL expose the public routes of SPEC §5.1 plus the additive package detail route: `home` (`/`), `services.index` (`/servicos`), `packages.show` (`/pacotes/{slug}`), `portfolio.index` (`/portfolio`), `portfolio.show` (`/portfolio/{slug}`), `about` (`/sobre`), `contact` (`/contato`) and `privacy` (`/privacidade`). Every public route MUST respond without authentication and MUST NOT expose unpublished content, internal fields, or internal notes (SPEC §19).
|
||||
|
||||
#### Scenario: Guest reaches every public route
|
||||
|
||||
- **WHEN** an unauthenticated visitor requests any public route listed above that has published content where required
|
||||
- **THEN** the response status MUST be 200
|
||||
- **AND** no redirect to `/admin/login` MUST occur
|
||||
|
||||
#### Scenario: Unpublished content is invisible
|
||||
|
||||
- **GIVEN** a service, portfolio case, wedding modality, or testimonial with `published_at` null
|
||||
- **WHEN** a visitor loads the corresponding public listing or home section
|
||||
- **THEN** the record MUST NOT appear in the rendered output
|
||||
|
||||
#### Scenario: Unpublished case detail returns 404
|
||||
|
||||
- **GIVEN** a portfolio case saved as draft
|
||||
- **WHEN** a visitor requests `/portfolio/{slug}` for that case
|
||||
- **THEN** the response status MUST be 404
|
||||
|
||||
#### Scenario: Unpublished wedding modality detail returns 404
|
||||
|
||||
- **GIVEN** a wedding modality saved as draft
|
||||
- **WHEN** a visitor requests `/pacotes/{slug}` for that modality
|
||||
- **THEN** the response status MUST be 404
|
||||
|
||||
#### Scenario: Published case detail becomes reachable
|
||||
|
||||
- **GIVEN** a portfolio case saved as draft
|
||||
- **WHEN** an admin fills the required fields and publishes the case
|
||||
- **THEN** `/portfolio/{slug}` MUST respond 200
|
||||
- **AND** the case MUST appear in the `/portfolio` listing
|
||||
|
||||
#### Scenario: Published wedding modality detail becomes reachable
|
||||
|
||||
- **GIVEN** a wedding modality saved as draft with required detail fields
|
||||
- **WHEN** an admin publishes the modality
|
||||
- **THEN** `/pacotes/{slug}` MUST respond 200
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wedding modality detail uses Heritage Editorial public layout
|
||||
The system SHALL render `/pacotes/{slug}` with the public layout (header/footer shared), Heritage Editorial tokens, and section order: package hero (eyebrow, bipartite title, lead, outline secondary actions as designed, hero media), dark benefit strip, included-items grid, audience split (media + checklist), final conversion CTA. The page MUST be server-rendered Blade without a public Livewire dependency. Missing optional images MUST NOT break the page (omit or degrade the media slot).
|
||||
|
||||
#### Scenario: Detail page section order
|
||||
- **GIVEN** a published modality with complete detail content
|
||||
- **WHEN** a visitor loads `/pacotes/{slug}`
|
||||
- **THEN** the document MUST include the hero, benefits, included items, audience and final CTA regions in that order
|
||||
- **AND** MUST use the public layout chrome
|
||||
|
||||
#### Scenario: Detail page has no console errors
|
||||
- **WHEN** the modality detail is loaded in a real browser at desktop and mobile viewports
|
||||
- **THEN** the browser console MUST contain no JavaScript errors
|
||||
@@ -0,0 +1,9 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Services wedding section links to modality detail
|
||||
When the services page renders published wedding modalities, each modality card SHALL expose a link to `/pacotes/{slug}` for published records that have a slug, while keeping the existing WhatsApp/briefing conversion CTA.
|
||||
|
||||
#### Scenario: Services card navigates to package detail
|
||||
- **GIVEN** at least one published wedding modality with a slug
|
||||
- **WHEN** a visitor loads `/servicos`
|
||||
- **THEN** the wedding modalities region MUST include a link to that modality's `/pacotes/{slug}` URL
|
||||
@@ -0,0 +1,58 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wedding modalities are managed as publishable ordered content
|
||||
The system SHALL let an admin manage `WeddingPackage` records with name, unique slug, level, summary, ordered scope items, CTA label, sort order and `published_at`. Only published records SHALL appear in public wedding card 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 with stable unique slugs
|
||||
|
||||
### 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`. The same rule MUST apply on modality cards and on the modality detail page final CTA.
|
||||
|
||||
#### Scenario: Valid WhatsApp setting creates contextual link
|
||||
- **GIVEN** a valid official WhatsApp number
|
||||
- **WHEN** a visitor activates a wedding modality CTA on a card or detail page
|
||||
- **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 on a card or detail page
|
||||
- **THEN** the visitor reaches `/briefing` with `servico_interesse` prefilled
|
||||
|
||||
### Requirement: Wedding modality detail content is CMS-managed
|
||||
The system SHALL store per-modality detail fields: eyebrow, bipartite title (`title_line`, `title_emphasis`), hero lead, optional hero image with alt, ordered benefits (`icon_key`, label), ordered included items (`icon_key`, title, description), audience heading/intro/points and optional audience image with alt, final CTA heading/body, and optional `meta_title` / `meta_description`. Icon keys MUST come from a closed catalog enforced in admin validation. `summary` and `scope_items` MUST remain available for card surfaces.
|
||||
|
||||
#### Scenario: Admin can edit detail sections
|
||||
- **WHEN** an admin opens a WeddingPackage in Filament
|
||||
- **THEN** the form MUST expose detail sections for hero, benefits, included items, audience, final CTA and SEO
|
||||
- **AND** icon fields MUST offer only catalog keys
|
||||
|
||||
#### Scenario: Seeder fills detail for official modalities
|
||||
- **WHEN** the content seeder runs
|
||||
- **THEN** each official modality MUST have slug and non-empty hero lead plus at least one benefit and one included item suitable for public render
|
||||
|
||||
### Requirement: Published modality detail page is publicly reachable by slug
|
||||
The system SHALL expose `packages.show` at `/pacotes/{slug}` without authentication. Only modalities with non-null `published_at` in the past or present MUST resolve. Draft or unknown slugs MUST return HTTP 404 without exposing internal fields.
|
||||
|
||||
#### Scenario: Published modality detail returns 200
|
||||
- **GIVEN** a published wedding modality with slug `essenza`
|
||||
- **WHEN** a visitor requests `/pacotes/essenza`
|
||||
- **THEN** the response status MUST be 200
|
||||
- **AND** the page MUST render hero title parts, benefits, included items, audience block and final CTA using CMS values
|
||||
|
||||
#### Scenario: Draft modality detail returns 404
|
||||
- **GIVEN** a wedding modality saved as draft
|
||||
- **WHEN** a visitor requests `/pacotes/{slug}` for that modality
|
||||
- **THEN** the response status MUST be 404
|
||||
|
||||
#### Scenario: Cards link to the detail page
|
||||
- **GIVEN** a published modality with a slug
|
||||
- **WHEN** a visitor views the home or services wedding cards
|
||||
- **THEN** a navigation control MUST link to `/pacotes/{slug}`
|
||||
- **AND** the primary conversion CTA MUST remain the WhatsApp or briefing channel
|
||||
29
openspec/changes/package-detail-pages/tasks.md
Normal file
29
openspec/changes/package-detail-pages/tasks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## 1. Data model and domain catalog
|
||||
|
||||
- [x] 1.1 Migration additive em `wedding_packages`: `slug` unique (backfill a partir de `name`), campos de detalhe (eyebrow, title_line, title_emphasis, hero_lead, hero_image_*, benefits jsonb, included_items jsonb, audience_*, final_cta_*, meta_*), atualizar model `$fillable`/casts e factory. Evidência: `database/migrations/2026_08_12_120000_extend_wedding_packages_for_detail_pages.php` (roda limpo), `app/Models/WeddingPackage.php` (fillable+casts+booted slug), `database/factories/WeddingPackageFactory.php` (slug + campos de detalhe).
|
||||
- [x] 1.2 `PackageIconCatalog` (chaves fechadas + labels pt-BR) e validação de `icon_key` em benefits/included_items. Evidência: `app/Domain/Marketing/PackageIconCatalog.php` (6 chaves: calendar/checklist/users/map/heart/spark), Select icon_key em `WeddingPackageForm` usa `PackageIconCatalog::labels()`.
|
||||
- [x] 1.3 Feature/unit: factory published com slug; scope published inalterado; icon_key inválido rejeitado na validação admin/request path usado pelo Filament. Evidência: `WeddingPackageTest` (4 testes: scope published + seeder, factory slug autofill, icon catalog aceita/rejeita `paw`).
|
||||
|
||||
## 2. Application + public route
|
||||
|
||||
- [x] 2.1 Query `FindPublishedWeddingPackageBySlug` + teste 200/null para draft. Evidência: `app/Application/Queries/Marketing/FindPublishedWeddingPackageBySlug.php`; `FindPublishedWeddingPackageBySlugTest` (retorna published, null para draft/inexistente).
|
||||
- [x] 2.2 `PackageController@show`, rota nomeada `packages.show` `/pacotes/{slug}`, PageMeta (meta_title/description fallback name/summary|hero_lead, OG hero). Evidência: `app/Http/Controllers/PublicSite/PackageController.php`, `routes/web.php` (`packages.show`), `PageMeta::forPackage` (title=meta_title??title_line??name, description=meta_description??summary, ogImageUrl=hero_image_path).
|
||||
- [x] 2.3 Estender `GetSitemapEntries` com pacotes publicados; teste sitemap inclui/exclui draft. Evidência: `GetSitemapEntries` appends pacotes published; `SitemapTest::test_sitemap_includes_only_published_packages` (inclui `essenza`, exclui `rascunho-pacote`).
|
||||
|
||||
## 3. Blade detail (Heritage Editorial)
|
||||
|
||||
- [x] 3.1 View `pages/packages/show` + componentes `package-hero`, `package-benefits`, `package-included`, `package-audience`; layout `layouts.public`; tokens DESIGN.md (sem hex do mock). Evidência: `resources/views/pages/packages/show.blade.php` + `resources/views/components/public/package-{hero,benefits,included,audience}.blade.php` + `resources/views/components/package/icon.blade.php` (SVG de catálogo); classes `amare-*` e `text-hero-spread` (tokens), sem hex do mock.
|
||||
- [x] 3.2 CTA final reutiliza builder WhatsApp/briefing dos cards; teste feature assert `wa.me` / `briefing?servico_interesse`. Evidência: `app/Support/PackageContactLink::for()` (helper compartilhado), `package-final-cta.blade.php` usa builder; `PackageDetailTest` (tests 4-5: wa.me com número, briefing fallback).
|
||||
- [x] 3.3 Feature: render de seções a partir do CMS; 404 draft; guest sem auth. Evidência: `PackageDetailTest` (5 testes: render de seções CMS assertSee, 404 draft, 404 slug inexistente, CTA wa.me/briefing).
|
||||
|
||||
## 4. Filament + seeder + card links
|
||||
|
||||
- [x] 4.1 `WeddingPackageResource`: seções Hero, Benefícios, Inclusos, Audiência, CTA final, SEO; FileUpload imagens; Select icon_key do catálogo. Evidência: `app/Filament/Resources/WeddingPackages/Schemas/WeddingPackageForm.php` (sections Identificação/Hero/Diferenciais/Inclusos/Audiência/CTA final/SEO; `PublicImageUploadRules::fileUpload/altTextField`; `Select icon_key` options `PackageIconCatalog::labels()`); `WeddingPackagesTable` ganhou coluna `slug`.
|
||||
- [x] 4.2 Seeder/update Essenza (copy mock), Conduzione e Grand Jour (estrutura paralela) com slugs `essenza`, `conduzione`, `grand-jour`. Evidência: `database/seeders/WeddingPackagesSeeder.php` (3 pacotes com slug + campos de detalhe completos); `WeddingPackageTest::test_content_seeder_creates_official_wedding_modalities` verde.
|
||||
- [x] 4.3 Home + `/servicos` cards: link secundário para `packages.show`; CTA primário WA intacto; testes de regressão de CTA. Evidência: `home/packages.blade.php` + `home/wedding-packages.blade.php` + `services/index.blade.php` (link 'Conhecer esta modalidade' + CTA primário refatorado via `PackageContactLink`); `HomePageContentTest` (22 testes: 143 assertions, incl. href packages.show).
|
||||
|
||||
## 5. Verification gate
|
||||
|
||||
- [x] 5.1 `composer pint:check` + `composer phpstan` no escopo alterado. Evidência: `vendor/bin/pint --test` passou (fix `ordered_imports` em `routes/web.php`); `composer phpstan` (level 5, `--memory-limit=1G --debug`) = No errors.
|
||||
- [x] 5.2 `composer test:unit` + `composer test:feature` verdes (incl. novos testes de pacote). Evidência: `test:unit` 42 passed (186 assertions); `test:feature` 193 passed (1182 assertions); `PackageDetailTest` (5), `FindPublishedWeddingPackageBySlugTest`, `SitemapTest` (3) verdes.
|
||||
- [x] 5.3 `openspec validate package-detail-pages` e marcar tasks só com evidência. Evidência: `openspec validate package-detail-pages` = "Change 'package-detail-pages' is valid"; tasks 1.1-5.3 marcadas acima com referências.
|
||||
@@ -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.
|
||||
@@ -67,10 +67,13 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
|
||||
### Requirement: Listing and detail pages exist for catalog content
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
|
||||
|
||||
#### Scenario: Services listing shows published services
|
||||
#### Scenario: Services listing renders editorial sections with published modalities
|
||||
|
||||
- **WHEN** a visitor loads `/servicos`
|
||||
- **THEN** every published service MUST be listed with title and summary in `sort_order`
|
||||
- **THEN** the page MUST present the two vertentes (Amare Casamentos and Amare Corporate) with CTAs to their sections
|
||||
- **AND** every published wedding modality MUST be rendered in `sort_order` with tag, name, subtitle, summary and scope
|
||||
- **AND** a quick-read comparison of the modalities MUST use each modality's `compare_heading` and `compare_summary`
|
||||
- **AND** unpublished modalities MUST NOT be rendered
|
||||
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
|
||||
|
||||
#### Scenario: Gallery respects stored order
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "site-amare",
|
||||
"name": "daisyui",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,6 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"daisyui": "^5.7.16",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
@@ -790,6 +791,16 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.16",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.16.tgz",
|
||||
"integrity": "sha512-V9CJxvrWIXTDuP/0tpijjWaKaKJNYo2IrULJfTWs3/DjW9rPqswk1n8NCLQRLrTutmlG2Ech7nkJDpCnF+6Dzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"daisyui": "^5.7.16",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
@import 'tailwindcss';
|
||||
@import './tokens.css';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: false;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "amare";
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
|
||||
--color-base-100: var(--amare-color-bg);
|
||||
--color-base-200: var(--amare-color-bg-deep);
|
||||
--color-base-300: var(--amare-color-bg-archive);
|
||||
--color-base-content: var(--amare-color-text);
|
||||
--color-primary: var(--amare-color-accent);
|
||||
--color-primary-content: var(--amare-color-accent-text);
|
||||
--color-secondary: var(--amare-color-sage);
|
||||
--color-secondary-content: var(--amare-color-text);
|
||||
--color-accent: var(--amare-color-accent-deep);
|
||||
--color-accent-content: var(--amare-color-accent-text);
|
||||
--color-neutral: var(--amare-color-muted);
|
||||
--color-neutral-content: var(--amare-color-accent-text);
|
||||
--color-info: #2563eb;
|
||||
--color-info-content: #ffffff;
|
||||
--color-success: var(--amare-color-success);
|
||||
--color-success-content: var(--amare-color-accent-text);
|
||||
--color-warning: var(--amare-color-warning);
|
||||
--color-warning-content: var(--amare-color-accent-text);
|
||||
--color-error: var(--amare-color-error);
|
||||
--color-error-content: var(--amare-color-accent-text);
|
||||
|
||||
--radius-selector: 0;
|
||||
--radius-field: 0;
|
||||
--radius-box: 0;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../views/**/*.blade.php';
|
||||
@@ -227,3 +267,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* daisyUI emits unlayered; match focus ring to the amare accent. */
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus,
|
||||
.checkbox:focus-visible {
|
||||
--input-color: var(--amare-color-accent);
|
||||
}
|
||||
|
||||
246
resources/views/components/home/briefing.blade.php
Normal file
246
resources/views/components/home/briefing.blade.php
Normal file
@@ -0,0 +1,246 @@
|
||||
@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="alert alert-error border border-amare-error/40">
|
||||
<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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-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="select w-full text-amare-text @error('tipo_evento') select-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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-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="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-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="textarea w-full min-h-[110px] text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-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="checkbox checkbox-primary mt-1 shrink-0"
|
||||
@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="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
|
||||
>
|
||||
Enviar briefing
|
||||
</button>
|
||||
<p class="text-sm text-amare-muted">
|
||||
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
61
resources/views/components/home/corporate.blade.php
Normal file
61
resources/views/components/home/corporate.blade.php
Normal file
@@ -0,0 +1,61 @@
|
||||
@props([
|
||||
'settings',
|
||||
'eyebrow' => '04 — Amare Corporate',
|
||||
'heading' => 'Projetos corporativos tratados como experiência, não apenas operação.',
|
||||
'meta' => 'No MVP, esta frente pode apresentar claramente o que a Amare oferece mesmo sem inventar um portfólio corporativo que ainda não existe.',
|
||||
'steps' => null,
|
||||
'showMeta' => true,
|
||||
'ctaLabel' => 'Falar sobre um evento corporativo',
|
||||
'ctaHref' => '#contato',
|
||||
'showAside' => true,
|
||||
])
|
||||
|
||||
@php
|
||||
$steps = $steps ?? ($settings->corporate_steps ?? []);
|
||||
@endphp
|
||||
|
||||
<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 @if ($showAside) lg:grid-cols-2 lg:gap-[70px] @endif" 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">{{ $eyebrow }}</p>
|
||||
<h2 id="corporate-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
|
||||
</div>
|
||||
|
||||
@if ($showMeta && filled($meta))
|
||||
<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">
|
||||
{{ $meta }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="border-t border-amare-border" data-reveal data-reveal-from="up">
|
||||
@foreach ($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="{{ $ctaHref }}" class="btn btn-primary mt-7 self-start text-xs font-bold uppercase tracking-[0.09em]" data-reveal data-reveal-from="up">
|
||||
{{ $ctaLabel }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if ($showAside)
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,6 +1,7 @@
|
||||
@props([
|
||||
'settings',
|
||||
'editorial' => false,
|
||||
'ctaHref' => null,
|
||||
])
|
||||
|
||||
<section aria-labelledby="final-cta-heading" @class([
|
||||
@@ -17,8 +18,8 @@
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="{{ route('contact') }}"
|
||||
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"
|
||||
href="{{ $ctaHref ?? route('briefing') }}"
|
||||
class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@@ -3,32 +3,28 @@
|
||||
])
|
||||
|
||||
<x-public.photo-hero
|
||||
@class(['home-chapter'])
|
||||
data-chapter="hero"
|
||||
:image-path="$settings->hero_image_path"
|
||||
:image-alt="$settings->hero_image_alt"
|
||||
:image-alt="$settings->hero_image_alt ?: $settings->brand_name"
|
||||
: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"
|
||||
data-chapter="hero"
|
||||
>
|
||||
<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">
|
||||
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-[470px] text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
<p class="mt-4 max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</x-public.photo-hero>
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
resources/views/components/home/packages.blade.php
Normal file
95
resources/views/components/home/packages.blade.php
Normal file
@@ -0,0 +1,95 @@
|
||||
@props([
|
||||
'packages',
|
||||
'settings',
|
||||
'eyebrow' => '03 — Amare Casamentos',
|
||||
'heading' => 'Um acompanhamento para cada momento.',
|
||||
'intro' => '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.',
|
||||
'kicker' => 'level',
|
||||
'showSubtitle' => false,
|
||||
'showIntro' => true,
|
||||
'showNote' => true,
|
||||
'ctaRoute' => null,
|
||||
'bandCtaHref' => null,
|
||||
'bandBody' => '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.',
|
||||
'note' => 'Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.',
|
||||
])
|
||||
|
||||
@php
|
||||
$bandLink = $bandCtaHref
|
||||
? ['href' => $bandCtaHref, 'isWhatsapp' => false]
|
||||
: \App\Support\PackageContactLink::generic($settings);
|
||||
@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">{{ $eyebrow }}</p>
|
||||
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
|
||||
</div>
|
||||
@if ($showIntro && filled($intro))
|
||||
<p class="max-w-[600px] text-amare-text-muted">{{ $intro }}</p>
|
||||
@endif
|
||||
</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">{{ $kicker === 'tag' ? $package->tag : $package->level }}</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $package->name }}</h3>
|
||||
@if ($showSubtitle && filled($package->subtitle))
|
||||
<p class="text-[clamp(1.0625rem,1.7vw,1.25rem)] font-medium leading-snug text-amare-text">{{ $package->subtitle }}</p>
|
||||
@endif
|
||||
<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>
|
||||
<div class="space-y-5">
|
||||
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
|
||||
<a
|
||||
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
|
||||
@if ($ctaRoute === null && $contactLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
|
||||
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
|
||||
>
|
||||
{{ $package->cta_label }}
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('packages.show', $package->slug) }}"
|
||||
class="btn btn-ghost min-h-0 px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"
|
||||
>
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
|
||||
</a>
|
||||
</div>
|
||||
</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">{{ $bandBody }}</p>
|
||||
</div>
|
||||
<a
|
||||
href="{{ $bandLink['href'] }}"
|
||||
@if ($bandLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
|
||||
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>
|
||||
|
||||
@if ($showNote && filled($note))
|
||||
<p class="mt-6 text-sm text-amare-muted">{{ $note }}</p>
|
||||
@endif
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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;
|
||||
$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
|
||||
<article
|
||||
|
||||
<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([
|
||||
'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,
|
||||
'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'],
|
||||
])
|
||||
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
|
||||
@if ($slot['tall']) data-editorial-portfolio-item="feature" @endif
|
||||
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,
|
||||
])
|
||||
/>
|
||||
@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 class="space-y-2">
|
||||
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
|
||||
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
|
||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||
<span class="border-b border-amare-accent-text pb-1">Ver caso</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 border-t border-amare-accent-text/30 pt-5 md:flex-row md:items-end md:justify-between">
|
||||
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
||||
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
|
||||
</p>
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||
<span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@endforeach
|
||||
</ul>
|
||||
<p>
|
||||
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -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">
|
||||
@@ -26,7 +26,7 @@
|
||||
</ol>
|
||||
|
||||
<p>
|
||||
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@endif
|
||||
|
||||
50
resources/views/components/home/vertentes.blade.php
Normal file
50
resources/views/components/home/vertentes.blade.php
Normal file
@@ -0,0 +1,50 @@
|
||||
@props([
|
||||
'eyebrow' => '02 — Duas vertentes, uma mesma forma de cuidar',
|
||||
'heading' => 'Como podemos acompanhar você?',
|
||||
'cards' => [
|
||||
[
|
||||
'label' => 'Amare Casamentos',
|
||||
'title' => 'Planejamento e condução para viver o processo com mais leveza.',
|
||||
'body' => 'Do início da organização à reta final, modalidades pensadas para diferentes momentos do casal.',
|
||||
'href' => '#casamentos',
|
||||
'cta' => 'Conhecer casamentos',
|
||||
],
|
||||
[
|
||||
'label' => 'Amare Corporate',
|
||||
'title' => 'Eventos empresariais com organização, intenção e execução segura.',
|
||||
'body' => 'Uma frente própria para projetos corporativos, com linguagem e necessidades diferentes das celebrações sociais.',
|
||||
'href' => '#corporate',
|
||||
'cta' => 'Conhecer Corporate',
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
<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">{{ $eyebrow }}</p>
|
||||
<h2 id="vertentes-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-2 md:gap-[24px]">
|
||||
@foreach ($cards as $card)
|
||||
<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">{{ $card['label'] }}</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $card['title'] }}</h3>
|
||||
<p class="text-amare-text-muted">{{ $card['body'] }}</p>
|
||||
<a href="{{ $card['href'] }}" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
||||
{{ $card['cta'] }}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
17
resources/views/components/home/wedding-packages.blade.php
Normal file
17
resources/views/components/home/wedding-packages.blade.php
Normal file
@@ -0,0 +1,17 @@
|
||||
@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($contactLink = \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
|
||||
<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><div class="space-y-2"><a href="{{ $contactLink['href'] }}" @if ($contactLink['isWhatsapp']) target="_blank" rel="noopener" @endif class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a><a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span></a></div></li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
32
resources/views/components/package/icon.blade.php
Normal file
32
resources/views/components/package/icon.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
@props(['icon' => null])
|
||||
|
||||
<svg {{ $attributes->merge(['class' => 'h-6 w-6']) }} viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true">
|
||||
@switch($icon)
|
||||
@case('calendar')
|
||||
<rect x="6" y="9" width="36" height="32" />
|
||||
<path d="M6 17h36M16 4v8M32 4v8" />
|
||||
<path d="M14 25h6M14 33h6M28 25h6M28 33h6" />
|
||||
@break
|
||||
@case('checklist')
|
||||
<path d="M8 14l4 4 8-9M8 25l4 4 8-9M8 36l4 4 8-9" />
|
||||
<path d="M28 14h12M28 25h12M28 36h12" />
|
||||
@break
|
||||
@case('users')
|
||||
<circle cx="17" cy="14" r="6" />
|
||||
<path d="M5 40c0-7 5.4-12 12-12s12 5 12 12" />
|
||||
<circle cx="33" cy="16" r="5" />
|
||||
<path d="M31 29c4.5 1.5 8 5.5 8.7 11" />
|
||||
@break
|
||||
@case('map')
|
||||
<path d="M24 6c-6.5 0-12 5.4-12 12.2 0 9 12 21.8 12 21.8s12-12.8 12-21.8C36 11.4 30.5 6 24 6z" />
|
||||
<circle cx="24" cy="18" r="4" />
|
||||
@break
|
||||
@case('heart')
|
||||
<path d="M24 40C13 32 5 25 5 16.4 5 10.7 9.5 6 15.2 6c3.4 0 6.4 1.9 8.8 4.7C26.4 7.9 29.4 6 32.8 6 38.5 6 43 10.7 43 16.4c0 8.6-8 15.6-19 23.6z" />
|
||||
@break
|
||||
@case('spark')
|
||||
<path d="M24 6l3.4 11.6L39 21l-11.6 3.4L24 36l-3.4-11.6L9 21l11.6-3.4z" />
|
||||
<path d="M37 34l1.8 6.2L45 42l-6.2 1.8L37 50l-1.8-6.2L29 42l6.2-1.8z" />
|
||||
@break
|
||||
@endswitch
|
||||
</svg>
|
||||
40
resources/views/components/public/package-audience.blade.php
Normal file
40
resources/views/components/public/package-audience.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
@props([
|
||||
'imagePath' => null,
|
||||
'imageAlt' => null,
|
||||
'heading' => null,
|
||||
'intro' => null,
|
||||
'points' => [],
|
||||
])
|
||||
|
||||
@if ($heading || count($points) > 0)
|
||||
<section aria-labelledby="audience-heading" class="border-b border-amare-border bg-amare-bg-deep" data-reveal-group>
|
||||
<div class="grid min-h-[390px] lg:grid-cols-2">
|
||||
<div class="min-h-[300px] bg-amare-bg lg:min-h-0" data-reveal-media data-reveal-from="left">
|
||||
@if ($imagePath)
|
||||
<x-media.image :path="$imagePath" :alt="$imageAlt ?: $heading" loading="lazy" sizes="(max-width: 1023px) 100vw, 50vw" class="img-editorial h-full w-full object-cover" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center bg-amare-bg-deep px-6 py-16 lg:px-[clamp(3rem,6vw,6rem)]">
|
||||
<div class="w-full max-w-[430px]" data-reveal data-reveal-from="right">
|
||||
@if ($heading)
|
||||
<h2 id="audience-heading" class="text-3xl font-medium uppercase tracking-[0.05em]">{{ $heading }}</h2>
|
||||
<div class="mt-4 h-px w-12 bg-amare-border" aria-hidden="true"></div>
|
||||
@endif
|
||||
@if ($intro)
|
||||
<p class="mt-6 text-lg text-amare-text-muted">{{ $intro }}</p>
|
||||
@endif
|
||||
@if (count($points) > 0)
|
||||
<ul class="mt-8 space-y-3">
|
||||
@foreach ($points as $point)
|
||||
<li class="flex items-start gap-3">
|
||||
<span aria-hidden="true" class="text-amare-accent">✓</span>
|
||||
<span>{{ $point }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
14
resources/views/components/public/package-benefits.blade.php
Normal file
14
resources/views/components/public/package-benefits.blade.php
Normal file
@@ -0,0 +1,14 @@
|
||||
@props(['benefits' => []])
|
||||
|
||||
@if (count($benefits) > 0)
|
||||
<section aria-label="Diferenciais da assessoria" class="border-b border-amare-border bg-amare-accent-deep text-amare-accent-text" data-reveal-group>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4">
|
||||
@foreach ($benefits as $benefit)
|
||||
<div class="flex min-h-[132px] flex-col items-center justify-center gap-3 px-4 py-8 text-center" data-reveal data-reveal-from="up">
|
||||
<x-package.icon :icon="$benefit['icon_key']" class="h-9 w-9" />
|
||||
<span class="max-w-[180px] text-sm uppercase tracking-[0.06em]">{{ $benefit['label'] }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@@ -0,0 +1,25 @@
|
||||
@props([
|
||||
'settings',
|
||||
'heading' => null,
|
||||
'body' => null,
|
||||
'packageName' => null,
|
||||
'packageMessage' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '', $packageMessage);
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="final-cta-heading" class="border-b border-amare-border bg-amare-bg py-20 md:py-24" data-chapter="final-cta" data-reveal-group>
|
||||
<div class="container-amare max-w-3xl space-y-6 py-4 text-center" data-reveal data-reveal-from="up">
|
||||
@if ($heading)
|
||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">{{ $heading }}</h2>
|
||||
@endif
|
||||
@if ($body)
|
||||
<p class="mx-auto max-w-2xl text-amare-muted">{{ $body }}</p>
|
||||
@endif
|
||||
<div class="pt-4">
|
||||
<a href="{{ $link['href'] }}" @if ($link['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
42
resources/views/components/public/package-hero.blade.php
Normal file
42
resources/views/components/public/package-hero.blade.php
Normal file
@@ -0,0 +1,42 @@
|
||||
@props([
|
||||
'imagePath' => null,
|
||||
'imageAlt' => null,
|
||||
'eyebrow' => null,
|
||||
'titleLine' => null,
|
||||
'titleEmphasis' => null,
|
||||
'lead' => null,
|
||||
'headingId' => 'package-hero-heading',
|
||||
])
|
||||
|
||||
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
|
||||
<div class="grid min-h-[calc(100dvh-5rem)] lg:h-[calc(100dvh-5rem)] lg:min-h-0 lg:grid-cols-12" data-photo-hero>
|
||||
<div class="flex min-w-0 items-center bg-amare-bg px-6 py-16 lg:col-span-5 lg:px-[clamp(3rem,6vw,7rem)]" data-hero-content data-reveal-group>
|
||||
<div class="w-full max-w-[470px] space-y-7">
|
||||
@if ($eyebrow)
|
||||
<div class="flex items-center gap-4" data-motion-beat="seal">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" />
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text" data-motion-beat="title">
|
||||
{{ $titleLine }}
|
||||
@if ($titleEmphasis)
|
||||
<em class="mt-4 block text-[0.7em] italic text-amare-accent-deep">{{ $titleEmphasis }}</em>
|
||||
@endif
|
||||
</h1>
|
||||
<div class="h-[42px] w-px bg-amare-accent" aria-hidden="true"></div>
|
||||
@if ($lead)
|
||||
<p class="max-w-[470px] text-lg text-amare-text-muted">{{ $lead }}</p>
|
||||
@endif
|
||||
<div data-motion-beat="cta">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-split-hero data-motion-beat="media" class="min-h-[56dvh] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-7 lg:min-h-0" data-reveal-media>
|
||||
@if ($imagePath)
|
||||
<x-media.image :path="$imagePath" :alt="$imageAlt ?: $titleLine" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 58vw" class="img-editorial h-full w-full object-cover" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
20
resources/views/components/public/package-included.blade.php
Normal file
20
resources/views/components/public/package-included.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@props(['items' => []])
|
||||
|
||||
@if (count($items) > 0)
|
||||
<section aria-labelledby="included-heading" class="border-b border-amare-border bg-amare-bg" data-reveal-group>
|
||||
<div class="container-amare py-16 md:py-24">
|
||||
<p class="text-center text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Modalidade</p>
|
||||
<h2 id="included-heading" class="mt-3 text-center text-3xl font-medium md:text-4xl">O que está incluso</h2>
|
||||
<div class="mx-auto mt-4 h-px w-14 bg-amare-border" aria-hidden="true"></div>
|
||||
<div class="mt-12 grid gap-y-10 md:grid-cols-3 md:gap-x-6 md:gap-y-0">
|
||||
@foreach ($items as $item)
|
||||
<div class="text-center md:border-l md:border-amare-border md:first:border-l-0 md:px-10" data-reveal data-reveal-from="up">
|
||||
<x-package.icon :icon="$item['icon_key']" class="mx-auto h-10 w-10 text-amare-accent-deep" />
|
||||
<h3 class="mt-4 text-lg font-medium uppercase tracking-[0.05em]">{{ $item['title'] }}</h3>
|
||||
<p class="mx-auto mt-3 max-w-[310px] text-amare-text-muted">{{ $item['description'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
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>
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="amare">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -55,28 +55,29 @@
|
||||
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="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Amare</a>
|
||||
<a href="{{ route('briefing') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted 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="btn btn-outline hidden min-h-0 px-6 text-xs font-bold uppercase tracking-[0.09em] md:inline-flex">
|
||||
Conte seu evento
|
||||
</a>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||
class="menu-button btn btn-square btn-ghost border border-amare-border text-amare-text md:hidden"
|
||||
aria-label="Abrir menu"
|
||||
aria-controls="main-nav"
|
||||
aria-expanded="false"
|
||||
@@ -97,50 +98,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">
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'briefing-sent')
|
||||
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
|
||||
<div role="status" class="alert mt-8 border border-amare-border bg-amare-bg-deep">
|
||||
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
|
||||
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
|
||||
</div>
|
||||
@else
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('contact.store') }}"
|
||||
action="{{ route('briefing.store') }}"
|
||||
class="mt-10 max-w-3xl space-y-10"
|
||||
data-contact-form
|
||||
>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
|
||||
<div role="alert" class="alert alert-error border border-amare-error/40">
|
||||
<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>
|
||||
@@ -79,7 +79,7 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-error @enderror"
|
||||
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
|
||||
>
|
||||
@error('nome')
|
||||
@@ -98,7 +98,7 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-error @enderror"
|
||||
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
|
||||
>
|
||||
@error('email')
|
||||
@@ -117,7 +117,7 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-error @enderror"
|
||||
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
|
||||
>
|
||||
@error('telefone')
|
||||
@@ -131,12 +131,12 @@
|
||||
id="tipo_evento"
|
||||
name="tipo_evento"
|
||||
required
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
|
||||
class="select w-full text-amare-text @error('tipo_evento') select-error @enderror"
|
||||
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
||||
>
|
||||
<option value="" selected disabled>Selecione...</option>
|
||||
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
|
||||
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
|
||||
<option value="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>
|
||||
@@ -154,7 +154,7 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-error @enderror"
|
||||
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
|
||||
>
|
||||
@error('data_periodo')
|
||||
@@ -172,7 +172,7 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-error @enderror"
|
||||
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
|
||||
>
|
||||
@error('cidade')
|
||||
@@ -192,7 +192,7 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-error @enderror"
|
||||
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
|
||||
>
|
||||
@error('convidados')
|
||||
@@ -206,10 +206,10 @@
|
||||
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"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-error @enderror"
|
||||
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
|
||||
>
|
||||
@error('servico_interesse')
|
||||
@@ -227,7 +227,7 @@
|
||||
rows="6"
|
||||
maxlength="3000"
|
||||
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
|
||||
class="textarea w-full text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-error @enderror"
|
||||
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
|
||||
>{{ old('mensagem') }}</textarea>
|
||||
@error('mensagem')
|
||||
@@ -244,7 +244,7 @@
|
||||
value="1"
|
||||
required
|
||||
@checked(old('privacidade'))
|
||||
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
|
||||
class="checkbox checkbox-primary mt-1 shrink-0"
|
||||
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
|
||||
>
|
||||
<span class="text-sm text-amare-muted">
|
||||
@@ -262,7 +262,7 @@
|
||||
<button
|
||||
type="submit"
|
||||
data-submit-button
|
||||
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
|
||||
class="btn btn-primary min-h-11 px-8 text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
Enviar briefing
|
||||
</button>
|
||||
|
||||
@@ -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
|
||||
|
||||
36
resources/views/pages/packages/show.blade.php
Normal file
36
resources/views/pages/packages/show.blade.php
Normal file
@@ -0,0 +1,36 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<article>
|
||||
<x-public.package-hero
|
||||
:image-path="$package->hero_image_path"
|
||||
:image-alt="$package->hero_image_alt"
|
||||
:eyebrow="$package->eyebrow"
|
||||
:title-line="$package->title_line"
|
||||
:title-emphasis="$package->title_emphasis"
|
||||
:lead="$package->hero_lead"
|
||||
>
|
||||
<a href="#final-cta-heading" class="btn btn-outline text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
|
||||
</x-public.package-hero>
|
||||
|
||||
<x-public.package-benefits :benefits="$package->benefits ?? []" />
|
||||
|
||||
<x-public.package-included :items="$package->included_items ?? []" />
|
||||
|
||||
<x-public.package-audience
|
||||
:image-path="$package->audience_image_path"
|
||||
:image-alt="$package->audience_image_alt"
|
||||
:heading="$package->audience_heading"
|
||||
:intro="$package->audience_intro"
|
||||
:points="$package->audience_points ?? []"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<x-public.package-final-cta
|
||||
:settings="$siteSettings"
|
||||
:heading="$package->final_cta_heading"
|
||||
:body="$package->final_cta_body"
|
||||
:package-name="$package->name"
|
||||
:package-message="$package->whatsapp_message"
|
||||
/>
|
||||
@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="btn btn-primary text-sm font-semibold uppercase tracking-[.12em]">Enviar apresentação</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -1,51 +1,95 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<x-public.photo-hero :image-path="$siteSettings->services_hero_image_path" :image-alt="$siteSettings->services_hero_image_alt" eyebrow="Serviços" title="Uma mesma excelência, diferentes ocasiões." summary="O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento." />
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
|
||||
@if ($services->isEmpty())
|
||||
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||
@else
|
||||
@php($hasPrioritizedImage = false)
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
|
||||
@foreach ($services as $index => $service)
|
||||
<article @class([
|
||||
'grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start',
|
||||
'md:translate-x-8' => $loop->even,
|
||||
]) data-reveal data-reveal-from="up">
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
||||
<p class="text-amare-muted">{{ $service->summary }}</p>
|
||||
<x-public.photo-hero
|
||||
:image-path="$siteSettings->services_hero_image_path"
|
||||
:image-alt="$siteSettings->services_hero_image_alt"
|
||||
eyebrow="Serviços"
|
||||
title="O cuidado certo para cada tipo de evento."
|
||||
summary="A Amare acompanha projetos sociais e corporativos com planejamento, organização e condução profissional. O formato muda conforme o momento, o evento e a necessidade de cada cliente."
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<a href="#vertentes" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
|
||||
Conhecer os serviços
|
||||
</a>
|
||||
<a href="{{ route('contact') }}" class="btn btn-ghost min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Tenho um evento em mente</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@if (filled($service->cover_image_path))
|
||||
@php($prioritizeImage = ! $hasPrioritizedImage)
|
||||
<x-media.image
|
||||
:path="$service->cover_image_path"
|
||||
:alt="$service->cover_image_alt ?: $service->title"
|
||||
:loading="$prioritizeImage ? 'eager' : 'lazy'"
|
||||
:fetchpriority="$prioritizeImage ? 'high' : null"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
||||
class="img-editorial aspect-[16/10] w-full object-cover"
|
||||
</x-public.photo-hero>
|
||||
|
||||
<x-home.vertentes
|
||||
eyebrow="Duas vertentes"
|
||||
heading="Escolha por contexto, não por uma lista de pacotes."
|
||||
:cards="[
|
||||
[
|
||||
'label' => 'Amare Casamentos',
|
||||
'title' => 'Acompanhamento para diferentes momentos do planejamento.',
|
||||
'body' => 'Três modalidades para casais que estão começando, já avançaram ou precisam de apoio na reta final.',
|
||||
'href' => '#casamentos',
|
||||
'cta' => 'Ver modalidades',
|
||||
],
|
||||
[
|
||||
'label' => 'Amare Corporate',
|
||||
'title' => 'Planejamento e execução para eventos empresariais.',
|
||||
'body' => 'Uma frente própria, com linguagem profissional e estrutura adequada a projetos corporativos.',
|
||||
'href' => '#corporate',
|
||||
'cta' => 'Ver Corporate',
|
||||
],
|
||||
]"
|
||||
/>
|
||||
@php($hasPrioritizedImage = true)
|
||||
@endif
|
||||
@if (filled($service->description))
|
||||
<div class="text-amare-muted">
|
||||
{!! nl2br(e($service->description)) !!}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-home.packages
|
||||
:packages="$weddingPackages"
|
||||
:settings="$siteSettings"
|
||||
eyebrow="Amare Casamentos"
|
||||
heading="Três formas de ter a Amare ao lado."
|
||||
:show-intro="false"
|
||||
kicker="tag"
|
||||
:show-subtitle="true"
|
||||
cta-route="contact"
|
||||
:band-cta-href="route('contact')"
|
||||
band-body="Conte um pouco sobre o casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático."
|
||||
:show-note="false"
|
||||
/>
|
||||
|
||||
@if ($weddingPackages->isNotEmpty())
|
||||
<section aria-label="Comparação rápida entre as modalidades" class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
|
||||
@foreach ($weddingPackages as $package)
|
||||
@if (filled($package->compare_heading))
|
||||
<article class="flex min-h-[300px] flex-col justify-between gap-6 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->name }}</p>
|
||||
<h3 class="text-[clamp(1.25rem,2vw,1.5rem)] font-medium leading-tight text-amare-text">{{ $package->compare_heading }}</h3>
|
||||
<p class="text-amare-text-muted">{{ $package->compare_summary }}</p>
|
||||
</div>
|
||||
<a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 self-start px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
|
||||
</a>
|
||||
</article>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
<x-home.corporate
|
||||
:settings="$siteSettings"
|
||||
eyebrow="Amare Corporate"
|
||||
heading="Organização, produção e condução com linguagem empresarial."
|
||||
:show-meta="false"
|
||||
:steps="[
|
||||
['title' => 'Planejamento', 'body' => 'Estruturação do evento, escopo, prioridades e cronograma.'],
|
||||
['title' => 'Produção', 'body' => 'Coordenação dos elementos e fornecedores necessários ao projeto.'],
|
||||
['title' => 'Execução', 'body' => 'Acompanhamento e condução do evento conforme o planejamento aprovado.'],
|
||||
]"
|
||||
cta-label="Falar sobre um evento Corporate"
|
||||
:cta-href="route('contact')"
|
||||
:show-aside="false"
|
||||
/>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" :cta-href="route('contact')" />
|
||||
@endsection
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\PublicSite\ContactController;
|
||||
use App\Http\Controllers\PublicSite\HomeController;
|
||||
use App\Http\Controllers\PublicSite\PackageController;
|
||||
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;
|
||||
@@ -16,11 +18,16 @@ Route::get('/', HomeController::class)->name('home');
|
||||
Route::get('/servicos', [ServiceController::class, 'index'])->name('services.index');
|
||||
Route::get('/portfolio', [PortfolioController::class, 'index'])->name('portfolio.index');
|
||||
Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('portfolio.show');
|
||||
Route::get('/pacotes/{slug}', [PackageController::class, 'show'])->name('packages.show');
|
||||
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,50 +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();
|
||||
}
|
||||
$page->assertPathIs('/contato');
|
||||
});
|
||||
|
||||
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, [
|
||||
$page = $this->visit($routes[0], [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
foreach ($routes as $index => $route) {
|
||||
if ($index > 0) {
|
||||
$page->navigate($route);
|
||||
}
|
||||
|
||||
$duration = $page->script(<<<'JS'
|
||||
() => {
|
||||
const probe = document.createElement('div');
|
||||
|
||||
@@ -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'
|
||||
() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user