Compare commits

...

16 Commits

Author SHA1 Message Date
400c147a74 feat: CTA dos cards e faixa de modalidades enviam ao WhatsApp com mensagem por pacote
Campo whatsapp_message editável por modalidade (Filament) com fallback para
template padrão. Cards da home, faixa 'Conversar com a Amare' e CTA final
da página de detalhe abrem o WhatsApp diretamente quando número configurado,
mantendo briefing como fallback.
2026-08-12 10:11:06 -03:00
08c6d62498 Merge pull request #60 from manoel-freitas/feat/package-detail-pages
Some checks failed
CI / static (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / feature (push) Has been cancelled
CI / browser (push) Has been cancelled
CI / container (push) Has been cancelled
feat: páginas de detalhe das modalidades de assessoria
2026-08-12 09:26:22 -03:00
75cce50bd5 Merge origin/main into feat/package-detail-pages 2026-08-12 09:25:11 -03:00
a0963dd57a feat: páginas de detalhe das modalidades de assessoria 2026-08-12 09:18:57 -03:00
77dc592963 Merge pull request #59 from manoel-freitas/feat/services-page-preview
feat: Serviços — página conforme preview (Hero editorial + vertentes + modalidades + compare + corporate)
2026-08-12 09:00:04 -03:00
623e45cbea test: Serviços — cobertura da nova página e atualização da spec 2026-08-12 08:55:29 -03:00
455bc4b7d8 feat: Serviços — página conforme preview com componentes reutilizáveis 2026-08-12 08:55:24 -03:00
3f55180b73 feat: Modalidades — novos campos de preview (tag, subtitle, comparação) 2026-08-12 08:55:20 -03:00
ea630328c9 feat: remover canal de fornecedores e parcerias do briefing da home (#58) 2026-08-12 08:40:28 -03:00
1425d7aaa1 fix: alinhar testes browser ao contrato de CTA home (#57) 2026-08-11 23:19:47 -03:00
62a19147ad Merge pull request #56 from manoel-freitas/feature/daisyui
feat: adota daisyUI 5 com tema amare
2026-08-11 22:54:03 -03:00
90632ada88 Merge remote-tracking branch 'origin/main' into feature/daisyui
# Conflicts:
#	resources/views/components/home/hero.blade.php
2026-08-11 22:45:45 -03:00
ce90cedb18 Merge remote-tracking branch 'origin/main' into feature/daisyui
# Conflicts:
#	resources/views/components/home/final-cta.blade.php
#	resources/views/components/home/hero.blade.php
#	resources/views/layouts/public.blade.php
2026-08-11 22:45:04 -03:00
a7d72f3756 Merge pull request #54 from manoel-freitas/feat/home-photo-hero
feat(home): normalizar hero com componente photo-hero compartilhado
2026-08-11 22:44:31 -03:00
eca261ceba docs: remove TDD focus, emphasize DRY and YAGNI (#55) 2026-08-11 22:43:13 -03:00
0d1ca40c11 feat: adota daisyUI 5 com tema amare nos componentes públicos 2026-08-11 22:39:20 -03:00
64 changed files with 1953 additions and 230 deletions

View File

@@ -31,9 +31,13 @@ Hooks live in `.husky/` and auto-install on any plain `npm install` via the `pre
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
## Design Principles: DRY & YAGNI
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse a piece of logic as soon as it is genuinely duplicated in more than one place — but not before. **YAGNI**: do not add abstraction, configurability, or layers speculatively; add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement. Duplication that appears once is not yet a reason to abstract — wait for a second real occurrence before generalizing. This repo already encodes YAGNI in `openspec/config.yaml` (no generic repositories / `BaseService`); keep that spirit in new code. Avoid over-engineering and avoid premature extraction.
## Testing Guidelines
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced, but changed behavior must have regression coverage.
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Tests are verification, not a design driver — write them to cover behavior you've already implemented, matching the layer you changed. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced; add regression tests where a bug was fixed or behavior is non-obvious, without making tests a front-loaded design ceremony.
## Commit & Pull Request Guidelines

View File

@@ -14,6 +14,10 @@ If the import above did not load, read `AGENTS.md` at the repo root now — it i
- Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`.
- Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`).
## Design principles: DRY & YAGNI (not TDD)
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse only once logic is genuinely duplicated in more than one place. **YAGNI**: no speculative abstraction, configurability, or layers — add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement; avoid over-engineering and premature extraction. Tests are verification, not a design driver: write them to cover behavior already implemented, not as a front-loaded TDD ceremony.
## Environment note
PHP and Composer are **not on PATH** in this environment, and `vendor/` and `node_modules/` are absent. Every `composer …` / `php artisan …` command in `AGENTS.md` and `README.md` assumes a PHP 8.4+ runtime with Composer 2 installed. Verify the toolchain before promising a command ran.

View File

@@ -6,6 +6,7 @@ namespace App\Application\Data;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Support\Facades\Storage;
final readonly class PageMeta
@@ -84,6 +85,43 @@ final readonly class PageMeta
);
}
/**
* @param array<string, mixed>|null $jsonLd
*/
public static function forPackage(
WeddingPackage $package,
string $canonical,
SiteSetting $settings,
?array $jsonLd = null,
): self {
$title = filled($package->meta_title)
? (string) $package->meta_title
: (filled($package->title_line) ? (string) $package->title_line : (string) $package->name);
$description = filled($package->meta_description)
? (string) $package->meta_description
: (filled($package->summary) ? (string) $package->summary : self::defaultDescription($settings));
$ogImageUrl = filled($package->hero_image_path)
? url(Storage::disk('public')->url((string) $package->hero_image_path))
: self::defaultOgImageUrl($settings);
$ogImageAlt = filled($package->hero_image_alt)
? (string) $package->hero_image_alt
: $settings->default_og_image_alt;
return new self(
title: $title,
description: $description,
canonical: $canonical,
ogType: 'article',
ogImageUrl: $ogImageUrl,
ogImageAlt: $ogImageAlt,
jsonLd: $jsonLd,
siteName: (string) $settings->brand_name,
);
}
/**
* Build the metadata for branded error pages.
*

View File

@@ -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();
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Application\Queries\Marketing;
use App\Models\PortfolioCase;
use App\Models\WeddingPackage;
use Illuminate\Support\Carbon;
final class GetSitemapEntries
@@ -38,6 +39,21 @@ final class GetSitemapEntries
];
}
$packages = WeddingPackage::query()
->published()
->orderBy('sort_order')
->get(['slug', 'updated_at']);
foreach ($packages as $package) {
/** @var Carbon|null $updatedAt */
$updatedAt = $package->updated_at;
$entries[] = [
'loc' => route('packages.show', $package->slug),
'lastmod' => $updatedAt?->toAtomString(),
];
}
return $entries;
}
}

View 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;
}
}

View File

@@ -4,10 +4,14 @@ declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Schemas;
use App\Domain\Marketing\PackageIconCatalog;
use App\Support\PublicImageUploadRules;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class WeddingPackageForm
@@ -16,42 +20,178 @@ class WeddingPackageForm
{
return $schema
->components([
TextInput::make('name')
->label('Nome da modalidade')
->required()
->maxLength(255),
TextInput::make('level')
->label('Número / tipo (ex.: 01 / COMPLETA)')
->required()
->maxLength(255),
Textarea::make('summary')
->label('Resumo')
->required()
->rows(4),
Repeater::make('scope_items')
->label('O que está incluído')
Section::make('Identificação')
->schema([
TextInput::make('item')
->label('Item')
TextInput::make('name')
->label('Nome da modalidade')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
if (blank($get('slug'))) {
$set('slug', str($state)->slug()->toString());
}
}),
TextInput::make('slug')
->label('Slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
TextInput::make('level')
->label('Número / tipo (ex.: 01 / COMPLETA)')
->required()
->maxLength(255),
TextInput::make('tag')
->label('Etiqueta curta (ex.: Assessoria completa)')
->maxLength(255),
TextInput::make('subtitle')
->label('Frase de apoio')
->maxLength(255),
Textarea::make('summary')
->label('Resumo')
->required()
->rows(4),
Repeater::make('scope_items')
->label('O que está incluído')
->schema([
TextInput::make('item')
->label('Item')
->required()
->maxLength(255),
])
->defaultItems(4)
->minItems(1)
->addActionLabel('Adicionar item')
->required(),
TextInput::make('cta_label')
->label('Texto do botão')
->required()
->maxLength(255),
Textarea::make('whatsapp_message')
->label('Mensagem do WhatsApp (deixe vazio para usar o padrão)')
->helperText('Texto enviado ao cliente ao tocar no botão de contato. O nome da modalidade é citado automaticamente se vazio.')
->rows(3),
TextInput::make('compare_heading')
->label('Comparação — título (leitura rápida)')
->maxLength(255),
TextInput::make('compare_summary')
->label('Comparação — resumo (leitura rápida)')
->maxLength(255),
TextInput::make('sort_order')
->label('Ordem')
->numeric()
->default(0)
->required(),
DateTimePicker::make('published_at')
->label('Publicado em')
->seconds(false),
])
->defaultItems(4)
->minItems(1)
->addActionLabel('Adicionar item')
->required(),
TextInput::make('cta_label')
->label('Texto do botão')
->required()
->maxLength(255),
TextInput::make('sort_order')
->label('Ordem')
->numeric()
->default(0)
->required(),
DateTimePicker::make('published_at')
->label('Publicado em')
->seconds(false),
->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),
]);
}
}

View File

@@ -24,6 +24,9 @@ class WeddingPackagesTable
TextColumn::make('level')
->label('Número / tipo')
->searchable(),
TextColumn::make('slug')
->label('Slug')
->searchable(),
TextColumn::make('published_at')
->label('Publicado em')
->dateTime()

View 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(),
],
),
]);
}
}

View File

@@ -12,17 +12,72 @@ use Illuminate\Database\Eloquent\Attributes\UsePolicy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* @property string $name
* @property string|null $slug
* @property string $level
* @property string|null $tag
* @property string|null $subtitle
* @property string $summary
* @property list<string> $scope_items
* @property string $cta_label
* @property string|null $whatsapp_message
* @property string|null $compare_heading
* @property string|null $compare_summary
* @property string|null $eyebrow
* @property string|null $title_line
* @property string|null $title_emphasis
* @property string|null $hero_lead
* @property string|null $hero_image_path
* @property string|null $hero_image_alt
* @property list<array{icon_key: string, label: string}>|null $benefits
* @property list<array{icon_key: string, title: string, description: string}>|null $included_items
* @property string|null $audience_heading
* @property string|null $audience_intro
* @property list<string>|null $audience_points
* @property string|null $audience_image_path
* @property string|null $audience_image_alt
* @property string|null $final_cta_heading
* @property string|null $final_cta_body
* @property string|null $meta_title
* @property string|null $meta_description
* @property int $sort_order
* @property Carbon|null $published_at
*/
#[Fillable(['name', 'level', 'summary', 'scope_items', 'cta_label', 'sort_order', 'published_at'])]
#[Fillable([
'name',
'slug',
'level',
'tag',
'subtitle',
'summary',
'scope_items',
'cta_label',
'whatsapp_message',
'compare_heading',
'compare_summary',
'sort_order',
'published_at',
'eyebrow',
'title_line',
'title_emphasis',
'hero_lead',
'hero_image_path',
'hero_image_alt',
'benefits',
'included_items',
'audience_heading',
'audience_intro',
'audience_points',
'audience_image_path',
'audience_image_alt',
'final_cta_heading',
'final_cta_body',
'meta_title',
'meta_description',
])]
#[UsePolicy(WeddingPackagePolicy::class)]
class WeddingPackage extends Model
{
@@ -31,9 +86,25 @@ class WeddingPackage extends Model
use HasPublication;
protected static function booted(): void
{
static::saving(function (WeddingPackage $weddingPackage): void {
if (blank($weddingPackage->slug) && filled($weddingPackage->name)) {
$weddingPackage->slug = Str::slug($weddingPackage->name);
}
});
}
/** @return array<string, string|class-string> */
protected function casts(): array
{
return ['scope_items' => 'array', 'sort_order' => 'integer', 'published_at' => 'datetime'];
return [
'scope_items' => 'array',
'benefits' => 'array',
'included_items' => 'array',
'audience_points' => 'array',
'sort_order' => 'integer',
'published_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\SiteSetting;
final class PackageContactLink
{
/**
* Resolve the contextual contact link for a wedding package modality.
*
* Uses the package-specific WhatsApp message when provided; otherwise
* falls back to the default template with the modality name.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function for(SiteSetting $settings, string $packageName, ?string $customMessage = null): array
{
$message = filled($customMessage)
? $customMessage
: 'Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.';
return self::build($settings, $message, $packageName);
}
/**
* Resolve a generic contact link (no specific modality), used by the
* "Conversar com a Amare" guidance band.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function generic(SiteSetting $settings): array
{
return self::build(
$settings,
'Olá, ainda estou decidindo a modalidade ideal para o meu casamento. Podemos conversar?',
null,
);
}
/**
* @return array{href: string, isWhatsapp: bool}
*/
private static function build(SiteSetting $settings, string $message, ?string $packageName): array
{
$digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
$isWhatsapp = strlen($digits) >= 10;
return [
'href' => $isWhatsapp
? 'https://wa.me/'.$digits.'?text='.rawurlencode($message)
: route('briefing', $packageName !== null ? ['servico_interesse' => $packageName] : []),
'isWhatsapp' => $isWhatsapp,
];
}
}

View File

@@ -6,6 +6,7 @@ namespace Database\Factories;
use App\Models\WeddingPackage;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<WeddingPackage> */
class WeddingPackageFactory extends Factory
@@ -15,14 +16,36 @@ class WeddingPackageFactory extends Factory
/** @return array<string, mixed> */
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => fake()->unique()->words(2, true),
'name' => $name,
'slug' => Str::slug($name),
'level' => 'Assessoria',
'tag' => fake()->words(2, true),
'subtitle' => fake()->sentence(),
'summary' => fake()->sentence(),
'scope_items' => [fake()->sentence()],
'cta_label' => 'Conversar sobre esta modalidade',
'compare_heading' => fake()->words(3, true),
'compare_summary' => fake()->sentence(),
'sort_order' => 0,
'published_at' => null,
'eyebrow' => 'Assessoria',
'title_line' => fake()->words(2, true),
'title_emphasis' => fake()->word(),
'hero_lead' => fake()->paragraph(),
'benefits' => [
['icon_key' => 'checklist', 'label' => fake()->word()],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => fake()->word(), 'description' => fake()->sentence()],
],
'audience_heading' => 'Para quem é este pacote',
'audience_intro' => fake()->paragraph(),
'audience_points' => [fake()->sentence()],
'final_cta_heading' => 'Vamos conversar?',
'final_cta_body' => fake()->paragraph(),
];
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->string('tag')->nullable();
$table->string('subtitle')->nullable();
$table->string('compare_heading')->nullable();
$table->string('compare_summary')->nullable();
});
}
public function down(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->dropColumn(['tag', 'subtitle', 'compare_heading', 'compare_summary']);
});
}
};

View File

@@ -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]);
}
}
};

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->text('whatsapp_message')->nullable()->after('cta_label');
});
}
public function down(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->dropColumn('whatsapp_message');
});
}
};

View File

@@ -9,9 +9,11 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* Wedding package modalities shown on the home page (03 Amare Casamentos).
* Copy is approved from the structural model preview (preview(1).html) and
* remains editable via the Filament resource.
* Wedding package modalities shown on the home page (03 Amare Casamentos)
* and the services page. Copy is approved from the structural model preview
* (preview(2).html) and remains editable via the Filament resource. Detail
* copy follows the complete-assessoria preview
* (amare-assessoria-completa.html) adapted to each modality.
*/
class WeddingPackagesSeeder extends Seeder
{
@@ -22,42 +24,144 @@ class WeddingPackagesSeeder extends Seeder
$packages = [
[
'name' => 'Essenza',
'slug' => 'essenza',
'level' => '01 / COMPLETA',
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
'tag' => 'Assessoria completa',
'subtitle' => 'Do planejamento ao grande dia.',
'summary' => 'Para casais que desejam contar com a Amare desde o início ou ainda estão estruturando etapas importantes.',
'scope_items' => [
'Planejamento e organização',
'Gestão de etapas e prioridades',
'Acompanhamento de fornecedores',
'Coordenação do grande dia',
'Estruturação do planejamento',
'Cronogramas, prazos e prioridades',
'Orientação e gestão de fornecedores',
'Alinhamento entre os envolvidos',
'Coordenação da execução',
],
'cta_label' => 'Quero conhecer a Essenza',
'compare_heading' => 'Começar com a Amare',
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
'sort_order' => 1,
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Completa',
'hero_lead' => 'Do planejamento ao grande dia, com calma, precisão e cuidado.',
'benefits' => [
['icon_key' => 'calendar', 'label' => 'Organização integral'],
['icon_key' => 'checklist', 'label' => 'Planejamento personalizado'],
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
],
'included_items' => [
['icon_key' => 'heart', 'title' => 'Escuta e briefing', 'description' => 'Cada casal é único; começamos por ouvir.'],
['icon_key' => 'checklist', 'title' => 'Planejamento completo', 'description' => 'Cada etapa mapeada com clareza.'],
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
['icon_key' => 'calendar', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
['icon_key' => 'spark', 'title' => 'Produção e logística', 'description' => 'Dia a dia do evento organizado nos detalhes.'],
['icon_key' => 'users', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
],
'audience_heading' => 'Para quem é este pacote',
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
'audience_points' => [
'Casamentos',
'Aniversários',
'Eventos sociais e celebrações',
'Eventos corporativos',
],
'final_cta_heading' => 'Pronta para começar?',
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
],
[
'name' => 'Conduzione',
'slug' => 'conduzione',
'level' => '02 / PARCIAL',
'summary' => 'Para quem já começou e deseja estruturar o que falta, organizar fornecedores e seguir com acompanhamento profissional.',
'tag' => 'Assessoria parcial',
'subtitle' => 'Para quem já começou e quer seguir acompanhado.',
'summary' => 'Para casais que já tomaram decisões e contrataram parte dos fornecedores, mas precisam estruturar o que falta.',
'scope_items' => [
'Diagnóstico do planejamento',
'Diagnóstico do planejamento existente',
'Organização das pendências',
'Gestão dos próximos passos',
'Coordenação do grande dia',
'Gestão dos fornecedores contratados',
'Orientação para próximas decisões',
'Coordenação da execução',
],
'cta_label' => 'Quero conhecer a Conduzione',
'compare_heading' => 'Trazer a Amare para o caminho',
'compare_summary' => 'O planejamento existe, mas ainda há decisões e gestão pela frente.',
'sort_order' => 2,
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Parcial',
'hero_lead' => 'Para retomar o planejamento, organizar o que falta e seguir com acompanhamento profissional.',
'benefits' => [
['icon_key' => 'checklist', 'label' => 'Diagnóstico completo'],
['icon_key' => 'calendar', 'label' => 'Organização integral'],
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => 'Diagnóstico do planejamento', 'description' => 'Um olhar profissional sobre o que já existe.'],
['icon_key' => 'calendar', 'title' => 'Organização das pendências', 'description' => 'Prioridades claras, etapa a etapa.'],
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
['icon_key' => 'spark', 'title' => 'Gestão dos próximos passos', 'description' => 'Um plano para chegar tranquilo ao grande dia.'],
['icon_key' => 'users', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
],
'audience_heading' => 'Para quem é este pacote',
'audience_intro' => 'Para quem já começou a planejar e quer retomar as rédeas com acompanhamento profissional.',
'audience_points' => [
'Casamentos em andamento',
'Casais com pouco tempo disponível',
'Quem precisa priorizar pendências',
'Quem busca fornecedores confiáveis',
],
'final_cta_heading' => 'Pronta para retomar?',
'final_cta_body' => 'Conte onde seu planejamento está e receba uma proposta.',
],
[
'name' => 'Grand Jour',
'slug' => 'grand-jour',
'level' => '03 / FINAL',
'summary' => 'Para casais que já planejaram o casamento e precisam de uma equipe para assumir alinhamentos e operação na reta final.',
'tag' => 'Assessoria final',
'subtitle' => 'O planejamento está pronto. Agora é hora de viver.',
'summary' => 'Para casais na reta final que precisam de uma equipe para assumir alinhamentos, cronograma e operação.',
'scope_items' => [
'Imersão no planejamento existente',
'Alinhamento de fornecedores',
'Cronograma final',
'Conferência das informações',
'Alinhamento dos fornecedores',
'Organização do cronograma final',
'Gestão da operação do evento',
],
'cta_label' => 'Quero conhecer a Grand Jour',
'compare_heading' => 'Entregar a operação para a Amare',
'compare_summary' => 'O projeto está pronto e o foco passa a ser alinhamento e execução.',
'sort_order' => 3,
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Final',
'hero_lead' => 'Para assumir os alinhamentos e a operação quando o grande dia está próximo.',
'benefits' => [
['icon_key' => 'checklist', 'label' => 'Imersão no que já existe'],
['icon_key' => 'users', 'label' => 'Alinhamento de fornecedores'],
['icon_key' => 'calendar', 'label' => 'Cronograma final'],
['icon_key' => 'heart', 'label' => 'Operação com leveza'],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => 'Imersão no planejamento', 'description' => 'Entendemos cada decisão já tomada.'],
['icon_key' => 'map', 'title' => 'Alinhamento de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
['icon_key' => 'calendar', 'title' => 'Cronograma final', 'description' => 'Cada etapa posicionada na reta final.'],
['icon_key' => 'spark', 'title' => 'Ensaios e detalhes', 'description' => 'Produção e logística nos detalhes.'],
['icon_key' => 'users', 'title' => 'Operação do evento', 'description' => 'Gestão da equipe e do dia a dia.'],
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
],
'audience_heading' => 'Para quem é este pacote',
'audience_intro' => 'Para casais já planejados que querem chegar ao grande dia com equipe e operação no lugar.',
'audience_points' => [
'Casamentos já planejados',
'Casais na reta final',
'Quem precisa de operação no dia',
'Quem quer viver o momento com leveza',
],
'final_cta_heading' => 'Pronta para o grande dia?',
'final_cta_body' => 'Conte a data do seu evento e receba uma proposta.',
],
];

View File

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

View 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.

View 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).

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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.

View File

@@ -67,10 +67,13 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
### Requirement: Listing and detail pages exist for catalog content
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
#### Scenario: Services listing shows published services
#### Scenario: Services listing renders editorial sections with published modalities
- **WHEN** a visitor loads `/servicos`
- **THEN** every published service MUST be listed with title and summary in `sort_order`
- **THEN** the page MUST present the two vertentes (Amare Casamentos and Amare Corporate) with CTAs to their sections
- **AND** every published wedding modality MUST be rendered in `sort_order` with tag, name, subtitle, summary and scope
- **AND** a quick-read comparison of the modalities MUST use each modality's `compare_heading` and `compare_summary`
- **AND** unpublished modalities MUST NOT be rendered
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
#### Scenario: Gallery respects stored order

13
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "site-amare",
"name": "daisyui",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -7,6 +7,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
"daisyui": "^5.7.16",
"husky": "^9.1.7",
"laravel-vite-plugin": "^3.1",
"playwright": "^1.62.0",
@@ -790,6 +791,16 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/daisyui": {
"version": "5.7.16",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.16.tgz",
"integrity": "sha512-V9CJxvrWIXTDuP/0tpijjWaKaKJNYo2IrULJfTWs3/DjW9rPqswk1n8NCLQRLrTutmlG2Ech7nkJDpCnF+6Dzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",

View File

@@ -10,6 +10,7 @@
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
"daisyui": "^5.7.16",
"husky": "^9.1.7",
"laravel-vite-plugin": "^3.1",
"playwright": "^1.62.0",

View File

@@ -1,6 +1,46 @@
@import 'tailwindcss';
@import './tokens.css';
@plugin "daisyui" {
themes: false;
}
@plugin "daisyui/theme" {
name: "amare";
default: true;
color-scheme: light;
--color-base-100: var(--amare-color-bg);
--color-base-200: var(--amare-color-bg-deep);
--color-base-300: var(--amare-color-bg-archive);
--color-base-content: var(--amare-color-text);
--color-primary: var(--amare-color-accent);
--color-primary-content: var(--amare-color-accent-text);
--color-secondary: var(--amare-color-sage);
--color-secondary-content: var(--amare-color-text);
--color-accent: var(--amare-color-accent-deep);
--color-accent-content: var(--amare-color-accent-text);
--color-neutral: var(--amare-color-muted);
--color-neutral-content: var(--amare-color-accent-text);
--color-info: #2563eb;
--color-info-content: #ffffff;
--color-success: var(--amare-color-success);
--color-success-content: var(--amare-color-accent-text);
--color-warning: var(--amare-color-warning);
--color-warning-content: var(--amare-color-accent-text);
--color-error: var(--amare-color-error);
--color-error-content: var(--amare-color-accent-text);
--radius-selector: 0;
--radius-field: 0;
--radius-box: 0;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../views/**/*.blade.php';
@@ -227,3 +267,11 @@
}
}
}
/* daisyUI emits unlayered; match focus ring to the amare accent. */
.input:focus,
.select:focus,
.textarea:focus,
.checkbox:focus-visible {
--input-color: var(--amare-color-accent);
}

View File

@@ -32,7 +32,7 @@
</div>
@if ($errors->any())
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
<div role="alert" class="alert alert-error border border-amare-error/40">
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
</div>
@@ -50,7 +50,7 @@
autocomplete="name"
maxlength="120"
placeholder="Seu nome"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-error @enderror"
@error('nome') aria-invalid="true" aria-describedby="briefing-nome-error" @enderror
>
@error('nome')
@@ -69,7 +69,7 @@
autocomplete="email"
maxlength="254"
placeholder="voce@email.com"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-error @enderror"
@error('email') aria-invalid="true" aria-describedby="briefing-email-error" @enderror
>
@error('email')
@@ -88,7 +88,7 @@
autocomplete="tel"
maxlength="40"
placeholder="(11) 90000-0000"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-error @enderror"
@error('telefone') aria-invalid="true" aria-describedby="briefing-telefone-error" @enderror
>
@error('telefone')
@@ -102,7 +102,7 @@
id="briefing-tipo_evento"
name="tipo_evento"
required
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
class="select w-full text-amare-text @error('tipo_evento') select-error @enderror"
@error('tipo_evento') aria-invalid="true" aria-describedby="briefing-tipo_evento-error" @enderror
>
<option value="" selected disabled>Selecione...</option>
@@ -125,7 +125,7 @@
value="{{ old('data_periodo') }}"
maxlength="80"
placeholder="Ex.: novembro de 2027"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-error @enderror"
@error('data_periodo') aria-invalid="true" aria-describedby="briefing-data_periodo-error" @enderror
>
@error('data_periodo')
@@ -143,7 +143,7 @@
required
maxlength="80"
placeholder="Ex.: São Paulo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-error @enderror"
@error('cidade') aria-invalid="true" aria-describedby="briefing-cidade-error" @enderror
>
@error('cidade')
@@ -163,7 +163,7 @@
inputmode="numeric"
autocomplete="off"
placeholder="Ex.: 120"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-error @enderror"
@error('convidados') aria-invalid="true" aria-describedby="briefing-convidados-error" @enderror
>
@error('convidados')
@@ -180,7 +180,7 @@
value="{{ old('servico_interesse') }}"
maxlength="120"
placeholder="Ex.: planejamento completo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-error @enderror"
@error('servico_interesse') aria-invalid="true" aria-describedby="briefing-servico_interesse-error" @enderror
>
@error('servico_interesse')
@@ -198,7 +198,7 @@
rows="5"
maxlength="3000"
placeholder="O que já está definido? Em que ponto você precisa de ajuda?"
class="w-full min-h-[110px] border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
class="textarea w-full min-h-[110px] text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-error @enderror"
@error('mensagem') aria-invalid="true" aria-describedby="briefing-mensagem-error" @enderror
>{{ old('mensagem') }}</textarea>
@error('mensagem')
@@ -215,7 +215,7 @@
value="1"
required
@checked(old('privacidade'))
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
class="checkbox checkbox-primary mt-1 shrink-0"
@error('privacidade') aria-invalid="true" aria-describedby="briefing-privacidade-error" @enderror
>
<span class="text-sm text-amare-muted">
@@ -233,7 +233,7 @@
<button
type="submit"
data-submit-button
class="inline-flex min-h-[48px] items-center justify-center border border-transparent bg-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
>
Enviar briefing
</button>
@@ -241,10 +241,6 @@
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
</p>
</div>
<div class="border border-dashed border-amare-sage px-6 py-5">
<p class="text-sm text-amare-muted">Fornecedores e parcerias pós-MVP. Não misturar este público ao briefing comercial. Quando validado, criar um canal/formulário institucional próprio.</p>
</div>
</form>
</div>
</section>

View File

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

View File

@@ -1,6 +1,7 @@
@props([
'settings',
'editorial' => false,
'ctaHref' => null,
])
<section aria-labelledby="final-cta-heading" @class([
@@ -17,8 +18,8 @@
</p>
<div>
<a
href="{{ route('briefing') }}"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
href="{{ $ctaHref ?? route('briefing') }}"
class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
>
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a>

View File

@@ -13,12 +13,12 @@
brand-mark
>
<div class="flex flex-wrap items-center gap-4">
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover">
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a>
@if (filled($settings->hero_secondary_cta_label))
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif

View File

@@ -1,13 +1,24 @@
@props([
'packages',
'settings',
'eyebrow' => '03 — Amare Casamentos',
'heading' => 'Um acompanhamento para cada momento.',
'intro' => 'As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.',
'kicker' => 'level',
'showSubtitle' => false,
'showIntro' => true,
'showNote' => true,
'ctaRoute' => null,
'bandCtaHref' => null,
'bandBody' => 'Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.',
'note' => 'Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.',
])
@php
$whatsappDigits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
$hasWhatsapp = strlen($whatsappDigits) >= 10;
$bandLink = $bandCtaHref
? ['href' => $bandCtaHref, 'isWhatsapp' => false]
: \App\Support\PackageContactLink::generic($settings);
@endphp
<section
aria-labelledby="packages-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
@@ -17,10 +28,12 @@
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between" data-reveal data-reveal-from="up">
<div class="max-w-2xl space-y-4">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">03 Amare Casamentos</p>
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Um acompanhamento para cada momento.</h2>
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
</div>
<p class="max-w-[600px] text-amare-text-muted">As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.</p>
@if ($showIntro && filled($intro))
<p class="max-w-[600px] text-amare-text-muted">{{ $intro }}</p>
@endif
</div>
@if ($packages->isNotEmpty())
@@ -28,8 +41,11 @@
@foreach ($packages as $package)
<article class="flex min-h-[480px] flex-col justify-between gap-8 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
<div class="space-y-5">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->level }}</p>
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $kicker === 'tag' ? $package->tag : $package->level }}</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $package->name }}</h3>
@if ($showSubtitle && filled($package->subtitle))
<p class="text-[clamp(1.0625rem,1.7vw,1.25rem)] font-medium leading-snug text-amare-text">{{ $package->subtitle }}</p>
@endif
<p class="text-amare-text-muted">{{ $package->summary }}</p>
<ul class="space-y-3 text-amare-text-muted">
@foreach ($package->scope_items as $item)
@@ -37,18 +53,22 @@
@endforeach
</ul>
</div>
@php
$waLink = $hasWhatsapp
? 'https://wa.me/'.$whatsappDigits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.')
: route('briefing', ['servico_interesse' => $package->name]);
@endphp
<a
href="{{ $waLink }}"
@if ($hasWhatsapp) target="_blank" rel="noopener noreferrer" @endif
class="inline-flex min-h-[48px] items-center justify-center border border-transparent bg-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
>
{{ $package->cta_label }}
</a>
<div class="space-y-5">
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
<a
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
@if ($ctaRoute === null && $contactLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
>
{{ $package->cta_label }}
</a>
<a
href="{{ route('packages.show', $package->slug) }}"
class="btn btn-ghost min-h-0 px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"
>
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
</a>
</div>
</article>
@endforeach
</div>
@@ -57,13 +77,19 @@
<div class="mt-6 flex flex-col justify-between gap-8 bg-amare-accent-deep p-8 text-amare-accent-text md:flex-row md:items-center md:p-12" data-reveal data-reveal-from="up">
<div class="max-w-xl space-y-3">
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight">Ainda não sabe qual modalidade é ideal?</h3>
<p class="text-amare-accent-text/80">Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.</p>
<p class="text-amare-accent-text/80">{{ $bandBody }}</p>
</div>
<a href="{{ route('briefing') }}" class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto">
<a
href="{{ $bandLink['href'] }}"
@if ($bandLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto"
>
Conversar com a Amare
</a>
</div>
<p class="mt-6 text-sm text-amare-muted">Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.</p>
@if ($showNote && filled($note))
<p class="mt-6 text-sm text-amare-muted">{{ $note }}</p>
@endif
</div>
</section>

View File

@@ -34,7 +34,7 @@
@endforeach
</ul>
<p>
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
</a>
</p>

View File

@@ -26,7 +26,7 @@
</ol>
<p>
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
</a>
</p>

View File

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

View File

@@ -8,9 +8,8 @@
@else
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
@foreach ($packages as $package)
@php($digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number))
@php($href = strlen((string) $digits) >= 10 ? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.') : route('briefing', ['servico_interesse' => $package->name]))
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><a href="{{ $href }}" @if (str_starts_with($href, 'https://wa.me/')) target="_blank" rel="noopener" @endif class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a></li>
@php($contactLink = \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><div class="space-y-2"><a href="{{ $contactLink['href'] }}" @if ($contactLink['isWhatsapp']) target="_blank" rel="noopener" @endif class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a><a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span></a></div></li>
@endforeach
</ol>
@endif

View 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>

View 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

View 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

View File

@@ -0,0 +1,25 @@
@props([
'settings',
'heading' => null,
'body' => null,
'packageName' => null,
'packageMessage' => null,
])
@php
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '', $packageMessage);
@endphp
<section aria-labelledby="final-cta-heading" class="border-b border-amare-border bg-amare-bg py-20 md:py-24" data-chapter="final-cta" data-reveal-group>
<div class="container-amare max-w-3xl space-y-6 py-4 text-center" data-reveal data-reveal-from="up">
@if ($heading)
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">{{ $heading }}</h2>
@endif
@if ($body)
<p class="mx-auto max-w-2xl text-amare-muted">{{ $body }}</p>
@endif
<div class="pt-4">
<a href="{{ $link['href'] }}" @if ($link['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
</div>
</div>
</section>

View 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>

View 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

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
<p data-reveal data-reveal-from="up">
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home
</a>
</p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
<p data-reveal data-reveal-from="up">
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home
</a>
</p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
<p data-reveal data-reveal-from="up">
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home
</a>
</p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
<p data-reveal data-reveal-from="up">
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home
</a>
</p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
<p data-reveal data-reveal-from="up">
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home
</a>
</p>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="amare">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -58,11 +58,11 @@
<header class="site-header sticky top-0 z-40 border-b border-amare-border bg-amare-bg/95 backdrop-blur-sm">
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr] md:h-[74px] md:py-0">
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Início</a>
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Serviços</a>
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Portfólio</a>
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Amare</a>
<a href="{{ route('briefing') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
<a href="{{ route('home') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Início</a>
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Serviços</a>
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Portfólio</a>
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Amare</a>
<a href="{{ route('briefing') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:hidden">Solicitar proposta</a>
</nav>
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
@@ -70,13 +70,14 @@
</a>
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
<a href="{{ route('briefing') }}" class="hidden items-center justify-center border border-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-deep transition-colors hover:bg-amare-accent hover:text-amare-accent-text md:inline-flex min-h-[48px]">
<a href="{{ route('briefing') }}" class="btn btn-outline hidden min-h-0 px-6 text-xs font-bold uppercase tracking-[0.09em] md:inline-flex">
Conte seu evento
</a>
</a>
<button
type="button"
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
class="menu-button btn btn-square btn-ghost border border-amare-border text-amare-text md:hidden"
aria-label="Abrir menu"
aria-controls="main-nav"
aria-expanded="false"

View File

@@ -42,7 +42,7 @@
</p>
@if (session('status') === 'briefing-sent')
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
<div role="status" class="alert mt-8 border border-amare-border bg-amare-bg-deep">
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
</div>
@@ -61,7 +61,7 @@
</div>
@if ($errors->any())
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
<div role="alert" class="alert alert-error border border-amare-error/40">
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
</div>
@@ -79,7 +79,7 @@
autocomplete="name"
maxlength="120"
placeholder="Seu nome"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-error @enderror"
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
>
@error('nome')
@@ -98,7 +98,7 @@
autocomplete="email"
maxlength="254"
placeholder="voce@email.com"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-error @enderror"
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
>
@error('email')
@@ -117,7 +117,7 @@
autocomplete="tel"
maxlength="40"
placeholder="(11) 90000-0000"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-error @enderror"
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
>
@error('telefone')
@@ -131,7 +131,7 @@
id="tipo_evento"
name="tipo_evento"
required
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
class="select w-full text-amare-text @error('tipo_evento') select-error @enderror"
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
>
<option value="" selected disabled>Selecione...</option>
@@ -154,7 +154,7 @@
value="{{ old('data_periodo') }}"
maxlength="80"
placeholder="Ex.: novembro de 2027"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-error @enderror"
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
>
@error('data_periodo')
@@ -172,7 +172,7 @@
required
maxlength="80"
placeholder="Ex.: São Paulo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-error @enderror"
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
>
@error('cidade')
@@ -192,7 +192,7 @@
inputmode="numeric"
autocomplete="off"
placeholder="Ex.: 120"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-error @enderror"
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
>
@error('convidados')
@@ -209,7 +209,7 @@
value="{{ old('servico_interesse', request('servico_interesse')) }}"
maxlength="120"
placeholder="Ex.: planejamento completo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-error @enderror"
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
>
@error('servico_interesse')
@@ -227,7 +227,7 @@
rows="6"
maxlength="3000"
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
class="textarea w-full text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-error @enderror"
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
>{{ old('mensagem') }}</textarea>
@error('mensagem')
@@ -244,7 +244,7 @@
value="1"
required
@checked(old('privacidade'))
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
class="checkbox checkbox-primary mt-1 shrink-0"
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
>
<span class="text-sm text-amare-muted">
@@ -262,7 +262,7 @@
<button
type="submit"
data-submit-button
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
class="btn btn-primary min-h-11 px-8 text-sm font-semibold uppercase tracking-[0.12em]"
>
Enviar briefing
</button>

View File

@@ -0,0 +1,36 @@
@extends('layouts.public')
@section('content')
<article>
<x-public.package-hero
:image-path="$package->hero_image_path"
:image-alt="$package->hero_image_alt"
:eyebrow="$package->eyebrow"
:title-line="$package->title_line"
:title-emphasis="$package->title_emphasis"
:lead="$package->hero_lead"
>
<a href="#final-cta-heading" class="btn btn-outline text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
</x-public.package-hero>
<x-public.package-benefits :benefits="$package->benefits ?? []" />
<x-public.package-included :items="$package->included_items ?? []" />
<x-public.package-audience
:image-path="$package->audience_image_path"
:image-alt="$package->audience_image_alt"
:heading="$package->audience_heading"
:intro="$package->audience_intro"
:points="$package->audience_points ?? []"
/>
</article>
<x-public.package-final-cta
:settings="$siteSettings"
:heading="$package->final_cta_heading"
:body="$package->final_cta_body"
:package-name="$package->name"
:package-message="$package->whatsapp_message"
/>
@endsection

View File

@@ -33,7 +33,7 @@
</div>
<div><label class="mb-2 block text-xs font-semibold uppercase tracking-[.14em]" for="mensagem">Mensagem *</label><textarea class="w-full border border-amare-border bg-transparent p-3 focus:border-amare-accent focus:outline-none" id="mensagem" name="mensagem" rows="6" required maxlength="3000">{{ old('mensagem') }}</textarea>@error('mensagem')<p class="mt-2 text-sm text-amare-error">{{ $message }}</p>@enderror</div>
<p class="max-w-2xl text-sm text-amare-muted">Ao enviar, você reconhece o uso destes dados exclusivamente para analisar uma possível parceria, conforme a <a class="text-amare-accent underline" href="{{ route('privacy') }}">política de privacidade</a>.</p>
<button type="submit" data-submit-button class="inline-flex min-h-11 bg-amare-accent px-6 py-3 text-sm font-semibold uppercase tracking-[.12em] text-amare-accent-text hover:bg-amare-accent-deep">Enviar apresentação</button>
<button type="submit" data-submit-button class="btn btn-primary text-sm font-semibold uppercase tracking-[.12em]">Enviar apresentação</button>
</form>
@endif
</div>

View File

@@ -1,55 +1,95 @@
@extends('layouts.public')
@section('content')
<x-public.photo-hero :image-path="$siteSettings->services_hero_image_path" :image-alt="$siteSettings->services_hero_image_alt" eyebrow="Serviços" title="Uma mesma excelência, diferentes ocasiões." summary="O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento." />
<div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-10 py-16 md:py-24">
@if ($services->isEmpty())
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
@else
@php($hasPrioritizedImage = false)
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
@foreach ($services as $index => $service)
<article @class([
'grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start',
'md:translate-x-8' => $loop->even,
]) data-reveal data-reveal-from="up">
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<div class="space-y-3">
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
<p class="text-amare-muted">{{ $service->summary }}</p>
</div>
<div class="space-y-4">
@if (filled($service->cover_image_path))
@php($prioritizeImage = ! $hasPrioritizedImage)
<x-media.image
:path="$service->cover_image_path"
:alt="$service->cover_image_alt ?: $service->title"
:loading="$prioritizeImage ? 'eager' : 'lazy'"
:fetchpriority="$prioritizeImage ? 'high' : null"
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
class="img-editorial aspect-[16/10] w-full object-cover"
/>
@php($hasPrioritizedImage = true)
@endif
@if (filled($service->description))
<div class="text-amare-muted">
{!! nl2br(e($service->description)) !!}
</div>
@endif
</div>
</article>
@endforeach
</div>
@endif
<x-public.photo-hero
:image-path="$siteSettings->services_hero_image_path"
:image-alt="$siteSettings->services_hero_image_alt"
eyebrow="Serviços"
title="O cuidado certo para cada tipo de evento."
summary="A Amare acompanha projetos sociais e corporativos com planejamento, organização e condução profissional. O formato muda conforme o momento, o evento e a necessidade de cada cliente."
>
<div class="flex flex-wrap items-center gap-4">
<a href="#vertentes" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
Conhecer os serviços
</a>
<a href="{{ route('contact') }}" class="btn btn-ghost min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Tenho um evento em mente</span>
</a>
</div>
</div>
</x-public.photo-hero>
<x-home.vertentes
eyebrow="Duas vertentes"
heading="Escolha por contexto, não por uma lista de pacotes."
:cards="[
[
'label' => 'Amare Casamentos',
'title' => 'Acompanhamento para diferentes momentos do planejamento.',
'body' => 'Três modalidades para casais que estão começando, já avançaram ou precisam de apoio na reta final.',
'href' => '#casamentos',
'cta' => 'Ver modalidades',
],
[
'label' => 'Amare Corporate',
'title' => 'Planejamento e execução para eventos empresariais.',
'body' => 'Uma frente própria, com linguagem profissional e estrutura adequada a projetos corporativos.',
'href' => '#corporate',
'cta' => 'Ver Corporate',
],
]"
/>
<x-home.packages
:packages="$weddingPackages"
:settings="$siteSettings"
eyebrow="Amare Casamentos"
heading="Três formas de ter a Amare ao lado."
:show-intro="false"
kicker="tag"
:show-subtitle="true"
cta-route="contact"
:band-cta-href="route('contact')"
band-body="Conte um pouco sobre o casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático."
:show-note="false"
/>
@if ($weddingPackages->isNotEmpty())
<section class="border-b border-amare-border bg-amare-bg-deep py-16 md:py-24" aria-labelledby="wedding-packages-heading"><div class="container-amare"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="wedding-packages-heading" class="mt-3 text-3xl font-medium">Modalidades de assessoria</h2><div class="mt-8 grid gap-6 md:grid-cols-3">@foreach ($weddingPackages as $package)<article class="border-t border-amare-border pt-4"><p class="text-sm text-amare-accent">{{ $package->level }}</p><h3 class="mt-2 text-2xl font-medium">{{ $package->name }}</h3><p class="mt-2 text-amare-muted">{{ $package->summary }}</p><ul class="mt-4 space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li>{{ $item }}</li>@endforeach</ul></article>@endforeach</div></div></section>
<section aria-label="Comparação rápida entre as modalidades" class="border-b border-amare-border bg-amare-bg">
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
@foreach ($weddingPackages as $package)
@if (filled($package->compare_heading))
<article class="flex min-h-[300px] flex-col justify-between gap-6 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
<div class="space-y-4">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->name }}</p>
<h3 class="text-[clamp(1.25rem,2vw,1.5rem)] font-medium leading-tight text-amare-text">{{ $package->compare_heading }}</h3>
<p class="text-amare-text-muted">{{ $package->compare_summary }}</p>
</div>
<a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 self-start px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
</a>
</article>
@endif
@endforeach
</div>
</div>
</section>
@endif
<x-home.final-cta :settings="$siteSettings" />
<x-home.corporate
:settings="$siteSettings"
eyebrow="Amare Corporate"
heading="Organização, produção e condução com linguagem empresarial."
:show-meta="false"
:steps="[
['title' => 'Planejamento', 'body' => 'Estruturação do evento, escopo, prioridades e cronograma.'],
['title' => 'Produção', 'body' => 'Coordenação dos elementos e fornecedores necessários ao projeto.'],
['title' => 'Execução', 'body' => 'Acompanhamento e condução do evento conforme o planejamento aprovado.'],
]"
cta-label="Falar sobre um evento Corporate"
:cta-href="route('contact')"
:show-aside="false"
/>
<x-home.final-cta :settings="$siteSettings" :cta-href="route('contact')" />
@endsection

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Http\Controllers\PublicSite\ContactController;
use App\Http\Controllers\PublicSite\HomeController;
use App\Http\Controllers\PublicSite\PackageController;
use App\Http\Controllers\PublicSite\PageController;
use App\Http\Controllers\PublicSite\PartnerInquiryController;
use App\Http\Controllers\PublicSite\PortfolioController;
@@ -17,6 +18,7 @@ Route::get('/', HomeController::class)->name('home');
Route::get('/servicos', [ServiceController::class, 'index'])->name('services.index');
Route::get('/portfolio', [PortfolioController::class, 'index'])->name('portfolio.index');
Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('portfolio.show');
Route::get('/pacotes/{slug}', [PackageController::class, 'show'])->name('packages.show');
Route::get('/sobre', [PageController::class, 'about'])->name('about');
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
Route::get('/contato', [PageController::class, 'contact'])->name('contact');

View File

@@ -93,8 +93,7 @@ it('reaches the primary CTA by keyboard and activates it', function (): void {
$page->keys('[data-testid="home-primary-cta"]', 'Enter');
$hash = $page->script('() => window.location.hash');
expect($hash)->toBe('#sobre');
$page->assertPathIs('/contato');
});
it('disables transitions when prefers-reduced-motion is reduce', function (): void {

View File

@@ -158,7 +158,7 @@ it('opens home motion, reveals once, and allows CTA during open', function (): v
$page->click('[data-testid="home-primary-cta"]');
expect($page->script('() => window.location.hash'))->toBe('#sobre');
$page->assertPathIs('/contato');
});
it('keeps content and navigation usable without javascript', function (): void {
@@ -171,7 +171,7 @@ it('keeps content and navigation usable without javascript', function (): void {
->assertVisible('[data-testid="home-primary-cta"]')
->click('[data-testid="home-primary-cta"]');
expect($page->script('() => window.location.hash'))->toBe('#sobre');
$page->assertPathIs('/contato');
});
it('keeps targets final when intersection observer is unavailable', function (): void {

View File

@@ -8,7 +8,7 @@ it('renders the public home page', function (): void {
$settings = SiteSetting::instance();
$this->visit('/')
->assertSee('Eventos com intenção, cuidado e presença.')
->assertSee($settings->hero_title)
->assertSee($settings->brand_name);
});

View File

@@ -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);
}
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Tests\Feature\Marketing;
use App\Domain\Marketing\PackageIconCatalog;
use App\Models\WeddingPackage;
use Database\Seeders\ContentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class WeddingPackageTest extends TestCase
@@ -37,4 +39,27 @@ class WeddingPackageTest extends TestCase
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'));
}
}

View File

@@ -107,8 +107,7 @@ class HomePageContentTest extends TestCase
], false)
->assertSee('FOTO DE CASAMENTO 01')
->assertSee('Nome e data reais — aguardando autorização')
->assertSee('Conteúdo em construção')
->assertSee('Fornecedores e parcerias — pós-MVP');
->assertSee('Conteúdo em construção');
}
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
@@ -170,6 +169,7 @@ class HomePageContentTest extends TestCase
WeddingPackage::factory()->published()->create([
'level' => '01 / COMPLETA',
'name' => 'Essenza',
'slug' => 'essenza',
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
'scope_items' => ['Planejamento e organização', 'Gestão de etapas e prioridades'],
'cta_label' => 'Quero conhecer a Essenza',
@@ -188,6 +188,8 @@ class HomePageContentTest extends TestCase
->assertSee('Gestão de etapas e prioridades')
->assertSee('Quero conhecer a Essenza')
->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('Conversar com a Amare')
->assertSee('Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.');
@@ -214,6 +216,44 @@ class HomePageContentTest extends TestCase
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
}
public function test_package_cta_uses_custom_whatsapp_message_when_provided(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
WeddingPackage::factory()->published()->create([
'name' => 'Grand Jour',
'cta_label' => 'Quero conhecer a Grand Jour',
'whatsapp_message' => 'Olá, quero saber mais sobre a modalidade Grand Jour para o meu casamento em julho.',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, quero saber mais sobre a modalidade Grand Jour para o meu casamento em julho.'),
false,
)
->assertDontSee('gostaria de conversar sobre a modalidade Grand Jour para meu casamento', false);
}
public function test_guidance_band_uses_whatsapp_when_a_number_is_configured(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
WeddingPackage::factory()->published()->create();
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, ainda estou decidindo a modalidade ideal para o meu casamento. Podemos conversar?'),
false,
)
->assertSee('Conversar com a Amare');
}
public function test_corporate_steps_and_placeholder_render_from_settings(): void
{
SiteSetting::instance()->update([

View File

@@ -42,8 +42,8 @@ class MediaPerformanceTest extends TestCase
$about = $this->get(route('about'))->assertOk()->getContent();
$detail = $this->get(route('portfolio.show', $firstCase->slug))->assertOk()->getContent();
$this->assertMatchesRegularExpression('/service-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
$this->assertMatchesRegularExpression('/service-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $services);
$this->assertStringNotContainsString('service-first.jpg', $services);
$this->assertStringNotContainsString('service-later.jpg', $services);
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
$this->assertMatchesRegularExpression('/case-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $portfolio);
$this->assertMatchesRegularExpression('/about\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $about);
@@ -89,7 +89,7 @@ class MediaPerformanceTest extends TestCase
$services = $this->get(route('services.index'))->assertOk()->getContent();
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
$this->assertMatchesRegularExpression('/visible-service\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
$this->assertStringNotContainsString('visible-service.jpg', $services);
$this->assertMatchesRegularExpression('/visible-case\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PackageDetailTest extends TestCase
{
use RefreshDatabase;
public function test_published_package_renders_all_detail_sections(): void
{
SiteSetting::instance();
$package = WeddingPackage::factory()->published()->create([
'slug' => 'essenza',
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Completa',
'hero_lead' => 'Do planejamento ao grande dia, com calma, precisão e cuidado.',
'benefits' => [
['icon_key' => 'calendar', 'label' => 'Organização integral'],
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => 'Planejamento completo', 'description' => 'Cada etapa mapeada com clareza.'],
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
],
'audience_heading' => 'Para quem é este pacote',
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
'audience_points' => ['Casamentos', 'Eventos corporativos'],
'final_cta_heading' => 'Pronta para começar?',
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
]);
$response = $this->get(route('packages.show', $package->slug));
$response
->assertOk()
->assertSee('Assessoria')
->assertSee('Completa')
->assertSee('Do planejamento ao grande dia, com calma, precisão e cuidado.')
->assertSee('Organização integral')
->assertSee('Fornecedores selecionados')
->assertSee('O que está incluso')
->assertSee('Planejamento completo')
->assertSee('Cada etapa mapeada com clareza.')
->assertSee('Gestão de fornecedores')
->assertSee('Para quem é este pacote')
->assertSee('Casamentos')
->assertSee('Eventos corporativos')
->assertSee('Pronta para começar?')
->assertSee('Conte seu evento para a Amare e receba uma proposta.')
->assertSee('Solicitar proposta');
}
public function test_draft_package_returns_404(): void
{
SiteSetting::instance();
$package = WeddingPackage::factory()->create([
'slug' => 'rascunho-interno',
'title_line' => 'Rascunho',
]);
$response = $this->get(route('packages.show', $package->slug));
$response->assertNotFound();
}
public function test_unknown_slug_returns_404(): void
{
SiteSetting::instance();
$this->get(route('packages.show', 'inexistente'))->assertNotFound();
}
public function test_package_cta_uses_whatsapp_when_a_number_is_configured(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
$package = WeddingPackage::factory()->published()->create([
'name' => 'Essenza',
]);
$response = $this->get(route('packages.show', $package->slug));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade Essenza para meu casamento.'),
false,
)
->assertSee('target="_blank"', false)
->assertDontSee(route('briefing', ['servico_interesse' => 'Essenza']), false);
}
public function test_package_final_cta_uses_custom_whatsapp_message_when_provided(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
$package = WeddingPackage::factory()->published()->create([
'name' => 'Essenza',
'whatsapp_message' => 'Olá, tenho interesse na assessoria Essenza. Podem me passar mais detalhes?',
]);
$response = $this->get(route('packages.show', $package->slug));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, tenho interesse na assessoria Essenza. Podem me passar mais detalhes?'),
false,
)
->assertDontSee('gostaria de conversar sobre a modalidade Essenza para meu casamento', false);
}
public function test_package_cta_falls_back_to_briefing_without_whatsapp_number(): void
{
SiteSetting::instance();
$package = WeddingPackage::factory()->published()->create([
'name' => 'Essenza',
]);
$response = $this->get(route('packages.show', $package->slug));
$response
->assertOk()
->assertSee(route('briefing', ['servico_interesse' => 'Essenza']), false)
->assertDontSee('wa.me/', false);
}
}

View File

@@ -6,8 +6,8 @@ namespace Tests\Feature\PublicSite;
use App\Models\PortfolioCase;
use App\Models\PortfolioImage;
use App\Models\Service;
use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@@ -18,17 +18,45 @@ class PublicPagesTest extends TestCase
{
use RefreshDatabase;
public function test_services_index_lists_published_services_in_sort_order(): void
public function test_services_index_renders_editorial_sections_with_published_modalities_only(): void
{
SiteSetting::instance();
Service::factory()->create(['title' => 'Rascunho', 'published_at' => null]);
Service::factory()->published()->create(['title' => 'Serviço B', 'sort_order' => 20]);
Service::factory()->published()->create(['title' => 'Serviço A', 'sort_order' => 10]);
WeddingPackage::factory()->create(['name' => 'Rascunho', 'published_at' => null]);
WeddingPackage::factory()->published()->create([
'name' => 'Essenza',
'level' => '01 / COMPLETA',
'tag' => 'Assessoria completa',
'subtitle' => 'Do planejamento ao grande dia.',
'summary' => 'Para casais que desejam contar com a Amare desde o início.',
'scope_items' => ['Estruturação do planejamento', 'Coordenação da execução'],
'cta_label' => 'Quero conhecer a Essenza',
'compare_heading' => 'Começar com a Amare',
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
'sort_order' => 10,
]);
$this->get(route('services.index'))
->assertOk()
->assertSeeInOrder(['Serviço A', 'Serviço B'])
->assertSee('O cuidado certo para cada tipo de evento.')
->assertSee('Conhecer os serviços')
->assertSee('Tenho um evento em mente')
->assertSee('Duas vertentes')
->assertSee('Escolha por contexto, não por uma lista de pacotes.')
->assertSee('Amare Casamentos')
->assertSee('Acompanhamento para diferentes momentos do planejamento.')
->assertSee('Amare Corporate')
->assertSee('Planejamento e execução para eventos empresariais.')
->assertSee('Três formas de ter a Amare ao lado.')
->assertSee('Assessoria completa')
->assertSee('Essenza')
->assertSee('Do planejamento ao grande dia.')
->assertSee('Quero conhecer a Essenza')
->assertSee('Começar com a Amare')
->assertSee('Organização, produção e condução com linguagem empresarial.')
->assertSee('Estruturação do evento, escopo, prioridades e cronograma.')
->assertSee('Falar sobre um evento Corporate')
->assertSee('Do casamento ao evento corporativo, tudo começa com uma boa conversa.')
->assertDontSee('Rascunho');
}

View File

@@ -6,6 +6,7 @@ namespace Tests\Feature\PublicSite;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -60,4 +61,23 @@ class SitemapTest extends TestCase
->assertOk()
->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);
}
}