Compare commits
5 Commits
f7fdf3930c
...
feat/man-1
| Author | SHA1 | Date | |
|---|---|---|---|
| 68632c32df | |||
| 70cc65227a | |||
| 213d6843e7 | |||
| f8765add85 | |||
| 168a21e459 |
@@ -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.
|
||||
|
||||
|
||||
24
PRODUCT.md
24
PRODUCT.md
@@ -14,36 +14,42 @@ web
|
||||
|
||||
## Product Purpose
|
||||
|
||||
A Amare Assessoria usa uma única plataforma para apresentar sua marca, converter visitantes em oportunidades e conduzir a operação dos eventos. O produto deve reduzir planilhas e informações dispersas, preservar o fluxo de trabalho da equipe e deixar claro o que exige atenção.
|
||||
No recorte de lançamento, a Amare Assessoria usa o site institucional e seu CMS para apresentar a marca, publicar conteúdo e converter visitantes em pedidos de proposta por e-mail. O produto futuro continua planejado como uma única plataforma para conduzir leads e a operação dos eventos, mas essa evolução não faz parte do lançamento de 31/08/2026.
|
||||
|
||||
Sucesso significa:
|
||||
Sucesso no lançamento significa:
|
||||
|
||||
- visitantes qualificados concluírem o briefing;
|
||||
- a assessora atualizar os conteúdos essenciais sem depender de desenvolvimento;
|
||||
- o site equilibrar eventos sociais e corporativos com conteúdo real e autorizado;
|
||||
- acessibilidade, desempenho, jornadas críticas e deploy serem verificados antes da publicação.
|
||||
|
||||
Nas fases futuras, sucesso também significará:
|
||||
|
||||
- leads avançarem pelo pipeline sem depender de planilhas externas;
|
||||
- leads conquistados virarem eventos sem redigitação;
|
||||
- pendências reais do dia aparecerem com clareza;
|
||||
- tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento;
|
||||
- jornadas críticas, acessibilidade e qualidade visual serem verificadas antes de cada deploy.
|
||||
- tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento.
|
||||
|
||||
## Positioning
|
||||
|
||||
Site público premium e operação interna formam um fluxo único. A mesma plataforma que transmite profissionalismo e capta a demanda transforma o lead em evento e orienta a equipe pelas pendências e exceções que precisam de ação, sem exigir troca radical de processo.
|
||||
No lançamento, o site público premium e o CMS interno formam a superfície entregue: a mesma aplicação transmite profissionalismo, capta a demanda por e-mail e permite manter o conteúdo essencial. A operação interna completa permanece como direção futura para transformar leads em eventos e orientar a equipe pelas pendências e exceções que precisam de ação, sem exigir troca radical de processo.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- Atuação em São Paulo, capital.
|
||||
- Serviços: casamentos, eventos corporativos e celebrações.
|
||||
- Operação realizada por assessora principal e equipe pequena.
|
||||
- Jornada prevista: descoberta no site, briefing, qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
|
||||
- Jornada de lançamento: descoberta no site, briefing por e-mail e contato comercial fora da plataforma.
|
||||
- Jornada futura prevista: qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
|
||||
- Interface pública em português do Brasil; dados financeiros em reais.
|
||||
- Área interna acessível por administradora e assistentes com papéis simples.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- Produto single-tenant para a operação da Amare; não é SaaS para várias assessorias.
|
||||
- Duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament.
|
||||
- MVP inclui briefing, CRM de leads, conversão em evento, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções, notificações, auditoria, SEO básico e deploy automatizado.
|
||||
- Implementação atual cobre fundação, site público e CMS; módulos operacionais posteriores ainda fazem parte do escopo planejado.
|
||||
- O lançamento inclui site público responsivo, CMS em Filament, briefing enviado por e-mail sem persistência de Lead, usuários internos simples, SEO básico, acessibilidade automatizada e deploy em contêiner.
|
||||
- CRM de leads, conversão em evento, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções, notificações e auditoria permanecem especificados para as Fases 2 a 5, adiadas sem data pela ADR-016.
|
||||
- O produto futuro mantém duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament; no lançamento, a área interna cobre apenas CMS, configurações e usuários.
|
||||
- Não inclui portal ou login para cliente final, aplicativo nativo, pagamentos online, RSVP, gestão de convidados, assinatura eletrônica, integração oficial com WhatsApp, multi-tenancy ou PWA offline avançada.
|
||||
- Contas internas usam os papéis `admin` e `assistant`; permissões granulares ficam fora do MVP.
|
||||
- Fatos ainda em aberto: processo real de qualificação comercial, política de retenção LGPD para leads perdidos, canais oficiais, domínio e infraestrutura de produção.
|
||||
|
||||
2
SPEC.md
2
SPEC.md
@@ -2524,7 +2524,7 @@ O lançamento está concluído somente quando:
|
||||
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
||||
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado;
|
||||
- usuários e Policies estão corretos;
|
||||
- testes unit, feature, visuais e arquitetura estão verdes;
|
||||
- testes unit, feature, browser funcionais e arquitetura estão verdes;
|
||||
- CI bloqueia regressões;
|
||||
- imagem FrankenPHP é reproduzível;
|
||||
- staging e produção usam a mesma imagem promovida;
|
||||
|
||||
@@ -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);
|
||||
|
||||
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Hero full-bleed da home Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fazer a fotografia configurada do hero da home preencher toda a coluna direita abaixo do header, preservando a leitura editorial e o fallback sem foto.
|
||||
|
||||
**Architecture:** `x-home.hero` continuará a renderizar as duas variantes. A variante com imagem usará uma grade full-width com a coluna textual ancorada à régua global e a mídia como segundo trilho sem container; a variante tonal permanece contida. Os testes de feature e browser documentam os contratos de markup, tamanho e responsividade.
|
||||
|
||||
**Tech Stack:** Laravel Blade, Tailwind CSS 4 utilities, Pest feature/browser tests, Vite.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Layout e contratos do hero
|
||||
|
||||
### Task 1: Cobrir o spread full-bleed
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
|
||||
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
- [ ] **Step 1: Escrever as expectativas de feature para a variante com foto**
|
||||
|
||||
Exigir `data-split-hero`, `data-hero-content`, `data-motion="page-open"`, `data-reveal-group`, `aria-labelledby="hero-heading"`, `loading="eager"`, `fetchpriority="high"` e `sizes="(max-width: 767px) 100vw, 55vw"`. Com `hero_image_path` nulo, exigir `data-tonal-hero`, os mesmos atributos de motion/semântica, ausência de `data-split-hero` e ausência de `<img` dentro de `[data-tonal-hero]`.
|
||||
|
||||
- [ ] **Step 2: Estender a verificação browser de geometria**
|
||||
|
||||
No viewport 1440×1000, obter os retângulos de `.site-header`, `[data-chapter="hero"]`, `[data-hero-content]` e `[data-split-hero]`; esperar, com tolerância de 1px, que `hero.top === header.bottom`, `media.top === hero.top`, `media.bottom === hero.bottom`, `media.right === innerWidth` e `hero.height === innerHeight - header.height`. No viewport 390×844, comparar `data-split-hero` com `[data-hero-content]`; esperar que a mídia inicie depois do conteúdo, que `width / height` fique entre 0,79 e 0,81, e que não exista overflow horizontal. Antes das duas visitas de fallback, zerar `hero_image_path`; numa visita com reduced motion, esperar conteúdo visível. Em outra com `reducedMotion: no-preference` e `IntersectionObserver` desabilitado antes da navegação, esperar ausência de `data-motion` em `document.documentElement`, cada `[data-motion="page-open"]` com `.is-active`, cada `[data-reveal]` com `.is-revealed` e `aria-labelledby="hero-heading"`.
|
||||
|
||||
- [ ] **Step 3: Executar os testes focados e confirmar a falha inicial**
|
||||
|
||||
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
Expected: falha nas novas expectativas de geometria/markup até a alteração do componente.
|
||||
|
||||
### Task 2: Implementar a grade full-bleed
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/components/home/hero.blade.php`
|
||||
|
||||
- [ ] **Step 1: Separar a variante com imagem do container compartilhado**
|
||||
|
||||
Substituir `min-h-[100dvh]` do `<section>` por `md:h-[calc(100dvh-5rem)]` quando `hero_image_path` estiver preenchido e renderizar um wrapper `grid min-h-[calc(100dvh-5rem)] md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]`. A coluna de texto deve manter `data-reveal-group`, receber `data-hero-content`, ter `padding-left: max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, padding direito fluido e centralização vertical.
|
||||
|
||||
- [ ] **Step 2: Tornar a mídia contínua**
|
||||
|
||||
Aplicar ao bloco `data-split-hero` `overflow-hidden`, `bg-amare-bg-deep`, `md:min-h-full`; abaixo de `md`, aplicar `aspect-[4/5]`. Manter `data-motion-beat="media"`, `data-reveal-media`, `object-cover`, path/alt CMS e mudar `sizes` para `(max-width: 767px) 100vw, 55vw`.
|
||||
|
||||
- [ ] **Step 3: Preservar o fallback**
|
||||
|
||||
Manter o wrapper tonal existente para hero sem imagem, com `data-tonal-hero`, `data-motion="page-open"` e `data-reveal-group`; não renderizar o bloco de mídia nessa variante.
|
||||
|
||||
- [ ] **Step 4: Executar os testes focados e confirmar a passagem**
|
||||
|
||||
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Verificar e registrar
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/components/home/hero.blade.php`
|
||||
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
|
||||
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
- [ ] **Step 1: Formatar e executar as verificações proporcionais**
|
||||
|
||||
Run: `composer pint && composer phpstan && composer test:feature && composer test:browser && npm run build`
|
||||
|
||||
Expected: todos os comandos passam.
|
||||
|
||||
- [ ] **Step 2: Inspecionar o diff e registrar**
|
||||
|
||||
Run: `git diff --check && git status --short`
|
||||
|
||||
Expected: apenas os três arquivos de implementação/testes e a documentação deste plano/especificação aparecem como escopo.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
Run: `git add resources/views/components/home/hero.blade.php tests/Feature/PublicSite/ImmersivePhotoHeroTest.php tests/Browser/HomeEditorialCadenceTest.php docs/superpowers && git commit -m "feat(home): tornar hero fotográfico full-bleed"`
|
||||
|
||||
Expected: commit focado, sem artefatos de build ou screenshots.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Hero da home com fotografia full-bleed
|
||||
|
||||
## Objetivo
|
||||
|
||||
Dar à fotografia do hero da home a mesma presença contínua da referência aprovada: no desktop, ela preenche integralmente a coluna direita abaixo do header; o conteúdo editorial permanece concentrado na coluna esquerda.
|
||||
|
||||
## Escopo
|
||||
|
||||
- Alterar somente o componente `resources/views/components/home/hero.blade.php` e seus testes diretos.
|
||||
- Quando `hero_image_path` existir, usar uma grade externa `0.9fr / 1.1fr` a partir de `md` (768px), com altura exata `calc(100dvh - 5rem)`: o espaço restante depois do header desktop. A mídia deve chegar ao topo e à base da seção, sem padding vertical ou lateral de container.
|
||||
- Manter o texto, CTAs, imagem CMS, `alt`, carregamento eager, `fetchpriority`, responsividade de imagem e atributos de motion existentes.
|
||||
- Abaixo de `md`, manter a ordem texto seguido de imagem, sem overflow horizontal, e fixar a mídia em `aspect-ratio: 4 / 5` com `object-cover`.
|
||||
- Quando não houver imagem configurada, manter a abertura tonal, tipográfica e contida já existente, sem área vazia para mídia.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Não alterar conteúdo do CMS, imagens, Open Graph, outras rotas públicas, header, animações, tokens globais ou dependências.
|
||||
- Não criar ou publicar mídia nova.
|
||||
|
||||
## Estrutura
|
||||
|
||||
O componente continuará sendo a única unidade de layout do hero. A variante com foto terá um wrapper full-width em desktop. A coluna textual terá `data-hero-content`, margem esquerda `max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, a mesma régua esquerda de `container-amare`, e padding direito fluido para não comprimir a leitura. O bloco de mídia ocupará os 55% da grade, receberá `data-split-hero` e `data-reveal-media`, e deixará de ficar limitado pelo container. O atributo `sizes` será `(max-width: 767px) 100vw, 55vw`. A variante sem foto permanece no wrapper atual.
|
||||
|
||||
## Critérios de aceitação
|
||||
|
||||
- Em desktop, `data-split-hero` inicia sob o header e mede `calc(100dvh - 5rem)`, sem margens externas de container.
|
||||
- A foto usa `object-cover`, `loading="eager"`, `fetchpriority="high"`, path/alt próprios do hero e `sizes="(max-width: 767px) 100vw, 55vw"`.
|
||||
- Em mobile, o conteúdo essencial continua acessível antes da foto, sem overflow nem texto recortado.
|
||||
- O fallback sem foto continua com `data-tonal-hero`, sem `data-split-hero` e sem mídia renderizada.
|
||||
- `data-motion="page-open"`, `data-reveal-group`, beats existentes, motion reduzido, CTAs e semântica atual continuam funcionais.
|
||||
- Testes verificam os contratos full-bleed da variante com foto, a ausência de mídia no fallback, e a continuidade de motion/semântica.
|
||||
@@ -21,4 +21,4 @@
|
||||
|
||||
- [x] 4.1 Run focused feature/browser tests, build, Pint, isolated-cache PHPStan and strict OpenSpec validation
|
||||
- [x] 4.2 Run `composer quality` and visually review desktop/mobile with normal and reduced motion
|
||||
- [ ] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree
|
||||
- [x] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree — delivered in PR #22 with the requested screenshots and local verification; the PR merged as `0aee15e`, the worktree/remote branch no longer exists, and the current implementation is covered by all five green CI jobs on `main` (run 31501097667). Pixel snapshots were later removed by PR #44 and are not part of the current gate.
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
## 6. Phase 0 exit evidence
|
||||
|
||||
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
|
||||
- [x] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output) — run 31395107465 deployed `7e68c0e` successfully; smoke recorded `/up`, `/` and `/admin/login` returning HTTP 200.
|
||||
- [ ] 6.2 Verify rollback to previous SHA works once on staging
|
||||
- [x] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any — flipped L2338 (FrankenPHP/Compose) and the auth/npm-audit/coverage bullets to `[x]`; left the staging hello-world bullet and the phase exit-criterion line unchecked (Dokploy deploy still failing, out of scope here)
|
||||
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
||||
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (1–3) also complete
|
||||
- [x] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any — local runtime/auth/audit/coverage evidence was recorded by PR #39, and the staging exit criterion was subsequently marked complete from successful run 31395107465.
|
||||
- [x] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green — local quality evidence is recorded in task 2.4; PR #39 passed `static`, `unit`, `feature`, `browser` and `container`, then run 31395107465 deployed that exact merge SHA and passed all three smoke endpoints.
|
||||
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks are complete — blocked by tasks 3.4 and 6.2, tracked separately in MAN-125.
|
||||
|
||||
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.
|
||||
@@ -57,3 +57,31 @@ The system SHALL define a container healthcheck that verifies application availa
|
||||
- **WHEN** the application inside the container fails to respond healthy on `/up`
|
||||
- **THEN** the container healthcheck MUST report unhealthy status
|
||||
|
||||
### Requirement: Local Compose runs FrankenPHP application alongside PostgreSQL
|
||||
|
||||
The local Docker Compose stack SHALL include a FrankenPHP application service built from the project Dockerfile, dependent on a healthy PostgreSQL service, exposing the application on port 8000 (or documented equivalent). FrankenPHP MUST run in regular mode (ADR-006).
|
||||
|
||||
#### Scenario: Developer starts full local stack
|
||||
|
||||
- **WHEN** a developer runs `docker compose up -d` with the application service enabled
|
||||
- **THEN** PostgreSQL and the FrankenPHP app containers MUST become healthy
|
||||
- **AND** `GET /up` on the app port MUST return HTTP 200
|
||||
|
||||
#### Scenario: App waits for database readiness
|
||||
|
||||
- **WHEN** the application service starts
|
||||
- **THEN** it MUST depend on the PostgreSQL healthcheck succeeding before becoming ready
|
||||
|
||||
### Requirement: Runtime PHP version is canonically 8.4
|
||||
|
||||
Docker, CI, and project documentation MUST treat PHP 8.4 as the canonical runtime for this MVP. Statements that require PHP 8.5+ MUST NOT remain in project docs while the runtime image uses 8.4.
|
||||
|
||||
#### Scenario: Dockerfile pins PHP 8.4
|
||||
|
||||
- **WHEN** the production/runtime image is built
|
||||
- **THEN** the FrankenPHP base image MUST use PHP 8.4
|
||||
|
||||
#### Scenario: Documentation matches the runtime
|
||||
|
||||
- **WHEN** a contributor reads the README requirements
|
||||
- **THEN** the documented PHP version MUST be 8.4 (compatible with Composer `^8.3`)
|
||||
|
||||
@@ -126,11 +126,16 @@ The system SHALL provide optimized Amare brand logo assets derived from the offi
|
||||
- **THEN** the uploaded logo MUST be used instead of the static fallback
|
||||
|
||||
### Requirement: Editorial image treatment remains self-hosted and deterministic
|
||||
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Editorial layouts MUST use CSS-only tonal treatment and alternating aspect ratios for portfolio media while retaining document order and the image component's `srcset`, `sizes`, dimensions and loading behavior. The system MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
|
||||
|
||||
#### Scenario: Public pages do not depend on external stock hosts
|
||||
|
||||
- **WHEN** the visual or browser suite loads covered public routes
|
||||
- **THEN** content images MUST resolve from the application media disk or static fixtures
|
||||
- **AND** MUST NOT require network access to third-party stock hosts
|
||||
|
||||
#### Scenario: Editorial portfolio media retains responsive delivery
|
||||
|
||||
- **WHEN** a portfolio listing or gallery applies an editorial image proportion
|
||||
- **THEN** the rendered image MUST still expose the responsive media component markup
|
||||
- **AND** image order and lazy-loading behavior MUST remain intact
|
||||
|
||||
@@ -53,3 +53,11 @@ The public site SHALL express hierarchy through tonal paper fields, 1px botanica
|
||||
- **THEN** they MUST NOT depend on `--amare-shadow-*` card elevation for hierarchy
|
||||
- **AND** separation MUST come from borders, tonal backgrounds, or whitespace
|
||||
|
||||
### Requirement: Editorial motion tokens are centralized
|
||||
|
||||
The system SHALL define shared public motion tokens for an approximately 500 ms entrance, short interaction feedback, exponential ease-out, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Public motion CSS MUST consume these tokens instead of duplicating arbitrary values.
|
||||
|
||||
#### Scenario: Motion runtime uses shared values
|
||||
- **WHEN** page openings, reveals, or interaction feedback are styled
|
||||
- **THEN** duration, easing, stagger, and distance MUST be derived from centralized tokens
|
||||
|
||||
|
||||
@@ -26,3 +26,17 @@ The system SHALL return a failure status when the application cannot initialize
|
||||
- **WHEN** the application fails to boot due to misconfiguration or missing dependencies
|
||||
- **THEN** the health endpoint MUST NOT return HTTP 200
|
||||
|
||||
### Requirement: Staging health and smoke use the public /up endpoint
|
||||
|
||||
Staging healthchecks and post-deploy smoke MUST call `GET /up` without authentication and MUST treat a non-200 response as deployment failure (SPEC §15.5, §16.2). The health response MUST NOT expose secrets.
|
||||
|
||||
#### Scenario: Staging healthcheck probes /up
|
||||
|
||||
- **WHEN** Dokploy or the container runtime evaluates application health after deploy
|
||||
- **THEN** it MUST request `/up`
|
||||
- **AND** MUST require HTTP 200 before marking the service healthy
|
||||
|
||||
#### Scenario: Smoke failure on /up fails the deploy gate
|
||||
|
||||
- **WHEN** post-deploy smoke requests `/up` and receives a non-200 response
|
||||
- **THEN** the staging promotion MUST be considered failed
|
||||
|
||||
@@ -58,3 +58,35 @@ The system SHALL restrict user management (create, update, deactivate) to users
|
||||
- **WHEN** an authenticated assistant attempts to access user management in the panel
|
||||
- **THEN** the system MUST deny access via authorization policy
|
||||
|
||||
### Requirement: Verified email is required for panel access
|
||||
|
||||
Internal users MUST have a verified email address to access the Filament panel (SPEC §12.1, ADM-01). Active users with unverified email MUST be denied panel access. Development seeds MUST mark local demo users as verified.
|
||||
|
||||
#### Scenario: Unverified active user is denied panel access
|
||||
|
||||
- **WHEN** an active user with null `email_verified_at` authenticates
|
||||
- **THEN** the system MUST NOT grant access to the Filament panel
|
||||
|
||||
#### Scenario: Verified active user can access the panel
|
||||
|
||||
- **WHEN** an active user with a non-null `email_verified_at` submits valid credentials
|
||||
- **THEN** the system authenticates the user and allows Filament panel access subject to role rules
|
||||
|
||||
#### Scenario: Local seed users are verified
|
||||
|
||||
- **WHEN** `DatabaseSeeder` creates the local admin and assistant
|
||||
- **THEN** both users MUST have `email_verified_at` set
|
||||
|
||||
### Requirement: Password reset flow is covered by automated tests
|
||||
|
||||
The secure password reset flow for internal users MUST be covered by feature tests that assert a reset request for a registered email queues/sends a reset notification without revealing whether the email exists to the client (SPEC §12.1; existing password-reset requirement).
|
||||
|
||||
#### Scenario: Reset request does not reveal account existence
|
||||
|
||||
- **WHEN** a visitor submits a password reset for an unknown email
|
||||
- **THEN** the response MUST not disclose that the email is unregistered
|
||||
|
||||
#### Scenario: Registered email receives reset notification
|
||||
|
||||
- **WHEN** a visitor submits a password reset for a registered email
|
||||
- **THEN** the system MUST dispatch the password reset notification (faked in tests)
|
||||
|
||||
79
openspec/specs/public-site-motion/spec.md
Normal file
79
openspec/specs/public-site-motion/spec.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# public-site-motion Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define progressive, accessible and non-blocking motion choreography for the public site, including shared timings, one-shot reveals, interaction feedback and safe fallbacks.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Public pages use shared progressive motion choreography
|
||||
|
||||
The public site SHALL expose a declarative motion contract using `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal`, and `data-reveal-from="up|left|right"`. Home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages MUST use the contract without introducing route transitions.
|
||||
|
||||
#### Scenario: Visual public route exposes motion hooks
|
||||
- **WHEN** a visitor loads any visual public route or branded error page
|
||||
- **THEN** the rendered page MUST expose a page opening hook and appropriate reveal hooks
|
||||
|
||||
#### Scenario: Technical endpoints remain outside motion
|
||||
- **WHEN** a visitor requests `sitemap.xml` or `robots.txt`
|
||||
- **THEN** the response MUST NOT depend on the public motion runtime
|
||||
|
||||
### Requirement: Editorial entrance timing and direction are consistent
|
||||
|
||||
Entrances SHALL use shared tokens for an approximately 500 ms duration, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Text MUST remain fully visible; motion MUST use transforms and MAY use media clipping without opacity fades for text.
|
||||
|
||||
#### Scenario: Reveal group computes capped stagger
|
||||
- **WHEN** more than four reveal items are rendered in one group
|
||||
- **THEN** the applied delay MUST NOT exceed 270 ms
|
||||
|
||||
#### Scenario: Text enters without opacity fade
|
||||
- **WHEN** page opening or scroll reveal motion runs
|
||||
- **THEN** text MUST remain visible throughout the transition
|
||||
|
||||
### Requirement: Reveals execute once without blocking interaction
|
||||
|
||||
Each scroll reveal SHALL activate once, reach its final state, and be removed from observation. Visitors MUST be able to activate links and form controls while entrance motion is running.
|
||||
|
||||
#### Scenario: Revealed content leaves observer
|
||||
- **WHEN** a reveal target intersects the configured viewport threshold
|
||||
- **THEN** it MUST receive the final state and be unobserved
|
||||
- **AND** scrolling away and back MUST NOT replay the reveal
|
||||
|
||||
#### Scenario: CTA remains interactive during entrance
|
||||
- **WHEN** a visitor activates a CTA while its entrance is in progress
|
||||
- **THEN** navigation MUST proceed without waiting for animation completion
|
||||
|
||||
### Requirement: Motion enhancement has immediate safe fallbacks
|
||||
|
||||
Without JavaScript, without `IntersectionObserver`, or when `prefers-reduced-motion: reduce` is active, the site SHALL render every motion target immediately in its final usable state. Non-essential animation and transition MUST NOT run under reduced motion.
|
||||
|
||||
#### Scenario: JavaScript is unavailable
|
||||
- **WHEN** a public page renders without executing JavaScript
|
||||
- **THEN** all content MUST be visible and usable in its final state
|
||||
|
||||
#### Scenario: IntersectionObserver is unavailable
|
||||
- **WHEN** JavaScript runs but `IntersectionObserver` is not supported
|
||||
- **THEN** all reveal targets MUST remain in their final state
|
||||
|
||||
#### Scenario: Reduced motion is preferred
|
||||
- **WHEN** the browser reports `prefers-reduced-motion: reduce`
|
||||
- **THEN** page openings, reveals, media clips, and non-essential feedback transitions MUST NOT run
|
||||
|
||||
### Requirement: Public interaction feedback is short and accessible
|
||||
|
||||
CTAs, links, navigation, and form controls SHALL provide short feedback using shared tokens while preserving visible `:focus-visible` indication. Alerts and validation messages MUST NOT be animated by this contract.
|
||||
|
||||
#### Scenario: Keyboard focus remains visible
|
||||
- **WHEN** a visitor focuses an interactive element with the keyboard
|
||||
- **THEN** its focus indicator MUST remain visible and MUST NOT be displaced by motion feedback
|
||||
|
||||
#### Scenario: Validation message appears without motion
|
||||
- **WHEN** server validation renders an error message
|
||||
- **THEN** the message MUST appear in its final position without entrance animation
|
||||
|
||||
### Requirement: Home progress updates at most once per animation frame
|
||||
|
||||
Scroll-driven chapter index and progress updates on the home SHALL be coalesced through `requestAnimationFrame`, with at most one pending update per frame.
|
||||
|
||||
#### Scenario: Multiple scroll events occur in one frame
|
||||
- **WHEN** multiple scroll events fire before the next animation frame
|
||||
- **THEN** the home progress calculation MUST execute only once for that frame
|
||||
@@ -34,8 +34,7 @@ The system SHALL expose the public routes of SPEC §5.1: `home` (`/`), `services
|
||||
- **AND** the case MUST appear in the `/portfolio` listing
|
||||
|
||||
### Requirement: Home renders the editorial structure from CMS content
|
||||
|
||||
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition (asymmetric spreads on desktop, linear sequence on mobile) rather than rounded card grids.
|
||||
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition: asymmetric spreads and tonal fields on desktop, with portfolio images in alternating editorial proportions, and a linear complete sequence on mobile rather than rounded card grids.
|
||||
|
||||
#### Scenario: Published content is displayed in configured order
|
||||
|
||||
@@ -44,6 +43,7 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
|
||||
- **THEN** the published content MUST be displayed following the `sort_order` and featured flags
|
||||
- **AND** the hero MUST show the values stored in `site_settings`
|
||||
- **AND** the manifesto, method and positioning sections MUST be present
|
||||
- **AND** the portfolio section MUST use an olive tonal field with editorial image proportions
|
||||
|
||||
#### Scenario: CTA leads to the contact placeholder page
|
||||
|
||||
@@ -65,8 +65,7 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
|
||||
- **THEN** the browser console MUST contain no JavaScript errors
|
||||
|
||||
### Requirement: Listing and detail pages exist for catalog content
|
||||
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery.
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
|
||||
|
||||
#### Scenario: Services listing shows published services
|
||||
|
||||
@@ -79,6 +78,7 @@ The system SHALL render a services listing (WEB-02) and a portfolio listing plus
|
||||
- **GIVEN** a published case with multiple gallery images
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the images MUST be rendered ordered by `sort_order`
|
||||
- **AND** desktop presentation MUST alternate editorial image proportions without changing that order
|
||||
|
||||
#### Scenario: Listings paginate open-ended growth
|
||||
|
||||
@@ -86,8 +86,7 @@ The system SHALL render a services listing (WEB-02) and a portfolio listing plus
|
||||
- **THEN** `/portfolio` MUST paginate instead of rendering all records
|
||||
|
||||
### Requirement: Institutional and error pages have brand identity
|
||||
|
||||
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
|
||||
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. Contact, privacy and error surfaces MUST remain sober tonal layouts without decorative photography. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
|
||||
|
||||
#### Scenario: Unknown URL renders branded 404
|
||||
|
||||
@@ -146,3 +145,16 @@ When public pages render illustrative/demo photography that is not an authorized
|
||||
|
||||
- **WHEN** the home or portfolio renders placeholder photography
|
||||
- **THEN** a visible note or equivalent disclosure MUST indicate the imagery is illustrative pending authorized assets
|
||||
|
||||
### Requirement: Visual public and error pages expose the shared motion contract
|
||||
|
||||
The system SHALL apply the shared progressive opening and reveal contract to home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages. The contract MUST NOT change page copy, content ordering, route behavior, or structural layout.
|
||||
|
||||
#### Scenario: Institutional page uses shared opening
|
||||
- **WHEN** a visitor loads `/sobre`, `/contato`, or `/privacidade`
|
||||
- **THEN** the page MUST expose the shared page opening hook and section reveal hooks
|
||||
|
||||
#### Scenario: Error page remains branded and progressively enhanced
|
||||
- **WHEN** Laravel renders a branded 404, 419, 429, 500, or 503 response
|
||||
- **THEN** the page MUST expose the shared opening hook
|
||||
- **AND** all error guidance and navigation MUST remain immediately usable without enhancement
|
||||
|
||||
@@ -70,3 +70,40 @@ The system SHALL execute browser tests using Pest Browser/Playwright against an
|
||||
|
||||
- **WHEN** a browser test fails in CI
|
||||
- **THEN** the job MUST publish application logs and browser logs as diagnostics
|
||||
|
||||
### Requirement: Quality gate includes npm dependency audit
|
||||
|
||||
The system SHALL run an npm audit as part of the quality/static gate according to the project policy (SPEC §12.6, §13.9). Audit failure MUST block merge unless an explicit, documented exception exists.
|
||||
|
||||
#### Scenario: Vulnerable dependency fails static gate
|
||||
|
||||
- **WHEN** `npm audit` reports a policy-violating vulnerability in production dependencies
|
||||
- **THEN** the static/quality gate MUST fail and block merge
|
||||
|
||||
#### Scenario: Developer runs quality locally with audit
|
||||
|
||||
- **WHEN** a developer runs `composer quality` (or the documented npm audit step it requires)
|
||||
- **THEN** the npm audit MUST execute as part of the gate sequence
|
||||
|
||||
### Requirement: Domain and Application maintain minimum coverage
|
||||
|
||||
The CI unit gate SHALL measure code coverage for `App\Domain` and `App\Application` and MUST fail when either namespace falls below 80% (SPEC §13.7). Views, migrations, generated code, and framework code MUST NOT be required to meet this threshold.
|
||||
|
||||
#### Scenario: Coverage drop below threshold blocks merge
|
||||
|
||||
- **WHEN** Domain or Application coverage is below 80% on the unit CI job
|
||||
- **THEN** the job MUST fail
|
||||
|
||||
#### Scenario: Coverage ignores non-domain layers
|
||||
|
||||
- **WHEN** coverage is computed for the gate
|
||||
- **THEN** Blade views and migrations MUST NOT be counted toward the Domain/Application threshold
|
||||
|
||||
### Requirement: Staging deploy job runs only after blocking CI jobs
|
||||
|
||||
When deploying from `main`, the staging deploy workflow MUST require the five blocking CI jobs (`static`, `unit`, `feature`, `browser`, `container`) to succeed before building/pushing the image and triggering Dokploy (SPEC §14.1, §14.3).
|
||||
|
||||
#### Scenario: Failed browser job prevents staging deploy
|
||||
|
||||
- **WHEN** the `browser` CI job fails on `main`
|
||||
- **THEN** the staging deploy workflow MUST NOT promote a new image
|
||||
|
||||
83
openspec/specs/staging-deployment/spec.md
Normal file
83
openspec/specs/staging-deployment/spec.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# staging-deployment Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define immutable staging and production promotion through GHCR and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Staging deploys an immutable application image by commit SHA
|
||||
|
||||
The system SHALL deploy staging from a single FrankenPHP application image tagged with the Git commit SHA and published to GHCR (SPEC §14.3, §15.2). Web, queue worker, and scheduler processes MUST use that same image digest. Rebuilds per process on the staging host MUST NOT be the promotion path.
|
||||
|
||||
#### Scenario: Same image serves all application processes
|
||||
|
||||
- **WHEN** a staging deployment is promoted for commit SHA `abc123`
|
||||
- **THEN** web, queue, and scheduler MUST run from `ghcr.io/<owner>/<repo>:abc123` (or equivalent digest)
|
||||
- **AND** MUST NOT rebuild distinct images per process
|
||||
|
||||
#### Scenario: CI publishes the image before Dokploy deploy
|
||||
|
||||
- **WHEN** `main` passes the blocking CI jobs
|
||||
- **THEN** the deploy workflow MUST build and push the SHA-tagged image before triggering Dokploy
|
||||
|
||||
### Requirement: Staging runs on Dokploy Compose on the operator VPS
|
||||
|
||||
Staging SHALL be operated as a Dokploy Docker Compose deployment on the operator-managed VPS. PostgreSQL MUST be a managed private database service; application media MUST use the configured production object storage disk (`r2`) and mail MUST use the configured Resend transport when enabled.
|
||||
|
||||
#### Scenario: Compose defines required processes
|
||||
|
||||
- **WHEN** staging Compose is deployed
|
||||
- **THEN** it MUST include web, queue worker, scheduler, and a one-shot migrate job using the application image
|
||||
|
||||
#### Scenario: Database is not embedded in the application image
|
||||
|
||||
- **WHEN** staging boots
|
||||
- **THEN** PostgreSQL MUST be provided by the Dokploy/database service
|
||||
- **AND** MUST NOT live inside the application image layers
|
||||
|
||||
### Requirement: Staging deploy migrates then proves health with smoke checks
|
||||
|
||||
A staging promotion MUST run database migrations against the staging database, wait for `/up` to report healthy, and execute smoke HTTP checks for `/up`, the public home, and `/admin/login` (SPEC §14.3, §15.5, §16.2).
|
||||
|
||||
#### Scenario: Unhealthy deploy is not considered successful
|
||||
|
||||
- **WHEN** `/up` fails after a staging deploy attempt
|
||||
- **THEN** the deployment MUST be treated as failed
|
||||
- **AND** MUST NOT be recorded as a successful Phase 0 exit criterion
|
||||
|
||||
#### Scenario: Smoke checks cover public and login surfaces
|
||||
|
||||
- **WHEN** staging deploy health succeeds
|
||||
- **THEN** smoke checks MUST request `/`, `/up`, and `/admin/login`
|
||||
- **AND** each MUST return HTTP 200
|
||||
|
||||
### Requirement: Staging rollback uses the previous image SHA
|
||||
|
||||
Rollback SHALL redeploy a previously published SHA-tagged image without rebuilding from source (SPEC §14.3).
|
||||
|
||||
#### Scenario: Operator rolls back to previous SHA
|
||||
|
||||
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
|
||||
- **THEN** web, queue, and scheduler MUST run that previous image
|
||||
- **AND** no source rebuild MUST be required
|
||||
|
||||
### Requirement: Production promotion reuses the same immutable digest
|
||||
|
||||
Production SHALL be promoted from an already-published SHA-tagged image without rebuilding from source. Promotion MUST require explicit human action (SPEC §14.3).
|
||||
|
||||
#### Scenario: Operator promotes a staging-approved SHA to production
|
||||
|
||||
- **WHEN** the operator confirms promotion of commit SHA `abc123`
|
||||
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123`
|
||||
- **AND** MUST NOT rebuild from source for that promotion
|
||||
|
||||
### Requirement: Database backups exist before production cutover
|
||||
|
||||
Staging and production PostgreSQL services SHALL have automated daily backups with retention of at least 14 days, and a documented restore procedure MUST be verified on staging before the first production promotion (SPEC §16.3).
|
||||
|
||||
#### Scenario: Staging restore is proven before production promotion
|
||||
|
||||
- **WHEN** the operator prepares the first production promotion
|
||||
- **THEN** a restore from a staging backup MUST have been documented and successfully tested
|
||||
- **AND** production MUST have daily backup configured with retention of at least 14 days
|
||||
@@ -6,7 +6,7 @@ Define testimonial management, publication, authorization, and featured filterin
|
||||
## Requirements
|
||||
### Requirement: Testimonials are managed with publication control
|
||||
|
||||
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
|
||||
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote, MUST omit blank/whitespace-only quotes before creating the rendered sequence, and MUST assign alternating `left`/`right` reveal directions according to the remaining rendered column order at desktop and mobile viewports. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
|
||||
|
||||
#### Scenario: Unpublished testimonial is excluded
|
||||
|
||||
@@ -34,6 +34,18 @@ The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote te
|
||||
- **WHEN** the home testimonials section is rendered
|
||||
- **THEN** each paragraph MUST appear as distinct block text rather than a single collapsed line
|
||||
|
||||
#### Scenario: Blank quotes do not affect alternation
|
||||
|
||||
- **GIVEN** the published testimonial collection contains a blank quote between two non-blank quotes
|
||||
- **WHEN** the home testimonials section is rendered
|
||||
- **THEN** the blank testimonial MUST be omitted
|
||||
- **AND** the two rendered testimonials MUST receive alternating `left` and `right` directions based on their rendered order
|
||||
|
||||
#### Scenario: Direction alternation remains on mobile
|
||||
|
||||
- **WHEN** testimonials are viewed at a mobile viewport
|
||||
- **THEN** their explicit `left` and `right` directions MUST be preserved with the reduced mobile distance
|
||||
|
||||
### Requirement: Real wedding testimonials are seeded from authorized source copy
|
||||
|
||||
Content and visual seeders SHALL replace fictional testimonials with the five real wedding testimonials from `depoimentos.md`, preserving author couple names, quote wording, and date/context. Until final publication authorization is confirmed, production deployments MUST keep those records unpublished or gated by explicit admin publish action.
|
||||
|
||||
@@ -6,7 +6,7 @@ Define automated accessibility checks, semantic structure, keyboard operability,
|
||||
## Requirements
|
||||
### Requirement: Public routes have no critical or serious accessibility issues
|
||||
|
||||
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial redesign. A critical or serious issue MUST fail the suite.
|
||||
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial motion enhancement. A critical or serious issue MUST fail the suite.
|
||||
|
||||
#### Scenario: Critical issue blocks the suite
|
||||
|
||||
@@ -16,7 +16,7 @@ The system SHALL run automated accessibility checks on the public routes covered
|
||||
#### Scenario: Covered routes are checked
|
||||
|
||||
- **WHEN** the accessibility suite runs
|
||||
- **THEN** the home, services listing, portfolio listing and case detail MUST each be checked
|
||||
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
|
||||
|
||||
### Requirement: Public pages use accessible semantic structure
|
||||
|
||||
@@ -65,19 +65,34 @@ Visitors SHALL be able to reach and activate every interactive element with the
|
||||
|
||||
### Requirement: Reduced motion preference is honored
|
||||
|
||||
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including editorial hover scales and menu transitions introduced by the redesign.
|
||||
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including page openings, editorial reveals, image hover scales, interaction feedback and menu transitions. All affected content MUST render in its final visible and interactive state.
|
||||
|
||||
#### Scenario: Reduced motion disables transitions
|
||||
|
||||
- **GIVEN** the browser reports `prefers-reduced-motion: reduce`
|
||||
- **WHEN** a public page is loaded
|
||||
- **THEN** decorative transitions and animations MUST NOT run
|
||||
- **AND** every motion target MUST be in its final visible and interactive state
|
||||
|
||||
### Requirement: Public pages emit no console errors
|
||||
|
||||
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including pages that load the mobile navigation script.
|
||||
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including home, services listing, portfolio listing, case detail, about, contact, privacy, branded 404, and pages that load the mobile navigation or motion runtime.
|
||||
|
||||
#### Scenario: Console stays clean on covered routes
|
||||
|
||||
- **WHEN** a covered public route is loaded in the browser suite
|
||||
- **THEN** the console MUST contain no error-level messages
|
||||
|
||||
### Requirement: Motion enhancement failure does not hide or disable content
|
||||
|
||||
The public site SHALL remain readable, keyboard-operable, and interactive when JavaScript is disabled or `IntersectionObserver` is unavailable. Motion targets MUST default to their final state without relying on timeout recovery.
|
||||
|
||||
#### Scenario: JavaScript disabled keeps public page usable
|
||||
|
||||
- **WHEN** a visitor loads a public page with JavaScript disabled
|
||||
- **THEN** headings, copy, navigation, CTAs, and form controls MUST be visible and operable
|
||||
|
||||
#### Scenario: Observer unavailable keeps reveals final
|
||||
|
||||
- **WHEN** the motion runtime executes without `IntersectionObserver`
|
||||
- **THEN** reveal targets MUST remain in the final state
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -22,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;
|
||||
|
||||
|
||||
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,7 +17,7 @@
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="{{ route('contact') }}"
|
||||
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' }}
|
||||
|
||||
@@ -2,35 +2,21 @@
|
||||
'settings',
|
||||
])
|
||||
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
class="home-chapter min-h-[100dvh] border-b border-amare-border bg-amare-bg"
|
||||
<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"
|
||||
>
|
||||
<div @class([
|
||||
'container-amare grid min-h-[100dvh] gap-12 py-20 md:py-10',
|
||||
'md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-stretch' => filled($settings->hero_image_path),
|
||||
'md:max-w-4xl md:items-center' => blank($settings->hero_image_path),
|
||||
]) data-reveal-group @if (blank($settings->hero_image_path)) data-tonal-hero @endif>
|
||||
<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="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">
|
||||
<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>
|
||||
|
||||
@@ -42,14 +28,7 @@
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
<p class="max-w-[470px] text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_image_path))
|
||||
<div data-split-hero data-motion-beat="media" class="min-h-80 overflow-hidden bg-amare-bg-deep md:min-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: 768px) 100vw, 55vw" class="img-editorial h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
</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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,49 +7,14 @@
|
||||
'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">
|
||||
@if ($brandMark || filled($eyebrow))
|
||||
<div class="mb-5 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
|
||||
@if (filled($eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-3xl text-display 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>
|
||||
@endif
|
||||
@if (filled($metadata))
|
||||
<p class="mt-4 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>
|
||||
@endif
|
||||
</div>
|
||||
</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="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="flex items-center gap-4" data-motion-beat="seal">
|
||||
@if ($brandMark)
|
||||
@@ -60,12 +25,52 @@
|
||||
@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-hero-spread font-medium 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-text-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-text-muted">{{ $metadata }}</p>
|
||||
@endif
|
||||
@if (trim((string) $slot) !== '')
|
||||
<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',
|
||||
'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)
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@endif
|
||||
@if (filled($eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<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="max-w-[470px] text-lg text-amare-muted">{{ $summary }}</p>
|
||||
@endif
|
||||
@if (filled($metadata))
|
||||
<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>
|
||||
@@ -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="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||
<a href="{{ route('home') }}#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,7 +70,7 @@
|
||||
</a>
|
||||
|
||||
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
||||
<a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
@else
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('contact.store') }}"
|
||||
action="{{ route('briefing.store') }}"
|
||||
class="mt-10 max-w-3xl space-y-10"
|
||||
data-contact-form
|
||||
>
|
||||
@@ -135,8 +135,8 @@
|
||||
@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>
|
||||
@@ -206,7 +206,7 @@
|
||||
type="text"
|
||||
id="servico_interesse"
|
||||
name="servico_interesse"
|
||||
value="{{ old('servico_interesse') }}"
|
||||
value="{{ old('servico_interesse', request('servico_interesse')) }}"
|
||||
maxlength="120"
|
||||
placeholder="Ex.: planejamento completo"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
|
||||
|
||||
@@ -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,6 +12,135 @@ beforeEach(function (): void {
|
||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||
});
|
||||
|
||||
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'
|
||||
() => {
|
||||
const header = document.querySelector('.site-header');
|
||||
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();
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const mediaRect = media?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 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,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'startsBelowHeader' => true,
|
||||
'fillsRemainingViewport' => true,
|
||||
'mediaTouchesHeroTop' => true,
|
||||
'mediaTouchesHeroBottom' => true,
|
||||
'mediaTouchesViewportRight' => true,
|
||||
'splitImageObjectFit' => 'cover',
|
||||
'columnRatio' => 0.714,
|
||||
'readingColumnWithinLimit' => true,
|
||||
'titleClipsHorizontally' => false,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the home hero linear through tablet with a full-width four-by-five crop', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(1023, 1000);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const mediaRect = media?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
|
||||
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'mediaFollowsContent' => true,
|
||||
'mediaFillsViewportWidth' => true,
|
||||
'mediaAspectRatio' => 0.8,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks the home hero media after its content as a full-width four-by-five crop on mobile', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(390, 844);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const mediaRect = media?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
|
||||
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'mediaFollowsContent' => true,
|
||||
'mediaFillsViewportWidth' => true,
|
||||
'mediaAspectRatio' => 0.8,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the full home cadence accessible and contained at both viewports', function (): void {
|
||||
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||
$page = $this->visit('/', [
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -35,11 +35,22 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-motion="page-open"', 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-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: 768px) 100vw, 55vw"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false)
|
||||
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
|
||||
}
|
||||
|
||||
@@ -59,9 +70,18 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
$case = PortfolioCase::factory()->published()->create(['cover_image_path' => '', 'cover_image_alt' => '']);
|
||||
|
||||
foreach ([route('home'), route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug), route('about')] as $route) {
|
||||
$this->get($route)
|
||||
$response = $this->get($route)
|
||||
->assertOk()
|
||||
->assertSee('data-tonal-hero', false);
|
||||
|
||||
if ($route === route('home')) {
|
||||
$response
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('id="hero-heading"', false)
|
||||
->assertSee('data-testid="home-primary-cta"', false)
|
||||
->assertDontSee('data-split-hero', false)
|
||||
->assertDontSee('content/heroes/home.jpg', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user