Compare commits

...

4 Commits

49 changed files with 1326 additions and 410 deletions

View File

@@ -15,6 +15,10 @@ This is a Laravel 13 application for an event-planning consultancy. Application
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`. Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
## Worktrees
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
## Git Hooks (husky) ## Git Hooks (husky)
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks. Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.

View File

@@ -21,6 +21,8 @@ final readonly class PageMeta
public ?string $ogImageUrl = null, public ?string $ogImageUrl = null,
public ?string $ogImageAlt = null, public ?string $ogImageAlt = null,
public ?array $jsonLd = null, public ?array $jsonLd = null,
public string $siteName = '',
public string $robots = 'index, follow',
) {} ) {}
/** /**
@@ -44,6 +46,7 @@ final readonly class PageMeta
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings), ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt, ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
jsonLd: $jsonLd, jsonLd: $jsonLd,
siteName: (string) $settings->brand_name,
); );
} }
@@ -77,9 +80,49 @@ final readonly class PageMeta
ogImageUrl: $ogImageUrl, ogImageUrl: $ogImageUrl,
ogImageAlt: $ogImageAlt, ogImageAlt: $ogImageAlt,
jsonLd: $jsonLd, jsonLd: $jsonLd,
siteName: (string) $settings->brand_name,
); );
} }
/**
* Build the metadata for branded error pages (404/500).
*
* Error pages carry no canonical, are excluded from search indexes and use
* the page name suffixed with the brand name as their title.
*/
public static function forErrorPage(
SiteSetting $settings,
int $status = 404,
): self {
[$title, $description] = $status === 500
? ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.']
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.'];
return new self(
title: trim($title).' - '.$settings->brand_name,
description: $description,
canonical: '',
robots: 'noindex, nofollow',
siteName: (string) $settings->brand_name,
);
}
/**
* Append the brand name to a page title when it is not already present.
*/
public static function withBrandSuffix(string $title, SiteSetting $settings): string
{
$brand = filled($settings->default_meta_title)
? (string) $settings->default_meta_title
: (string) $settings->brand_name;
if (str_contains($title, $brand)) {
return $title;
}
return trim($title).' - '.$brand;
}
private static function defaultTitle(SiteSetting $settings): string private static function defaultTitle(SiteSetting $settings): string
{ {
return filled($settings->default_meta_title) return filled($settings->default_meta_title)

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\ContactBriefingRequest;
use App\Mail\ContactBriefing;
use App\Mail\ContactBriefingConfirmation;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class ContactController extends Controller
{
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
public function store(ContactBriefingRequest $request): RedirectResponse
{
if (filled($request->input('empresa'))) {
return $this->success();
}
$validated = $request->validated();
unset($validated['privacidade']);
if ($this->isDuplicate($validated)) {
return $this->success();
}
$this->rememberSubmission($validated);
$settings = SiteSetting::instance();
$fields = $this->buildFields($validated);
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
return $this->success();
}
/**
* @param array<string, mixed> $validated
*/
private function buildFields(array $validated): array
{
return [
'Nome' => (string) $validated['nome'],
'E-mail' => (string) $validated['email'],
'Telefone/WhatsApp' => (string) $validated['telefone'],
'Tipo de evento' => (string) $validated['tipo_evento'],
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
'Cidade' => (string) $validated['cidade'],
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
'Mensagem' => (string) $validated['mensagem'],
];
}
/**
* @param array<string, mixed> $validated
*/
private function isDuplicate(array $validated): bool
{
$hash = $this->hash($validated);
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
}
/**
* @param array<string, mixed> $validated
*/
private function rememberSubmission(array $validated): void
{
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
}
/**
* @param array<string, mixed> $validated
*/
private function hash(array $validated): string
{
return hash('sha256', serialize($validated));
}
/**
* @param array<string, mixed> $fields
*/
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
{
$attempt = function () use ($settings, $name, $email, $fields): void {
if (filled($settings->email)) {
Mail::to($settings->email)->send(new ContactBriefing($fields));
}
if (filled(config('mail.default'))) {
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
}
};
try {
$attempt();
} catch (Throwable $exception) {
Log::error('Falha ao enviar briefing de contato', [
'exception' => $exception,
]);
}
}
private function success(): RedirectResponse
{
return redirect()->route('contact')->with('status', 'briefing-sent');
}
}

View File

@@ -20,7 +20,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('about'), canonical: route('about'),
settings: $settings, settings: $settings,
title: 'Sobre', title: PageMeta::withBrandSuffix('Sobre', $settings),
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'), description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
), ),
]); ]);
@@ -35,7 +35,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('privacy'), canonical: route('privacy'),
settings: $settings, settings: $settings,
title: 'Política de privacidade', title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
description: 'Política de privacidade da '.$settings->brand_name.'.', description: 'Política de privacidade da '.$settings->brand_name.'.',
), ),
]); ]);
@@ -50,7 +50,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('contact'), canonical: route('contact'),
settings: $settings, settings: $settings,
title: 'Contato', title: PageMeta::withBrandSuffix('Contato', $settings),
description: 'Fale com a '.$settings->brand_name.'.', description: 'Fale com a '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('portfolio.index'), canonical: route('portfolio.index'),
settings: $settings, settings: $settings,
title: 'Portfólio', title: PageMeta::withBrandSuffix('Portfólio', $settings),
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.', description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -23,7 +23,7 @@ final class ServiceController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('services.index'), canonical: route('services.index'),
settings: $settings, settings: $settings,
title: 'Serviços', title: PageMeta::withBrandSuffix('Serviços', $settings),
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.', description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\PublicSite;
use Illuminate\Foundation\Http\FormRequest;
final class ContactBriefingRequest extends FormRequest
{
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'nome' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:254'],
'telefone' => ['required', 'string', 'max:40'],
'tipo_evento' => ['required', 'string', 'max:80'],
'data_periodo' => ['nullable', 'string', 'max:80'],
'cidade' => ['required', 'string', 'max:80'],
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
'servico_interesse' => ['nullable', 'string', 'max:120'],
'mensagem' => ['required', 'string', 'max:3000'],
'privacidade' => ['accepted'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'nome.required' => 'Informe seu nome completo.',
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
'email.required' => 'Informe seu e-mail.',
'email.email' => 'Informe um e-mail válido.',
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
'telefone.required' => 'Informe um telefone ou WhatsApp.',
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
'tipo_evento.required' => 'Selecione o tipo de evento.',
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
'cidade.required' => 'Informe a cidade do evento.',
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
'convidados.integer' => 'Informe um número de convidados válido.',
'convidados.min' => 'O número de convidados deve ser maior que zero.',
'convidados.max' => 'O número de convidados informado é inválido.',
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
'mensagem.required' => 'Conte brevemente o que você precisa.',
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
];
}
}

View File

@@ -0,0 +1,40 @@
<?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 ContactBriefing 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: 'Novo briefing de contato — Amare Assessoria',
);
}
public function content(): Content
{
return new Content(
html: 'emails.contact-briefing',
text: 'emails.contact-briefing-text',
);
}
}

View File

@@ -0,0 +1,38 @@
<?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 ContactBriefingConfirmation extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $name,
public readonly string $brandName,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Recebemos sua mensagem — '.$this->brandName,
);
}
public function content(): Content
{
return new Content(
html: 'emails.contact-briefing-confirmation',
text: 'emails.contact-briefing-confirmation-text',
);
}
}

View File

@@ -7,6 +7,9 @@ namespace App\Providers;
use App\Application\Data\PageMeta; use App\Application\Data\PageMeta;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance; use Illuminate\View\View as ViewInstance;
@@ -28,6 +31,7 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->configureLivewireTemporaryUploads(); $this->configureLivewireTemporaryUploads();
$this->freezeClockWhenConfigured(); $this->freezeClockWhenConfigured();
$this->configureRateLimiters();
View::composer('layouts.public', function (ViewInstance $view): void { View::composer('layouts.public', function (ViewInstance $view): void {
$settings = $view->offsetExists('siteSettings') $settings = $view->offsetExists('siteSettings')
@@ -63,6 +67,13 @@ class AppServiceProvider extends ServiceProvider
config(['livewire.temporary_file_upload.disk' => 'local']); config(['livewire.temporary_file_upload.disk' => 'local']);
} }
private function configureRateLimiters(): void
{
RateLimiter::for('contact-briefing', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
});
}
private function freezeClockWhenConfigured(): void private function freezeClockWhenConfigured(): void
{ {
if ($this->app->environment('production')) { if ($this->app->environment('production')) {

262
frontend-audit.md Normal file
View File

@@ -0,0 +1,262 @@
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
## Objetivo
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
* UI e UX;
* responsividade;
* posicionamento e conteúdo;
* acessibilidade;
* conversão;
* SEO;
* performance;
* robustez;
* qualidade e manutenção do código.
## Contexto do produto
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
Ela atende:
* casamentos;
* eventos sociais e celebrações particulares;
* eventos corporativos.
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
A experiência precisa equilibrar:
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
Preview de referência:
`https://amare.preview.hellomanoel.com/`
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
## Direção visual
Preserve a identidade visual existente quando ela funcionar corretamente:
* fundos off-white ou bege claro;
* verde oliva;
* composição editorial;
* fotografias em destaque;
* espaços em branco generosos;
* EB Garamond em títulos e destaques;
* elementos minimalistas;
* animações discretas;
* aparência refinada e atemporal.
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
Avalie especialmente:
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
* verdes claros com contraste insuficiente;
* CTAs discretos demais;
* espaços vazios excessivos no celular;
* imagens que reforcem somente o posicionamento de casamento;
* falta de equilíbrio entre conteúdo social e corporativo;
* inconsistência tipográfica ou de espaçamento;
* aparência de template genérico de casamento.
## Escopo da auditoria
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
### Produto e conteúdo
* clareza da proposta de valor;
* equilíbrio entre eventos sociais e corporativos;
* hierarquia das informações;
* conteúdo genérico, redundante ou sem função;
* coerência entre títulos, textos, imagens e CTAs;
* clareza dos serviços;
* confiança e credibilidade;
* caminho até contato ou solicitação de proposta.
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
### UI e experiência
* navegação;
* header e footer;
* menu mobile;
* hierarquia visual;
* tipografia;
* espaçamento;
* grids;
* CTAs;
* formulários;
* estados interativos;
* consistência entre páginas;
* experiência mobile-first;
* ausência de overflow, cortes, sobreposições ou distorções;
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
### Acessibilidade
Use WCAG 2.2 AA como referência prática.
Corrija problemas de:
* HTML semântico;
* hierarquia de headings;
* navegação por teclado;
* foco visível;
* contraste;
* nomes acessíveis;
* labels;
* mensagens de erro;
* áreas de toque;
* textos alternativos;
* menu mobile;
* overlays;
* preferência por redução de movimento;
* uso correto de links e botões.
Prefira HTML semântico a ARIA desnecessária.
### Formulários e conversão
Garanta, quando aplicável:
* campos e obrigatoriedade claros;
* validação adequada;
* mensagens de erro específicas;
* estado de envio;
* prevenção de envio duplicado;
* estados de sucesso e falha;
* tratamento de erro de rede;
* preservação dos dados após erros recuperáveis;
* proteção antispam simples;
* privacidade e LGPD;
* links e mensagens de WhatsApp corretos;
* ausência de segredos expostos no cliente.
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
### SEO
Corrija, quando aplicável:
* títulos exclusivos por rota;
* meta descriptions;
* canonical;
* Open Graph;
* Twitter cards;
* sitemap;
* robots;
* favicon;
* idioma;
* headings;
* URLs;
* links internos;
* página 404;
* metadados sociais;
* indexação distinta entre preview e produção.
Não crie dados estruturados com informações não confirmadas.
### Performance
Corrija problemas relevantes relacionados a:
* imagens;
* tamanhos responsivos;
* formatos modernos;
* lazy loading;
* LCP;
* CLS;
* INP;
* fontes;
* scripts desnecessários;
* hidratação excessiva;
* JavaScript evitável;
* componentes client-side sem necessidade;
* animações custosas;
* dependências pesadas;
* carregamento de conteúdo abaixo da dobra.
Preserve a qualidade visual das fotografias.
### Qualidade técnica
Corrija:
* erros de TypeScript;
* erros de lint;
* erros de build;
* warnings de hidratação;
* erros de console;
* requisições quebradas;
* links inválidos;
* rotas órfãs;
* componentes duplicados quando a consolidação simplificar o projeto;
* tratamento de erros ausente;
* tipagem insegura;
* complexidade desnecessária diretamente relacionada ao escopo.
## Prioridades
Considere:
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
* **P2:** refinamento não essencial para o lançamento.
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
## Restrições
* Preserve a stack e a arquitetura existentes quando forem adequadas.
* Prefira mudanças simples, localizadas e fáceis de manter.
* Não reescreva o projeto sem necessidade concreta.
* Não adicione bibliotecas quando a solução atual for suficiente.
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
* Não altere arquivos não relacionados sem justificativa.
* Preserve alterações locais preexistentes.
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
* Não reduza acessibilidade para preservar estética.
* Não deixe mocks ou soluções temporárias como implementação final.
## Critérios de conclusão
A tarefa estará concluída quando:
* todas as rotas públicas existentes tiverem sido auditadas;
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
* o site funcionar corretamente em mobile e desktop;
* não houver overflow horizontal, sobreposição ou componentes quebrados;
* menu, navegação, links, CTAs e formulários funcionarem;
* o posicionamento social e corporativo estiver claro;
* a acessibilidade essencial estiver atendida;
* os metadados e fundamentos de SEO estiverem corretos;
* os principais problemas de performance tiverem sido tratados;
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
Não declare uma validação como aprovada sem executá-la.
## Entrega final
Ao concluir, apresente:
1. rotas auditadas;
2. problemas principais encontrados;
3. alterações implementadas;
4. arquivos modificados;
5. comandos executados e seus resultados;
6. decisões e trade-offs relevantes;
7. informações que dependem da cliente;
8. itens P2 mantidos fora do escopo.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 4.2 KiB

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#556B2F"/>
<text x="32" y="45" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" fill="#FBF9F4" text-anchor="middle">A</text>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -88,6 +88,14 @@
filter: saturate(0.88) contrast(0.96); filter: saturate(0.88) contrast(0.96);
} }
@utility honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
@layer components { @layer components {
.main-nav.is-open { .main-nav.is-open {
display: flex; display: flex;
@@ -150,6 +158,9 @@
[data-chapter-index] a { [data-chapter-index] a {
position: relative; position: relative;
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding-left: 0.75rem; padding-left: 0.75rem;
font-size: var(--amare-text-xs); font-size: var(--amare-text-xs);
font-weight: 600; font-weight: 600;

View File

@@ -4,39 +4,62 @@ document.addEventListener('DOMContentLoaded', () => {
const menuButton = document.querySelector('[data-menu-button]'); const menuButton = document.querySelector('[data-menu-button]');
const navigation = document.querySelector('[data-main-nav]'); const navigation = document.querySelector('[data-main-nav]');
if (!menuButton || !navigation) { if (menuButton && navigation) {
return; const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
}
const setOpen = (isOpen) => { const setOpen = (isOpen, { returnFocus = false } = {}) => {
navigation.classList.toggle('is-open', isOpen); navigation.classList.toggle('is-open', isOpen);
navigation.classList.toggle('hidden', !isOpen && !window.matchMedia('(min-width: 768px)').matches); navigation.classList.toggle('hidden', !isOpen && !isDesktop());
navigation.classList.toggle('flex', isOpen || window.matchMedia('(min-width: 768px)').matches); navigation.classList.toggle('flex', isOpen || isDesktop());
document.body.classList.toggle('menu-open', isOpen); document.body.classList.toggle('menu-open', isOpen);
menuButton.setAttribute('aria-expanded', String(isOpen)); menuButton.setAttribute('aria-expanded', String(isOpen));
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu'); menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
if (!isOpen && returnFocus) {
menuButton.focus();
}
}; };
menuButton.addEventListener('click', () => { menuButton.addEventListener('click', () => {
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true'; const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
setOpen(isOpen); setOpen(isOpen);
if (isOpen) {
const firstLink = navigation.querySelector('a');
if (firstLink) {
firstLink.focus();
}
}
}); });
navigation.querySelectorAll('a').forEach((link) => { navigation.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => setOpen(false)); link.addEventListener('click', () => setOpen(false));
}); });
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => { navigation.addEventListener('keydown', (event) => {
if (event.matches) { if (event.key === 'Escape') {
setOpen(false); setOpen(false, { returnFocus: true });
navigation.classList.remove('hidden');
navigation.classList.add('flex');
} else {
navigation.classList.remove('is-open', 'flex');
navigation.classList.add('hidden');
document.body.classList.remove('menu-open');
menuButton.setAttribute('aria-expanded', 'false');
menuButton.setAttribute('aria-label', 'Abrir menu');
} }
}); });
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
setOpen(false);
if (event.matches) {
navigation.classList.remove('hidden');
navigation.classList.add('flex');
}
});
}
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
form.addEventListener('submit', () => {
const button = form.querySelector('[data-submit-button]');
if (button) {
button.disabled = true;
button.setAttribute('aria-busy', 'true');
}
});
});
}); });

View File

@@ -19,7 +19,6 @@
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light'; $variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
$kind = $mark ? 'mark' : 'lockup'; $kind = $mark ? 'mark' : 'lockup';
$staticSrc = asset("brand/{$kind}-{$variant}.webp"); $staticSrc = asset("brand/{$kind}-{$variant}.webp");
$staticFallback = asset("brand/{$kind}-{$variant}.png");
$src = filled($uploadedPath) $src = filled($uploadedPath)
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath) ? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
@@ -34,8 +33,4 @@
'decoding' => 'async', 'decoding' => 'async',
'loading' => 'eager', 'loading' => 'eager',
]) }} ]) }}
@if (! filled($uploadedPath))
data-brand-fallback="{{ $staticFallback }}"
@endif
data-brand-variant="{{ $variant }}"
/> />

View File

@@ -6,7 +6,11 @@
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath; $manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
$manifest = is_string($manifestFile) && is_file($manifestFile) $manifest = is_string($manifestFile) && is_file($manifestFile)
? json_decode((string) file_get_contents($manifestFile), true) ? \Illuminate\Support\Facades\Cache::remember(
'fonts-manifest:'.md5($manifestFile.':'.(string) filemtime($manifestFile)),
3600,
static fn () => json_decode((string) file_get_contents($manifestFile), true) ?: null,
)
: null; : null;
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null; $cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;

View File

@@ -1,37 +0,0 @@
@props([
'cases',
])
@if ($cases->isNotEmpty())
<section aria-labelledby="cases-heading" class="border-b border-amare-border py-16">
<div class="container-amare space-y-8">
<div class="max-w-2xl space-y-3">
<h2 id="cases-heading" class="text-3xl font-semibold text-amare-text">Casos selecionados</h2>
<p class="text-amare-text-muted">Histórias recentes de celebrações conduzidas pela Amare.</p>
</div>
<div class="grid gap-8">
@foreach ($cases as $case)
<article class="grid gap-4 border-t border-amare-border pt-6 md:grid-cols-[200px_minmax(0,1fr)]">
@if (filled($case->cover_image_path))
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="200px"
class="aspect-square w-full object-cover"
/>
@endif
<div class="space-y-2">
<h3 class="text-2xl font-semibold text-amare-text">{{ $case->title }}</h3>
<p class="text-sm text-amare-text-muted">{{ $case->event_type }}@if($case->city) · {{ $case->city }}@endif</p>
<p class="text-amare-text-muted">{{ $case->summary }}</p>
<a href="{{ url('/portfolio/'.$case->slug) }}" class="inline-flex text-sm font-semibold text-amare-accent hover:text-amare-accent-hover">
Ver caso
</a>
</div>
</article>
@endforeach
</div>
</div>
</section>
@endif

View File

@@ -5,14 +5,14 @@
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta"> <section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
<div class="container-amare space-y-6 text-center" data-reveal> <div class="container-amare space-y-6 text-center" data-reveal>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Todo grande encontro começa com uma boa conversa.</h2> <h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted"> <p class="mx-auto max-w-2xl text-amare-muted">
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos. Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
</p> </p>
<div> <div>
<a <a
href="{{ route('contact') }}" href="{{ route('contact') }}"
class="inline-flex 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" 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 }} {{ $settings->hero_cta_label }}
</a> </a>

View File

@@ -29,7 +29,7 @@
<a <a
href="{{ route('contact') }}" href="{{ route('contact') }}"
data-testid="home-primary-cta" data-testid="home-primary-cta"
class="inline-flex 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" 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 }} {{ $settings->hero_cta_label }}
</a> </a>
@@ -37,9 +37,9 @@
@if (filled($settings->hero_secondary_cta_label)) @if (filled($settings->hero_secondary_cta_label))
<a <a
href="{{ route('portfolio.index') }}" href="{{ route('portfolio.index') }}"
class="inline-flex items-center border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
> >
{{ $settings->hero_secondary_cta_label }} <span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a> </a>
@endif @endif
</div> </div>
@@ -55,6 +55,7 @@
:path="$settings->default_og_image_path" :path="$settings->default_og_image_path"
:alt="$settings->default_og_image_alt ?: $settings->brand_name" :alt="$settings->default_og_image_alt ?: $settings->brand_name"
loading="eager" loading="eager"
fetchpriority="high"
sizes="(max-width: 768px) 100vw, 40vw" sizes="(max-width: 768px) 100vw, 40vw"
class="img-editorial h-full w-full object-cover" class="img-editorial h-full w-full object-cover"
/> />

View File

@@ -27,8 +27,8 @@
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-2xl font-medium">{{ $case->title }}</h3> <h3 class="text-2xl font-medium">{{ $case->title }}</h3>
<p class="text-amare-accent-text/80">{{ $case->summary }}</p> <p class="text-amare-accent-text/80">{{ $case->summary }}</p>
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text"> <a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
Ver caso <span class="border-b border-amare-accent-text pb-1">Ver caso</span>
</a> </a>
</div> </div>
</article> </article>
@@ -39,8 +39,8 @@
<p class="max-w-2xl text-sm text-amare-accent-text/75"> <p class="max-w-2xl text-sm text-amare-accent-text/75">
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização. Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
</p> </p>
<a href="{{ route('portfolio.index') }}" class="border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text"> <a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
Conhecer o portfólio <span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
</a> </a>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,31 +0,0 @@
@props([
'cases',
])
@if ($cases->isNotEmpty())
<section aria-labelledby="proof-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
<div class="container-amare space-y-8">
<div class="max-w-2xl space-y-3">
<h2 id="proof-heading" class="text-3xl font-semibold text-amare-text">Prova visual</h2>
<p class="text-amare-text-muted">Eventos em destaque que mostram o cuidado com cada celebração.</p>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
@foreach ($cases as $case)
<article class="space-y-3">
@if (filled($case->cover_image_path))
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) 100vw, 33vw"
class="aspect-[4/3] w-full object-cover"
/>
@endif
<h3 class="text-xl font-semibold text-amare-text">{{ $case->title }}</h3>
<p class="text-sm text-amare-text-muted">{{ $case->summary }}</p>
</article>
@endforeach
</div>
</div>
</section>
@endif

View File

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

View File

@@ -3,6 +3,7 @@
'alt', 'alt',
'sizes' => '(max-width: 768px) 100vw, 960px', 'sizes' => '(max-width: 768px) 100vw, 960px',
'loading' => 'lazy', 'loading' => 'lazy',
'fetchpriority' => null,
'width' => null, 'width' => null,
'height' => null, 'height' => null,
'disk' => null, 'disk' => null,
@@ -42,6 +43,7 @@
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif @if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif @if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
loading="{{ $loadingValue }}" loading="{{ $loadingValue }}"
@if ($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
@if ($class) class="{{ $class }}" @endif @if ($class) class="{{ $class }}" @endif
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }} {{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
> >

View File

@@ -3,12 +3,18 @@
]) ])
<meta name="description" content="{{ $pageMeta->description }}"> <meta name="description" content="{{ $pageMeta->description }}">
<link rel="canonical" href="{{ $pageMeta->canonical }}"> @if (filled($pageMeta->canonical))
<link rel="canonical" href="{{ $pageMeta->canonical }}">
@endif
<meta property="og:locale" content="pt_BR">
<meta property="og:site_name" content="{{ $pageMeta->siteName }}">
<meta property="og:title" content="{{ $pageMeta->title }}"> <meta property="og:title" content="{{ $pageMeta->title }}">
<meta property="og:description" content="{{ $pageMeta->description }}"> <meta property="og:description" content="{{ $pageMeta->description }}">
<meta property="og:type" content="{{ $pageMeta->ogType }}"> <meta property="og:type" content="{{ $pageMeta->ogType }}">
<meta property="og:url" content="{{ $pageMeta->canonical }}"> @if (filled($pageMeta->canonical))
<meta property="og:url" content="{{ $pageMeta->canonical }}">
@endif
@if ($pageMeta->ogImageUrl) @if ($pageMeta->ogImageUrl)
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}"> <meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
@if ($pageMeta->ogImageAlt) @if ($pageMeta->ogImageAlt)
@@ -16,6 +22,19 @@
@endif @endif
@endif @endif
@if ($pageMeta->jsonLd) <meta name="twitter:card" content="summary_large_image">
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script> <meta name="twitter:title" content="{{ $pageMeta->title }}">
<meta name="twitter:description" content="{{ $pageMeta->description }}">
@if ($pageMeta->ogImageUrl)
<meta name="twitter:image" content="{{ $pageMeta->ogImageUrl }}">
@endif
@if (app()->environment('production'))
<meta name="robots" content="{{ $pageMeta->robots }}">
@else
<meta name="robots" content="noindex, nofollow">
@endif
@if ($pageMeta->jsonLd)
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) !!}</script>
@endif @endif

View File

@@ -0,0 +1,5 @@
Olá, {{ $name }}.
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
{{ $brandName }}

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recebemos sua mensagem</title>
<style>
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
.wrap { max-width: 600px; margin: 0 auto; }
p { font-size: 15px; line-height: 1.6; }
.signature { margin-top: 24px; color: #5D6155; }
</style>
</head>
<body>
<div class="wrap">
<p>Olá, {{ $name }}.</p>
<p>
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
</p>
<p class="signature">
{{ $brandName }}
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
Novo briefing de contato
@foreach ($fields as $label => $value)
{{ $label }}: {{ $value ?: '—' }}
@endforeach
Enviado pelo site {{ config('app.name') }}.

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Novo briefing de contato</title>
<style>
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
.wrap { max-width: 600px; margin: 0 auto; }
h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 10px 12px; border-bottom: 1px solid #C5C8B8; font-size: 14px; }
th { width: 40%; font-weight: 600; color: #556B2F; }
.footer { margin-top: 16px; font-size: 12px; color: #5D6155; }
</style>
</head>
<body>
<div class="wrap">
<h1>Novo briefing de contato</h1>
<table>
@foreach ($fields as $label => $value)
<tr>
<th scope="row">{{ $label }}</th>
<td>{{ $value ?: '—' }}</td>
</tr>
@endforeach
</table>
<p class="footer">
Enviado pelo site {{ config('app.name') }}.
</p>
</div>
</body>
</html>

View File

@@ -1,3 +1,17 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 404);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Página não encontrada - Amare Assessoria',
description: 'A página que você procura não existe ou foi movida.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@@ -6,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p> <p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
<p> <p>
<a href="{{ route('home') }}" class="inline-flex bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"> <a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -1,3 +1,17 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 500);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Algo deu errado - Amare Assessoria',
description: 'Não foi possível concluir o pedido. Tente novamente em instantes.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@@ -6,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p> <p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
<p> <p>
<a href="{{ route('home') }}" class="inline-flex bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"> <a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -4,12 +4,33 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta name="theme-color" content="#FBF9F4">
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<title>{{ $pageMeta->title }}</title> <title>{{ $pageMeta->title }}</title>
<x-seo.meta :page-meta="$pageMeta" /> <x-seo.meta :page-meta="$pageMeta" />
<x-fonts /> <x-fonts />
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
<noscript>
<style>
.main-nav {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.main-nav {
flex-direction: row;
}
}
.menu-button {
display: none;
}
</style>
</noscript>
</head> </head>
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased"> <body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text"> <a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
@@ -18,12 +39,12 @@
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm"> <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]"> <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:border-0 md:pt-0" aria-label="Principal" data-main-nav> <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="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Início</a> <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="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Serviços</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="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Portfólio</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="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Amare</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="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Contato</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>
</nav> </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"> <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">
@@ -31,13 +52,13 @@
</a> </a>
<div class="order-2 flex items-center justify-end gap-3 md:order-3"> <div class="order-2 flex items-center justify-end gap-3 md:order-3">
<a href="{{ route('contact') }}" class="hidden text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex"> <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">
Solicitar proposta Solicitar proposta
</a> </a>
<button <button
type="button" type="button"
class="menu-button inline-flex h-10 w-10 items-center justify-center border border-amare-border text-amare-text md:hidden" class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
aria-label="Abrir menu" aria-label="Abrir menu"
aria-controls="main-nav" aria-controls="main-nav"
aria-expanded="false" aria-expanded="false"
@@ -63,39 +84,45 @@
<div class="space-y-4"> <div class="space-y-4">
<x-brand.logo variant="on-light" class="h-12 w-auto" /> <x-brand.logo variant="on-light" class="h-12 w-auto" />
<p class="max-w-md text-amare-muted"> <p class="max-w-md text-amare-muted">
{{ $siteSettings->about_summary ?: 'Assessoria, produção e organização de eventos sociais e corporativos em São Paulo - SP.' }} {{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
</p> </p>
</div> </div>
<div class="space-y-3 text-sm"> <div class="text-sm">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
<p><a href="{{ route('services.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></p> <ul class="mt-3 space-y-3">
<p><a href="{{ route('portfolio.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></p> <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>
<p><a href="{{ route('about') }}" class="text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></p> <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>
<p><a href="{{ route('contact') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Contato</a></p> <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>
<p><a href="{{ route('privacy') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></p> <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>
<li><a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></li>
</ul>
</div> </div>
<div class="space-y-3 text-sm"> <div class="text-sm">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
<address class="mt-3 space-y-3 not-italic">
@if ($siteSettings->city) @if ($siteSettings->city)
<p class="text-amare-muted">{{ $siteSettings->city }}</p> <p class="text-amare-muted">{{ $siteSettings->city }}</p>
@endif @endif
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p class="text-amare-muted">{{ $siteSettings->phone }}</p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a>
</p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
</p> </p>
@endif @endif
@endforeach @endforeach
</address>
</div> </div>
</div> </div>
@@ -103,7 +130,7 @@
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between"> <div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
<p>&copy; {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p> <p>&copy; {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
<p> <p>
<a href="{{ route('privacy') }}" class="transition-colors hover:text-amare-accent">Política de privacidade</a> <a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,13 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg"> <section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24"> <div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
<div class="space-y-6"> <div class="space-y-6">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1>
<p class="max-w-2xl text-lg text-amare-muted"> <p class="max-w-2xl text-lg text-amare-muted">
Em breve você poderá enviar um briefing por aqui. Enquanto isso, fale conosco pelos canais abaixo. Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
</p> </p>
</div> </div>
@@ -15,20 +15,263 @@
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p> <p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p>{{ $siteSettings->phone }}</p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a>
</p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
</p> </p>
@endif @endif
@endforeach @endforeach
</div> </div>
</div> </div>
</section>
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
<div class="container-amare py-16 md:py-24">
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
<p class="mt-2 max-w-2xl text-amare-muted">
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
</p>
@if (session('status') === 'briefing-sent')
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
</div> </div>
@else
<form
method="POST"
action="{{ route('contact.store') }}"
class="mt-10 max-w-3xl space-y-10"
data-contact-form
>
@csrf
<div class="honeypot" aria-hidden="true">
<label for="empresa">Não preencha este campo</label>
<input type="text" id="empresa" name="empresa" tabindex="-1" autocomplete="off">
</div>
@if ($errors->any())
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
</div>
@endif
<div class="grid gap-8 sm:grid-cols-2">
<div>
<label for="nome" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Nome completo *</label>
<input
type="text"
id="nome"
name="nome"
value="{{ old('nome') }}"
required
autocomplete="name"
maxlength="120"
placeholder="Seu nome"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
>
@error('nome')
<p id="nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="email" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">E-mail *</label>
<input
type="email"
id="email"
name="email"
value="{{ old('email') }}"
required
autocomplete="email"
maxlength="254"
placeholder="voce@email.com"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
>
@error('email')
<p id="email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="telefone" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Telefone/WhatsApp *</label>
<input
type="tel"
id="telefone"
name="telefone"
value="{{ old('telefone') }}"
required
autocomplete="tel"
maxlength="40"
placeholder="(11) 90000-0000"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
>
@error('telefone')
<p id="telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="tipo_evento" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Tipo de evento *</label>
<select
id="tipo_evento"
name="tipo_evento"
required
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
@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="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>
@error('tipo_evento')
<p id="tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="data_periodo" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Data ou período desejado</label>
<input
type="text"
id="data_periodo"
name="data_periodo"
value="{{ old('data_periodo') }}"
maxlength="80"
placeholder="Ex.: novembro de 2027"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
>
@error('data_periodo')
<p id="data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="cidade" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Cidade do evento *</label>
<input
type="text"
id="cidade"
name="cidade"
value="{{ old('cidade') }}"
required
maxlength="80"
placeholder="Ex.: São Paulo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
>
@error('cidade')
<p id="cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="convidados" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Número estimado de convidados</label>
<input
type="number"
id="convidados"
name="convidados"
value="{{ old('convidados') }}"
min="1"
max="100000"
inputmode="numeric"
autocomplete="off"
placeholder="Ex.: 120"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
>
@error('convidados')
<p id="convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="servico_interesse" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Serviço de interesse</label>
<input
type="text"
id="servico_interesse"
name="servico_interesse"
value="{{ old('servico_interesse') }}"
maxlength="120"
placeholder="Ex.: planejamento completo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
>
@error('servico_interesse')
<p id="servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
</div>
<div>
<label for="mensagem" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Mensagem / principal preocupação *</label>
<textarea
id="mensagem"
name="mensagem"
required
rows="6"
maxlength="3000"
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
>{{ old('mensagem') }}</textarea>
@error('mensagem')
<p id="mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="privacidade" class="flex items-start gap-3 py-2">
<input
type="checkbox"
id="privacidade"
name="privacidade"
value="1"
required
@checked(old('privacidade'))
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
>
<span class="text-sm text-amare-muted">
Li e aceito a
<a href="{{ route('privacy') }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">política de privacidade</a>
e autorizo o tratamento dos meus dados para fins de atendimento.*
</span>
</label>
@error('privacidade')
<p id="privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div class="flex flex-col items-start gap-4">
<button
type="submit"
data-submit-button
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
>
Enviar briefing
</button>
<p class="text-sm text-amare-muted">
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
</p>
</div>
</form>
@endif
</div>
</section>
@endsection @endsection

View File

@@ -10,7 +10,7 @@
</div> </div>
@if ($cases->isEmpty()) @if ($cases->isEmpty())
<p class="text-amare-muted">Em breve publicaremos novos casos.</p> <p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
@else @else
<div class="grid gap-10 md:grid-cols-2"> <div class="grid gap-10 md:grid-cols-2">
@foreach ($cases as $case) @foreach ($cases as $case)
@@ -40,7 +40,7 @@
</div> </div>
@endif @endif
<p class="text-sm text-amare-accent">Imagens demonstrativas até existir acervo autorizado da Amare.</p> <p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
</div> </div>
</div> </div>
@endsection @endsection

View File

@@ -10,7 +10,7 @@
</div> </div>
@if ($services->isEmpty()) @if ($services->isEmpty())
<p class="text-amare-muted">Em breve publicaremos o catálogo de serviços.</p> <p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
@else @else
<div class="divide-y divide-amare-border border-y border-amare-border"> <div class="divide-y divide-amare-border border-y border-amare-border">
@foreach ($services as $index => $service) @foreach ($services as $index => $service)

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Controllers\PublicSite\ContactController;
use App\Http\Controllers\PublicSite\HomeController; use App\Http\Controllers\PublicSite\HomeController;
use App\Http\Controllers\PublicSite\PageController; use App\Http\Controllers\PublicSite\PageController;
use App\Http\Controllers\PublicSite\PortfolioController; use App\Http\Controllers\PublicSite\PortfolioController;
@@ -18,5 +19,8 @@ Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('por
Route::get('/sobre', [PageController::class, 'about'])->name('about'); Route::get('/sobre', [PageController::class, 'about'])->name('about');
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy'); Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
Route::get('/contato', [PageController::class, 'contact'])->name('contact'); Route::get('/contato', [PageController::class, 'contact'])->name('contact');
Route::post('/contato', [ContactController::class, 'store'])
->middleware('throttle:contact-briefing')
->name('contact.store');
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
Route::get('/robots.txt', RobotsController::class)->name('robots'); Route::get('/robots.txt', RobotsController::class)->name('robots');

View File

@@ -1,8 +1,47 @@
# Tasks: Git hooks (pre-commit + pre-push) # Frontend Audit — Amare site
- [x] Install husky + add prepare script ## T0 — Baseline
- [x] Create .husky/pre-commit (pint + phpstan) - [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
- [x] Create .husky/pre-push (DB gate + tests) - [x] Registrado estado baseline; Lighthouse final consolidado no T6
- [x] Install hooks into repo, chmod +x - [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
- [x] Verify hooks (DB up/down, pre-commit)
- [x] Document hooks in AGENTS.md ## T1 — Formulário de contato (e-mail, sem CRM)
- [x] Form em /contato (campos, LGPD obrigatório)
- [x] Rota POST /contato + validação + honeypot + throttle
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
- [x] A11y do form (labels, erros ligados)
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
## T2 — A11y/navegação
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
- [x] Footer: ul/li + address
## T3 — SEO/metadados
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
- [x] og:locale, og:site_name, Twitter cards
- [x] Favicon real + link, theme-color
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
- [x] noindex fora de produção (robots meta condicional)
## T4 — Conteúdo/posicionamento
- [x] Remover welcome.blade.php, home/cases, home/proof
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
- [x] CTA consistente mobile/desktop
- [x] Corrigir copy "Em breve..." nas listas
- [x] Rebalancear copy corporativo/social
## T5 — Performance
- [x] fetchpriority + dimensões hero (LCP)
- [x] Guarda CLS no media/image
- [x] Cache manifest de fontes
- [x] Escape JSON-LD
## T6 — Validação final
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
- [x] Relatório de entrega

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Mail\ContactBriefing;
use App\Mail\ContactBriefingConfirmation;
use App\Models\SiteSetting;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ContactBriefingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function validPayload(array $overrides = []): array
{
return array_merge([
'nome' => 'Maria Silva',
'email' => 'maria@example.com',
'telefone' => '(11) 98888-7777',
'tipo_evento' => 'Casamento',
'data_periodo' => 'novembro de 2027',
'cidade' => 'São Paulo',
'convidados' => '120',
'servico_interesse' => 'Planejamento completo',
'mensagem' => 'Queremos um casamento ao ar livre para 120 convidados.',
'privacidade' => '1',
'empresa' => '',
], $overrides);
}
public function test_contact_page_renders_briefing_form_with_all_fields(): void
{
SiteSetting::instance();
$response = $this->get(route('contact'));
$response
->assertOk()
->assertSee('<form', false)
->assertSee('action="'.route('contact.store').'"', false)
->assertSee('name="nome"', false)
->assertSee('name="email"', false)
->assertSee('name="telefone"', false)
->assertSee('name="tipo_evento"', false)
->assertSee('name="data_periodo"', false)
->assertSee('name="cidade"', false)
->assertSee('name="convidados"', false)
->assertSee('name="servico_interesse"', false)
->assertSee('name="mensagem"', false)
->assertSee('name="privacidade"', false)
->assertSee('name="empresa"', false)
->assertSee(route('privacy'), false);
}
public function test_valid_submission_sends_briefing_and_confirmation_emails(): void
{
$settings = SiteSetting::instance();
Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload());
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
return $mail->hasTo($settings->email);
});
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
return $mail->hasTo('maria@example.com');
});
}
public function test_honeypot_submission_is_dropped_without_sending_emails(): void
{
SiteSetting::instance();
Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload([
'empresa' => 'http://spam.example',
]));
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertNothingSent();
}
public function test_submission_requires_privacy_acceptance(): void
{
SiteSetting::instance();
Mail::fake();
$response = $this->from(route('contact'))->post(route('contact.store'), $this->validPayload([
'privacidade' => '',
]));
$response->assertRedirect(route('contact'));
$response->assertSessionHasErrors('privacidade');
Mail::assertNothingSent();
}
public function test_invalid_submission_returns_validation_errors(): void
{
SiteSetting::instance();
Mail::fake();
$response = $this->from(route('contact'))->post(route('contact.store'), []);
$response->assertRedirect(route('contact'));
foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) {
$response->assertSessionHasErrors($field);
}
Mail::assertNothingSent();
}
public function test_duplicate_submission_is_not_sent_twice(): void
{
SiteSetting::instance();
Mail::fake();
$payload = $this->validPayload();
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
Mail::assertQueued(ContactBriefing::class, 1);
Mail::assertQueued(ContactBriefingConfirmation::class, 1);
}
public function test_email_failure_does_not_block_submission(): void
{
SiteSetting::instance();
config(['mail.default' => 'mailer-inexistente']);
$response = $this->post(route('contact.store'), $this->validPayload());
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
}
}

View File

@@ -134,8 +134,10 @@ class PublicPagesTest extends TestCase
->assertDontSee('Fortaleza') ->assertDontSee('Fortaleza')
->assertSee('https://instagram.com/amare', false) ->assertSee('https://instagram.com/amare', false)
->assertSee('min-h-[calc(100dvh-14rem)]', false) ->assertSee('min-h-[calc(100dvh-14rem)]', false)
->assertDontSee('<form', false) ->assertSee('<form', false)
->assertDontSee('</form>', false); ->assertSee('name="nome"', false)
->assertSee('name="privacidade"', false)
->assertSee(route('privacy'), false);
} }
public function test_site_setting_defaults_use_sao_paulo_and_official_email(): void public function test_site_setting_defaults_use_sao_paulo_and_official_email(): void