diff --git a/CLAUDE.md b/CLAUDE.md index f7a31d33..85a99664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,11 +23,16 @@ CI (`.github/workflows/laravel.yml`) runs on push/PR to `main`. It uses PHP 8.3 - Commits use Conventional Commits (`feat:`, `refactor:`, `style:`, `chore:`, `fix:`), written in English. - Work happens on feature branches. PRs target `main`. - Admin "builder" screens (component library + live preview + step inspector) follow `docs/PADRAO_UI_BUILDER.md`. Every admin page gets the compact one-line header (breadcrumb › title, subheading as a `?` tooltip, actions on the right) from the overridden `resources/views/vendor/filament-panels/components/header/index.blade.php`; set heading/subheading/actions through Filament methods, never build a header inside the view. Public-site blocks follow `docs/estilo-minimalista-design.md`. +- **Admin form fields (required on every form):** + - every field has a `->helperText()` (short, in Portuguese); + - every field that supports it has a `->prefixIcon()` (TextInput, Select, DatePicker/DateTimePicker, ColorPicker…; Textarea, FileUpload, RichEditor and Toggle have no prefix icon in Filament 3); + - every `Toggle` has `->onIcon('heroicon-m-check')`; + - every button group (`ToggleButtons`, `Radio` styled as buttons) has an icon on each option (`->icons([...])`). ## Architecture ### Settings singleton -`App\Models\Setting` is a single row whose `settings` column is a JSON array. Read values with `Setting::get('website.navigation')` (dot notation, memoized per request). The top-level keys are `website`, `company`, `layout` and `security`. Admins edit them in `app/Filament/Pages/DesignAndSeo.php` and `SettingResource`. Maintenance mode is enforced by `CheckMaintenanceMode` middleware, which is appended globally in `bootstrap/app.php` and skips `admin*` and `livewire*` routes. +`App\Models\Setting` is a single row whose `settings` column is a JSON array. Read values with `Setting::get('website.navigation')` (dot notation, memoized per request). The top-level keys are `website`, `company`, `layout` and `security`. Admins edit them in `app/Filament/Pages/DesignAndSeo.php` and `SettingResource`, which is a single centered page without tabs (`EditCompany`: company, address, PDF, rental and security sections) built like Design e SEO: save/discard in the header, a "Tudo salvo" footer, aside sections (view and styles in `resources/views/filament/settings/`). Maintenance mode is enforced by `CheckMaintenanceMode` middleware, which is appended globally in `bootstrap/app.php` and skips `admin*` and `livewire*` routes. ### Public site = Page Builder - `routes/web.php` ends with a catch-all `/{slug?}` → `PageController@show`. Any new public route **must be registered above it**. diff --git a/app/Filament/Resources/SettingResource.php b/app/Filament/Resources/SettingResource.php index 9f838543..16e3d560 100644 --- a/app/Filament/Resources/SettingResource.php +++ b/app/Filament/Resources/SettingResource.php @@ -2,24 +2,28 @@ namespace App\Filament\Resources; +use App\Enums\AccessArea; +use App\Filament\Concerns\BelongsToArea; use App\Filament\Resources\SettingResource\Pages; use App\Models\Setting; use Filament\Forms\Form; use Filament\Resources\Resource; -use Filament\Pages\SubNavigationPosition; -use Filament\Resources\Pages\Page; class SettingResource extends Resource { - use \App\Filament\Concerns\BelongsToArea; + use BelongsToArea; - public static function getAccessArea(): \App\Enums\AccessArea + public static function getAccessArea(): AccessArea { - return \App\Enums\AccessArea::Configuracoes; + return AccessArea::Configuracoes; } protected static ?string $model = Setting::class; + protected static ?string $modelLabel = 'configuração'; + + protected static ?string $pluralModelLabel = 'configurações'; + public static function getNavigationGroup(): ?string { return 'Configurações'; @@ -31,8 +35,6 @@ public static function getNavigationGroup(): ?string protected static ?string $navigationLabel = 'Configurações'; - protected static SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top; - public static function getNavigationUrl(): string { return Pages\EditCompany::getUrl(['record' => Setting::first()?->id ?? 1]); @@ -40,24 +42,15 @@ public static function getNavigationUrl(): string public static function form(Form $form): Form { - // O formulário agora é definido individualmente em cada página de edição + // O formulário é definido na página de edição return $form; } - public static function getRecordSubNavigation(Page $page): array - { - return $page->generateNavigationItems([ - Pages\EditCompany::class, - Pages\EditSecurity::class, - ]); - } - public static function getPages(): array { return [ 'index' => Pages\ListSettings::route('/'), 'company' => Pages\EditCompany::route('/{record}/company'), - 'security' => Pages\EditSecurity::route('/{record}/security'), ]; } } diff --git a/app/Filament/Resources/SettingResource/Pages/EditCompany.php b/app/Filament/Resources/SettingResource/Pages/EditCompany.php index ed6de48b..f3dc606f 100644 --- a/app/Filament/Resources/SettingResource/Pages/EditCompany.php +++ b/app/Filament/Resources/SettingResource/Pages/EditCompany.php @@ -2,208 +2,391 @@ namespace App\Filament\Resources\SettingResource\Pages; +use App\Filament\Resources\AlertResource; use App\Filament\Resources\SettingResource; +use App\Models\Alert; +use App\Models\Setting; +use App\Services\AddressFinderService; +use Filament\Actions\Action; +use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Section; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Forms\Form; +use Filament\Forms\Get; +use Filament\Forms\Set; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Enums\MaxWidth; +use Illuminate\Support\HtmlString; +/** + * Configurações da empresa e do acesso ao site numa página só, centralizada: + * seções com título à esquerda e campos à direita, salvar no cabeçalho. + */ class EditCompany extends EditRecord { protected static string $resource = SettingResource::class; - protected static ?string $navigationLabel = 'Dados da Empresa'; - - protected static ?string $navigationIcon = 'heroicon-o-building-office-2'; + protected static string $view = 'filament.settings.page'; public function getTitle(): string { - return 'Dados da Empresa'; + return 'Configurações'; + } + + public function getSubheading(): ?string + { + return 'Dados da empresa, endereço, orçamentos, locação e segurança do site.'; + } + + public function getMaxContentWidth(): MaxWidth + { + return MaxWidth::FiveExtraLarge; + } + + public function getBreadcrumbs(): array + { + return []; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('discard') + ->label('Descartar') + ->color('gray') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->modalHeading('Descartar alterações?') + ->modalDescription('O formulário volta para a última versão salva.') + ->modalSubmitActionLabel('Descartar') + ->action(function (): void { + $this->fillForm(); + $this->dispatch('settings-saved'); + + Notification::make()->title('Alterações descartadas')->send(); + }) + // Só aparece com alterações pendentes (a view marca ) + ->extraAttributes(['data-st-discard' => true]), + Action::make('save') + ->label('Salvar alterações') + ->icon('heroicon-o-check') + ->keyBindings(['mod+s']) + ->action('save'), + ]; + } + + protected function getFormActions(): array + { + return []; } public function form(Form $form): Form { return $form + ->columns(1) ->schema([ - \Filament\Forms\Components\Tabs::make('Configurações') - ->columnSpanFull() - ->persistTabInQueryString() - ->tabs([ - \Filament\Forms\Components\Tabs\Tab::make('Dados Institucionais') - ->icon('heroicon-o-building-office') - ->schema([ - Grid::make(2) - ->schema([ - TextInput::make('settings.company.trade_name') - ->label('Nome Fantasia') - ->prefixIcon('heroicon-o-building-storefront') - ->helperText('O nome pelo qual sua empresa é conhecida publicamente.') - ->required(), - TextInput::make('settings.company.legal_name') - ->label('Razão Social') - ->prefixIcon('heroicon-o-scale') - ->helperText('Nome oficial registrado em cartório.'), - TextInput::make('settings.company.document') - ->label('CNPJ') - ->prefixIcon('heroicon-o-identification') - ->helperText('Cadastro Nacional da Pessoa Jurídica.') - ->mask('99.999.999/9999-99') - ->placeholder('00.000.000/0000-00'), - TextInput::make('settings.company.ie') - ->label('Inscrição Estadual') - ->prefixIcon('heroicon-o-document-text') - ->helperText('Registro de contribuinte do ICMS.'), - TextInput::make('settings.company.im') - ->label('Inscrição Municipal') - ->prefixIcon('heroicon-o-document-text') - ->helperText('Registro de contribuinte municipal (ISS).'), - TextInput::make('settings.company.email') - ->label('E-mail Comercial') - ->prefixIcon('heroicon-o-envelope') - ->helperText('Endereço de e-mail para contatos oficiais.') - ->email(), - TextInput::make('settings.company.phone') - ->label('Telefone Principal') - ->prefixIcon('heroicon-o-phone') - ->helperText('Telefone fixo ou celular com DDD.') - ->tel(), - ]), - ]), - - \Filament\Forms\Components\Tabs\Tab::make('Localização') - ->icon('heroicon-o-map-pin') - ->schema([ - TextInput::make('settings.company.address.postcode') - ->label('CEP') - ->prefixIcon('heroicon-o-map') - ->helperText('Código de Endereçamento Postal.') - ->mask('99999-999') - ->placeholder('00000-000') - ->live() - ->afterStateUpdated(function ($state, \Filament\Forms\Set $set) { - if (strlen($state ?? '') === 9) { - (new \App\Services\AddressFinderService( - $state, - $set, - [ - 'logradouro' => 'settings.company.address.street', - 'bairro' => 'settings.company.address.neighborhood', - 'localidade' => 'settings.company.address.city', - 'uf' => 'settings.company.address.state', - ], - 'settings.company.address.postcode' - ))->find(); - } - }), - Grid::make(3) - ->schema([ - TextInput::make('settings.company.address.street') - ->label('Logradouro') - ->prefixIcon('heroicon-o-map-pin') - ->helperText('Rua, Avenida, etc.') - ->columnSpan(2), - TextInput::make('settings.company.address.number') - ->label('Número') - ->prefixIcon('heroicon-o-hashtag') - ->helperText('Número do imóvel ou S/N.') - ->columnSpan(1), - TextInput::make('settings.company.address.neighborhood') - ->label('Bairro') - ->prefixIcon('heroicon-o-home') - ->helperText('Nome do bairro.'), - TextInput::make('settings.company.address.city') - ->label('Cidade') - ->prefixIcon('heroicon-o-building-office') - ->helperText('Cidade da seu sede.'), - TextInput::make('settings.company.address.state') - ->label('Estado/UF') - ->prefixIcon('heroicon-o-globe-americas') - ->helperText('Sigla do estado (Ex: SP).'), - ]), - \Filament\Forms\Components\Textarea::make('settings.company.maps_code') - ->label('Código de Incorporação (Google Maps)') - ->helperText('Cole aqui o '), - ]), - - \Filament\Forms\Components\Tabs\Tab::make('Horário de Atendimento') - ->icon('heroicon-o-clock') - ->schema([ - TextInput::make('settings.company.opening_hours') - ->label('Horário de Funcionamento') - ->prefixIcon('heroicon-o-clock') - ->helperText('Descreva o horário de atendimento (Ex: Segunda a Sexta, 08:00 às 18:00).') - ->placeholder('Segunda a Sexta, 08:00 às 18:00') - ->required(), - ]), - - \Filament\Forms\Components\Tabs\Tab::make('Informações de Orçamento') - ->icon('heroicon-o-document-text') - ->schema([ - \Filament\Forms\Components\FileUpload::make('settings.company.invoice_logo') - ->label('Logo para Documentos (PDF)') - ->helperText('Logo que aparecerá no topo dos orçamentos e notas. Use formatos transparentes (PNG/SVG) para melhor resultado.') - ->image() - ->directory('logos') - ->visibility('public') - ->columnSpanFull(), - \Filament\Forms\Components\Textarea::make('settings.company.budget_information') - ->label('Textos Padrões e Termos') - ->helperText('Condições de pagamento, validade, prazos e observações legais que aparecerão no rodapé do PDF.') - ->rows(6) - ->columnSpanFull(), - ]), - - \Filament\Forms\Components\Tabs\Tab::make('Locação de Equipamentos') - ->icon('heroicon-o-truck') - ->schema([ - \Filament\Forms\Components\Toggle::make('settings.company.equipment_rental.is_active') - ->label('Ativar Módulo de Locação') - ->helperText('Suspender ou ativar o módulo de locação globalmente no site inteiro.') - ->onIcon('heroicon-o-check') - ->default(true), - TextInput::make('settings.company.equipment_rental.whatsapp_number') - ->label('WhatsApp Direto para Locação') - ->prefixIcon('heroicon-o-chat-bubble-left-right') - ->helperText('Se em branco, usará o número padrão do site para enviar a mensagem de locação.') - ->tel(), - \Filament\Forms\Components\Toggle::make('settings.company.equipment_rental.redirect_to_contact') - ->label('Redirecionar para página personalizada') - ->helperText('Se ativado, ao invés do WhatsApp, o botão redirecionará o usuário para o endereço informado abaixo.') - ->onIcon('heroicon-o-check') - ->live(), - TextInput::make('settings.company.equipment_rental.contact_page_url') - ->label('Endereço da Página de Atendimento') - ->prefixIcon('heroicon-o-link') - ->helperText('Digite a URL para onde o botão deve redirecionar (ex: /contato ou https://site.com/form).') - ->visible(fn (\Filament\Forms\Get $get) => $get('settings.company.equipment_rental.redirect_to_contact')), - ]), - ]), + ...$this->companySections(), + ...$this->addressSections(), + ...$this->budgetSections(), + ...$this->rentalSections(), + ...$this->securitySections(), ]); } - protected function getHeaderActions(): array + protected function companySections(): array { - return []; + return [ + Section::make('Identificação') + ->description('Aparece no rodapé do site e no cabeçalho dos orçamentos.') + ->aside() + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('settings.company.trade_name') + ->label('Nome fantasia') + ->prefixIcon('heroicon-o-building-storefront') + ->helperText('Nome pelo qual a empresa é conhecida.') + ->required(), + TextInput::make('settings.company.legal_name') + ->label('Razão social') + ->prefixIcon('heroicon-o-scale') + ->helperText('Nome oficial registrado.'), + TextInput::make('settings.company.document') + ->label('CNPJ') + ->prefixIcon('heroicon-o-identification') + ->helperText('Sai no cabeçalho do PDF.') + ->mask('99.999.999/9999-99') + ->placeholder('00.000.000/0000-00'), + TextInput::make('settings.company.ie') + ->label('Inscrição estadual') + ->prefixIcon('heroicon-o-document-text') + ->helperText('Contribuinte do ICMS. Opcional.'), + TextInput::make('settings.company.im') + ->label('Inscrição municipal') + ->prefixIcon('heroicon-o-document-text') + ->helperText('Contribuinte do ISS. Opcional.'), + ]), + ]), + + Section::make('Contato') + ->description('Canais oficiais mostrados aos clientes.') + ->aside() + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('settings.company.email') + ->label('E-mail comercial') + ->prefixIcon('heroicon-o-envelope') + ->helperText('Para contatos oficiais.') + ->email(), + TextInput::make('settings.company.phone') + ->label('Telefone') + ->prefixIcon('heroicon-o-phone') + ->helperText('Fixo ou celular, com DDD.') + ->placeholder('(21) 90000-0000') + ->tel(), + ]), + TextInput::make('settings.company.opening_hours') + ->label('Horário de atendimento') + ->prefixIcon('heroicon-o-clock') + ->placeholder('Segunda a Sexta, 08:00 às 18:00') + ->helperText('Exibido no topo do site e na seção de mapa.') + ->required(), + ]), + ]; } - public function getSubheading(): ?string + protected function addressSections(): array + { + return [ + Section::make('Sede') + ->description('Digite o CEP para preencher o restante automaticamente.') + ->aside() + ->schema([ + Grid::make(['default' => 1, 'sm' => 6])->schema([ + TextInput::make('settings.company.address.postcode') + ->label('CEP') + ->prefixIcon('heroicon-o-map') + ->helperText('Preenche o endereço.') + ->mask('99999-999') + ->placeholder('00000-000') + ->live(onBlur: true) + ->afterStateUpdated(function (?string $state, Set $set): void { + if (strlen($state ?? '') !== 9) { + return; + } + + (new AddressFinderService( + $state, + $set, + [ + 'logradouro' => 'settings.company.address.street', + 'bairro' => 'settings.company.address.neighborhood', + 'localidade' => 'settings.company.address.city', + 'uf' => 'settings.company.address.state', + ], + 'settings.company.address.postcode' + ))->find(); + }) + ->columnSpan(['sm' => 2]), + TextInput::make('settings.company.address.street') + ->label('Logradouro') + ->prefixIcon('heroicon-o-map-pin') + ->helperText('Rua, avenida, estrada.') + ->columnStart(['sm' => 1]) + ->columnSpan(['sm' => 4]), + TextInput::make('settings.company.address.number') + ->label('Número') + ->prefixIcon('heroicon-o-hashtag') + ->helperText('Ou S/N.') + ->placeholder('S/N') + ->columnSpan(['sm' => 2]), + TextInput::make('settings.company.address.neighborhood') + ->label('Bairro') + ->prefixIcon('heroicon-o-home') + ->helperText('Bairro da sede.') + ->columnSpan(['sm' => 2]), + TextInput::make('settings.company.address.city') + ->label('Cidade') + ->prefixIcon('heroicon-o-building-office') + ->helperText('Cidade da sede.') + ->columnSpan(['sm' => 2]), + TextInput::make('settings.company.address.state') + ->label('UF') + ->prefixIcon('heroicon-o-globe-americas') + ->helperText('Sigla do estado.') + ->placeholder('RJ') + ->maxLength(2) + ->columnSpan(['sm' => 2]), + ]), + ]), + + Section::make('Mapa') + ->description('Mostrado na seção de mapa do site.') + ->aside() + ->schema([ + Textarea::make('settings.company.maps_code') + ->label('Código do Google Maps') + ->helperText('No Google Maps: Compartilhar › Incorporar um mapa › Copiar HTML.') + ->placeholder('') + ->rows(4) + ->extraInputAttributes(['class' => 'font-mono text-xs', 'spellcheck' => 'false']), + ]), + ]; + } + + protected function budgetSections(): array { - return 'Gerencie as informações institucionais, contatos e endereço da sua empresa.'; + return [ + Section::make('Documento PDF') + ->description('Logo e textos do orçamento enviado ao cliente.') + ->aside() + ->schema([ + FileUpload::make('settings.company.invoice_logo') + ->label('Logo do documento') + ->helperText('PNG ou SVG com fundo transparente.') + ->image() + ->imagePreviewHeight('96') + ->directory('logos') + ->visibility('public'), + Textarea::make('settings.company.budget_information') + ->label('Condições e observações') + ->helperText('Pagamento, validade e prazos. Vai no rodapé do PDF.') + ->rows(6), + ]), + ]; } - protected function getRedirectUrl(): string + protected function rentalSections(): array { - return static::getUrl(['record' => $this->getRecord()]); + return [ + Section::make('Locação de equipamentos') + ->description('Como o visitante pede um equipamento pelo site.') + ->aside() + ->schema([ + Toggle::make('settings.company.equipment_rental.is_active') + ->label('Locação disponível no site') + ->onIcon('heroicon-m-check') + ->helperText('Desligada, a vitrine de equipamentos mostra o serviço como suspenso.') + // O site trata a chave ausente como ativa + ->afterStateHydrated(fn (Toggle $component, $state) => $component->state($state ?? true)) + ->live(), + TextInput::make('settings.company.equipment_rental.whatsapp_number') + ->label('WhatsApp da locação') + ->prefixIcon('heroicon-o-chat-bubble-left-right') + ->placeholder('(21) 90000-0000') + ->helperText('Em branco, usa o WhatsApp principal do site.') + ->tel() + ->visible(fn (Get $get): bool => (bool) $get('settings.company.equipment_rental.is_active')), + Toggle::make('settings.company.equipment_rental.redirect_to_contact') + ->label('Enviar para uma página em vez do WhatsApp') + ->onIcon('heroicon-m-check') + ->helperText('O botão de locação abre a página informada abaixo.') + ->live() + ->visible(fn (Get $get): bool => (bool) $get('settings.company.equipment_rental.is_active')), + TextInput::make('settings.company.equipment_rental.contact_page_url') + ->label('Endereço da página') + ->prefixIcon('heroicon-o-link') + ->placeholder('/atendimento') + ->helperText('Caminho do site (/contato) ou link completo.') + ->visible(fn (Get $get): bool => $get('settings.company.equipment_rental.is_active') + && $get('settings.company.equipment_rental.redirect_to_contact')), + ]), + ]; + } + + protected function securitySections(): array + { + return [ + Section::make('Modo manutenção') + ->description('Tira o site do ar para os visitantes. O painel continua acessível.') + ->aside() + ->schema([ + Toggle::make('settings.security.maintenance_mode') + ->label('Site em manutenção') + ->onIcon('heroicon-m-check') + ->helperText('Visitantes veem só a página de manutenção.') + ->live(), + Textarea::make('settings.security.maintenance_message') + ->label('Mensagem para os visitantes') + ->helperText('Exibida na página de manutenção.') + ->rows(3) + ->visible(fn (Get $get): bool => (bool) $get('settings.security.maintenance_mode')), + ]), + + Section::make('Anti-spam') + ->description('Cloudflare Turnstile nos formulários de contato e orçamento.') + ->aside() + ->schema([ + Toggle::make('settings.security.turnstile.enabled') + ->label('Proteção ativa') + ->onIcon('heroicon-m-check') + ->helperText('Bloqueia robôs sem pedir nada ao visitante.') + ->live(), + Grid::make(['default' => 1, 'sm' => 2]) + ->visible(fn (Get $get): bool => (bool) $get('settings.security.turnstile.enabled')) + ->schema([ + TextInput::make('settings.security.turnstile.site_key') + ->label('Site key') + ->prefixIcon('heroicon-o-key') + ->helperText('Chave pública do painel da Cloudflare.') + ->placeholder('1x00000000000000000000AA') + ->required(fn (Get $get): bool => (bool) $get('settings.security.turnstile.enabled')), + TextInput::make('settings.security.turnstile.secret_key') + ->label('Secret key') + ->prefixIcon('heroicon-o-lock-closed') + ->helperText('Chave secreta. Não compartilhe.') + ->placeholder('1x0000000000000000000000000000000AA') + ->required(fn (Get $get): bool => (bool) $get('settings.security.turnstile.enabled')) + ->password() + ->revealable(), + ]), + ]), + + Section::make('Privacidade (LGPD)') + ->description('O aviso de cookies é um alerta do site.') + ->aside() + ->schema([ + Placeholder::make('cookie_consent_alert') + ->hiddenLabel() + ->content(function (): HtmlString { + $alert = Alert::where('style', Alert::STYLE_CONSENT)->orderBy('id')->first(); + $url = $alert + ? AlertResource::getUrl('edit', ['record' => $alert]) + : AlertResource::getUrl('create'); + $status = match (true) { + ! $alert => ['Ainda não criado', 'gray'], + $alert->status() === Alert::STATUS_ACTIVE => ['Ativo no site', 'success'], + default => ['Fora do ar', 'warning'], + }; + + return new HtmlString( + ''.e($status[0]).'' + .''.($alert ? 'Editar aviso de cookies' : 'Criar aviso de cookies').' →' + ); + }), + ]), + ]; + } + + protected function getRedirectUrl(): ?string + { + // Fica na mesma tela depois de salvar + return null; + } + + protected function afterSave(): void + { + $this->dispatch('settings-saved'); } protected function mutateFormDataBeforeSave(array $data): array { - $currentSettings = $this->getRecord()->settings ?? []; - $data['settings'] = array_replace_recursive($currentSettings, $data['settings']); - + $data['settings'] = Setting::merge($this->getRecord()->settings ?? [], $data['settings'] ?? []); + return $data; } } diff --git a/app/Filament/Resources/SettingResource/Pages/EditSecurity.php b/app/Filament/Resources/SettingResource/Pages/EditSecurity.php deleted file mode 100644 index 9ccfbcaa..00000000 --- a/app/Filament/Resources/SettingResource/Pages/EditSecurity.php +++ /dev/null @@ -1,117 +0,0 @@ -schema([ - Section::make('Controle de Acesso e Manutenção') - ->icon('heroicon-o-lock-closed') - ->description('Gerencie a visibilidade do site e restrições de IP.') - ->schema([ - Toggle::make('settings.security.maintenance_mode') - ->label('Modo Manutenção') - ->helperText('Quando ativado, os visitantes verão apenas a página de manutenção.') - ->reactive() - ->inline(false), - Textarea::make('settings.security.maintenance_message') - ->label('Mensagem de Manutenção') - ->helperText('Texto exibido para os visitantes enquanto o site estiver em manutenção.') - ->visible(fn($get) => $get('settings.security.maintenance_mode')) - ->rows(3), - ]), - - Section::make('Privacidade (LGPD)') - ->icon('heroicon-o-shield-exclamation') - ->description('Gerencie as configurações de privacidade e consentimento de cookies.') - ->schema([ - // O aviso de cookies agora é um alerta (estilo Consentimento) em Alertas e notificações - \Filament\Forms\Components\Placeholder::make('cookie_consent_alert') - ->label('Aviso de Cookies') - ->content(function (): \Illuminate\Support\HtmlString { - $alert = \App\Models\Alert::where('style', \App\Models\Alert::STYLE_CONSENT)->orderBy('id')->first(); - $url = $alert - ? \App\Filament\Resources\AlertResource::getUrl('edit', ['record' => $alert]) - : \App\Filament\Resources\AlertResource::getUrl('create'); - $status = $alert ? ($alert->status() === \App\Models\Alert::STATUS_ACTIVE ? 'ativo no site' : 'não está aparecendo no site') : 'ainda não criado'; - - return new \Illuminate\Support\HtmlString( - 'O banner de cookies agora é gerenciado em Alertas e notificações ('.e($status).'). ' - .'' - .($alert ? 'Editar aviso de cookies' : 'Criar aviso de cookies').'' - ); - }), - ]), - - Section::make('Cloudflare Turnstile (Anti-Spam)') - ->icon('heroicon-o-shield-check') - ->description('Proteja seus formulários contra bots de forma invisível.') - ->schema([ - Toggle::make('settings.security.turnstile.enabled') - ->label('Habilitar Turnstile') - ->helperText('Ativa a proteção anti-spam nos formulários de contato e orçamento.') - ->reactive() - ->inline(false), - \Filament\Forms\Components\Grid::make(2) - ->visible(fn($get) => $get('settings.security.turnstile.enabled')) - ->schema([ - \Filament\Forms\Components\TextInput::make('settings.security.turnstile.site_key') - ->label('Site Key') - ->placeholder('1x00000000000000000000AA') - ->required(fn($get) => $get('settings.security.turnstile.enabled')), - \Filament\Forms\Components\TextInput::make('settings.security.turnstile.secret_key') - ->label('Secret Key') - ->placeholder('1x0000000000000000000000000000000AA') - ->required(fn($get) => $get('settings.security.turnstile.enabled')) - ->password() - ->revealable(), - ]), - ]), - ]); - } - - protected function getHeaderActions(): array - { - return []; - } - - public function getSubheading(): ?string - { - return 'Gerencie as configurações de segurança, modo manutenção e proteções anti-spam.'; - } - - protected function getRedirectUrl(): string - { - return static::getUrl(['record' => $this->getRecord()]); - } - - protected function mutateFormDataBeforeSave(array $data): array - { - $currentSettings = $this->getRecord()->settings ?? []; - $data['settings'] = array_replace_recursive($currentSettings, $data['settings']); - - return $data; - } -} diff --git a/app/Filament/Resources/SettingResource/Pages/ListSettings.php b/app/Filament/Resources/SettingResource/Pages/ListSettings.php index e0032fe8..7b03c4c6 100644 --- a/app/Filament/Resources/SettingResource/Pages/ListSettings.php +++ b/app/Filament/Resources/SettingResource/Pages/ListSettings.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\SettingResource\Pages; use App\Filament\Resources\SettingResource; +use App\Models\Setting; use Filament\Resources\Pages\ListRecords; class ListSettings extends ListRecords @@ -11,6 +12,6 @@ class ListSettings extends ListRecords public function mount(): void { - $this->redirect(EditWebsite::getUrl(['record' => 1])); + $this->redirect(EditCompany::getUrl(['record' => Setting::firstOrCreate(['id' => 1])])); } } diff --git a/resources/views/filament/settings/page.blade.php b/resources/views/filament/settings/page.blade.php new file mode 100644 index 00000000..da49a98f --- /dev/null +++ b/resources/views/filament/settings/page.blade.php @@ -0,0 +1,40 @@ + + @include('filament.settings.styles') + +
+
+ {{ $this->form }} +
+ + +
+
diff --git a/resources/views/filament/settings/styles.blade.php b/resources/views/filament/settings/styles.blade.php new file mode 100644 index 00000000..628f7928 --- /dev/null +++ b/resources/views/filament/settings/styles.blade.php @@ -0,0 +1,71 @@ +{{-- Configurações (CSS puro). Prefixo st-; tokens e primitivas vêm de filament/admin/ui --}} + diff --git a/tests/Feature/SettingsPageTest.php b/tests/Feature/SettingsPageTest.php new file mode 100644 index 00000000..5458ee82 --- /dev/null +++ b/tests/Feature/SettingsPageTest.php @@ -0,0 +1,56 @@ +setting = Setting::create(['settings' => [ + 'website' => ['name' => 'Codhous', 'navigation' => [['label' => 'Início', 'url' => '/']]], + 'company' => ['trade_name' => 'Codhous', 'opening_hours' => 'Seg a Sex'], + ]]); + + $this->actingAs(User::factory()->create()); +}); + +test('a lista de configurações leva direto para a tela de edição', function () { + $this->get('/admin/settings') + ->assertRedirect(EditCompany::getUrl(['record' => $this->setting])); +}); + +test('configurações ficam numa página só, sem abas nem sub-navegação', function () { + $this->get(EditCompany::getUrl(['record' => $this->setting])) + ->assertOk() + ->assertSee('Anti-spam') + ->assertDontSee('fi-tabs', escape: false) + ->assertSee('Tudo salvo') + ->assertDontSee('fi-page-sub-navigation-tabs', escape: false); +}); + +test('salvar mantém as demais chaves das configurações', function () { + livewire(EditCompany::class, ['record' => $this->setting->getRouteKey()]) + ->fillForm([ + 'settings.company.trade_name' => 'Concreteira Nova', + 'settings.security.maintenance_mode' => true, + 'settings.security.maintenance_message' => 'Voltamos já', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $settings = $this->setting->fresh()->settings; + + expect(data_get($settings, 'company.trade_name'))->toBe('Concreteira Nova') + ->and(data_get($settings, 'company.opening_hours'))->toBe('Seg a Sex') + ->and(data_get($settings, 'security.maintenance_message'))->toBe('Voltamos já') + ->and(data_get($settings, 'website.navigation'))->toBe([['label' => 'Início', 'url' => '/']]); +}); + +test('locação sem configuração aparece como ativa, como no site', function () { + livewire(EditCompany::class, ['record' => $this->setting->getRouteKey()]) + ->assertFormSet(['settings.company.equipment_rental.is_active' => true]); +});