Compare commits
2 Commits
merge54
...
feat/man-1
| Author | SHA1 | Date | |
|---|---|---|---|
| 68632c32df | |||
| 70cc65227a |
@@ -73,7 +73,7 @@ Estrutura base usa grade central de 12 colunas no desktop e 4 no mobile, com lar
|
||||
|
||||
Composições editoriais alternam colunas assimétricas, imagens em diferentes proporções e blocos de leitura curtos. Formulários, listas e navegação mantêm alinhamento estrito. Ritmo estrutural usa múltiplos de `8px`; ajustes tipográficos usam `4px`.
|
||||
|
||||
Desktop aceita spreads, conteúdo deslocado e relações 5/7 ou 4/5. Mobile lineariza ordem sem esconder conteúdo essencial, mantém CTA acessível e troca spreads por sequências verticais. Fotografia pode ocupar viewport amplo, desde que texto e ação permaneçam legíveis.
|
||||
Desktop aceita spreads, conteúdo deslocado e relações 5/7 ou 4/5. Nas aberturas narrativas com fotografia, o spread padrão reserva 5/12 para o texto e 7/12 para a mídia; a coluna de leitura não passa de 470px, e o título mantém quebra normal. Mobile lineariza a ordem para texto seguido da fotografia sem esconder conteúdo essencial e mantém CTA acessível. Fotografia pode ocupar viewport amplo, desde que texto e ação permaneçam legíveis.
|
||||
|
||||
**The Editorial Rhythm Rule.** Seções alternam imagem, texto, densidade e silêncio; repetição de grades idênticas por toda página é proibida.
|
||||
|
||||
@@ -109,4 +109,4 @@ Círculos ficam reservados para retratos ou indicadores que exigem forma circula
|
||||
- **Don't** misturar famílias tipográficas sem redefinição explícita da identidade.
|
||||
- **Don't** transformar oliva em pequenos acentos sobre uma página dominada por branco puro.
|
||||
- **Don't** inventar cases, números, credenciais, clientes ou prova corporativa.
|
||||
- **Don't** usar decoração de casamento genérica como corações, flores desenhadas ou dourado ornamental.
|
||||
- **Don't** usar decoração de casamento genérica como corações, flores desenhadas ou dourado ornamental.
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final readonly class HomeContent
|
||||
@@ -16,11 +17,13 @@ final readonly class HomeContent
|
||||
* @param Collection<int, Service> $featuredServices
|
||||
* @param Collection<int, PortfolioCase> $featuredCases
|
||||
* @param Collection<int, Testimonial> $testimonials
|
||||
* @param Collection<int, WeddingPackage> $weddingPackages
|
||||
*/
|
||||
public function __construct(
|
||||
public SiteSetting $settings,
|
||||
public Collection $featuredServices,
|
||||
public Collection $featuredCases,
|
||||
public Collection $testimonials,
|
||||
public Collection $weddingPackages,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
final class GetHomeContent
|
||||
{
|
||||
@@ -31,6 +32,10 @@ final class GetHomeContent
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(),
|
||||
weddingPackages: WeddingPackage::query()
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,11 @@ class ManageSiteSettings extends Page
|
||||
->label('Telefone')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('whatsapp_number')
|
||||
->label('WhatsApp oficial para as modalidades')
|
||||
->tel()
|
||||
->maxLength(40)
|
||||
->helperText('Use o número com DDD. Sem um número válido, as CTAs levam ao briefing.'),
|
||||
TextInput::make('city')
|
||||
->label('Cidade')
|
||||
->maxLength(255),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Pages;
|
||||
|
||||
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateWeddingPackage extends CreateRecord
|
||||
{
|
||||
protected static string $resource = WeddingPackageResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Pages;
|
||||
|
||||
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditWeddingPackage extends EditRecord
|
||||
{
|
||||
protected static string $resource = WeddingPackageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Pages;
|
||||
|
||||
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWeddingPackages extends ListRecords
|
||||
{
|
||||
protected static string $resource = WeddingPackageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages;
|
||||
|
||||
use App\Filament\Resources\WeddingPackages\Pages\CreateWeddingPackage;
|
||||
use App\Filament\Resources\WeddingPackages\Pages\EditWeddingPackage;
|
||||
use App\Filament\Resources\WeddingPackages\Pages\ListWeddingPackages;
|
||||
use App\Models\WeddingPackage;
|
||||
use App\Policies\WeddingPackagePolicy;
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use UnitEnum;
|
||||
|
||||
class WeddingPackageResource extends Resource
|
||||
{
|
||||
protected static ?string $model = WeddingPackage::class;
|
||||
|
||||
protected static ?string $policy = WeddingPackagePolicy::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedHeart;
|
||||
|
||||
protected static ?string $navigationLabel = 'Modalidades de casamento';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
TextInput::make('name')->label('Nome')->required()->maxLength(120),
|
||||
TextInput::make('level')->label('Nível')->required()->maxLength(120),
|
||||
TextInput::make('summary')->label('Resumo')->required()->maxLength(255),
|
||||
TagsInput::make('scope_items')->label('Itens de escopo')->required()->columnSpanFull(),
|
||||
TextInput::make('cta_label')->label('CTA')->required()->maxLength(120),
|
||||
TextInput::make('sort_order')->label('Ordem')->numeric()->required()->default(0),
|
||||
DateTimePicker::make('published_at')->label('Publicado em')->seconds(false),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table->columns([TextColumn::make('name')->label('Nome')->searchable(), TextColumn::make('level')->label('Nível'), TextColumn::make('sort_order')->label('Ordem')->sortable(), TextColumn::make('published_at')->label('Publicado em')->dateTime()->sortable()])->defaultSort('sort_order');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return ['index' => ListWeddingPackages::route('/'), 'create' => CreateWeddingPackage::route('/create'), 'edit' => EditWeddingPackage::route('/{record}/edit')];
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,6 @@ final class ContactController extends Controller
|
||||
|
||||
private function success(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||
return redirect()->route('briefing')->with('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
return view('pages.contact', [
|
||||
return view('pages.partners', [
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('contact'),
|
||||
@@ -55,4 +55,14 @@ final class PageController extends Controller
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function briefing(): View
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
return view('pages.contact', [
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(canonical: route('briefing'), settings: $settings, title: PageMeta::withBrandSuffix('Briefing', $settings), description: 'Solicite uma proposta à '.$settings->brand_name.'.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Http/Controllers/PublicSite/PartnerInquiryController.php
Normal file
66
app/Http/Controllers/PublicSite/PartnerInquiryController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PublicSite\PartnerInquiryRequest;
|
||||
use App\Mail\PartnerInquiry;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
final class PartnerInquiryController extends Controller
|
||||
{
|
||||
private const string DUPLICATE_SESSION_KEY = 'partner_inquiry_hash';
|
||||
|
||||
public function store(PartnerInquiryRequest $request): RedirectResponse
|
||||
{
|
||||
if (filled($request->input('website'))) {
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
if (session()->get(self::DUPLICATE_SESSION_KEY) === $this->hash($validated)) {
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
||||
|
||||
try {
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
if (filled($settings->email)) {
|
||||
Mail::to($settings->email)->send(new PartnerInquiry([
|
||||
'Nome' => $validated['nome'],
|
||||
'Nome profissional ou empresa' => $validated['empresa'] ?? null,
|
||||
'E-mail' => $validated['email'],
|
||||
'Atuação ou serviço' => $validated['atuacao'],
|
||||
'Área de atendimento' => $validated['area_atendimento'],
|
||||
'Mensagem' => $validated['mensagem'],
|
||||
'Portfólio ou redes' => $validated['portfolio_redes'] ?? null,
|
||||
'Telefone' => $validated['telefone'] ?? null,
|
||||
]));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Falha ao enviar consulta de parceiro', ['exception' => $exception::class]);
|
||||
}
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $validated */
|
||||
private function hash(array $validated): string
|
||||
{
|
||||
return hash('sha256', serialize($validated));
|
||||
}
|
||||
|
||||
private function success(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('contact')->with('status', 'partner-inquiry-sent');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\GetPublishedServices;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
final class ServiceController extends Controller
|
||||
@@ -19,6 +20,7 @@ final class ServiceController extends Controller
|
||||
|
||||
return view('pages.services.index', [
|
||||
'services' => $services,
|
||||
'weddingPackages' => WeddingPackage::query()->published()->orderBy('sort_order')->get(),
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('services.index'),
|
||||
|
||||
32
app/Http/Requests/PublicSite/PartnerInquiryRequest.php
Normal file
32
app/Http/Requests/PublicSite/PartnerInquiryRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\PublicSite;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class PartnerInquiryRequest extends FormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
|
||||
$this->merge(['email' => is_string($email) ? mb_strtolower(trim($email)) : $email]);
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nome' => ['required', 'string', 'max:120'],
|
||||
'empresa' => ['nullable', 'string', 'max:160'],
|
||||
'email' => ['required', 'email', 'max:254'],
|
||||
'atuacao' => ['required', 'string', 'max:120'],
|
||||
'area_atendimento' => ['required', 'string', 'max:160'],
|
||||
'mensagem' => ['required', 'string', 'max:3000'],
|
||||
'portfolio_redes' => ['nullable', 'string', 'max:500'],
|
||||
'telefone' => ['nullable', 'string', 'max:40'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Mail/PartnerInquiry.php
Normal file
31
app/Mail/PartnerInquiry.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class PartnerInquiry extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/** @param array<string, mixed> $fields */
|
||||
public function __construct(public readonly array $fields) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: 'Nova consulta de fornecedor ou parceria — Amare Assessoria');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(html: 'emails.partner-inquiry', text: 'emails.partner-inquiry-text');
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'principles',
|
||||
'email',
|
||||
'phone',
|
||||
'whatsapp_number',
|
||||
'city',
|
||||
'social_links',
|
||||
'default_meta_title',
|
||||
@@ -85,6 +86,7 @@ class SiteSetting extends Model
|
||||
'principles' => self::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
'whatsapp_number' => null,
|
||||
'city' => 'São Paulo - SP',
|
||||
'social_links' => [],
|
||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||
|
||||
39
app/Models/WeddingPackage.php
Normal file
39
app/Models/WeddingPackage.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasPublication;
|
||||
use App\Policies\WeddingPackagePolicy;
|
||||
use Database\Factories\WeddingPackageFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
* @property string $level
|
||||
* @property string $summary
|
||||
* @property list<string> $scope_items
|
||||
* @property string $cta_label
|
||||
* @property int $sort_order
|
||||
* @property Carbon|null $published_at
|
||||
*/
|
||||
#[Fillable(['name', 'level', 'summary', 'scope_items', 'cta_label', 'sort_order', 'published_at'])]
|
||||
#[UsePolicy(WeddingPackagePolicy::class)]
|
||||
class WeddingPackage extends Model
|
||||
{
|
||||
/** @use HasFactory<WeddingPackageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasPublication;
|
||||
|
||||
/** @return array<string, string|class-string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['scope_items' => 'array', 'sort_order' => 'integer', 'published_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
36
app/Policies/WeddingPackagePolicy.php
Normal file
36
app/Policies/WeddingPackagePolicy.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
class WeddingPackagePolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function view(User $user, WeddingPackage $package): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function update(User $user, WeddingPackage $package): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function delete(User $user, WeddingPackage $package): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
||||
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
|
||||
});
|
||||
|
||||
RateLimiter::for('partner-inquiry', function (Request $request): Limit {
|
||||
return Limit::perMinute(5)->by($request->ip().'|partner-inquiry');
|
||||
});
|
||||
}
|
||||
|
||||
private function freezeClockWhenConfigured(): void
|
||||
|
||||
33
database/factories/WeddingPackageFactory.php
Normal file
33
database/factories/WeddingPackageFactory.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<WeddingPackage> */
|
||||
class WeddingPackageFactory extends Factory
|
||||
{
|
||||
protected $model = WeddingPackage::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'level' => 'Assessoria',
|
||||
'summary' => fake()->sentence(),
|
||||
'scope_items' => [fake()->sentence()],
|
||||
'cta_label' => 'Conversar sobre esta modalidade',
|
||||
'sort_order' => 0,
|
||||
'published_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function published(): static
|
||||
{
|
||||
return $this->state(fn (): array => ['published_at' => now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('whatsapp_number')->nullable()->after('phone');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn('whatsapp_number');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('wedding_packages', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('level');
|
||||
$table->string('summary');
|
||||
$table->jsonb('scope_items');
|
||||
$table->string('cta_label');
|
||||
$table->integer('sort_order')->default(0)->index();
|
||||
$table->timestamp('published_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('wedding_packages');
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\PortfolioImage;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -47,6 +48,7 @@ class ContentSeeder extends Seeder
|
||||
|
||||
$this->seedSiteSettings();
|
||||
$this->seedServices();
|
||||
$this->seedWeddingPackages();
|
||||
$this->seedPortfolioCases();
|
||||
$this->call(TestimonialsSeeder::class);
|
||||
}
|
||||
@@ -203,6 +205,19 @@ class ContentSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
private function seedWeddingPackages(): void
|
||||
{
|
||||
$packages = [
|
||||
['name' => 'Essenza', 'level' => 'Planejamento essencial', 'summary' => 'Direção para decisões centrais e um caminho de organização claro.', 'scope_items' => ['Leitura do contexto e prioridades', 'Direção de escopo e cronograma', 'Orientação para os próximos passos'], 'sort_order' => 1],
|
||||
['name' => 'Conduzione', 'level' => 'Assessoria de planejamento', 'summary' => 'Acompanhamento próximo para construir escolhas coerentes até o grande dia.', 'scope_items' => ['Planejamento e cronograma integrado', 'Coordenação de fornecedores', 'Alinhamentos e decisões de produção'], 'sort_order' => 2],
|
||||
['name' => 'Grand Jour', 'level' => 'Assessoria completa', 'summary' => 'Presença da escuta inicial à execução atenta de toda a celebração.', 'scope_items' => ['Direção completa do projeto', 'Produção e coordenação de fornecedores', 'Execução presencial no evento'], 'sort_order' => 3],
|
||||
];
|
||||
|
||||
foreach ($packages as $package) {
|
||||
WeddingPackage::query()->updateOrCreate(['name' => $package['name']], [...$package, 'cta_label' => 'Conversar sobre esta modalidade', 'published_at' => Carbon::parse(self::SEED_TIMESTAMP)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function copyFixture(string $fixtureName, string $destination): string
|
||||
{
|
||||
$source = base_path('database/fixtures/images/'.$fixtureName);
|
||||
|
||||
2
openspec/changes/hero-editorial-spread/.openspec.yaml
Normal file
2
openspec/changes/hero-editorial-spread/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-11
|
||||
48
openspec/changes/hero-editorial-spread/design.md
Normal file
48
openspec/changes/hero-editorial-spread/design.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## Context
|
||||
|
||||
As imagens de abertura por rota, `ResponsiveImage`, texto alternativo obrigatório e fallback tonal já existem. O componente público atual, porém, põe o texto sobre a fotografia e a home mantém duas colunas iguais. A referência aprovada pede uma abertura editorial legível, reutilizável e linear sem JavaScript.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Reunir as cinco aberturas narrativas em um único componente Blade de spread 5/7.
|
||||
- Limitar a coluna de leitura a 470 px e manter títulos com quebra normal, sem clipping ou overflow.
|
||||
- Preservar a ordem DOM texto → mídia em mobile, mídia responsiva, foco, motion progressivo e fallback tonal.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Alterar campos CMS, uploads, dados, rotas, cópia, SEO/Open Graph, contato, privacidade, erros ou dependências.
|
||||
- Criar ou atualizar snapshots, introduzir imagens externas ou transformar a rota de protótipo em funcionalidade de produção.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Um componente compartilhado recebe todo o conteúdo de abertura
|
||||
|
||||
`x-public.photo-hero` passa a renderizar texto, slot opcional e mídia tanto para as páginas institucionais quanto para a home. A home delega o seu conteúdo ao componente para impedir que os contratos de proporção, fallback e carregamento se separem. Duplicar uma segunda implementação da home foi descartado por já ter produzido divergência 50/50.
|
||||
|
||||
### Grade CSS 5/7 acima de `lg`; fluxo normal abaixo dela
|
||||
|
||||
No desktop, uma grade de doze colunas entrega 5/12 ao campo de texto e 7/12 à mídia. No mobile e tablet, a ordem do DOM produz texto seguido pela imagem de proporção 4/5. Posicionamento absoluto e painel sobre a foto foram descartados porque reduzem a largura de leitura previsível e dificultam a contenção do texto.
|
||||
|
||||
### Mídia de hero continua prioritária e dimensionada pela área real
|
||||
|
||||
A única imagem de abertura mantém `loading=eager`, `fetchpriority=high`, `object-cover` e o componente responsivo existente. Seu `sizes` informa `100vw` abaixo de `lg` e aproximadamente `58vw` acima dele, em vez dos antigos `50vw`/`100vw` indiscriminados. O fallback não emite imagem quando o campo está vazio.
|
||||
|
||||
### Motion preserva o conteúdo legível desde o HTML
|
||||
|
||||
Os atributos existentes de `page-open` e beats de mídia/título/CTA permanecem. Nenhum texto fica escondido por opacidade; movimento reduzido e ausência de JavaScript exibem o estado final.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Uma imagem de origem estreita pode recortar conteúdo importante em 7/12] → `object-cover` preserva o contrato atual; curadoria/crop da mídia continua sendo responsabilidade editorial.
|
||||
- [Conteúdo de título excepcionalmente longo pode pressionar a coluna] → limite de leitura, quebra normal e testes de ausência de overflow evitam palavras cortadas.
|
||||
- [Breakpoint pode atualizar depois de resize em browser test] → os testes aguardam dois frames de animação antes de medir a mídia.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Não há migração de dados. O deploy troca apenas markup e classes; o rollback do commit restaura a abertura anterior. Rotas sem imagem continuam no fallback tonal já previsto.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Nenhuma. Fotografias autorizadas de produção continuam uma dependência de conteúdo, não desta mudança.
|
||||
25
openspec/changes/hero-editorial-spread/proposal.md
Normal file
25
openspec/changes/hero-editorial-spread/proposal.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
As aberturas fotográficas já contam com mídia própria, mas a composição atual alterna uma imagem de fundo ou uma divisão 50/50 que achata a leitura editorial. Um spread consistente de texto 5/12 e imagem 7/12 reforça a hierarquia de WEB-01, WEB-02, WEB-03 e WEB-07 sem inventar conteúdo nem ampliar o CMS.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Define um componente único de abertura fotográfica em spread: conteúdo à esquerda em 5/12, fotografia à direita em 7/12, coluna de leitura limitada e imagem com recorte editorial.
|
||||
- Aplica o mesmo contrato às aberturas narrativas da home, serviços, portfólio, detalhe de case e Sobre; telas menores linearizam texto antes da mídia.
|
||||
- Mantém os campos de mídia existentes, carregamento prioritário apenas do hero, alternativa tonal sem imagem e a separação entre imagens editoriais e Open Graph.
|
||||
- Mantém contato, privacidade e páginas de erro como superfícies tonais sóbrias.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- Nenhuma.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `public-site-pages`: as cinco aberturas narrativas passam a exigir o spread editorial 5/7 em desktop e uma sequência texto-para-mídia em telas menores.
|
||||
- `content-media`: imagens de abertura passam a declarar tamanhos compatíveis com 100% da largura em mobile e 7/12 do viewport em desktop.
|
||||
|
||||
## Impact
|
||||
|
||||
Afeta os componentes Blade de hero público e da home, a documentação de design, os testes de renderização e browser das rotas públicas e as delta specs. Não altera rotas de produção, controladores, CMS, uploads, SEO/OG, dependências ou os itens fora do MVP em SPEC.md §4.2.
|
||||
@@ -0,0 +1,14 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Editorial hero media declares its responsive spread width
|
||||
Configured narrative hero media SHALL load eagerly with high priority and declare responsive sizes matching the rendered spread: full viewport width below the desktop breakpoint and seven twelfths of the viewport at desktop. Each configured hero image MUST retain meaningful alternative text; a missing image MUST use the tonal fallback without emitting media markup (SPEC §6.4, §6.6).
|
||||
|
||||
#### Scenario: A configured hero reports its responsive width
|
||||
- **WHEN** a narrative route renders configured hero media
|
||||
- **THEN** the image MUST declare `100vw` for smaller viewports and approximately `58vw` for the desktop spread
|
||||
- **AND** it MUST use eager loading and high fetch priority
|
||||
|
||||
#### Scenario: An unconfigured hero emits no media
|
||||
- **WHEN** a narrative route has no hero image path
|
||||
- **THEN** the opening MUST not render an image element
|
||||
- **AND** it MUST retain its readable tonal content
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Narrative openings use a readable editorial spread
|
||||
The home, services, portfolio listing, portfolio detail, and About routes SHALL render configured hero media as a Heritage Editorial spread: a 5/12 text field and 7/12 media field on desktop, with a text-first linear sequence on smaller viewports. The text field MUST limit reading width to 470 px, preserve normal word wrapping, and avoid horizontal overflow or clipped text. Contact, privacy, and error surfaces MUST remain sober tonal layouts (WEB-01, WEB-02, WEB-03, WEB-07).
|
||||
|
||||
#### Scenario: A configured narrative opening renders the desktop spread
|
||||
- **WHEN** a visitor loads a narrative route with its configured hero image at a desktop viewport
|
||||
- **THEN** its text field MUST occupy five of twelve grid columns and its media field seven of twelve grid columns
|
||||
- **AND** the media MUST use an editorial `object-cover` crop
|
||||
|
||||
#### Scenario: A configured narrative opening stacks without losing reading content
|
||||
- **WHEN** a visitor loads a narrative route with its configured hero image below the desktop breakpoint
|
||||
- **THEN** the text content MUST precede the media in DOM and visual order
|
||||
- **AND** the route MUST not horizontally overflow or clip text
|
||||
|
||||
#### Scenario: A narrative opening has no configured image
|
||||
- **WHEN** a visitor loads a narrative route without configured hero media
|
||||
- **THEN** the route MUST render its intentional tonal fallback without an empty image request
|
||||
|
||||
#### Scenario: Functional routes remain sober
|
||||
- **WHEN** a visitor loads contact or privacy
|
||||
- **THEN** the route MUST use its sober tonal opening
|
||||
- **AND** it MUST not render the narrative photo spread
|
||||
14
openspec/changes/hero-editorial-spread/tasks.md
Normal file
14
openspec/changes/hero-editorial-spread/tasks.md
Normal file
@@ -0,0 +1,14 @@
|
||||
## 1. Editorial contract
|
||||
|
||||
- [x] 1.1 Record the approved 5/7 spread rule in the Heritage Editorial design guidance.
|
||||
- [x] 1.2 Add failing feature and browser coverage for the 5/7 desktop grid, 470 px reading column, responsive image sizes, text-first mobile order, and contained text.
|
||||
|
||||
## 2. Shared opening implementation
|
||||
|
||||
- [x] 2.1 Rebuild the public photo hero as the single responsive spread component, retaining fallback, accessible media, motion attributes, optional content, and no-image behavior.
|
||||
- [x] 2.2 Delegate the home opening to the shared component while retaining its CTAs, note, chapter marker, and motion hooks.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Run focused feature and browser/accessibility/motion checks without creating or updating screenshots.
|
||||
- [x] 3.2 Run Pint, PHPStan, frontend build, and strict OpenSpec validation.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-11
|
||||
@@ -0,0 +1,58 @@
|
||||
## Context
|
||||
|
||||
The public site currently sends every commercial CTA to `/contato`, where the detailed event briefing lives. Its home chapters reflect the original editorial sequence, while the CMS only has generic `Service` records. Production seed gating already distinguishes environmental content but the public portfolio needs an explicit empty-acervo state so no demonstrative case can be mistaken for proof.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Keep the public surface server-rendered Blade with progressive vanilla JS and a single Heritage Editorial visual language.
|
||||
- Make wedding modalities typed, publishable, ordered CMS content; derive their WhatsApp CTA server-side with an explicit briefing fallback.
|
||||
- Split prospect and partner submissions at route, request, controller, mail and success-state boundaries.
|
||||
- Keep public data minimised: partner submissions are mailed, never persisted.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- No WhatsApp API/integration, CRM Lead creation, partner directory, Corporate service inventory, Corporate case, invented evidence or page builder.
|
||||
- No production migration of fixture cases into real proof; the authorized client content remains an operational prerequisite.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Typed `WeddingPackage` content instead of a JSON setting
|
||||
|
||||
`wedding_packages` has a dedicated model, migration, factory, policy, Filament resource and public query. This matches the existing publication/order model for Services and keeps content validation, ordering and authorization visible. A JSON array in `site_settings` would be faster initially but would weaken publication control and future editing.
|
||||
|
||||
### WhatsApp is a deeplink, not an integration
|
||||
|
||||
The setting stores digits-normalized `whatsapp_number`; a small value object builds `https://wa.me/<digits>?text=<encoded contextual message>`. If the setting is absent or invalid, the same CTA points to `/briefing?servico_interesse=<package>` so conversion still works. This offers the authorized official channel without violating SPEC §4.2's prohibition on an official integration.
|
||||
|
||||
### Separate partner boundary, not a mode on briefing
|
||||
|
||||
`PartnerInquiryRequest`, `PartnerInquiryController` and `PartnerInquiry` mailable have their own payload, honeypot key, throttle and session duplicate hash. It prevents event fields, marketing origin and confirmations from crossing into the partnership flow. A single controller with a discriminated input was rejected because it increases the chance of accidental routing or data disclosure.
|
||||
|
||||
### Honest public availability states
|
||||
|
||||
The home consumes `GetHomeContent`, which returns only actual published cases in production. The portfolio chapter itself remains present and shows an acervo-em-preparação note on empty data. Non-production visual fixtures continue to seed only for development/staging. Corporate remains copy plus a briefing CTA, deliberately without data-driven claims, media or a service list.
|
||||
|
||||
### Navigation links use canonical home fragments
|
||||
|
||||
Header/footer link to `/#amare`, `/#casamentos`, `/#corporate`, `/#portfolio` and `/#depoimentos` from other routes, and native fragments on the home. This preserves deep links and no-JS behavior; CSS scroll margin compensates for the sticky header.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Official WhatsApp number is not configured at deploy] → every package CTA has a deterministic briefing fallback and settings label calls out the operational requirement.
|
||||
- [Client proof is still unavailable] → production renders no fixture cases, a visible acervo note and no Corporate cases.
|
||||
- [New contact route changes incoming links] → `/contato` remains available for partners and header/footer continue to expose it; event CTA links move deliberately to `/briefing`.
|
||||
- [Detailed forms duplicate markup] → shared field styles and narrow controllers are preferred over generic, condition-heavy form components.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy the additive table/column migrations and code together.
|
||||
2. Run the content seeder to install official wedding modalities; set the official WhatsApp number in Filament before promoting CTAs.
|
||||
3. In production, leave portfolio cases unpublished until real authorized material is loaded; verify the empty-acervo state.
|
||||
4. Rollback is code-safe because routes remain additive and the legacy setting data is untouched; if necessary, unset the new WhatsApp column and retain the package rows without public publication.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Corporate institutional copy and authorized proof (MAN-98/MAN-99) remain client dependencies; no placeholder claims will be added.
|
||||
- The official WhatsApp number must be supplied before the campaign links are promoted.
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
A home atual apresenta os conteúdos do CMS, mas não conduz com clareza as duas frentes da Amare nem separa o pedido de proposta da relação com fornecedores. A estrutura de lançamento precisa tornar Casamentos e Corporate compreensíveis sem inventar prova, preservar o acervo real como requisito de publicação e abrir um canal mínimo, privado e específico para parcerias.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Reorganizar a home em capítulos editoriais ancoráveis: apresentação, Amare, vertentes, Casamentos, Corporate, acervo, depoimentos e fechamento.
|
||||
- Adicionar modalidades de casamento publicáveis e ordenáveis no CMS, com conteúdo inicial oficial para Essenza, Conduzione e Grand Jour.
|
||||
- Adicionar número oficial de WhatsApp às configurações e tornar as CTAs das modalidades contextuais, com fallback para o briefing.
|
||||
- Mover o formulário de pedido de proposta para `/briefing`; transformar `/contato` em uma jornada independente para fornecedores e parcerias.
|
||||
- Enviar a consulta de parceiro por e-mail próprio, sem criar Lead ou persistir dados; manter CSRF, honeypot, rate limit, validação e aviso de privacidade proporcional à finalidade.
|
||||
- Manter Corporate como apresentação institucional e CTA até haver copy, fotos e cases autorizados; em produção, não publicar fixtures de portfólio e informar que o acervo está em preparação.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `wedding-packages`: modalidades de casamento gerenciadas no CMS e CTAs contextuais de WhatsApp/briefing.
|
||||
- `partner-inquiries`: formulário público mínimo, privado e não persistente para fornecedores e parcerias.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `public-site-pages`: nova estrutura editorial da home, âncoras e separação entre briefing e contato de parceiros.
|
||||
- `service-catalog`: aprofundamento de serviços passa a apresentar as modalidades de casamento publicadas.
|
||||
- `site-settings`: configurações tipadas passam a incluir o número oficial de WhatsApp.
|
||||
- `transactional-email`: e-mail operacional separado para consultas de parceiros.
|
||||
- `content-media`: fixtures de casos ficam limitadas a desenvolvimento e staging; produção só mostra acervo real publicado.
|
||||
|
||||
## Impact
|
||||
|
||||
Afeta rotas, controllers, requests, mailables, modelos/migrations/seeders, Filament, consultas de marketing, componentes Blade, navegação, estilos e testes feature/browser. A mudança atende WEB-01, WEB-02, WEB-03, WEB-05, WEB-06 e WEB-07, sem introduzir integração oficial com WhatsApp, CRM, persistência de leads, marketplace ou outros itens fora do MVP (SPEC.md §4.2).
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Production never promotes visual fixture cases as Amare proof
|
||||
The content seeder SHALL restrict visual fixture portfolio cases to non-production environments. Production public output SHALL only render cases that were explicitly loaded and published as authorized content.
|
||||
|
||||
#### Scenario: Production seed does not create fixture cases
|
||||
- **WHEN** the content seeder runs with the production environment
|
||||
- **THEN** no fixture portfolio case or fixture portfolio image is published
|
||||
@@ -0,0 +1,16 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Partners have a separate, non-persistent contact journey
|
||||
The system SHALL expose `/contato` as a supplier and partnership form. It MUST collect name, optional professional/company name, e-mail, service/activity, coverage area, message, optional portfolio/social link and optional telephone. It SHALL send a dedicated internal e-mail and MUST NOT persist a Lead or partner record.
|
||||
|
||||
#### Scenario: Valid partner inquiry is delivered separately
|
||||
- **WHEN** a visitor submits valid partner data on `/contato`
|
||||
- **THEN** the configured internal recipient receives the dedicated partner inquiry e-mail
|
||||
- **AND** no briefing confirmation or Lead is created
|
||||
|
||||
### Requirement: Partner inquiry is protected and transparent
|
||||
The partner form SHALL enforce CSRF, server-side limits, honeypot, rate limiting and duplicate-click protection. It MUST show a purpose-specific privacy notice linking to the privacy policy.
|
||||
|
||||
#### Scenario: Honeypot blocks automated partner form submissions
|
||||
- **WHEN** the partner honeypot is filled
|
||||
- **THEN** the system reports generic success without sending an e-mail
|
||||
@@ -0,0 +1,38 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Home presents the weddings and corporate editorial journey
|
||||
The home SHALL expose anchored chapters for presentation, Amare, service directions, weddings, Corporate, portfolio/acervo, testimonials and final contact paths. It MUST retain the photographic hero, tonal fields, fine rules, EB Garamond and progressive/reduced-motion behavior. Corporate SHALL present only institutional copy and a briefing CTA until authorized copy and proof exist.
|
||||
|
||||
#### Scenario: Canonical anchor journey is reachable
|
||||
- **WHEN** a visitor loads the home or follows a `/#chapter` link from another public page
|
||||
- **THEN** the relevant chapter exists with a stable id and is reachable without JavaScript
|
||||
|
||||
#### Scenario: Corporate does not imply unauthorized proof
|
||||
- **WHEN** a visitor loads the Corporate chapter before client proof is published
|
||||
- **THEN** no Corporate service list, image, metric or case is rendered
|
||||
|
||||
### Requirement: Production portfolio remains truthful when no cases are published
|
||||
The public portfolio chapter SHALL render a visible acervo-em-preparação note when no real published cases are available. Production seeders MUST NOT publish development fixture cases.
|
||||
|
||||
#### Scenario: Empty production acervo is explicit
|
||||
- **GIVEN** production has no published portfolio cases
|
||||
- **WHEN** a visitor loads the home
|
||||
- **THEN** the portfolio chapter displays the acervo preparation note and no case card
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Contact page presents contact data as briefing placeholder
|
||||
|
||||
The `contact` route SHALL render the partner and supplier inquiry page using `site_settings` contact data. The detailed event proposal form SHALL be served by the `briefing` route at `/briefing`; public proposal CTAs MUST target that route. Neither public form MUST create a Lead in the launch scope.
|
||||
|
||||
#### Scenario: Contact page shows partner journey
|
||||
|
||||
- **WHEN** a visitor loads `/contato`
|
||||
- **THEN** partner-specific copy, fields and privacy notice MUST be displayed
|
||||
- **AND** the detailed event briefing MUST NOT be rendered
|
||||
|
||||
#### Scenario: Briefing route remains reachable for prospects
|
||||
|
||||
- **WHEN** a visitor loads `/briefing`
|
||||
- **THEN** the detailed event proposal form MUST be displayed
|
||||
- **AND** no Lead record MUST be created
|
||||
@@ -0,0 +1,9 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Services explain published wedding modalities
|
||||
The public services experience SHALL present published wedding modalities as the available Casamentos scope, including level, summary and ordered scope items. It MUST not treat unpublished modalities as public services.
|
||||
|
||||
#### Scenario: Draft modality remains private
|
||||
- **GIVEN** a wedding modality with `published_at` null
|
||||
- **WHEN** a visitor loads `/servicos`
|
||||
- **THEN** that modality MUST NOT be rendered
|
||||
@@ -0,0 +1,8 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Official WhatsApp number is a typed site setting
|
||||
The typed site settings singleton SHALL include an optional `whatsapp_number`. The admin form MUST validate and label it as the official number used by public modality CTAs; it MUST NOT create an external WhatsApp integration.
|
||||
|
||||
#### Scenario: Admin stores official WhatsApp number
|
||||
- **WHEN** an admin saves a valid number in site settings
|
||||
- **THEN** public wedding modality CTAs can derive the contextual deeplink
|
||||
@@ -0,0 +1,9 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Partner inquiries use a dedicated transactional message
|
||||
The system SHALL send supplier and partnership submissions in a dedicated `PartnerInquiry` mailable to the configured internal site recipient. It MUST NOT send the visitor briefing confirmation for this form.
|
||||
|
||||
#### Scenario: Partner email does not cross into briefing mail
|
||||
- **WHEN** a valid partner inquiry is submitted
|
||||
- **THEN** the recipient receives the partner field set
|
||||
- **AND** the visitor does not receive `ContactBriefingConfirmation`
|
||||
@@ -0,0 +1,26 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wedding modalities are managed as publishable ordered content
|
||||
The system SHALL let an admin manage `WeddingPackage` records with name, level, summary, ordered scope items, CTA label, sort order and `published_at`. Only published records SHALL appear in public wedding sections, in ascending `sort_order`.
|
||||
|
||||
#### Scenario: Published modalities follow CMS order
|
||||
- **GIVEN** published and draft wedding modalities with different sort orders
|
||||
- **WHEN** a visitor loads the home or services page
|
||||
- **THEN** only published modalities appear in ascending `sort_order`
|
||||
|
||||
#### Scenario: Official initial modalities are available
|
||||
- **WHEN** the content seeder runs
|
||||
- **THEN** Essenza, Conduzione and Grand Jour are created as the official initial modalities
|
||||
|
||||
### Requirement: Wedding modality CTA has a contextual channel and conversion fallback
|
||||
The system SHALL build a WhatsApp deeplink containing the selected modality when `whatsapp_number` is valid. When it is missing or invalid, the CTA SHALL point to `/briefing` with the modality prefilled as `servico_interesse`.
|
||||
|
||||
#### Scenario: Valid WhatsApp setting creates contextual link
|
||||
- **GIVEN** a valid official WhatsApp number
|
||||
- **WHEN** a visitor activates a wedding modality CTA
|
||||
- **THEN** the link targets `wa.me` with a URL-encoded message naming that modality
|
||||
|
||||
#### Scenario: Missing WhatsApp setting preserves briefing conversion
|
||||
- **GIVEN** no valid official WhatsApp number
|
||||
- **WHEN** a visitor activates a wedding modality CTA
|
||||
- **THEN** the visitor reaches `/briefing` with `servico_interesse` prefilled
|
||||
@@ -0,0 +1,25 @@
|
||||
## 1. CMS and public content model
|
||||
|
||||
- [x] 1.1 Add the typed official WhatsApp setting with migration, model/form validation and regression coverage.
|
||||
- [x] 1.2 Add the publishable, ordered WeddingPackage model, migration, factory, policy and Filament resource with failing-first coverage.
|
||||
- [x] 1.3 Add official Essenza, Conduzione and Grand Jour seed content and production-safe visual fixture gating.
|
||||
- [x] 1.4 Extend the public marketing query/data object to load wedding modalities and truthful acervo state.
|
||||
|
||||
## 2. Proposal and partner boundaries
|
||||
|
||||
- [x] 2.1 Move the existing event proposal request/form/mail route to `/briefing`, including prefilled modality interest.
|
||||
- [x] 2.2 Add the isolated partner inquiry request, controller, rate limit, honeypot, duplicate guard and dedicated mailable.
|
||||
- [x] 2.3 Cover each public form's validation, delivery, privacy copy, anti-spam path and absence of cross-flow mail/persistence.
|
||||
|
||||
## 3. Editorial public journey
|
||||
|
||||
- [x] 3.1 Recompose home chapters and stable anchors for Amare, directions, weddings, Corporate, acervo and testimonials.
|
||||
- [x] 3.2 Render published wedding modalities with contextual WhatsApp/fallback CTAs and keep Corporate proof-free.
|
||||
- [x] 3.3 Update navigation, footer and deep-route `/#chapter` links; preserve the services, portfolio, about and privacy deep dives.
|
||||
- [x] 3.4 Update `/servicos`, `/briefing` and `/contato` with the approved factual copy and Heritage Editorial form/layout treatment.
|
||||
|
||||
## 4. Verification and handoff
|
||||
|
||||
- [x] 4.1 Run the focused feature suite and static/formatting gates; fix all scoped regressions.
|
||||
- [x] 4.2 Run browser coverage at desktop and mobile, keyboard/axe and reduced-motion checks without visual snapshots.
|
||||
- [x] 4.3 Validate the OpenSpec change and update every completed task only with recorded verification evidence.
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "daisyui",
|
||||
"name": "site-amare",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,7 +7,6 @@
|
||||
"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",
|
||||
@@ -791,16 +790,6 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.16",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.16.tgz",
|
||||
"integrity": "sha512-V9CJxvrWIXTDuP/0tpijjWaKaKJNYo2IrULJfTWs3/DjW9rPqswk1n8NCLQRLrTutmlG2Ech7nkJDpCnF+6Dzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"daisyui": "^5.7.16",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
|
||||
@@ -1,46 +1,6 @@
|
||||
@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';
|
||||
@@ -62,6 +22,10 @@
|
||||
--text-display--line-height: 0.94;
|
||||
--text-display--letter-spacing: -0.01em;
|
||||
|
||||
--text-hero-spread: clamp(3rem, 5.5vw, 5rem);
|
||||
--text-hero-spread--line-height: 0.94;
|
||||
--text-hero-spread--letter-spacing: -0.01em;
|
||||
|
||||
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
|
||||
--text-headline--line-height: 1.02;
|
||||
|
||||
@@ -263,11 +227,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
3
resources/views/components/home/corporate.blade.php
Normal file
3
resources/views/components/home/corporate.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<section id="corporate" aria-labelledby="corporate-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="corporate">
|
||||
<div class="container-amare grid gap-8 md:grid-cols-12" data-reveal-group><div class="md:col-span-5" data-reveal data-reveal-from="left"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent-text">Corporate</p><h2 id="corporate-heading" class="mt-3 text-headline font-medium">Encontros que pedem intenção, método e presença.</h2></div><div class="space-y-6 md:col-span-5 md:col-start-7" data-reveal data-reveal-from="up"><p class="text-amare-accent-text/80">A Amare conduz projetos corporativos com escuta do contexto, clareza de direção e atenção à experiência de cada pessoa presente.</p><a href="{{ route('briefing', ['tipo_evento' => 'Evento corporativo']) }}" class="inline-flex min-h-11 items-center text-sm font-semibold"><span class="border-b border-amare-accent-text pb-1">Iniciar uma conversa</span></a></div></div>
|
||||
</section>
|
||||
@@ -17,8 +17,8 @@
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="{{ route('contact') }}"
|
||||
class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
href="{{ route('briefing') }}"
|
||||
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@@ -2,93 +2,33 @@
|
||||
'settings',
|
||||
])
|
||||
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
@class([
|
||||
'home-chapter border-b border-amare-border bg-amare-bg',
|
||||
'min-h-[100dvh]' => blank($settings->hero_image_path),
|
||||
'lg:h-[calc(100dvh-5rem)]' => filled($settings->hero_image_path),
|
||||
])
|
||||
<x-public.photo-hero
|
||||
:image-path="$settings->hero_image_path"
|
||||
:image-alt="$settings->hero_image_alt"
|
||||
:eyebrow="$settings->hero_eyebrow"
|
||||
:title="$settings->hero_title ?: 'Celebrações com propósito'"
|
||||
:summary="$settings->hero_subtitle"
|
||||
heading-id="hero-heading"
|
||||
brand-mark
|
||||
full-height-fallback
|
||||
class="home-chapter"
|
||||
data-chapter="hero"
|
||||
data-motion="page-open"
|
||||
>
|
||||
@if (filled($settings->hero_image_path))
|
||||
<div class="grid lg:h-full lg:grid-cols-2">
|
||||
<div
|
||||
data-hero-content
|
||||
data-reveal-group
|
||||
class="flex flex-col justify-center space-y-8 px-6 py-20 lg:py-12 lg:pr-[clamp(3rem,6vw,7rem)] lg:pl-[max(var(--amare-container-padding),calc((100vw-var(--amare-container-max))/2+var(--amare-container-padding)))]"
|
||||
>
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('briefing') }}" data-testid="home-primary-cta" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||
@endif
|
||||
|
||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-sm font-semibold">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div data-split-hero data-motion-beat="media" class="max-lg:aspect-[4/5] overflow-hidden bg-amare-bg-deep lg:h-full" data-reveal-media>
|
||||
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 50vw" class="img-editorial h-full w-full object-cover" />
|
||||
</div>
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="container-amare grid min-h-[100dvh] gap-12 py-20 md:max-w-4xl md:items-center md:py-10" data-reveal-group data-tonal-hero>
|
||||
<div class="flex flex-col justify-center space-y-8 md:py-12">
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||
@endif
|
||||
|
||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-sm font-semibold">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-[470px] text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</x-public.photo-hero>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" data-chapter="manifesto">
|
||||
<section id="amare" aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" data-chapter="manifesto">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group>
|
||||
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left">
|
||||
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
'cases',
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio">
|
||||
<section id="portfolio" aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up">
|
||||
<div class="md:col-span-7 md:col-start-5 space-y-3">
|
||||
@@ -12,6 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio>
|
||||
@foreach ($cases as $case)
|
||||
@php
|
||||
@@ -52,6 +52,9 @@
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="max-w-2xl border-t border-amare-accent-text/30 pt-5 text-amare-accent-text/80">Nosso acervo de eventos reais e autorizados está em preparação. Em breve, esta página reunirá recortes que contam melhor o trabalho da Amare.</p>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-4 border-t border-amare-accent-text/30 pt-5 md:flex-row md:items-end md:justify-between">
|
||||
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
||||
@@ -63,4 +66,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@endforeach
|
||||
</ul>
|
||||
<p>
|
||||
<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">
|
||||
<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">
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
])
|
||||
|
||||
@if ($services->isNotEmpty())
|
||||
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services">
|
||||
<section id="vertentes" aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-3 md:col-span-4">
|
||||
@@ -26,7 +26,7 @@
|
||||
</ol>
|
||||
|
||||
<p>
|
||||
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<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">
|
||||
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@endphp
|
||||
|
||||
@if ($testimonials->isNotEmpty())
|
||||
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials">
|
||||
<section id="depoimentos" aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up">
|
||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||
|
||||
18
resources/views/components/home/wedding-packages.blade.php
Normal file
18
resources/views/components/home/wedding-packages.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
@props(['packages', 'settings'])
|
||||
|
||||
<section id="casamentos" aria-labelledby="weddings-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24" data-chapter="weddings">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-6 md:grid-cols-12" data-reveal data-reveal-from="up"><div class="md:col-span-5"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="weddings-heading" class="mt-3 text-headline font-medium">Uma condução à altura do que importa.</h2></div><p class="max-w-xl text-amare-muted md:col-span-5 md:col-start-7">Modalidades para diferentes momentos de planejamento, sempre construídas a partir do contexto de cada celebração.</p></div>
|
||||
@if ($packages->isEmpty())
|
||||
<p class="border-t border-amare-border pt-5 text-amare-muted">As modalidades de casamento estão em atualização. Conte-nos sobre o seu momento no briefing.</p>
|
||||
@else
|
||||
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
|
||||
@foreach ($packages as $package)
|
||||
@php($digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number))
|
||||
@php($href = strlen((string) $digits) >= 10 ? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.') : route('briefing', ['servico_interesse' => $package->name]))
|
||||
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><a href="{{ $href }}" @if (str_starts_with($href, 'https://wa.me/')) target="_blank" rel="noopener" @endif class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a></li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@@ -7,25 +7,16 @@
|
||||
'metadata' => null,
|
||||
'headingId' => 'hero-heading',
|
||||
'brandMark' => false,
|
||||
'fullHeightFallback' => false,
|
||||
])
|
||||
|
||||
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
|
||||
@if (filled($imagePath))
|
||||
<div class="relative flex min-h-[calc(100dvh-5.5rem)] items-end overflow-hidden" data-photo-hero>
|
||||
<x-media.image
|
||||
:path="$imagePath"
|
||||
:alt="$imageAlt ?: $title"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
sizes="100vw"
|
||||
class="img-editorial absolute inset-0 h-full w-full object-cover"
|
||||
data-motion-beat="media"
|
||||
/>
|
||||
|
||||
<div class="container-amare relative z-10 w-full py-8 md:py-12">
|
||||
<div class="max-w-3xl border border-amare-border bg-amare-bg p-6 text-amare-text md:p-10" data-motion-beat="heading">
|
||||
<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 ($brandMark || filled($eyebrow))
|
||||
<div class="mb-5 flex items-center gap-4" data-motion-beat="seal">
|
||||
<div class="flex items-center gap-4" data-motion-beat="seal">
|
||||
@if ($brandMark)
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@endif
|
||||
@@ -34,22 +25,36 @@
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">{{ $title }}</h1>
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text">{{ $title }}</h1>
|
||||
@if (filled($summary))
|
||||
<p class="mt-5 max-w-2xl text-lg text-amare-text-muted">{{ $summary }}</p>
|
||||
<p class="max-w-[470px] text-lg text-amare-text-muted">{{ $summary }}</p>
|
||||
@endif
|
||||
@if (filled($metadata))
|
||||
<p class="mt-4 text-sm break-words text-amare-text-muted">{{ $metadata }}</p>
|
||||
<p class="max-w-[470px] text-sm break-words text-amare-text-muted">{{ $metadata }}</p>
|
||||
@endif
|
||||
@if (trim((string) $slot) !== '')
|
||||
<div class="mt-7" data-motion-beat="cta">{{ $slot }}</div>
|
||||
<div data-motion-beat="cta">{{ $slot }}</div>
|
||||
@endif
|
||||
</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>
|
||||
<x-media.image
|
||||
:path="$imagePath"
|
||||
:alt="$imageAlt ?: $title"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
sizes="(max-width: 1023px) 100vw, 58vw"
|
||||
class="img-editorial h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="container-amare py-16 md:py-24" data-tonal-hero>
|
||||
<div class="max-w-3xl space-y-4" data-motion-beat="heading">
|
||||
<div @class([
|
||||
'container-amare py-16 md:py-24',
|
||||
'min-h-[100dvh]' => $fullHeightFallback,
|
||||
]) data-tonal-hero>
|
||||
<div class="max-w-[470px] space-y-4" data-motion-beat="heading">
|
||||
@if ($brandMark || filled($eyebrow))
|
||||
<div class="flex items-center gap-4" data-motion-beat="seal">
|
||||
@if ($brandMark)
|
||||
@@ -60,12 +65,12 @@
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="text-headline font-medium tracking-tight text-amare-text">{{ $title }}</h1>
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-[470px] whitespace-normal break-normal text-headline font-medium tracking-tight text-amare-text">{{ $title }}</h1>
|
||||
@if (filled($summary))
|
||||
<p class="text-lg text-amare-muted">{{ $summary }}</p>
|
||||
<p class="max-w-[470px] text-lg text-amare-muted">{{ $summary }}</p>
|
||||
@endif
|
||||
@if (filled($metadata))
|
||||
<p class="text-sm break-words text-amare-muted">{{ $metadata }}</p>
|
||||
<p class="max-w-[470px] text-sm break-words text-amare-muted">{{ $metadata }}</p>
|
||||
@endif
|
||||
@if (trim((string) $slot) !== '')
|
||||
<div data-motion-beat="cta">{{ $slot }}</div>
|
||||
|
||||
5
resources/views/emails/partner-inquiry-text.blade.php
Normal file
5
resources/views/emails/partner-inquiry-text.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
Nova consulta de fornecedor ou parceria
|
||||
|
||||
@foreach ($fields as $label => $value)
|
||||
{{ $label }}: {{ $value ?: '—' }}
|
||||
@endforeach
|
||||
10
resources/views/emails/partner-inquiry.blade.php
Normal file
10
resources/views/emails/partner-inquiry.blade.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR"><head><meta charset="utf-8"><title>Nova consulta de parceria</title></head>
|
||||
<body>
|
||||
<h1>Nova consulta de fornecedor ou parceria</h1>
|
||||
<table>
|
||||
@foreach ($fields as $label => $value)
|
||||
<tr><th scope="row">{{ $label }}</th><td>{{ $value ?: '—' }}</td></tr>
|
||||
@endforeach
|
||||
</table>
|
||||
</body></html>
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
<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">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
<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">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
<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">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
<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">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
<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">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="amare">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<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/80 bg-amare-bg/90 backdrop-blur-sm">
|
||||
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
||||
<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="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||
<a href="{{ route('home') }}#amare" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('home') }}#casamentos" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Casamentos</a>
|
||||
<a href="{{ route('home') }}#corporate" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Corporate</a>
|
||||
<a href="{{ route('home') }}#portfolio" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('briefing') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||
</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,13 @@
|
||||
</a>
|
||||
|
||||
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
||||
<a href="{{ route('contact') }}" class="btn btn-ghost hidden min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent hover:text-amare-accent-deep md:inline-flex">
|
||||
<a href="{{ route('briefing') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||
Solicitar proposta
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="menu-button btn btn-square btn-ghost border border-amare-border text-amare-text md:hidden"
|
||||
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||
aria-label="Abrir menu"
|
||||
aria-controls="main-nav"
|
||||
aria-expanded="false"
|
||||
@@ -109,9 +109,9 @@
|
||||
<div class="text-sm">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li><a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></li>
|
||||
<li><a href="{{ route('home') }}#casamentos" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Casamentos</a></li>
|
||||
<li><a href="{{ route('home') }}#corporate" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Corporate</a></li>
|
||||
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
|
||||
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
||||
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -42,14 +42,14 @@
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'briefing-sent')
|
||||
<div role="status" class="alert mt-8 border border-amare-border bg-amare-bg-deep">
|
||||
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
|
||||
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
|
||||
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
|
||||
</div>
|
||||
@else
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('contact.store') }}"
|
||||
action="{{ route('briefing.store') }}"
|
||||
class="mt-10 max-w-3xl space-y-10"
|
||||
data-contact-form
|
||||
>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div role="alert" class="alert alert-error border border-amare-error/40">
|
||||
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
|
||||
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
|
||||
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@
|
||||
autocomplete="name"
|
||||
maxlength="120"
|
||||
placeholder="Seu nome"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
|
||||
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
|
||||
>
|
||||
@error('nome')
|
||||
@@ -98,7 +98,7 @@
|
||||
autocomplete="email"
|
||||
maxlength="254"
|
||||
placeholder="voce@email.com"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
|
||||
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
|
||||
>
|
||||
@error('email')
|
||||
@@ -117,7 +117,7 @@
|
||||
autocomplete="tel"
|
||||
maxlength="40"
|
||||
placeholder="(11) 90000-0000"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
|
||||
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
|
||||
>
|
||||
@error('telefone')
|
||||
@@ -131,12 +131,12 @@
|
||||
id="tipo_evento"
|
||||
name="tipo_evento"
|
||||
required
|
||||
class="select w-full text-amare-text @error('tipo_evento') select-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
|
||||
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
||||
>
|
||||
<option value="" selected disabled>Selecione...</option>
|
||||
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
|
||||
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
|
||||
<option value="Casamento" @selected(old('tipo_evento', request('tipo_evento')) === 'Casamento')>Casamento</option>
|
||||
<option value="Evento corporativo" @selected(old('tipo_evento', request('tipo_evento')) === 'Evento corporativo')>Evento corporativo</option>
|
||||
<option value="Celebração intimista" @selected(old('tipo_evento') === 'Celebração intimista')>Celebração intimista</option>
|
||||
<option value="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
|
||||
</select>
|
||||
@@ -154,7 +154,7 @@
|
||||
value="{{ old('data_periodo') }}"
|
||||
maxlength="80"
|
||||
placeholder="Ex.: novembro de 2027"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
|
||||
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
|
||||
>
|
||||
@error('data_periodo')
|
||||
@@ -172,7 +172,7 @@
|
||||
required
|
||||
maxlength="80"
|
||||
placeholder="Ex.: São Paulo"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
|
||||
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
|
||||
>
|
||||
@error('cidade')
|
||||
@@ -192,7 +192,7 @@
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Ex.: 120"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
|
||||
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
|
||||
>
|
||||
@error('convidados')
|
||||
@@ -206,10 +206,10 @@
|
||||
type="text"
|
||||
id="servico_interesse"
|
||||
name="servico_interesse"
|
||||
value="{{ old('servico_interesse') }}"
|
||||
value="{{ old('servico_interesse', request('servico_interesse')) }}"
|
||||
maxlength="120"
|
||||
placeholder="Ex.: planejamento completo"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-error @enderror"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
|
||||
@error('servico_interesse') aria-invalid="true" aria-describedby="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="textarea w-full text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-error @enderror"
|
||||
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"
|
||||
@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="checkbox checkbox-primary mt-1 shrink-0"
|
||||
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
|
||||
@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="btn btn-primary min-h-11 px-8 text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
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"
|
||||
>
|
||||
Enviar briefing
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<x-home.hero :settings="$content->settings" />
|
||||
<x-home.manifesto :settings="$content->settings" />
|
||||
<x-home.services :services="$content->featuredServices" />
|
||||
<x-home.wedding-packages :packages="$content->weddingPackages" :settings="$content->settings" />
|
||||
<x-home.corporate />
|
||||
<x-home.portfolio :cases="$content->featuredCases" />
|
||||
<x-home.method :settings="$content->settings" />
|
||||
<x-home.testimonials :testimonials="$content->testimonials" />
|
||||
|
||||
41
resources/views/pages/partners.blade.php
Normal file
41
resources/views/pages/partners.blade.php
Normal file
@@ -0,0 +1,41 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<section class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="container-amare grid gap-12 py-16 md:grid-cols-[1.15fr_.85fr] md:py-24" data-reveal-group>
|
||||
<div class="space-y-6" data-motion-beat="heading" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Contato profissional</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Fornecedores e parcerias</h1>
|
||||
<p class="max-w-2xl text-lg text-amare-muted">Se o seu trabalho conversa com a forma de atuar da Amare, apresente-se. Usaremos suas informações somente para avaliar uma possível relação profissional.</p>
|
||||
</div>
|
||||
<div class="border-t border-amare-border pt-6 text-amare-muted md:border-l md:border-t-0 md:pl-10 md:pt-0" data-reveal data-reveal-from="up">
|
||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||
@if ($siteSettings->email)<a class="mt-3 inline-flex min-h-11 items-center text-amare-accent underline hover:text-amare-accent-deep" href="mailto:{{ $siteSettings->email }}">{{ $siteSettings->email }}</a>@endif
|
||||
<p class="mt-5 text-sm">Para solicitar uma proposta para um evento, use o <a class="text-amare-accent underline" href="{{ route('briefing') }}">briefing</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-b border-amare-border bg-amare-bg-deep" aria-labelledby="partner-form-heading">
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<h2 id="partner-form-heading" class="text-3xl font-medium text-amare-text" data-reveal data-reveal-from="up">Apresente seu trabalho</h2>
|
||||
@if (session('status') === 'partner-inquiry-sent')
|
||||
<div role="status" class="mt-8 border border-amare-border bg-amare-bg px-6 py-5"><p class="font-semibold">Mensagem enviada.</p><p class="mt-1 text-amare-muted">Recebemos sua apresentação e entraremos em contato se houver aderência.</p></div>
|
||||
@else
|
||||
<form method="POST" action="{{ route('contact.store') }}" class="mt-10 max-w-3xl space-y-8" data-contact-form data-reveal data-reveal-from="up">
|
||||
@csrf
|
||||
<div class="honeypot" aria-hidden="true"><label for="website">Não preencha este campo</label><input id="website" name="website" tabindex="-1" autocomplete="off"></div>
|
||||
@if ($errors->any())<div role="alert" class="border border-amare-error/40 px-6 py-5">Revise os campos destacados abaixo.</div>@endif
|
||||
<div class="grid gap-8 sm:grid-cols-2">
|
||||
@foreach (['nome' => 'Nome *', 'empresa' => 'Nome profissional ou empresa', 'email' => 'E-mail *', 'atuacao' => 'Atuação ou serviço *', 'area_atendimento' => 'Área de atendimento *', 'portfolio_redes' => 'Portfólio ou redes', 'telefone' => 'Telefone'] as $field => $label)
|
||||
<div><label class="mb-2 block text-xs font-semibold uppercase tracking-[.14em]" for="{{ $field }}">{{ $label }}</label><input class="w-full border-0 border-b border-amare-border bg-transparent py-3 focus:border-amare-accent focus:outline-none @error($field) border-amare-error @enderror" id="{{ $field }}" name="{{ $field }}" value="{{ old($field) }}" maxlength="{{ $field === 'portfolio_redes' ? 500 : ($field === 'email' ? 254 : 160) }}" @required(in_array($field, ['nome','email','atuacao','area_atendimento'], true)) @error($field) aria-invalid="true" @enderror>@error($field)<p class="mt-2 text-sm text-amare-error">{{ $message }}</p>@enderror</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div><label class="mb-2 block text-xs font-semibold uppercase tracking-[.14em]" for="mensagem">Mensagem *</label><textarea class="w-full border border-amare-border bg-transparent p-3 focus:border-amare-accent focus:outline-none" id="mensagem" name="mensagem" rows="6" required maxlength="3000">{{ old('mensagem') }}</textarea>@error('mensagem')<p class="mt-2 text-sm text-amare-error">{{ $message }}</p>@enderror</div>
|
||||
<p class="max-w-2xl text-sm text-amare-muted">Ao enviar, você reconhece o uso destes dados exclusivamente para analisar uma possível parceria, conforme a <a class="text-amare-accent underline" href="{{ route('privacy') }}">política de privacidade</a>.</p>
|
||||
<button type="submit" data-submit-button class="inline-flex min-h-11 bg-amare-accent px-6 py-3 text-sm font-semibold uppercase tracking-[.12em] text-amare-accent-text hover:bg-amare-accent-deep">Enviar apresentação</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
@@ -47,5 +47,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($weddingPackages->isNotEmpty())
|
||||
<section class="border-b border-amare-border bg-amare-bg-deep py-16 md:py-24" aria-labelledby="wedding-packages-heading"><div class="container-amare"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="wedding-packages-heading" class="mt-3 text-3xl font-medium">Modalidades de assessoria</h2><div class="mt-8 grid gap-6 md:grid-cols-3">@foreach ($weddingPackages as $package)<article class="border-t border-amare-border pt-4"><p class="text-sm text-amare-accent">{{ $package->level }}</p><h3 class="mt-2 text-2xl font-medium">{{ $package->name }}</h3><p class="mt-2 text-amare-muted">{{ $package->summary }}</p><ul class="mt-4 space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li>{{ $item }}</li>@endforeach</ul></article>@endforeach</div></div></section>
|
||||
@endif
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Http\Controllers\PublicSite\ContactController;
|
||||
use App\Http\Controllers\PublicSite\HomeController;
|
||||
use App\Http\Controllers\PublicSite\PageController;
|
||||
use App\Http\Controllers\PublicSite\PartnerInquiryController;
|
||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||
use App\Http\Controllers\PublicSite\RobotsController;
|
||||
use App\Http\Controllers\PublicSite\ServiceController;
|
||||
@@ -19,8 +20,12 @@ Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('por
|
||||
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
||||
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||
Route::post('/contato', [ContactController::class, 'store'])
|
||||
->middleware('throttle:contact-briefing')
|
||||
Route::post('/contato', [PartnerInquiryController::class, 'store'])
|
||||
->middleware('throttle:partner-inquiry')
|
||||
->name('contact.store');
|
||||
Route::get('/briefing', [PageController::class, 'briefing'])->name('briefing');
|
||||
Route::post('/briefing', [ContactController::class, 'store'])
|
||||
->middleware('throttle:contact-briefing')
|
||||
->name('briefing.store');
|
||||
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
||||
Route::get('/robots.txt', RobotsController::class)->name('robots');
|
||||
|
||||
@@ -25,6 +25,7 @@ it('has no critical or serious accessibility issues on covered public routes', f
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
'/__missing-accessibility-page',
|
||||
];
|
||||
@@ -84,7 +85,7 @@ it('reaches the primary CTA by keyboard and activates it', function (): void {
|
||||
expect($outlineStyle)->not->toBe('none');
|
||||
|
||||
$page->keys('[data-testid="home-primary-cta"]', 'Enter')
|
||||
->assertPathIs('/contato');
|
||||
->assertPathIs('/briefing');
|
||||
});
|
||||
|
||||
it('loads covered public routes without console errors', function (): void {
|
||||
@@ -99,6 +100,7 @@ it('loads covered public routes without console errors', function (): void {
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
'/__missing-console-page',
|
||||
] as $route) {
|
||||
@@ -119,6 +121,7 @@ it('disables transitions when prefers-reduced-motion is reduce', function (): vo
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
];
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ beforeEach(function (): void {
|
||||
SiteSetting::instance();
|
||||
});
|
||||
|
||||
it('does not overflow horizontally when contact info contains long unbroken strings', function (): void {
|
||||
it('does not overflow horizontally when briefing contact info contains long unbroken strings', function (): void {
|
||||
SiteSetting::instance()->update([
|
||||
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
|
||||
'phone' => '(11) 99999-9999',
|
||||
]);
|
||||
|
||||
$page = $this->visit('/contato');
|
||||
$page = $this->visit('/briefing');
|
||||
|
||||
$overflow = $page->script(
|
||||
'() => document.documentElement.scrollWidth - document.documentElement.clientWidth',
|
||||
@@ -27,7 +27,7 @@ it('does not overflow horizontally when contact info contains long unbroken stri
|
||||
});
|
||||
|
||||
it('shows sending state on submit and resets on bfcache restore', function (): void {
|
||||
$page = $this->visit('/contato');
|
||||
$page = $this->visit('/briefing');
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => {
|
||||
|
||||
@@ -12,12 +12,13 @@ beforeEach(function (): void {
|
||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||
});
|
||||
|
||||
it('uses equal desktop columns while filling the opening below the header with full-bleed media', function (): void {
|
||||
it('uses a five-seven desktop spread with a bounded reading column and full-bleed media', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(1440, 1000);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
$page->script('() => document.fonts.ready');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
@@ -25,6 +26,7 @@ it('uses equal desktop columns while filling the opening below the header with f
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const heading = hero?.querySelector('h1');
|
||||
const image = media?.querySelector('img');
|
||||
const headerRect = header?.getBoundingClientRect();
|
||||
const heroRect = hero?.getBoundingClientRect();
|
||||
@@ -34,12 +36,23 @@ it('uses equal desktop columns while filling the opening below the header with f
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 1,
|
||||
fillsRemainingViewport: Math.abs((heroRect?.bottom ?? -1) - window.innerHeight) <= 1,
|
||||
fillsRemainingViewport: (heroRect?.bottom ?? 0) >= window.innerHeight - 1,
|
||||
mediaTouchesHeroTop: Math.abs((mediaRect?.top ?? -1) - (heroRect?.top ?? -2)) <= 1,
|
||||
mediaTouchesHeroBottom: Math.abs((mediaRect?.bottom ?? -1) - (heroRect?.bottom ?? -2)) <= 1,
|
||||
mediaTouchesViewportRight: Math.abs((mediaRect?.right ?? -1) - window.innerWidth) <= 1,
|
||||
splitImageObjectFit: image ? getComputedStyle(image).objectFit : null,
|
||||
columnRatio: Number(((contentRect?.width ?? 0) / (mediaRect?.width ?? 1)).toFixed(3)),
|
||||
readingColumnWithinLimit: (heading?.getBoundingClientRect().width ?? Number.POSITIVE_INFINITY) <= 470,
|
||||
titleClipsHorizontally: (() => {
|
||||
if (!heading) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
|
||||
return Array.from(range.getClientRects()).some((line) => line.right > heading.getBoundingClientRect().right + 1);
|
||||
})(),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
@@ -53,7 +66,9 @@ it('uses equal desktop columns while filling the opening below the header with f
|
||||
'mediaTouchesHeroBottom' => true,
|
||||
'mediaTouchesViewportRight' => true,
|
||||
'splitImageObjectFit' => 'cover',
|
||||
'columnRatio' => 1,
|
||||
'columnRatio' => 0.714,
|
||||
'readingColumnWithinLimit' => true,
|
||||
'titleClipsHorizontally' => false,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ it('keeps content and navigation usable without javascript', function (): void {
|
||||
->assertVisible('#hero-heading')
|
||||
->assertVisible('[data-testid="home-primary-cta"]')
|
||||
->click('[data-testid="home-primary-cta"]')
|
||||
->assertPathIs('/contato');
|
||||
->assertPathIs('/briefing');
|
||||
});
|
||||
|
||||
it('keeps targets final when intersection observer is unavailable', function (): void {
|
||||
@@ -227,7 +227,7 @@ it('allows cta activation while its opening is in progress', function (): void {
|
||||
JS);
|
||||
|
||||
$page->click('[data-testid="home-primary-cta"]')
|
||||
->assertPathIs('/contato');
|
||||
->assertPathIs('/briefing');
|
||||
});
|
||||
|
||||
it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void {
|
||||
@@ -242,6 +242,7 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
'/__missing-public-motion',
|
||||
];
|
||||
|
||||
@@ -14,5 +14,5 @@ it('renders the public home page', function (): void {
|
||||
|
||||
it('renders the admin login page', function (): void {
|
||||
$this->visit('/admin/login')
|
||||
->assertSee('E-mail');
|
||||
->assertVisible('input[type="email"]');
|
||||
});
|
||||
|
||||
@@ -75,11 +75,11 @@ class PasswordResetRequestTest extends TestCase
|
||||
// `request()` resets the form (see the base page's `$this->form->fill()`).
|
||||
$page->set('data.email', $user->email)
|
||||
->call('request')
|
||||
->assertNotified(Password::RESET_LINK_SENT);
|
||||
->assertNotified($this->expectedSentNotification());
|
||||
|
||||
$page->set('data.email', $user->email)
|
||||
->call('request')
|
||||
->assertNotified(Password::RESET_LINK_SENT);
|
||||
->assertNotified($this->expectedSentNotification());
|
||||
}
|
||||
|
||||
private function expectedSentNotification(): FilamentNotification
|
||||
|
||||
40
tests/Feature/Marketing/WeddingPackageTest.php
Normal file
40
tests/Feature/Marketing/WeddingPackageTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Marketing;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
use Database\Seeders\ContentSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WeddingPackageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_published_scope_excludes_drafts_and_keeps_cms_order(): void
|
||||
{
|
||||
WeddingPackage::factory()->create(['name' => 'Rascunho', 'published_at' => null]);
|
||||
$second = WeddingPackage::factory()->published()->create(['name' => 'Segundo', 'sort_order' => 20]);
|
||||
$first = WeddingPackage::factory()->published()->create(['name' => 'Primeiro', 'sort_order' => 10]);
|
||||
|
||||
$packages = WeddingPackage::query()->published()->orderBy('sort_order')->get();
|
||||
|
||||
$this->assertCount(2, $packages);
|
||||
$this->assertTrue($packages->first()?->is($first));
|
||||
$this->assertTrue($packages->last()?->is($second));
|
||||
}
|
||||
|
||||
public function test_content_seeder_creates_official_wedding_modalities(): void
|
||||
{
|
||||
$this->app->detectEnvironment(fn (): string => 'testing');
|
||||
|
||||
$this->seed(ContentSeeder::class);
|
||||
|
||||
$this->assertSame(
|
||||
['Essenza', 'Conduzione', 'Grand Jour'],
|
||||
WeddingPackage::query()->published()->orderBy('sort_order')->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,11 @@ class ContactBriefingOriginTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
|
||||
$this->get('/briefing?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
|
||||
->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -76,10 +76,10 @@ class ContactBriefingOriginTest extends TestCase
|
||||
Mail::fake();
|
||||
|
||||
// No GET beforehand: posting cold, exactly like a direct/no-JS submission.
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -97,11 +97,11 @@ class ContactBriefingOriginTest extends TestCase
|
||||
// identifiable e-mail/subscriber id) — only the site identity may
|
||||
// be captured as marketing origin, never the query string itself.
|
||||
$this->withHeader('referer', 'https://mail.example.com/click?email=maria%40example.com&subscriber_id=42')
|
||||
->get('/contato')
|
||||
->get('/briefing')
|
||||
->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -114,15 +114,15 @@ class ContactBriefingOriginTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=google&utm_medium=cpc')
|
||||
$this->get('/briefing?utm_source=google&utm_medium=cpc')
|
||||
->assertOk();
|
||||
|
||||
// Navigate elsewhere with no UTM parameters at all before submitting.
|
||||
$this->get('/servicos')->assertOk();
|
||||
$this->get('/sobre')->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -135,16 +135,16 @@ class ContactBriefingOriginTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=google&utm_medium=cpc')
|
||||
$this->get('/briefing?utm_source=google&utm_medium=cpc')
|
||||
->assertOk();
|
||||
|
||||
// A second page load with its own, different UTM parameters must
|
||||
// not overwrite what was captured on first touch.
|
||||
$this->get('/contato?utm_source=facebook&utm_medium=social')
|
||||
$this->get('/briefing?utm_source=facebook&utm_medium=social')
|
||||
->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -157,11 +157,11 @@ class ContactBriefingOriginTest extends TestCase
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=instagram&utm_campaign=segredo-interno')
|
||||
$this->get('/briefing?utm_source=instagram&utm_campaign=segredo-interno')
|
||||
->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
||||
@@ -187,10 +187,10 @@ class ContactBriefingOriginTest extends TestCase
|
||||
// the array shape the controller expects. The acceptance
|
||||
// criterion is that this never blocks the submission.
|
||||
$response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo'])
|
||||
->post(route('contact.store'), $this->validPayload());
|
||||
->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -206,13 +206,13 @@ class ContactBriefingOriginTest extends TestCase
|
||||
|
||||
$overlongCampaign = str_repeat('a', 300);
|
||||
|
||||
$this->get('/contato?'.http_build_query([
|
||||
$this->get('/briefing?'.http_build_query([
|
||||
'utm_source' => '<script>alert(1)</script>',
|
||||
'utm_campaign' => $overlongCampaign,
|
||||
]))->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
$capturedOrigin = null;
|
||||
|
||||
@@ -45,16 +45,16 @@ class ContactBriefingTest extends TestCase
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
public function test_contact_page_renders_briefing_form_with_all_fields(): void
|
||||
public function test_briefing_page_renders_event_form_with_all_fields(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$response = $this->get(route('contact'));
|
||||
$response = $this->get(route('briefing'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('<form', false)
|
||||
->assertSee('action="'.route('contact.store').'"', false)
|
||||
->assertSee('action="'.route('briefing.store').'"', false)
|
||||
->assertSee('name="nome"', false)
|
||||
->assertSee('name="email"', false)
|
||||
->assertSee('name="telefone"', false)
|
||||
@@ -74,10 +74,10 @@ class ContactBriefingTest extends TestCase
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
||||
@@ -94,13 +94,13 @@ class ContactBriefingTest extends TestCase
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload([
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload([
|
||||
'email' => ' Maria.Silva@EXAMPLE.com ',
|
||||
'telefone' => '+55 (11) 98888-7777',
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
||||
@@ -119,12 +119,12 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload([
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload([
|
||||
'empresa' => 'http://spam.example',
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
@@ -135,11 +135,11 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->from(route('contact'))->post(route('contact.store'), $this->validPayload([
|
||||
$response = $this->from(route('briefing'))->post(route('briefing.store'), $this->validPayload([
|
||||
'privacidade' => '',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('contact'));
|
||||
$response->assertRedirect(route('briefing'));
|
||||
$response->assertSessionHasErrors('privacidade');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
@@ -150,9 +150,9 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->from(route('contact'))->post(route('contact.store'), []);
|
||||
$response = $this->from(route('briefing'))->post(route('briefing.store'), []);
|
||||
|
||||
$response->assertRedirect(route('contact'));
|
||||
$response->assertRedirect(route('briefing'));
|
||||
|
||||
foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) {
|
||||
$response->assertSessionHasErrors($field);
|
||||
@@ -168,8 +168,8 @@ class ContactBriefingTest extends TestCase
|
||||
|
||||
$payload = $this->validPayload();
|
||||
|
||||
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||
$this->post(route('briefing.store'), $payload)->assertRedirect(route('briefing'));
|
||||
$this->post(route('briefing.store'), $payload)->assertRedirect(route('briefing'));
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, 1);
|
||||
Mail::assertQueued(ContactBriefingConfirmation::class, 1);
|
||||
@@ -180,10 +180,10 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
config(['mail.default' => 'mailer-inexistente']);
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -91,7 +92,7 @@ class HomePageContentTest extends TestCase
|
||||
->assertDontSee('Caso Rascunho')
|
||||
->assertDontSee('Autor Rascunho')
|
||||
->assertSee('data-testid="home-primary-cta"', false)
|
||||
->assertSee('href="'.route('contact').'"', false);
|
||||
->assertSee('href="'.route('briefing').'"', false);
|
||||
}
|
||||
|
||||
public function test_empty_sections_are_omitted_when_no_published_content(): void
|
||||
@@ -103,7 +104,8 @@ class HomePageContentTest extends TestCase
|
||||
$response
|
||||
->assertOk()
|
||||
->assertDontSee('id="services-heading"', false)
|
||||
->assertDontSee('id="portfolio-heading"', false)
|
||||
->assertSee('id="portfolio-heading"', false)
|
||||
->assertSee('Nosso acervo de eventos reais e autorizados está em preparação.')
|
||||
->assertDontSee('id="testimonials-heading"', false)
|
||||
->assertSee('id="manifesto-heading"', false)
|
||||
->assertSee('id="method-heading"', false)
|
||||
@@ -111,6 +113,34 @@ class HomePageContentTest extends TestCase
|
||||
->assertSee('id="final-cta-heading"', false);
|
||||
}
|
||||
|
||||
public function test_home_exposes_anchored_wedding_and_corporate_chapters_with_cta_fallback(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$settings->update(['whatsapp_number' => null]);
|
||||
WeddingPackage::factory()->published()->create(['name' => 'Essenza', 'sort_order' => 1]);
|
||||
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertSee('id="amare"', false)
|
||||
->assertSee('id="casamentos"', false)
|
||||
->assertSee('id="corporate"', false)
|
||||
->assertSee('id="portfolio"', false)
|
||||
->assertSee('Essenza')
|
||||
->assertSee(route('briefing', ['servico_interesse' => 'Essenza']), false)
|
||||
->assertSee(route('briefing', ['tipo_evento' => 'Evento corporativo']), false)
|
||||
->assertDontSee('https://wa.me/', false);
|
||||
}
|
||||
|
||||
public function test_wedding_cta_uses_contextual_whatsapp_link_when_official_number_is_valid(): void
|
||||
{
|
||||
SiteSetting::instance()->update(['whatsapp_number' => '+55 (11) 98888-7777']);
|
||||
WeddingPackage::factory()->published()->create(['name' => 'Grand Jour']);
|
||||
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertSee('https://wa.me/5511988887777?text=Ol%C3%A1%2C%20gostaria%20de%20conversar%20sobre%20a%20modalidade%20Grand%20Jour%20para%20meu%20casamento.', false);
|
||||
}
|
||||
|
||||
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
|
||||
@@ -37,16 +37,20 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('lg:h-[calc(100dvh-5rem)]', false)
|
||||
->assertSee('data-photo-hero', false)
|
||||
->assertSee('data-hero-content', false)
|
||||
->assertSee('data-split-hero', false)
|
||||
->assertSee('data-motion-beat="media"', false)
|
||||
->assertSee('lg:grid-cols-2', false)
|
||||
->assertSee('lg:grid-cols-12', false)
|
||||
->assertSee('lg:col-span-5', false)
|
||||
->assertSee('lg:col-span-7', false)
|
||||
->assertSee('max-w-[470px]', false)
|
||||
->assertSee('break-normal', false)
|
||||
->assertSee('max-lg:aspect-[4/5]', false)
|
||||
->assertSee('content/heroes/home.jpg', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 50vw"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false)
|
||||
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
|
||||
}
|
||||
|
||||
@@ -101,9 +105,22 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
$this->get($route)
|
||||
->assertOk()
|
||||
->assertSee('data-photo-hero', false)
|
||||
->assertSee('lg:grid-cols-12', false)
|
||||
->assertSee('lg:col-span-5', false)
|
||||
->assertSee('lg:col-span-7', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('sizes="100vw"', false);
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_contact_and_privacy_remain_sober_tonal_surfaces(): void
|
||||
{
|
||||
foreach ([route('contact'), route('privacy')] as $route) {
|
||||
$this->get($route)
|
||||
->assertOk()
|
||||
->assertDontSee('data-photo-hero', false)
|
||||
->assertDontSee('data-split-hero', false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class MotionMarkupTest extends TestCase
|
||||
->assertDontSee('data-chapter-index', false)
|
||||
->assertDontSee('data-chapter-progress', false)
|
||||
->assertDontSee('id="services-heading"', false)
|
||||
->assertDontSee('id="portfolio-heading"', false)
|
||||
->assertSee('id="portfolio-heading"', false)
|
||||
->assertDontSee('id="testimonials-heading"', false);
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ class MotionMarkupTest extends TestCase
|
||||
$this->get(route('portfolio.show', $case->slug)),
|
||||
$this->get(route('about')),
|
||||
$this->get(route('contact')),
|
||||
$this->get(route('briefing')),
|
||||
$this->get(route('privacy')),
|
||||
];
|
||||
|
||||
|
||||
80
tests/Feature/PublicSite/PartnerInquiryTest.php
Normal file
80
tests/Feature/PublicSite/PartnerInquiryTest.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Mail\PartnerInquiry;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PartnerInquiryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'nome' => 'Ana Costa',
|
||||
'empresa' => 'Flores da Ana',
|
||||
'email' => 'ana@example.com',
|
||||
'atuacao' => 'Florista',
|
||||
'area_atendimento' => 'São Paulo capital',
|
||||
'mensagem' => 'Gostaria de apresentar nosso portfólio para futuras parcerias.',
|
||||
'portfolio_redes' => 'https://instagram.com/floresdaana',
|
||||
'telefone' => '(11) 98888-7777',
|
||||
'website' => '',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
public function test_contact_page_is_a_partner_journey_not_the_event_briefing(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$this->get(route('contact'))
|
||||
->assertOk()
|
||||
->assertSee('Fornecedores e parcerias')
|
||||
->assertSee('name="atuacao"', false)
|
||||
->assertSee('name="area_atendimento"', false)
|
||||
->assertSee('name="website"', false)
|
||||
->assertDontSee('name="tipo_evento"', false)
|
||||
->assertSee(route('privacy'), false);
|
||||
}
|
||||
|
||||
public function test_valid_partner_inquiry_sends_only_the_dedicated_internal_email(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
->assertSessionHas('status', 'partner-inquiry-sent');
|
||||
|
||||
Mail::assertQueued(PartnerInquiry::class, fn (PartnerInquiry $mail): bool => $mail->hasTo($settings->email));
|
||||
Mail::assertQueued(PartnerInquiry::class, 1);
|
||||
}
|
||||
|
||||
public function test_partner_honeypot_reports_success_without_sending_mail(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload(['website' => 'https://spam.invalid']))
|
||||
->assertRedirect(route('contact'))
|
||||
->assertSessionHas('status', 'partner-inquiry-sent');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -184,11 +184,18 @@ class PublicPagesTest extends TestCase
|
||||
->assertSee('São Paulo - SP')
|
||||
->assertDontSee('Fortaleza')
|
||||
->assertSee('https://instagram.com/amare', false)
|
||||
->assertSee('min-h-[calc(100dvh-14rem)]', false)
|
||||
->assertSee('Fornecedores e parcerias')
|
||||
->assertSee('<form', false)
|
||||
->assertSee('name="nome"', false)
|
||||
->assertSee('name="privacidade"', false)
|
||||
->assertSee('name="atuacao"', false)
|
||||
->assertDontSee('name="tipo_evento"', false)
|
||||
->assertSee(route('privacy'), false);
|
||||
|
||||
$this->get(route('briefing'))
|
||||
->assertOk()
|
||||
->assertSee('min-h-[calc(100dvh-14rem)]', false)
|
||||
->assertSee('name="tipo_evento"', false)
|
||||
->assertSee('name="privacidade"', false);
|
||||
}
|
||||
|
||||
public function test_site_setting_defaults_use_sao_paulo_and_official_email(): void
|
||||
|
||||
Reference in New Issue
Block a user