Compare commits
11 Commits
feature/da
...
feat/whats
| Author | SHA1 | Date | |
|---|---|---|---|
| 400c147a74 | |||
| 08c6d62498 | |||
| 75cce50bd5 | |||
| a0963dd57a | |||
| 77dc592963 | |||
| 623e45cbea | |||
| 455bc4b7d8 | |||
| 3f55180b73 | |||
| ea630328c9 | |||
| 1425d7aaa1 | |||
| 62a19147ad |
@@ -6,6 +6,7 @@ namespace App\Application\Data;
|
|||||||
|
|
||||||
use App\Models\PortfolioCase;
|
use App\Models\PortfolioCase;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\WeddingPackage;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
final readonly class PageMeta
|
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.
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Application\Queries\Marketing;
|
namespace App\Application\Queries\Marketing;
|
||||||
|
|
||||||
use App\Models\PortfolioCase;
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\WeddingPackage;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
final class GetSitemapEntries
|
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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Resources\WeddingPackages\Schemas;
|
namespace App\Filament\Resources\WeddingPackages\Schemas;
|
||||||
|
|
||||||
|
use App\Domain\Marketing\PackageIconCatalog;
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Repeater;
|
use Filament\Forms\Components\Repeater;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
class WeddingPackageForm
|
class WeddingPackageForm
|
||||||
@@ -16,14 +20,33 @@ class WeddingPackageForm
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components([
|
||||||
|
Section::make('Identificação')
|
||||||
|
->schema([
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
->label('Nome da modalidade')
|
->label('Nome da modalidade')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->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')
|
TextInput::make('level')
|
||||||
->label('Número / tipo (ex.: 01 / COMPLETA)')
|
->label('Número / tipo (ex.: 01 / COMPLETA)')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->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')
|
Textarea::make('summary')
|
||||||
->label('Resumo')
|
->label('Resumo')
|
||||||
->required()
|
->required()
|
||||||
@@ -44,6 +67,16 @@ class WeddingPackageForm
|
|||||||
->label('Texto do botão')
|
->label('Texto do botão')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->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')
|
TextInput::make('sort_order')
|
||||||
->label('Ordem')
|
->label('Ordem')
|
||||||
->numeric()
|
->numeric()
|
||||||
@@ -52,6 +85,113 @@ class WeddingPackageForm
|
|||||||
DateTimePicker::make('published_at')
|
DateTimePicker::make('published_at')
|
||||||
->label('Publicado em')
|
->label('Publicado em')
|
||||||
->seconds(false),
|
->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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class WeddingPackagesTable
|
|||||||
TextColumn::make('level')
|
TextColumn::make('level')
|
||||||
->label('Número / tipo')
|
->label('Número / tipo')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
TextColumn::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->searchable(),
|
||||||
TextColumn::make('published_at')
|
TextColumn::make('published_at')
|
||||||
->label('Publicado em')
|
->label('Publicado em')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
|
|||||||
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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,72 @@ use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property string $name
|
* @property string $name
|
||||||
|
* @property string|null $slug
|
||||||
* @property string $level
|
* @property string $level
|
||||||
|
* @property string|null $tag
|
||||||
|
* @property string|null $subtitle
|
||||||
* @property string $summary
|
* @property string $summary
|
||||||
* @property list<string> $scope_items
|
* @property list<string> $scope_items
|
||||||
* @property string $cta_label
|
* @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 int $sort_order
|
||||||
* @property Carbon|null $published_at
|
* @property Carbon|null $published_at
|
||||||
*/
|
*/
|
||||||
#[Fillable(['name', 'level', 'summary', 'scope_items', 'cta_label', 'sort_order', '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)]
|
#[UsePolicy(WeddingPackagePolicy::class)]
|
||||||
class WeddingPackage extends Model
|
class WeddingPackage extends Model
|
||||||
{
|
{
|
||||||
@@ -31,9 +86,25 @@ class WeddingPackage extends Model
|
|||||||
|
|
||||||
use HasPublication;
|
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> */
|
/** @return array<string, string|class-string> */
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return ['scope_items' => 'array', 'sort_order' => 'integer', 'published_at' => 'datetime'];
|
return [
|
||||||
|
'scope_items' => 'array',
|
||||||
|
'benefits' => 'array',
|
||||||
|
'included_items' => 'array',
|
||||||
|
'audience_points' => 'array',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace Database\Factories;
|
|||||||
|
|
||||||
use App\Models\WeddingPackage;
|
use App\Models\WeddingPackage;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/** @extends Factory<WeddingPackage> */
|
/** @extends Factory<WeddingPackage> */
|
||||||
class WeddingPackageFactory extends Factory
|
class WeddingPackageFactory extends Factory
|
||||||
@@ -15,14 +16,36 @@ class WeddingPackageFactory extends Factory
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
|
$name = fake()->unique()->words(2, true);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => fake()->unique()->words(2, true),
|
'name' => $name,
|
||||||
|
'slug' => Str::slug($name),
|
||||||
'level' => 'Assessoria',
|
'level' => 'Assessoria',
|
||||||
|
'tag' => fake()->words(2, true),
|
||||||
|
'subtitle' => fake()->sentence(),
|
||||||
'summary' => fake()->sentence(),
|
'summary' => fake()->sentence(),
|
||||||
'scope_items' => [fake()->sentence()],
|
'scope_items' => [fake()->sentence()],
|
||||||
'cta_label' => 'Conversar sobre esta modalidade',
|
'cta_label' => 'Conversar sobre esta modalidade',
|
||||||
|
'compare_heading' => fake()->words(3, true),
|
||||||
|
'compare_summary' => fake()->sentence(),
|
||||||
'sort_order' => 0,
|
'sort_order' => 0,
|
||||||
'published_at' => null,
|
'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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,9 +9,11 @@ use Illuminate\Database\Seeder;
|
|||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wedding package modalities shown on the home page (03 — Amare Casamentos).
|
* Wedding package modalities shown on the home page (03 — Amare Casamentos)
|
||||||
* Copy is approved from the structural model preview (preview(1).html) and
|
* and the services page. Copy is approved from the structural model preview
|
||||||
* remains editable via the Filament resource.
|
* (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
|
class WeddingPackagesSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -22,42 +24,144 @@ class WeddingPackagesSeeder extends Seeder
|
|||||||
$packages = [
|
$packages = [
|
||||||
[
|
[
|
||||||
'name' => 'Essenza',
|
'name' => 'Essenza',
|
||||||
|
'slug' => 'essenza',
|
||||||
'level' => '01 / COMPLETA',
|
'level' => '01 / COMPLETA',
|
||||||
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
|
'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' => [
|
'scope_items' => [
|
||||||
'Planejamento e organização',
|
'Estruturação do planejamento',
|
||||||
'Gestão de etapas e prioridades',
|
'Cronogramas, prazos e prioridades',
|
||||||
'Acompanhamento de fornecedores',
|
'Orientação e gestão de fornecedores',
|
||||||
'Coordenação do grande dia',
|
'Alinhamento entre os envolvidos',
|
||||||
|
'Coordenação da execução',
|
||||||
],
|
],
|
||||||
'cta_label' => 'Quero conhecer a Essenza',
|
'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,
|
'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',
|
'name' => 'Conduzione',
|
||||||
|
'slug' => 'conduzione',
|
||||||
'level' => '02 / PARCIAL',
|
'level' => '02 / PARCIAL',
|
||||||
'summary' => 'Para quem já começou e deseja estruturar o que falta, organizar fornecedores e seguir com acompanhamento profissional.',
|
'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' => [
|
'scope_items' => [
|
||||||
'Diagnóstico do planejamento',
|
'Diagnóstico do planejamento existente',
|
||||||
'Organização das pendências',
|
'Organização das pendências',
|
||||||
'Gestão dos próximos passos',
|
'Gestão dos fornecedores contratados',
|
||||||
'Coordenação do grande dia',
|
'Orientação para próximas decisões',
|
||||||
|
'Coordenação da execução',
|
||||||
],
|
],
|
||||||
'cta_label' => 'Quero conhecer a Conduzione',
|
'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,
|
'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',
|
'name' => 'Grand Jour',
|
||||||
|
'slug' => 'grand-jour',
|
||||||
'level' => '03 / FINAL',
|
'level' => '03 / FINAL',
|
||||||
'summary' => 'Para casais que já planejaram o casamento e precisam de uma equipe para assumir alinhamentos e operação na reta final.',
|
'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' => [
|
'scope_items' => [
|
||||||
'Imersão no planejamento existente',
|
'Imersão no planejamento existente',
|
||||||
'Alinhamento de fornecedores',
|
'Conferência das informações',
|
||||||
'Cronograma final',
|
'Alinhamento dos fornecedores',
|
||||||
|
'Organização do cronograma final',
|
||||||
'Gestão da operação do evento',
|
'Gestão da operação do evento',
|
||||||
],
|
],
|
||||||
'cta_label' => 'Quero conhecer a Grand Jour',
|
'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,
|
'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.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
@@ -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
|
### 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.
|
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`
|
- **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)
|
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
|
||||||
|
|
||||||
#### Scenario: Gallery respects stored order
|
#### Scenario: Gallery respects stored order
|
||||||
|
|||||||
@@ -241,10 +241,6 @@
|
|||||||
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border border-dashed border-amare-sage px-6 py-5">
|
|
||||||
<p class="text-sm text-amare-muted">Fornecedores e parcerias — pós-MVP. Não misturar este público ao briefing comercial. Quando validado, criar um canal/formulário institucional próprio.</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,26 +1,40 @@
|
|||||||
@props([
|
@props([
|
||||||
'settings',
|
'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
|
<section
|
||||||
aria-labelledby="corporate-heading"
|
aria-labelledby="corporate-heading"
|
||||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||||
data-chapter="corporate"
|
data-chapter="corporate"
|
||||||
id="corporate"
|
id="corporate"
|
||||||
>
|
>
|
||||||
<div class="container-amare grid gap-12 py-16 md:py-24 lg:grid-cols-2 lg:gap-[70px]" data-reveal-group>
|
<div class="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="flex flex-col gap-8">
|
||||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">04 — Amare Corporate</p>
|
<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">Projetos corporativos tratados como experiência, não apenas operação.</h2>
|
<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>
|
</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">
|
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted" data-reveal data-reveal-from="up">
|
||||||
No MVP, esta frente pode apresentar claramente o que a Amare oferece mesmo sem inventar um portfólio corporativo que ainda não existe.
|
{{ $meta }}
|
||||||
</p>
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="border-t border-amare-border" data-reveal data-reveal-from="up">
|
<div class="border-t border-amare-border" data-reveal data-reveal-from="up">
|
||||||
@foreach ($settings->corporate_steps ?? [] as $index => $step)
|
@foreach ($steps as $index => $step)
|
||||||
<div class="flex gap-8 border-b border-amare-border py-[23px]">
|
<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>
|
<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">
|
<div class="space-y-1">
|
||||||
@@ -31,15 +45,17 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="#contato" class="btn btn-primary mt-7 self-start text-xs font-bold uppercase tracking-[0.09em]" data-reveal data-reveal-from="up">
|
<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">
|
||||||
Falar sobre um evento corporativo
|
{{ $ctaLabel }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<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>
|
<p class="max-w-sm text-amare-text-muted">Em vez de usar fotografias genéricas como se fossem cases, a página assume com elegância que o portfólio será construído com projetos reais.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'settings',
|
'settings',
|
||||||
'editorial' => false,
|
'editorial' => false,
|
||||||
|
'ctaHref' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
<section aria-labelledby="final-cta-heading" @class([
|
<section aria-labelledby="final-cta-heading" @class([
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href="{{ route('briefing') }}"
|
href="{{ $ctaHref ?? route('briefing') }}"
|
||||||
class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
|
class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
@props([
|
@props([
|
||||||
'packages',
|
'packages',
|
||||||
'settings',
|
'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
|
@php
|
||||||
$whatsappDigits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
|
$bandLink = $bandCtaHref
|
||||||
$hasWhatsapp = strlen($whatsappDigits) >= 10;
|
? ['href' => $bandCtaHref, 'isWhatsapp' => false]
|
||||||
|
: \App\Support\PackageContactLink::generic($settings);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-labelledby="packages-heading"
|
aria-labelledby="packages-heading"
|
||||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||||
@@ -17,10 +28,12 @@
|
|||||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
<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="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">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">03 — Amare Casamentos</p>
|
<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">Um acompanhamento para cada momento.</h2>
|
<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>
|
</div>
|
||||||
<p class="max-w-[600px] text-amare-text-muted">As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.</p>
|
@if ($showIntro && filled($intro))
|
||||||
|
<p class="max-w-[600px] text-amare-text-muted">{{ $intro }}</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($packages->isNotEmpty())
|
@if ($packages->isNotEmpty())
|
||||||
@@ -28,8 +41,11 @@
|
|||||||
@foreach ($packages as $package)
|
@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">
|
<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">
|
<div class="space-y-5">
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->level }}</p>
|
<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>
|
<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>
|
<p class="text-amare-text-muted">{{ $package->summary }}</p>
|
||||||
<ul class="space-y-3 text-amare-text-muted">
|
<ul class="space-y-3 text-amare-text-muted">
|
||||||
@foreach ($package->scope_items as $item)
|
@foreach ($package->scope_items as $item)
|
||||||
@@ -37,18 +53,22 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@php
|
<div class="space-y-5">
|
||||||
$waLink = $hasWhatsapp
|
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
|
||||||
? 'https://wa.me/'.$whatsappDigits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.')
|
|
||||||
: route('briefing', ['servico_interesse' => $package->name]);
|
|
||||||
@endphp
|
|
||||||
<a
|
<a
|
||||||
href="{{ $waLink }}"
|
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
|
||||||
@if ($hasWhatsapp) target="_blank" rel="noopener noreferrer" @endif
|
@if ($ctaRoute === null && $contactLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
|
||||||
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
|
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
|
||||||
>
|
>
|
||||||
{{ $package->cta_label }}
|
{{ $package->cta_label }}
|
||||||
</a>
|
</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>
|
</article>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@@ -57,13 +77,19 @@
|
|||||||
<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="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">
|
<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>
|
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight">Ainda não sabe qual modalidade é ideal?</h3>
|
||||||
<p class="text-amare-accent-text/80">Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.</p>
|
<p class="text-amare-accent-text/80">{{ $bandBody }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ route('briefing') }}" class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto">
|
<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
|
Conversar com a Amare
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-amare-muted">Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.</p>
|
@if ($showNote && filled($note))
|
||||||
|
<p class="mt-6 text-sm text-amare-muted">{{ $note }}</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
@props([])
|
@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
|
<section
|
||||||
aria-labelledby="vertentes-heading"
|
aria-labelledby="vertentes-heading"
|
||||||
@@ -8,34 +27,24 @@
|
|||||||
>
|
>
|
||||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
<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">
|
<div class="max-w-3xl space-y-4" data-reveal data-reveal-from="up">
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">02 — Duas vertentes, uma mesma forma de cuidar</p>
|
<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">Como podemos acompanhar você?</h2>
|
<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>
|
||||||
|
|
||||||
<div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-2 md:gap-[24px]">
|
<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">
|
<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 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">
|
<div class="relative flex flex-col gap-5">
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Amare Casamentos</p>
|
<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">Planejamento e condução para viver o processo com mais leveza.</h3>
|
<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">Do início da organização à reta final, modalidades pensadas para diferentes momentos do casal.</p>
|
<p class="text-amare-text-muted">{{ $card['body'] }}</p>
|
||||||
<a href="#casamentos" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
<a href="{{ $card['href'] }}" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
||||||
Conhecer casamentos
|
{{ $card['cta'] }}
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
|
|
||||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
|
|
||||||
<div class="relative flex flex-col gap-5">
|
|
||||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Amare Corporate</p>
|
|
||||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Eventos empresariais com organização, intenção e execução segura.</h3>
|
|
||||||
<p class="text-amare-text-muted">Uma frente própria para projetos corporativos, com linguagem e necessidades diferentes das celebrações sociais.</p>
|
|
||||||
<a href="#corporate" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
|
||||||
Conhecer Corporate
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
@else
|
@else
|
||||||
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
|
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
|
||||||
@foreach ($packages as $package)
|
@foreach ($packages as $package)
|
||||||
@php($digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number))
|
@php($contactLink = \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
|
||||||
@php($href = strlen((string) $digits) >= 10 ? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.') : route('briefing', ['servico_interesse' => $package->name]))
|
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><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>
|
||||||
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><a href="{{ $href }}" @if (str_starts_with($href, 'https://wa.me/')) target="_blank" rel="noopener" @endif class="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></li>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</ol>
|
</ol>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
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
|
||||||
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
|
||||||
@@ -1,55 +1,95 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@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." />
|
<x-public.photo-hero
|
||||||
|
:image-path="$siteSettings->services_hero_image_path"
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
:image-alt="$siteSettings->services_hero_image_alt"
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
eyebrow="Serviços"
|
||||||
|
title="O cuidado certo para cada tipo de evento."
|
||||||
@if ($services->isEmpty())
|
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."
|
||||||
<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
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
@php($hasPrioritizedImage = false)
|
<a href="#vertentes" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
|
||||||
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
|
Conhecer os serviços
|
||||||
@foreach ($services as $index => $service)
|
</a>
|
||||||
<article @class([
|
<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">
|
||||||
'grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start',
|
<span class="border-b border-amare-accent pb-1">Tenho um evento em mente</span>
|
||||||
'md:translate-x-8' => $loop->even,
|
</a>
|
||||||
]) 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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
</x-public.photo-hero>
|
||||||
@if (filled($service->cover_image_path))
|
|
||||||
@php($prioritizeImage = ! $hasPrioritizedImage)
|
<x-home.vertentes
|
||||||
<x-media.image
|
eyebrow="Duas vertentes"
|
||||||
:path="$service->cover_image_path"
|
heading="Escolha por contexto, não por uma lista de pacotes."
|
||||||
:alt="$service->cover_image_alt ?: $service->title"
|
:cards="[
|
||||||
:loading="$prioritizeImage ? 'eager' : 'lazy'"
|
[
|
||||||
:fetchpriority="$prioritizeImage ? 'high' : null"
|
'label' => 'Amare Casamentos',
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
'title' => 'Acompanhamento para diferentes momentos do planejamento.',
|
||||||
class="img-editorial aspect-[16/10] w-full object-cover"
|
'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',
|
||||||
|
],
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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"
|
||||||
/>
|
/>
|
||||||
@php($hasPrioritizedImage = true)
|
|
||||||
@endif
|
|
||||||
@if (filled($service->description))
|
|
||||||
<div class="text-amare-muted">
|
|
||||||
{!! nl2br(e($service->description)) !!}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($weddingPackages->isNotEmpty())
|
@if ($weddingPackages->isNotEmpty())
|
||||||
<section class="border-b border-amare-border bg-amare-bg-deep py-16 md:py-24" aria-labelledby="wedding-packages-heading"><div class="container-amare"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="wedding-packages-heading" class="mt-3 text-3xl font-medium">Modalidades de assessoria</h2><div class="mt-8 grid gap-6 md:grid-cols-3">@foreach ($weddingPackages as $package)<article class="border-t border-amare-border pt-4"><p class="text-sm text-amare-accent">{{ $package->level }}</p><h3 class="mt-2 text-2xl font-medium">{{ $package->name }}</h3><p class="mt-2 text-amare-muted">{{ $package->summary }}</p><ul class="mt-4 space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li>{{ $item }}</li>@endforeach</ul></article>@endforeach</div></div></section>
|
<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
|
@endif
|
||||||
|
|
||||||
<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
|
@endsection
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use App\Http\Controllers\PublicSite\ContactController;
|
use App\Http\Controllers\PublicSite\ContactController;
|
||||||
use App\Http\Controllers\PublicSite\HomeController;
|
use App\Http\Controllers\PublicSite\HomeController;
|
||||||
|
use App\Http\Controllers\PublicSite\PackageController;
|
||||||
use App\Http\Controllers\PublicSite\PageController;
|
use App\Http\Controllers\PublicSite\PageController;
|
||||||
use App\Http\Controllers\PublicSite\PartnerInquiryController;
|
use App\Http\Controllers\PublicSite\PartnerInquiryController;
|
||||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||||
@@ -17,6 +18,7 @@ Route::get('/', HomeController::class)->name('home');
|
|||||||
Route::get('/servicos', [ServiceController::class, 'index'])->name('services.index');
|
Route::get('/servicos', [ServiceController::class, 'index'])->name('services.index');
|
||||||
Route::get('/portfolio', [PortfolioController::class, 'index'])->name('portfolio.index');
|
Route::get('/portfolio', [PortfolioController::class, 'index'])->name('portfolio.index');
|
||||||
Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('portfolio.show');
|
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('/sobre', [PageController::class, 'about'])->name('about');
|
||||||
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ it('reaches the primary CTA by keyboard and activates it', function (): void {
|
|||||||
|
|
||||||
$page->keys('[data-testid="home-primary-cta"]', 'Enter');
|
$page->keys('[data-testid="home-primary-cta"]', 'Enter');
|
||||||
|
|
||||||
$hash = $page->script('() => window.location.hash');
|
$page->assertPathIs('/contato');
|
||||||
expect($hash)->toBe('#sobre');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables transitions when prefers-reduced-motion is reduce', function (): void {
|
it('disables transitions when prefers-reduced-motion is reduce', function (): void {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ it('opens home motion, reveals once, and allows CTA during open', function (): v
|
|||||||
|
|
||||||
$page->click('[data-testid="home-primary-cta"]');
|
$page->click('[data-testid="home-primary-cta"]');
|
||||||
|
|
||||||
expect($page->script('() => window.location.hash'))->toBe('#sobre');
|
$page->assertPathIs('/contato');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps content and navigation usable without javascript', function (): void {
|
it('keeps content and navigation usable without javascript', function (): void {
|
||||||
@@ -171,7 +171,7 @@ it('keeps content and navigation usable without javascript', function (): void {
|
|||||||
->assertVisible('[data-testid="home-primary-cta"]')
|
->assertVisible('[data-testid="home-primary-cta"]')
|
||||||
->click('[data-testid="home-primary-cta"]');
|
->click('[data-testid="home-primary-cta"]');
|
||||||
|
|
||||||
expect($page->script('() => window.location.hash'))->toBe('#sobre');
|
$page->assertPathIs('/contato');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps targets final when intersection observer is unavailable', function (): void {
|
it('keeps targets final when intersection observer is unavailable', function (): void {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ it('renders the public home page', function (): void {
|
|||||||
$settings = SiteSetting::instance();
|
$settings = SiteSetting::instance();
|
||||||
|
|
||||||
$this->visit('/')
|
$this->visit('/')
|
||||||
->assertSee('Eventos com intenção, cuidado e presença.')
|
->assertSee($settings->hero_title)
|
||||||
->assertSee($settings->brand_name);
|
->assertSee($settings->brand_name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Application\Queries\Marketing;
|
||||||
|
|
||||||
|
use App\Application\Queries\Marketing\FindPublishedWeddingPackageBySlug;
|
||||||
|
use App\Models\WeddingPackage;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class FindPublishedWeddingPackageBySlugTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_returns_published_package_and_null_for_draft_or_unknown(): void
|
||||||
|
{
|
||||||
|
$published = WeddingPackage::factory()->published()->create([
|
||||||
|
'slug' => 'essenza',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WeddingPackage::factory()->create([
|
||||||
|
'slug' => 'rascunho-interno',
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$found = (new FindPublishedWeddingPackageBySlug)('essenza');
|
||||||
|
$draft = (new FindPublishedWeddingPackageBySlug)('rascunho-interno');
|
||||||
|
$missing = (new FindPublishedWeddingPackageBySlug)('inexistente');
|
||||||
|
|
||||||
|
$this->assertNotNull($found);
|
||||||
|
$this->assertTrue($found->is($published));
|
||||||
|
$this->assertNull($draft);
|
||||||
|
$this->assertNull($missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature\Marketing;
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Domain\Marketing\PackageIconCatalog;
|
||||||
use App\Models\WeddingPackage;
|
use App\Models\WeddingPackage;
|
||||||
use Database\Seeders\ContentSeeder;
|
use Database\Seeders\ContentSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class WeddingPackageTest extends TestCase
|
class WeddingPackageTest extends TestCase
|
||||||
@@ -37,4 +39,27 @@ class WeddingPackageTest extends TestCase
|
|||||||
WeddingPackage::query()->published()->orderBy('sort_order')->pluck('name')->all(),
|
WeddingPackage::query()->published()->orderBy('sort_order')->pluck('name')->all(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_factory_generates_a_slug_and_autofills_blank_slug_from_name(): void
|
||||||
|
{
|
||||||
|
$package = WeddingPackage::factory()->published()->create();
|
||||||
|
|
||||||
|
$this->assertNotEmpty($package->slug);
|
||||||
|
$this->assertSame(Str::slug($package->name), $package->slug);
|
||||||
|
|
||||||
|
$explicit = WeddingPackage::factory()->create(['name' => 'Pacote com nome', 'slug' => null]);
|
||||||
|
|
||||||
|
$this->assertSame('pacote-com-nome', $explicit->slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_package_icon_catalog_accepts_known_keys_and_rejects_unknown(): void
|
||||||
|
{
|
||||||
|
foreach (PackageIconCatalog::keys() as $key) {
|
||||||
|
$this->assertTrue(PackageIconCatalog::isValid($key));
|
||||||
|
$this->assertNotNull(PackageIconCatalog::labelFor($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse(PackageIconCatalog::isValid('paw'));
|
||||||
|
$this->assertNull(PackageIconCatalog::labelFor('paw'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ class HomePageContentTest extends TestCase
|
|||||||
], false)
|
], false)
|
||||||
->assertSee('FOTO DE CASAMENTO 01')
|
->assertSee('FOTO DE CASAMENTO 01')
|
||||||
->assertSee('Nome e data reais — aguardando autorização')
|
->assertSee('Nome e data reais — aguardando autorização')
|
||||||
->assertSee('Conteúdo em construção')
|
->assertSee('Conteúdo em construção');
|
||||||
->assertSee('Fornecedores e parcerias — pós-MVP');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
|
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
|
||||||
@@ -170,6 +169,7 @@ class HomePageContentTest extends TestCase
|
|||||||
WeddingPackage::factory()->published()->create([
|
WeddingPackage::factory()->published()->create([
|
||||||
'level' => '01 / COMPLETA',
|
'level' => '01 / COMPLETA',
|
||||||
'name' => 'Essenza',
|
'name' => 'Essenza',
|
||||||
|
'slug' => 'essenza',
|
||||||
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
|
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
|
||||||
'scope_items' => ['Planejamento e organização', 'Gestão de etapas e prioridades'],
|
'scope_items' => ['Planejamento e organização', 'Gestão de etapas e prioridades'],
|
||||||
'cta_label' => 'Quero conhecer a Essenza',
|
'cta_label' => 'Quero conhecer a Essenza',
|
||||||
@@ -188,6 +188,8 @@ class HomePageContentTest extends TestCase
|
|||||||
->assertSee('Gestão de etapas e prioridades')
|
->assertSee('Gestão de etapas e prioridades')
|
||||||
->assertSee('Quero conhecer a Essenza')
|
->assertSee('Quero conhecer a Essenza')
|
||||||
->assertSee('href="'.route('briefing', ['servico_interesse' => 'Essenza']).'"', false)
|
->assertSee('href="'.route('briefing', ['servico_interesse' => 'Essenza']).'"', false)
|
||||||
|
->assertSee('href="'.route('packages.show', 'essenza').'"', false)
|
||||||
|
->assertSee('Conhecer esta modalidade')
|
||||||
->assertSee('Ainda não sabe qual modalidade é ideal?')
|
->assertSee('Ainda não sabe qual modalidade é ideal?')
|
||||||
->assertSee('Conversar com a Amare')
|
->assertSee('Conversar com a Amare')
|
||||||
->assertSee('Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.');
|
->assertSee('Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.');
|
||||||
@@ -214,6 +216,44 @@ class HomePageContentTest extends TestCase
|
|||||||
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
|
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_package_cta_uses_custom_whatsapp_message_when_provided(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
|
||||||
|
|
||||||
|
WeddingPackage::factory()->published()->create([
|
||||||
|
'name' => 'Grand Jour',
|
||||||
|
'cta_label' => 'Quero conhecer a Grand Jour',
|
||||||
|
'whatsapp_message' => 'Olá, quero saber mais sobre a modalidade Grand Jour para o meu casamento em julho.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('home'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(
|
||||||
|
'https://wa.me/5511988887777?text='.rawurlencode('Olá, quero saber mais sobre a modalidade Grand Jour para o meu casamento em julho.'),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
->assertDontSee('gostaria de conversar sobre a modalidade Grand Jour para meu casamento', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guidance_band_uses_whatsapp_when_a_number_is_configured(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
|
||||||
|
|
||||||
|
WeddingPackage::factory()->published()->create();
|
||||||
|
|
||||||
|
$response = $this->get(route('home'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(
|
||||||
|
'https://wa.me/5511988887777?text='.rawurlencode('Olá, ainda estou decidindo a modalidade ideal para o meu casamento. Podemos conversar?'),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
->assertSee('Conversar com a Amare');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_corporate_steps_and_placeholder_render_from_settings(): void
|
public function test_corporate_steps_and_placeholder_render_from_settings(): void
|
||||||
{
|
{
|
||||||
SiteSetting::instance()->update([
|
SiteSetting::instance()->update([
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ class MediaPerformanceTest extends TestCase
|
|||||||
$about = $this->get(route('about'))->assertOk()->getContent();
|
$about = $this->get(route('about'))->assertOk()->getContent();
|
||||||
$detail = $this->get(route('portfolio.show', $firstCase->slug))->assertOk()->getContent();
|
$detail = $this->get(route('portfolio.show', $firstCase->slug))->assertOk()->getContent();
|
||||||
|
|
||||||
$this->assertMatchesRegularExpression('/service-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
|
$this->assertStringNotContainsString('service-first.jpg', $services);
|
||||||
$this->assertMatchesRegularExpression('/service-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $services);
|
$this->assertStringNotContainsString('service-later.jpg', $services);
|
||||||
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
||||||
$this->assertMatchesRegularExpression('/case-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $portfolio);
|
$this->assertMatchesRegularExpression('/case-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $portfolio);
|
||||||
$this->assertMatchesRegularExpression('/about\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $about);
|
$this->assertMatchesRegularExpression('/about\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $about);
|
||||||
@@ -89,7 +89,7 @@ class MediaPerformanceTest extends TestCase
|
|||||||
$services = $this->get(route('services.index'))->assertOk()->getContent();
|
$services = $this->get(route('services.index'))->assertOk()->getContent();
|
||||||
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
|
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
|
||||||
|
|
||||||
$this->assertMatchesRegularExpression('/visible-service\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
|
$this->assertStringNotContainsString('visible-service.jpg', $services);
|
||||||
$this->assertMatchesRegularExpression('/visible-case\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
$this->assertMatchesRegularExpression('/visible-case\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
tests/Feature/PublicSite/PackageDetailTest.php
Normal file
138
tests/Feature/PublicSite/PackageDetailTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\PublicSite;
|
||||||
|
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\WeddingPackage;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PackageDetailTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_published_package_renders_all_detail_sections(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$package = WeddingPackage::factory()->published()->create([
|
||||||
|
'slug' => 'essenza',
|
||||||
|
'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' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||||
|
],
|
||||||
|
'included_items' => [
|
||||||
|
['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.'],
|
||||||
|
],
|
||||||
|
'audience_heading' => 'Para quem é este pacote',
|
||||||
|
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
|
||||||
|
'audience_points' => ['Casamentos', 'Eventos corporativos'],
|
||||||
|
'final_cta_heading' => 'Pronta para começar?',
|
||||||
|
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('packages.show', $package->slug));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Assessoria')
|
||||||
|
->assertSee('Completa')
|
||||||
|
->assertSee('Do planejamento ao grande dia, com calma, precisão e cuidado.')
|
||||||
|
->assertSee('Organização integral')
|
||||||
|
->assertSee('Fornecedores selecionados')
|
||||||
|
->assertSee('O que está incluso')
|
||||||
|
->assertSee('Planejamento completo')
|
||||||
|
->assertSee('Cada etapa mapeada com clareza.')
|
||||||
|
->assertSee('Gestão de fornecedores')
|
||||||
|
->assertSee('Para quem é este pacote')
|
||||||
|
->assertSee('Casamentos')
|
||||||
|
->assertSee('Eventos corporativos')
|
||||||
|
->assertSee('Pronta para começar?')
|
||||||
|
->assertSee('Conte seu evento para a Amare e receba uma proposta.')
|
||||||
|
->assertSee('Solicitar proposta');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_draft_package_returns_404(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$package = WeddingPackage::factory()->create([
|
||||||
|
'slug' => 'rascunho-interno',
|
||||||
|
'title_line' => 'Rascunho',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('packages.show', $package->slug));
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_slug_returns_404(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$this->get(route('packages.show', 'inexistente'))->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_package_cta_uses_whatsapp_when_a_number_is_configured(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
|
||||||
|
|
||||||
|
$package = WeddingPackage::factory()->published()->create([
|
||||||
|
'name' => 'Essenza',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('packages.show', $package->slug));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(
|
||||||
|
'https://wa.me/5511988887777?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade Essenza para meu casamento.'),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
->assertSee('target="_blank"', false)
|
||||||
|
->assertDontSee(route('briefing', ['servico_interesse' => 'Essenza']), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_package_final_cta_uses_custom_whatsapp_message_when_provided(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
|
||||||
|
|
||||||
|
$package = WeddingPackage::factory()->published()->create([
|
||||||
|
'name' => 'Essenza',
|
||||||
|
'whatsapp_message' => 'Olá, tenho interesse na assessoria Essenza. Podem me passar mais detalhes?',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('packages.show', $package->slug));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(
|
||||||
|
'https://wa.me/5511988887777?text='.rawurlencode('Olá, tenho interesse na assessoria Essenza. Podem me passar mais detalhes?'),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
->assertDontSee('gostaria de conversar sobre a modalidade Essenza para meu casamento', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_package_cta_falls_back_to_briefing_without_whatsapp_number(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$package = WeddingPackage::factory()->published()->create([
|
||||||
|
'name' => 'Essenza',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('packages.show', $package->slug));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(route('briefing', ['servico_interesse' => 'Essenza']), false)
|
||||||
|
->assertDontSee('wa.me/', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ namespace Tests\Feature\PublicSite;
|
|||||||
|
|
||||||
use App\Models\PortfolioCase;
|
use App\Models\PortfolioCase;
|
||||||
use App\Models\PortfolioImage;
|
use App\Models\PortfolioImage;
|
||||||
use App\Models\Service;
|
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\WeddingPackage;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -18,17 +18,45 @@ class PublicPagesTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_services_index_lists_published_services_in_sort_order(): void
|
public function test_services_index_renders_editorial_sections_with_published_modalities_only(): void
|
||||||
{
|
{
|
||||||
SiteSetting::instance();
|
SiteSetting::instance();
|
||||||
|
|
||||||
Service::factory()->create(['title' => 'Rascunho', 'published_at' => null]);
|
WeddingPackage::factory()->create(['name' => 'Rascunho', 'published_at' => null]);
|
||||||
Service::factory()->published()->create(['title' => 'Serviço B', 'sort_order' => 20]);
|
WeddingPackage::factory()->published()->create([
|
||||||
Service::factory()->published()->create(['title' => 'Serviço A', 'sort_order' => 10]);
|
'name' => '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.',
|
||||||
|
'scope_items' => ['Estruturação do planejamento', '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' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->get(route('services.index'))
|
$this->get(route('services.index'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSeeInOrder(['Serviço A', 'Serviço B'])
|
->assertSee('O cuidado certo para cada tipo de evento.')
|
||||||
|
->assertSee('Conhecer os serviços')
|
||||||
|
->assertSee('Tenho um evento em mente')
|
||||||
|
->assertSee('Duas vertentes')
|
||||||
|
->assertSee('Escolha por contexto, não por uma lista de pacotes.')
|
||||||
|
->assertSee('Amare Casamentos')
|
||||||
|
->assertSee('Acompanhamento para diferentes momentos do planejamento.')
|
||||||
|
->assertSee('Amare Corporate')
|
||||||
|
->assertSee('Planejamento e execução para eventos empresariais.')
|
||||||
|
->assertSee('Três formas de ter a Amare ao lado.')
|
||||||
|
->assertSee('Assessoria completa')
|
||||||
|
->assertSee('Essenza')
|
||||||
|
->assertSee('Do planejamento ao grande dia.')
|
||||||
|
->assertSee('Quero conhecer a Essenza')
|
||||||
|
->assertSee('Começar com a Amare')
|
||||||
|
->assertSee('Organização, produção e condução com linguagem empresarial.')
|
||||||
|
->assertSee('Estruturação do evento, escopo, prioridades e cronograma.')
|
||||||
|
->assertSee('Falar sobre um evento Corporate')
|
||||||
|
->assertSee('Do casamento ao evento corporativo, tudo começa com uma boa conversa.')
|
||||||
->assertDontSee('Rascunho');
|
->assertDontSee('Rascunho');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Tests\Feature\PublicSite;
|
|||||||
|
|
||||||
use App\Models\PortfolioCase;
|
use App\Models\PortfolioCase;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\WeddingPackage;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -60,4 +61,23 @@ class SitemapTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee(route('portfolio.show', $case->slug), false);
|
->assertSee(route('portfolio.show', $case->slug), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_sitemap_includes_only_published_packages(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$published = WeddingPackage::factory()->published()->create([
|
||||||
|
'slug' => 'essenza',
|
||||||
|
]);
|
||||||
|
|
||||||
|
WeddingPackage::factory()->create([
|
||||||
|
'slug' => 'rascunho-pacote',
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get('/sitemap.xml')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee(route('packages.show', $published->slug), false)
|
||||||
|
->assertDontSee('rascunho-pacote', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user