Merge pull request #60 from manoel-freitas/feat/package-detail-pages
feat: páginas de detalhe das modalidades de assessoria
This commit is contained in:
@@ -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,10 +20,23 @@ 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()
|
||||||
@@ -64,6 +81,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,9 +12,11 @@ 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 $tag
|
||||||
* @property string|null $subtitle
|
* @property string|null $subtitle
|
||||||
@@ -23,10 +25,57 @@ use Illuminate\Support\Carbon;
|
|||||||
* @property string $cta_label
|
* @property string $cta_label
|
||||||
* @property string|null $compare_heading
|
* @property string|null $compare_heading
|
||||||
* @property string|null $compare_summary
|
* @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', 'tag', 'subtitle', 'summary', 'scope_items', 'cta_label', 'compare_heading', 'compare_summary', 'sort_order', 'published_at'])]
|
#[Fillable([
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'level',
|
||||||
|
'tag',
|
||||||
|
'subtitle',
|
||||||
|
'summary',
|
||||||
|
'scope_items',
|
||||||
|
'cta_label',
|
||||||
|
'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
|
||||||
{
|
{
|
||||||
@@ -35,9 +84,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',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Support/PackageContactLink.php
Normal file
28
app/Support/PackageContactLink.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?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.
|
||||||
|
*
|
||||||
|
* @return array{href: string, isWhatsapp: bool}
|
||||||
|
*/
|
||||||
|
public static function for(SiteSetting $settings, string $packageName): array
|
||||||
|
{
|
||||||
|
$digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
|
||||||
|
$isWhatsapp = strlen($digits) >= 10;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'href' => $isWhatsapp
|
||||||
|
? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.')
|
||||||
|
: route('briefing', ['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,8 +16,11 @@ 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),
|
'tag' => fake()->words(2, true),
|
||||||
'subtitle' => fake()->sentence(),
|
'subtitle' => fake()->sentence(),
|
||||||
@@ -27,6 +31,21 @@ class WeddingPackageFactory extends Factory
|
|||||||
'compare_summary' => fake()->sentence(),
|
'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,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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,7 +11,9 @@ 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)
|
||||||
* and the services page. Copy is approved from the structural model preview
|
* and the services page. Copy is approved from the structural model preview
|
||||||
* (preview(2).html) and 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,6 +24,7 @@ class WeddingPackagesSeeder extends Seeder
|
|||||||
$packages = [
|
$packages = [
|
||||||
[
|
[
|
||||||
'name' => 'Essenza',
|
'name' => 'Essenza',
|
||||||
|
'slug' => 'essenza',
|
||||||
'level' => '01 / COMPLETA',
|
'level' => '01 / COMPLETA',
|
||||||
'tag' => 'Assessoria completa',
|
'tag' => 'Assessoria completa',
|
||||||
'subtitle' => 'Do planejamento ao grande dia.',
|
'subtitle' => 'Do planejamento ao grande dia.',
|
||||||
@@ -37,9 +40,38 @@ class WeddingPackagesSeeder extends Seeder
|
|||||||
'compare_heading' => 'Começar com a Amare',
|
'compare_heading' => 'Começar com a Amare',
|
||||||
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
|
'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',
|
||||||
'tag' => 'Assessoria parcial',
|
'tag' => 'Assessoria parcial',
|
||||||
'subtitle' => 'Para quem já começou e quer seguir acompanhado.',
|
'subtitle' => 'Para quem já começou e quer seguir acompanhado.',
|
||||||
@@ -55,9 +87,38 @@ class WeddingPackagesSeeder extends Seeder
|
|||||||
'compare_heading' => 'Trazer a Amare para o caminho',
|
'compare_heading' => 'Trazer a Amare para o caminho',
|
||||||
'compare_summary' => 'O planejamento existe, mas ainda há decisões e gestão pela frente.',
|
'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',
|
||||||
'tag' => 'Assessoria final',
|
'tag' => 'Assessoria final',
|
||||||
'subtitle' => 'O planejamento está pronto. Agora é hora de viver.',
|
'subtitle' => 'O planejamento está pronto. Agora é hora de viver.',
|
||||||
@@ -73,6 +134,34 @@ class WeddingPackagesSeeder extends Seeder
|
|||||||
'compare_heading' => 'Entregar a operação para a Amare',
|
'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.',
|
'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.
|
||||||
@@ -15,11 +15,8 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$whatsappDigits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
|
|
||||||
$hasWhatsapp = strlen($whatsappDigits) >= 10;
|
|
||||||
$bandHref = $bandCtaHref ?? route('briefing');
|
$bandHref = $bandCtaHref ?? route('briefing');
|
||||||
@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"
|
||||||
@@ -54,21 +51,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))
|
||||||
? 'https://wa.me/'.$whatsappDigits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.')
|
|
||||||
: route('briefing', ['servico_interesse' => $package->name]);
|
|
||||||
$href = $ctaRoute
|
|
||||||
? route($ctaRoute)
|
|
||||||
: $waLink;
|
|
||||||
@endphp
|
|
||||||
<a
|
<a
|
||||||
href="{{ $href }}"
|
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
|
||||||
@if ($ctaRoute === null && $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>
|
||||||
|
|||||||
@@ -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))
|
||||||
@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,24 @@
|
|||||||
|
@props([
|
||||||
|
'settings',
|
||||||
|
'heading' => null,
|
||||||
|
'body' => null,
|
||||||
|
'packageName' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '');
|
||||||
|
@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
|
||||||
35
resources/views/pages/packages/show.blade.php
Normal file
35
resources/views/pages/packages/show.blade.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@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"
|
||||||
|
/>
|
||||||
|
@endsection
|
||||||
@@ -65,6 +65,9 @@
|
|||||||
<h3 class="text-[clamp(1.25rem,2vw,1.5rem)] font-medium leading-tight text-amare-text">{{ $package->compare_heading }}</h3>
|
<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>
|
<p class="text-amare-text-muted">{{ $package->compare_summary }}</p>
|
||||||
</div>
|
</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>
|
</article>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,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',
|
||||||
@@ -187,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.');
|
||||||
|
|||||||
118
tests/Feature/PublicSite/PackageDetailTest.php
Normal file
118
tests/Feature/PublicSite/PackageDetailTest.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?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_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,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