feat: CMS de conteúdo do site (WEB-02/03/04/06)
Implementa gestão Filament de configurações, serviços, portfólio e depoimentos com policies admin-only, upload validado e seed determinístico. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
20
README.md
20
README.md
@@ -70,6 +70,26 @@ composer test:browser # E2E browser
|
|||||||
composer test # Todos os testes
|
composer test # Todos os testes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Banco de testes (PostgreSQL)
|
||||||
|
|
||||||
|
Feature tests usam PostgreSQL conforme `phpunit.xml` (`DB_DATABASE=amare_test`).
|
||||||
|
|
||||||
|
Crie o banco de teste uma vez (com PostgreSQL local via Docker Compose):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec amare-postgres psql -U amare -d amare -c "CREATE DATABASE amare_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Armazenamento de mídia
|
||||||
|
|
||||||
|
Uploads de conteúdo usam o disco `public`. Crie o symlink antes de servir arquivos localmente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan storage:link
|
||||||
|
```
|
||||||
|
|
||||||
|
Em produção, configure `FILESYSTEM_DISK=s3` no `.env`.
|
||||||
|
|
||||||
## Credenciais de desenvolvimento
|
## Credenciais de desenvolvimento
|
||||||
|
|
||||||
Após `php artisan db:seed`:
|
Após `php artisan db:seed`:
|
||||||
|
|||||||
243
app/Filament/Pages/ManageSiteSettings.php
Normal file
243
app/Filament/Pages/ManageSiteSettings.php
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Concerns\CanUseDatabaseTransactions;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Actions;
|
||||||
|
use Filament\Schemas\Components\Component;
|
||||||
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
|
use Filament\Schemas\Components\Form;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Exceptions\Halt;
|
||||||
|
use Filament\Support\Facades\FilamentView;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Throwable;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property-read Schema $form
|
||||||
|
*/
|
||||||
|
class ManageSiteSettings extends Page
|
||||||
|
{
|
||||||
|
use CanUseDatabaseTransactions;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Configurações';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Configurações do site';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public SiteSetting $record;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user !== null && $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->record = SiteSetting::instance();
|
||||||
|
|
||||||
|
abort_unless(auth()->user()?->can('update', $this->record), 403);
|
||||||
|
|
||||||
|
$this->fillForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hydrate(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()?->can('update', $this->record), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function fillForm(): void
|
||||||
|
{
|
||||||
|
$this->form->fill($this->record->attributesToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->beginDatabaseTransaction();
|
||||||
|
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
$this->record->update($data);
|
||||||
|
} catch (Halt $exception) {
|
||||||
|
$exception->shouldRollbackDatabaseTransaction() ?
|
||||||
|
$this->rollBackDatabaseTransaction() :
|
||||||
|
$this->commitDatabaseTransaction();
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->rollBackDatabaseTransaction();
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commitDatabaseTransaction();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Configurações salvas com sucesso.')
|
||||||
|
->send();
|
||||||
|
|
||||||
|
if ($redirectUrl = $this->getRedirectUrl()) {
|
||||||
|
$this->redirect($redirectUrl, navigate: FilamentView::hasSpaMode($redirectUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->operation('edit')
|
||||||
|
->model($this->record)
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Marca e hero')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('brand_name')
|
||||||
|
->label('Nome da marca')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('hero_eyebrow')
|
||||||
|
->label('Eyebrow do hero')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('hero_title')
|
||||||
|
->label('Título do hero')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('hero_subtitle')
|
||||||
|
->label('Subtítulo do hero')
|
||||||
|
->required()
|
||||||
|
->rows(3),
|
||||||
|
TextInput::make('hero_cta_label')
|
||||||
|
->label('Texto do CTA')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('about_summary')
|
||||||
|
->label('Resumo institucional')
|
||||||
|
->rows(3),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Contato')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('email')
|
||||||
|
->label('E-mail')
|
||||||
|
->email()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('phone')
|
||||||
|
->label('Telefone')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('city')
|
||||||
|
->label('Cidade')
|
||||||
|
->maxLength(255),
|
||||||
|
KeyValue::make('social_links')
|
||||||
|
->label('Redes sociais')
|
||||||
|
->keyLabel('Rede')
|
||||||
|
->valueLabel('URL')
|
||||||
|
->addActionLabel('Adicionar rede'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('SEO padrão')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('default_meta_title')
|
||||||
|
->label('Meta title padrão')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('default_meta_description')
|
||||||
|
->label('Meta description padrão')
|
||||||
|
->required()
|
||||||
|
->rows(3),
|
||||||
|
PublicImageUploadRules::fileUpload('default_og_image_path', 'Imagem Open Graph padrão', 'content/og'),
|
||||||
|
]),
|
||||||
|
Section::make('Analytics')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('analytics_enabled')
|
||||||
|
->label('Analytics habilitado')
|
||||||
|
->default(false),
|
||||||
|
Textarea::make('analytics_script')
|
||||||
|
->label('Script de analytics')
|
||||||
|
->rows(4)
|
||||||
|
->visible(fn (callable $get): bool => (bool) $get('analytics_enabled')),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action|ActionGroup>
|
||||||
|
*/
|
||||||
|
protected function getFormActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('save')
|
||||||
|
->label('Salvar')
|
||||||
|
->submit('save')
|
||||||
|
->keyBindings(['mod+s']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
$this->getFormContentComponent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormContentComponent(): Component
|
||||||
|
{
|
||||||
|
return Form::make([EmbeddedSchema::make('form')])
|
||||||
|
->id('form')
|
||||||
|
->livewireSubmitHandler('save')
|
||||||
|
->footer([
|
||||||
|
Actions::make($this->getFormActions())
|
||||||
|
->alignment($this->getFormActionsAlignment())
|
||||||
|
->fullWidth($this->hasFullWidthFormActions())
|
||||||
|
->sticky($this->areFormActionsSticky())
|
||||||
|
->key('form-actions'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasFullWidthFormActions(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreatePortfolioCase extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PortfolioCaseResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditPortfolioCase extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PortfolioCaseResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPortfolioCases extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PortfolioCaseResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\CreatePortfolioCase;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\EditPortfolioCase;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\ListPortfolioCases;
|
||||||
|
use App\Filament\Resources\PortfolioCases\RelationManagers\ImagesRelationManager;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Schemas\PortfolioCaseForm;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Tables\PortfolioCasesTable;
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Policies\PortfolioCasePolicy;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class PortfolioCaseResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = PortfolioCase::class;
|
||||||
|
|
||||||
|
protected static ?string $policy = PortfolioCasePolicy::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Portfólio';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'caso';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'portfólio';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PortfolioCaseForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return PortfolioCasesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ImagesRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListPortfolioCases::route('/'),
|
||||||
|
'create' => CreatePortfolioCase::route('/create'),
|
||||||
|
'edit' => EditPortfolioCase::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\RelationManagers;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ImagesRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'images';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Galeria';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'imagem';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'galeria';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
PublicImageUploadRules::fileUpload('path', 'Imagem', 'content/portfolio'),
|
||||||
|
PublicImageUploadRules::altTextField('alt_text', 'path'),
|
||||||
|
TextInput::make('caption')
|
||||||
|
->label('Legenda')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('alt_text')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('alt_text')
|
||||||
|
->label('Texto alternativo')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('caption')
|
||||||
|
->label('Legenda'),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->reorderable('sort_order')
|
||||||
|
->headerActions([
|
||||||
|
CreateAction::make(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Schemas;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PortfolioCaseForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||||
|
if (blank($get('slug'))) {
|
||||||
|
$set('slug', str($state)->slug()->toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Resumo')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('event_type')
|
||||||
|
->label('Tipo de evento')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('city')
|
||||||
|
->label('Cidade')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('venue')
|
||||||
|
->label('Local')
|
||||||
|
->maxLength(255),
|
||||||
|
DatePicker::make('event_date')
|
||||||
|
->label('Data do evento'),
|
||||||
|
Textarea::make('challenge')
|
||||||
|
->label('Desafio')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
Textarea::make('solution')
|
||||||
|
->label('Solução')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
Textarea::make('result')
|
||||||
|
->label('Resultado')
|
||||||
|
->rows(4),
|
||||||
|
PublicImageUploadRules::fileUpload('cover_image_path', 'Imagem de capa'),
|
||||||
|
PublicImageUploadRules::altTextField('cover_image_alt', 'cover_image_path'),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->default(false),
|
||||||
|
DateTimePicker::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->seconds(false),
|
||||||
|
TextInput::make('meta_title')
|
||||||
|
->label('Meta title')
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('meta_description')
|
||||||
|
->label('Meta description')
|
||||||
|
->rows(3),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PortfolioCasesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('event_type')
|
||||||
|
->label('Tipo')
|
||||||
|
->searchable(),
|
||||||
|
IconColumn::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Services/Pages/CreateService.php
Normal file
11
app/Filament/Resources/Services/Pages/CreateService.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateService extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceResource::class;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Services/Pages/EditService.php
Normal file
19
app/Filament/Resources/Services/Pages/EditService.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditService extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Services/Pages/ListServices.php
Normal file
19
app/Filament/Resources/Services/Pages/ListServices.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListServices extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Filament/Resources/Services/Schemas/ServiceForm.php
Normal file
58
app/Filament/Resources/Services/Schemas/ServiceForm.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Schemas;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class ServiceForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||||
|
if (blank($get('slug'))) {
|
||||||
|
$set('slug', str($state)->slug()->toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Resumo')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('description')
|
||||||
|
->label('Descrição')
|
||||||
|
->required()
|
||||||
|
->rows(6),
|
||||||
|
PublicImageUploadRules::fileUpload('cover_image_path', 'Imagem de capa'),
|
||||||
|
PublicImageUploadRules::altTextField('cover_image_alt', 'cover_image_path'),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->default(false),
|
||||||
|
DateTimePicker::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->seconds(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Filament/Resources/Services/ServiceResource.php
Normal file
62
app/Filament/Resources/Services/ServiceResource.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\Pages\CreateService;
|
||||||
|
use App\Filament\Resources\Services\Pages\EditService;
|
||||||
|
use App\Filament\Resources\Services\Pages\ListServices;
|
||||||
|
use App\Filament\Resources\Services\Schemas\ServiceForm;
|
||||||
|
use App\Filament\Resources\Services\Tables\ServicesTable;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Policies\ServicePolicy;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class ServiceResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Service::class;
|
||||||
|
|
||||||
|
protected static ?string $policy = ServicePolicy::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBriefcase;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Serviços';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'serviço';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'serviços';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return ServiceForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return ServicesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListServices::route('/'),
|
||||||
|
'create' => CreateService::route('/create'),
|
||||||
|
'edit' => EditService::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Filament/Resources/Services/Tables/ServicesTable.php
Normal file
54
app/Filament/Resources/Services/Tables/ServicesTable.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ServicesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->searchable(),
|
||||||
|
IconColumn::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateTestimonial extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TestimonialResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditTestimonial extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TestimonialResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListTestimonials extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = TestimonialResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Schemas;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class TestimonialForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Textarea::make('quote')
|
||||||
|
->label('Depoimento')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
TextInput::make('author_name')
|
||||||
|
->label('Nome do autor')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('context')
|
||||||
|
->label('Contexto')
|
||||||
|
->maxLength(255),
|
||||||
|
PublicImageUploadRules::fileUpload('photo_path', 'Foto', 'content/testimonials'),
|
||||||
|
PublicImageUploadRules::altTextField('photo_alt', 'photo_path'),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->default(false),
|
||||||
|
DateTimePicker::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->seconds(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class TestimonialsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('author_name')
|
||||||
|
->label('Autor')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('quote')
|
||||||
|
->label('Depoimento')
|
||||||
|
->limit(60),
|
||||||
|
IconColumn::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->filters([
|
||||||
|
TernaryFilter::make('is_featured')
|
||||||
|
->label('Destaque'),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Filament/Resources/Testimonials/TestimonialResource.php
Normal file
62
app/Filament/Resources/Testimonials/TestimonialResource.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
||||||
|
use App\Filament\Resources\Testimonials\Schemas\TestimonialForm;
|
||||||
|
use App\Filament\Resources\Testimonials\Tables\TestimonialsTable;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Policies\TestimonialPolicy;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class TestimonialResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Testimonial::class;
|
||||||
|
|
||||||
|
protected static ?string $policy = TestimonialPolicy::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Depoimentos';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'depoimento';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'depoimentos';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 4;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return TestimonialForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return TestimonialsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListTestimonials::route('/'),
|
||||||
|
'create' => CreateTestimonial::route('/create'),
|
||||||
|
'edit' => EditTestimonial::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Models/Concerns/HasPublication.php
Normal file
19
app/Models/Concerns/HasPublication.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
trait HasPublication
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('published_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Models/PortfolioCase.php
Normal file
78
app/Models/PortfolioCase.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasPublication;
|
||||||
|
use App\Policies\PortfolioCasePolicy;
|
||||||
|
use Database\Factories\PortfolioCaseFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Carbon|null $published_at
|
||||||
|
* @property Carbon|null $event_date
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'summary',
|
||||||
|
'event_type',
|
||||||
|
'city',
|
||||||
|
'venue',
|
||||||
|
'event_date',
|
||||||
|
'challenge',
|
||||||
|
'solution',
|
||||||
|
'result',
|
||||||
|
'cover_image_path',
|
||||||
|
'cover_image_alt',
|
||||||
|
'is_featured',
|
||||||
|
'sort_order',
|
||||||
|
'published_at',
|
||||||
|
'meta_title',
|
||||||
|
'meta_description',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(PortfolioCasePolicy::class)]
|
||||||
|
class PortfolioCase extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PortfolioCaseFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasPublication;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (PortfolioCase $portfolioCase): void {
|
||||||
|
if (blank($portfolioCase->slug) && filled($portfolioCase->title)) {
|
||||||
|
$portfolioCase->slug = Str::slug($portfolioCase->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<PortfolioImage, $this>
|
||||||
|
*/
|
||||||
|
public function images(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PortfolioImage::class)->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'event_date' => 'date',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Models/PortfolioImage.php
Normal file
37
app/Models/PortfolioImage.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'portfolio_case_id',
|
||||||
|
'path',
|
||||||
|
'alt_text',
|
||||||
|
'caption',
|
||||||
|
'sort_order',
|
||||||
|
])]
|
||||||
|
class PortfolioImage extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<PortfolioCase, $this>
|
||||||
|
*/
|
||||||
|
public function portfolioCase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PortfolioCase::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Models/Service.php
Normal file
60
app/Models/Service.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasPublication;
|
||||||
|
use App\Policies\ServicePolicy;
|
||||||
|
use Database\Factories\ServiceFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Carbon|null $published_at
|
||||||
|
* @property bool $is_featured
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'summary',
|
||||||
|
'description',
|
||||||
|
'cover_image_path',
|
||||||
|
'cover_image_alt',
|
||||||
|
'sort_order',
|
||||||
|
'is_featured',
|
||||||
|
'published_at',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(ServicePolicy::class)]
|
||||||
|
class Service extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<ServiceFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasPublication;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (Service $service): void {
|
||||||
|
if (blank($service->slug) && filled($service->title)) {
|
||||||
|
$service->slug = Str::slug($service->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Models/SiteSetting.php
Normal file
65
app/Models/SiteSetting.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Policies\SiteSettingPolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property array<string, string|null> $social_links
|
||||||
|
* @property bool $analytics_enabled
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'brand_name',
|
||||||
|
'hero_eyebrow',
|
||||||
|
'hero_title',
|
||||||
|
'hero_subtitle',
|
||||||
|
'hero_cta_label',
|
||||||
|
'about_summary',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'city',
|
||||||
|
'social_links',
|
||||||
|
'default_meta_title',
|
||||||
|
'default_meta_description',
|
||||||
|
'default_og_image_path',
|
||||||
|
'analytics_enabled',
|
||||||
|
'analytics_script',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(SiteSettingPolicy::class)]
|
||||||
|
class SiteSetting extends Model
|
||||||
|
{
|
||||||
|
public static function instance(): self
|
||||||
|
{
|
||||||
|
return static::query()->firstOrCreate([], [
|
||||||
|
'brand_name' => 'Amare Assessoria',
|
||||||
|
'hero_eyebrow' => 'Assessoria de eventos',
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos.',
|
||||||
|
'hero_cta_label' => 'Solicitar orçamento',
|
||||||
|
'about_summary' => 'Assessoria boutique em Fortaleza.',
|
||||||
|
'email' => 'contato@amare.local',
|
||||||
|
'phone' => '(85) 99999-9999',
|
||||||
|
'city' => 'Fortaleza, CE',
|
||||||
|
'social_links' => [],
|
||||||
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
||||||
|
'analytics_enabled' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_links' => 'array',
|
||||||
|
'analytics_enabled' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/Testimonial.php
Normal file
49
app/Models/Testimonial.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasPublication;
|
||||||
|
use App\Policies\TestimonialPolicy;
|
||||||
|
use Database\Factories\TestimonialFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Carbon|null $published_at
|
||||||
|
* @property bool $is_featured
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'quote',
|
||||||
|
'author_name',
|
||||||
|
'context',
|
||||||
|
'photo_path',
|
||||||
|
'photo_alt',
|
||||||
|
'sort_order',
|
||||||
|
'is_featured',
|
||||||
|
'published_at',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(TestimonialPolicy::class)]
|
||||||
|
class Testimonial extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TestimonialFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasPublication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Policies/PortfolioCasePolicy.php
Normal file
46
app/Policies/PortfolioCasePolicy.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PortfolioCasePolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Policies/ServicePolicy.php
Normal file
46
app/Policies/ServicePolicy.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class ServicePolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Policies/SiteSettingPolicy.php
Normal file
36
app/Policies/SiteSettingPolicy.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class SiteSettingPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, SiteSetting $siteSetting): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, SiteSetting $siteSetting): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, SiteSetting $siteSetting): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Policies/TestimonialPolicy.php
Normal file
46
app/Policies/TestimonialPolicy.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class TestimonialPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Support/PublicImageUploadRules.php
Normal file
57
app/Support/PublicImageUploadRules.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Filament\Forms\Components\FileUpload;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Illuminate\Validation\Rules\File;
|
||||||
|
|
||||||
|
final class PublicImageUploadRules
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||||
|
|
||||||
|
public const MAX_SIZE_KILOBYTES = 10240;
|
||||||
|
|
||||||
|
public static function fileUpload(string $name, string $label, string $directory = 'content'): FileUpload
|
||||||
|
{
|
||||||
|
return FileUpload::make($name)
|
||||||
|
->label($label)
|
||||||
|
->disk(self::disk())
|
||||||
|
->directory($directory)
|
||||||
|
->acceptedFileTypes(self::ALLOWED_MIMES)
|
||||||
|
->maxSize(self::MAX_SIZE_KILOBYTES)
|
||||||
|
->getUploadedFileNameForStorageUsing(
|
||||||
|
fn ($file): string => (string) str()->uuid().'.'.$file->getClientOriginalExtension(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function altTextField(string $name, string $imageField, string $label = 'Texto alternativo'): TextInput
|
||||||
|
{
|
||||||
|
return TextInput::make($name)
|
||||||
|
->label($label)
|
||||||
|
->required(fn (Closure $get): bool => filled($get($imageField)))
|
||||||
|
->maxLength(255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<File>
|
||||||
|
*/
|
||||||
|
public static function validationRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
File::types(self::ALLOWED_EXTENSIONS)->max(self::MAX_SIZE_KILOBYTES),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function disk(): string
|
||||||
|
{
|
||||||
|
return config('filesystems.default') === 's3' ? 's3' : 'public';
|
||||||
|
}
|
||||||
|
}
|
||||||
51
database/factories/PortfolioCaseFactory.php
Normal file
51
database/factories/PortfolioCaseFactory.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<PortfolioCase>
|
||||||
|
*/
|
||||||
|
class PortfolioCaseFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = PortfolioCase::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = fake()->unique()->words(4, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => str($title)->slug()->toString(),
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'event_type' => 'Casamento',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => fake()->company(),
|
||||||
|
'event_date' => fake()->date(),
|
||||||
|
'challenge' => fake()->paragraph(),
|
||||||
|
'solution' => fake()->paragraph(),
|
||||||
|
'result' => fake()->paragraph(),
|
||||||
|
'cover_image_path' => 'content/fixture-cover.jpg',
|
||||||
|
'cover_image_alt' => 'Capa do caso',
|
||||||
|
'is_featured' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'published_at' => null,
|
||||||
|
'meta_title' => null,
|
||||||
|
'meta_description' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
database/factories/ServiceFactory.php
Normal file
50
database/factories/ServiceFactory.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Service>
|
||||||
|
*/
|
||||||
|
class ServiceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Service::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = fake()->unique()->words(3, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => str($title)->slug()->toString(),
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'description' => fake()->paragraphs(2, true),
|
||||||
|
'cover_image_path' => null,
|
||||||
|
'cover_image_alt' => null,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featured(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'is_featured' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
database/factories/TestimonialFactory.php
Normal file
47
database/factories/TestimonialFactory.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Testimonial>
|
||||||
|
*/
|
||||||
|
class TestimonialFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Testimonial::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quote' => fake()->paragraph(),
|
||||||
|
'author_name' => fake()->name(),
|
||||||
|
'context' => fake()->optional()->jobTitle(),
|
||||||
|
'photo_path' => null,
|
||||||
|
'photo_alt' => null,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featured(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'is_featured' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('brand_name');
|
||||||
|
$table->string('hero_eyebrow')->nullable();
|
||||||
|
$table->string('hero_title');
|
||||||
|
$table->text('hero_subtitle');
|
||||||
|
$table->string('hero_cta_label');
|
||||||
|
$table->text('about_summary')->nullable();
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('phone');
|
||||||
|
$table->string('city')->nullable();
|
||||||
|
$table->jsonb('social_links')->default('[]');
|
||||||
|
$table->string('default_meta_title');
|
||||||
|
$table->text('default_meta_description');
|
||||||
|
$table->string('default_og_image_path')->nullable();
|
||||||
|
$table->boolean('analytics_enabled')->default(false);
|
||||||
|
$table->text('analytics_script')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('site_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('services', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('summary');
|
||||||
|
$table->text('description');
|
||||||
|
$table->string('cover_image_path')->nullable();
|
||||||
|
$table->string('cover_image_alt')->nullable();
|
||||||
|
$table->integer('sort_order')->default(0)->index();
|
||||||
|
$table->boolean('is_featured')->default(false)->index();
|
||||||
|
$table->timestamp('published_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('services');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('portfolio_cases', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('summary');
|
||||||
|
$table->string('event_type');
|
||||||
|
$table->string('city')->nullable();
|
||||||
|
$table->string('venue')->nullable();
|
||||||
|
$table->date('event_date')->nullable();
|
||||||
|
$table->text('challenge');
|
||||||
|
$table->text('solution');
|
||||||
|
$table->text('result')->nullable();
|
||||||
|
$table->string('cover_image_path');
|
||||||
|
$table->string('cover_image_alt');
|
||||||
|
$table->boolean('is_featured')->default(false)->index();
|
||||||
|
$table->integer('sort_order')->default(0)->index();
|
||||||
|
$table->timestamp('published_at')->nullable()->index();
|
||||||
|
$table->string('meta_title')->nullable();
|
||||||
|
$table->text('meta_description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('portfolio_cases');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('portfolio_images', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('portfolio_case_id')->constrained('portfolio_cases')->cascadeOnDelete();
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('alt_text');
|
||||||
|
$table->string('caption')->nullable();
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['portfolio_case_id', 'sort_order']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('portfolio_images');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('testimonials', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->text('quote');
|
||||||
|
$table->string('author_name');
|
||||||
|
$table->string('context')->nullable();
|
||||||
|
$table->string('photo_path')->nullable();
|
||||||
|
$table->string('photo_alt')->nullable();
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->boolean('is_featured')->default(false)->index();
|
||||||
|
$table->timestamp('published_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('testimonials');
|
||||||
|
}
|
||||||
|
};
|
||||||
213
database/seeders/ContentSeeder.php
Normal file
213
database/seeders/ContentSeeder.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\PortfolioImage;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ContentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->seedSiteSettings();
|
||||||
|
$this->seedServices();
|
||||||
|
$this->seedPortfolioCases();
|
||||||
|
$this->seedTestimonials();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedSiteSettings(): void
|
||||||
|
{
|
||||||
|
SiteSetting::query()->updateOrCreate([], [
|
||||||
|
'brand_name' => 'Amare Assessoria',
|
||||||
|
'hero_eyebrow' => 'Assessoria de eventos',
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em Fortaleza.',
|
||||||
|
'hero_cta_label' => 'Solicitar orçamento',
|
||||||
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis.',
|
||||||
|
'email' => 'contato@amare.local',
|
||||||
|
'phone' => '(85) 99999-9999',
|
||||||
|
'city' => 'Fortaleza, CE',
|
||||||
|
'social_links' => [
|
||||||
|
'instagram' => 'https://instagram.com/amare',
|
||||||
|
],
|
||||||
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
||||||
|
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'content/og/og-default.jpg'),
|
||||||
|
'analytics_enabled' => false,
|
||||||
|
'analytics_script' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedServices(): void
|
||||||
|
{
|
||||||
|
$services = [
|
||||||
|
[
|
||||||
|
'title' => 'Casamentos',
|
||||||
|
'slug' => 'casamentos',
|
||||||
|
'summary' => 'Planejamento completo do grande dia.',
|
||||||
|
'description' => 'Do briefing ao último brinde, cuidamos de cada detalhe do casamento.',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Eventos corporativos',
|
||||||
|
'slug' => 'eventos-corporativos',
|
||||||
|
'summary' => 'Experiências alinhadas à marca.',
|
||||||
|
'description' => 'Lançamentos, convenções e encontros corporativos com operação impecável.',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Celebrações intimistas',
|
||||||
|
'slug' => 'celebracoes-intimistas',
|
||||||
|
'summary' => 'Encontros especiais em escala reduzida.',
|
||||||
|
'description' => 'Aniversários, mini weddings e jantares com curadoria editorial.',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($services as $service) {
|
||||||
|
Service::query()->updateOrCreate(
|
||||||
|
['slug' => $service['slug']],
|
||||||
|
[
|
||||||
|
...$service,
|
||||||
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||||
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedPortfolioCases(): void
|
||||||
|
{
|
||||||
|
$cases = [
|
||||||
|
[
|
||||||
|
'title' => 'Casamento Ana e Lucas',
|
||||||
|
'slug' => 'casamento-ana-lucas',
|
||||||
|
'summary' => 'Cerimônia ao ar livre em Fortaleza.',
|
||||||
|
'event_type' => 'Casamento',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Espaço Jardim Atlântico',
|
||||||
|
'event_date' => '2025-11-20',
|
||||||
|
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
||||||
|
'solution' => 'Operação sincronizada com sinalização e timing detalhado.',
|
||||||
|
'result' => 'Experiência fluida para 180 convidados.',
|
||||||
|
'sort_order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Lançamento Verano',
|
||||||
|
'slug' => 'lancamento-verano',
|
||||||
|
'summary' => 'Evento corporativo de lançamento de coleção.',
|
||||||
|
'event_type' => 'Corporativo',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Centro de Convenções',
|
||||||
|
'event_date' => '2025-09-10',
|
||||||
|
'challenge' => 'Ativar marca em ambiente multiestação.',
|
||||||
|
'solution' => 'Fluxo de convidados e fornecedores com cronograma minuto a minuto.',
|
||||||
|
'result' => 'Alta percepção de marca e cobertura de imprensa.',
|
||||||
|
'sort_order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Mini wedding Marina',
|
||||||
|
'slug' => 'mini-wedding-marina',
|
||||||
|
'summary' => 'Celebração intimista à beira-mar.',
|
||||||
|
'event_type' => 'Mini wedding',
|
||||||
|
'city' => 'Caucaia',
|
||||||
|
'venue' => 'Pousada da Praia',
|
||||||
|
'event_date' => '2025-06-02',
|
||||||
|
'challenge' => 'Clima e logística em área externa.',
|
||||||
|
'solution' => 'Plano B estruturado e fornecedores locais alinhados.',
|
||||||
|
'result' => 'Cerimônia emocionante com 40 convidados.',
|
||||||
|
'sort_order' => 3,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as $caseData) {
|
||||||
|
$case = PortfolioCase::query()->updateOrCreate(
|
||||||
|
['slug' => $caseData['slug']],
|
||||||
|
[
|
||||||
|
...$caseData,
|
||||||
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
|
'is_featured' => true,
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
PortfolioImage::query()->where('portfolio_case_id', $case->id)->delete();
|
||||||
|
|
||||||
|
foreach ([1, 2] as $index) {
|
||||||
|
PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||||
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
|
'sort_order' => $index,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTestimonials(): void
|
||||||
|
{
|
||||||
|
$testimonials = [
|
||||||
|
[
|
||||||
|
'quote' => 'A Amare transformou nosso casamento em uma experiência inesquecível.',
|
||||||
|
'author_name' => 'Ana Souza',
|
||||||
|
'context' => 'Noiva',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Profissionalismo do início ao fim no lançamento da nossa coleção.',
|
||||||
|
'author_name' => 'Marcos Lima',
|
||||||
|
'context' => 'Diretor de marketing',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Cuidaram de cada detalhe com sensibilidade e precisão.',
|
||||||
|
'author_name' => 'Marina Costa',
|
||||||
|
'context' => 'Anfitriã',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testimonials as $testimonial) {
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'author_name' => $testimonial['author_name'],
|
||||||
|
'quote' => $testimonial['quote'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
...$testimonial,
|
||||||
|
'photo_path' => $this->copyFixture('testimonial.jpg', 'content/testimonials/'.str($testimonial['author_name'])->slug().'.jpg'),
|
||||||
|
'photo_alt' => 'Foto de '.$testimonial['author_name'],
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
|
{
|
||||||
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
|
|
||||||
|
return $destination;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,5 +35,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->call(ContentSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
openspec/changes/build-content-cms/.openspec.yaml
Normal file
2
openspec/changes/build-content-cms/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
97
openspec/changes/build-content-cms/design.md
Normal file
97
openspec/changes/build-content-cms/design.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Fase 0 entregou Laravel 13, Filament 5 em `/admin`, design tokens, autenticação interna e pipeline CI. O repositório possui apenas home placeholder em [routes/web.php](../../routes/web.php). Esta change implementa a **metade CMS** da Fase 1 (SPEC §18): persistência e gestão Filament de conteúdo, sem rotas públicas.
|
||||||
|
|
||||||
|
Padrões existentes a seguir:
|
||||||
|
|
||||||
|
- Resources Filament com form/table separados ([app/Filament/Resources/Users/](../../app/Filament/Resources/Users/))
|
||||||
|
- Policies admin-only ([app/Policies/UserPolicy.php](../../app/Policies/UserPolicy.php))
|
||||||
|
- `declare(strict_types=1);` em todo PHP próprio
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Assessora admin gerencia settings, serviços, portfólio e depoimentos no Filament.
|
||||||
|
- Dados persistidos conforme SPEC §8.2, prontos para consumo pela change `build-public-site`.
|
||||||
|
- Feature tests locais em PostgreSQL (jsonb, constraints reais).
|
||||||
|
- Seed determinístico parcial para desenvolvimento e testes futuros.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Rotas públicas, home, SEO, sitemap, snapshots visuais, briefing.
|
||||||
|
- Actions de publicação com auditoria (`PublishPortfolioCase` etc.) — adiadas até Fase 5.
|
||||||
|
- Variantes responsivas de imagem, page builder, categorias dinâmicas.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Publicação via `published_at` único
|
||||||
|
|
||||||
|
**Decisão:** `published_at` nullable timestamp é a única fonte de verdade. Scope `published()` no model (`whereNotNull('published_at')`). Sem coluna `is_published`.
|
||||||
|
|
||||||
|
**Alternativa rejeitada:** boolean `is_published` + `published_at` — viola SPEC §1.1 (não persistir status derivável).
|
||||||
|
|
||||||
|
### 2. Mídia em disco `public` local, S3 em produção
|
||||||
|
|
||||||
|
**Decisão:** uploads de capa/galeria/foto usam disco Laravel configurável via `FILESYSTEM_DISK`. Local: `public` + `php artisan storage:link`. Produção: `s3`. Path gerado com UUID/hash, nunca nome original.
|
||||||
|
|
||||||
|
**Alternativa rejeitada:** blobs no PostgreSQL — proibido por SPEC §6.4.
|
||||||
|
|
||||||
|
### 3. Validação de mídia centralizada
|
||||||
|
|
||||||
|
**Decisão:** classe em `app/Support/` (ex.: `PublicImageUploadRules`) com allowlist MIME (`image/jpeg`, `image/png`, `image/webp`), extensões correspondentes, limite 10 MB, alt text obrigatório quando imagem presente. Reutilizada nos Resources Filament.
|
||||||
|
|
||||||
|
### 4. Sem Actions de domínio nesta fase
|
||||||
|
|
||||||
|
**Decisão:** Filament Resources salvam diretamente via Eloquent. Actions (`PublishPortfolioCase`, `UnpublishPortfolioCase`) só serão criadas quando auditoria (Fase 5) exigir efeito colateral transacional.
|
||||||
|
|
||||||
|
**Rationale:** SPEC §9.5 — não criar Action para CRUD sem regra adicional.
|
||||||
|
|
||||||
|
### 5. Auditoria de publicação adiada
|
||||||
|
|
||||||
|
**Decisão:** publicar/despublicar **não** grava `audit_logs` nesta change.
|
||||||
|
|
||||||
|
**Desvio documentado:** ADM-02 lista publicação como auditável, mas tabela `audit_logs` é Fase 5. Retomar em change de hardening.
|
||||||
|
|
||||||
|
### 6. Autorização admin-only para CMS
|
||||||
|
|
||||||
|
**Decisão:** Policies espelham `UserPolicy` — `$user->isAdmin()` para view/create/update/delete em conteúdo do site. Assistant recebe 403 em todos os Resources de conteúdo.
|
||||||
|
|
||||||
|
**Referência:** WEB-02 — "admin pode gerenciar; assistant não pode".
|
||||||
|
|
||||||
|
### 7. Site settings como singleton Filament Page
|
||||||
|
|
||||||
|
**Decisão:** model `SiteSetting` com método `instance()` (primeiro registro ou create default). Filament `ManageSiteSettings` page (não Resource de lista) — evita CRUD genérico de chave/valor (SPEC §WEB-06).
|
||||||
|
|
||||||
|
### 8. Slug automático a partir do título
|
||||||
|
|
||||||
|
**Decisão:** slug gerado no model (observer ou mutator) com `Str::slug()`, unique constraint no banco. Admin pode editar slug no form.
|
||||||
|
|
||||||
|
### 9. Navegação Filament
|
||||||
|
|
||||||
|
**Decisão:** grupo `Conteúdo do site` com sort order conforme SPEC §5.2:
|
||||||
|
|
||||||
|
1. Configurações (singleton page)
|
||||||
|
2. Serviços
|
||||||
|
3. Portfólio
|
||||||
|
4. Depoimentos
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Risco | Mitigação |
|
||||||
|
|---|---|
|
||||||
|
| Uploads locais sem S3 em dev | `Storage::fake()` nos testes; documentar `storage:link` no README |
|
||||||
|
| Galeria ordenada complexa no Filament | Relation manager com `sort_order` e reorderable |
|
||||||
|
| jsonb `social_links` difere SQLite | phpunit.xml já migrado para PostgreSQL |
|
||||||
|
| Conteúdo criado sem rotas públicas | seed + testes Filament validam CMS; rotas vêm em `build-public-site` |
|
||||||
|
| Auditoria ausente em publicação | desvio explícito; retomar Fase 5 |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Rodar migrations em ordem: `site_settings` → `services` → `portfolio_cases` + `portfolio_images` → `testimonials`.
|
||||||
|
2. Executar seed de conteúdo após migrations.
|
||||||
|
3. Sem rollback de dados em produção (greenfield); `migrate:rollback` suportado em dev.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Nenhuma bloqueante. Fontes tipográficas finais da home ficam para `build-public-site`.
|
||||||
42
openspec/changes/build-content-cms/proposal.md
Normal file
42
openspec/changes/build-content-cms/proposal.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
A Fase 0 — Fundação está concluída e arquivada; o próximo passo do MVP é permitir que a assessora gerencie conteúdo do site no Filament antes de expor rotas públicas. Sem modelos, migrations e Resources internos para configurações, serviços, portfólio e depoimentos, a Fase 1 não pode avançar e o site permanece estático.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Criar tabelas e models conforme SPEC §8.2: `site_settings`, `services`, `portfolio_cases`, `portfolio_images`, `testimonials`.
|
||||||
|
- Implementar CMS Filament no grupo **Conteúdo do site** (SPEC §5.2) para WEB-02, WEB-03, WEB-04 e WEB-06.
|
||||||
|
- Introduzir regras compartilhadas de upload de mídia pública (allowlist MIME, limite de tamanho, path aleatório, alt text obrigatório).
|
||||||
|
- Policies admin-only para conteúdo do site; assistant não gerencia CMS (WEB-02).
|
||||||
|
- Factories e seed parcial de demonstração (3 serviços, 3 casos, 3 depoimentos, settings).
|
||||||
|
- Alinhar `phpunit.xml` para feature tests locais em PostgreSQL (delta de `quality-gates`).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Conforme [SPEC.md §4.2](../../SPEC.md) e a divisão da Fase 1:
|
||||||
|
|
||||||
|
- Rotas públicas, home editorial (WEB-01), páginas institucionais (WEB-07), briefing (WEB-05).
|
||||||
|
- SEO, sitemap, robots, snapshots visuais e testes de acessibilidade do site público.
|
||||||
|
- Page builder, CRUD de categorias, auditoria de publicação (`audit_logs` — Fase 5).
|
||||||
|
- Variantes responsivas de imagem (adiadas para change `build-public-site`).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `site-settings`: singleton tipado de configurações globais do site (WEB-06).
|
||||||
|
- `service-catalog`: CRUD de serviços publicáveis com slug único (WEB-02).
|
||||||
|
- `portfolio-cases`: casos de portfólio com galeria ordenada (WEB-03).
|
||||||
|
- `testimonials`: depoimentos publicáveis (WEB-04).
|
||||||
|
- `content-media`: validação e armazenamento compartilhado de imagens públicas (SPEC §6.4, §12.4).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `quality-gates`: feature tests locais MUST usar PostgreSQL, alinhados ao CI (SPEC §14.2, §20).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Cria**: migrations, models, factories, Policies, Filament Resources/Pages em `app/Filament/`, seeders, testes feature em `tests/Feature/Marketing/`.
|
||||||
|
- **Altera**: `phpunit.xml`, `DatabaseSeeder`, navegação do painel Filament.
|
||||||
|
- **Depende de**: specs arquivadas da fundação (`internal-authentication`, `design-tokens`, `quality-gates`).
|
||||||
|
- **Sem impacto** em rotas públicas existentes além do placeholder `/`.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public content images are validated and stored securely
|
||||||
|
|
||||||
|
The system SHALL validate public content uploads (service covers, portfolio covers/gallery, testimonial photos, site OG image) per SPEC §6.4 and §12.4. Validation MUST enforce MIME allowlist (jpeg, png, webp), matching extensions, maximum size of 10 MB, and mandatory alt text when an image is uploaded.
|
||||||
|
|
||||||
|
#### Scenario: Invalid MIME is rejected
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a file with disallowed MIME type
|
||||||
|
- **THEN** validation MUST fail with a pt-BR error message
|
||||||
|
|
||||||
|
#### Scenario: Oversized file is rejected
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads an image exceeding 10 MB
|
||||||
|
- **THEN** validation MUST fail
|
||||||
|
|
||||||
|
#### Scenario: Storage path is not derived from original filename
|
||||||
|
|
||||||
|
- **WHEN** an image is stored
|
||||||
|
- **THEN** the physical path MUST be generated (UUID/hash based)
|
||||||
|
- **AND** MUST NOT use the original upload filename as the storage key
|
||||||
|
|
||||||
|
#### Scenario: Alt text required with image
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a cover or gallery image without alt text
|
||||||
|
- **THEN** validation MUST fail
|
||||||
|
|
||||||
|
#### Scenario: Images are not stored in PostgreSQL
|
||||||
|
|
||||||
|
- **WHEN** content with images is persisted
|
||||||
|
- **THEN** only the filesystem path MUST be stored in the database
|
||||||
|
- **AND** binary image data MUST NOT be written to PostgreSQL columns
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Portfolio cases support editorial content and ordered gallery
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage portfolio cases (SPEC WEB-03) with title, unique slug, summary, event type, optional city/venue/event date, challenge, solution, optional result, cover image with alt text, featured flag, sort order, `published_at`, and optional SEO meta fields. Each case MUST support an ordered gallery via `portfolio_images` (path, alt text, optional caption, sort order).
|
||||||
|
|
||||||
|
#### Scenario: Draft case is not publicly visible
|
||||||
|
|
||||||
|
- **WHEN** a portfolio case has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published case meets acceptance criteria
|
||||||
|
|
||||||
|
- **WHEN** an admin fills required fields and sets `published_at`
|
||||||
|
- **THEN** the case MUST be included in the `published()` scope
|
||||||
|
- **AND** cover and gallery images MUST have alt text when present
|
||||||
|
|
||||||
|
#### Scenario: Gallery images maintain order
|
||||||
|
|
||||||
|
- **WHEN** an admin reorders gallery images in Filament
|
||||||
|
- **THEN** `sort_order` MUST reflect the chosen order per `portfolio_case_id`
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage portfolio
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access portfolio cases
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Cascade delete removes gallery images
|
||||||
|
|
||||||
|
- **WHEN** a portfolio case is deleted
|
||||||
|
- **THEN** associated `portfolio_images` records MUST be removed (FK cascade)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Local feature tests use PostgreSQL
|
||||||
|
|
||||||
|
The system SHALL configure `phpunit.xml` so that the Feature test suite uses PostgreSQL with the same connection parameters as CI (`DB_CONNECTION=pgsql`, host, port, database `amare_test`, credentials). Local feature tests MUST NOT default to SQLite `:memory:`.
|
||||||
|
|
||||||
|
#### Scenario: Developer runs feature tests locally
|
||||||
|
|
||||||
|
- **WHEN** a developer runs `composer test:feature` with PostgreSQL available
|
||||||
|
- **THEN** tests MUST execute against PostgreSQL
|
||||||
|
- **AND** MUST NOT silently fall back to SQLite
|
||||||
|
|
||||||
|
#### Scenario: Feature tests exercise PostgreSQL-specific types
|
||||||
|
|
||||||
|
- **WHEN** feature tests persist records with jsonb columns (e.g., `social_links`)
|
||||||
|
- **THEN** migrations and constraints MUST be validated against PostgreSQL semantics
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Services are managed in Filament with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to create, update, and delete services (SPEC WEB-02). Each service MUST have title, unique slug, summary, description, optional cover image with alt text, sort order, featured flag, and `published_at`.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished service is not publicly visible
|
||||||
|
|
||||||
|
- **WHEN** a service has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it from public queries
|
||||||
|
|
||||||
|
#### Scenario: Published service is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` on a service with required fields
|
||||||
|
- **THEN** the service MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Slug uniqueness is enforced
|
||||||
|
|
||||||
|
- **WHEN** an admin attempts to save two services with the same slug
|
||||||
|
- **THEN** validation or database constraint MUST reject the duplicate
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage services
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the services Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Delete requires confirmation
|
||||||
|
|
||||||
|
- **WHEN** an admin deletes a service in Filament
|
||||||
|
- **THEN** the UI MUST require explicit confirmation before deletion
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Site settings singleton is manageable by admin only
|
||||||
|
|
||||||
|
The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, hero copy (eyebrow, title, subtitle, CTA label), about summary, contact email/phone/city, social links (jsonb), default meta title/description, default OG image path, and optional analytics fields disabled by default.
|
||||||
|
|
||||||
|
#### Scenario: Admin updates site settings
|
||||||
|
|
||||||
|
- **WHEN** an admin saves the site settings form in Filament
|
||||||
|
- **THEN** the singleton record is updated
|
||||||
|
- **AND** labels and validation messages are in pt-BR
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot access site settings
|
||||||
|
|
||||||
|
- **WHEN** an assistant navigates to site settings in Filament
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Singleton avoids generic key-value store
|
||||||
|
|
||||||
|
- **WHEN** site settings are stored
|
||||||
|
- **THEN** the system MUST use typed columns on `site_settings`
|
||||||
|
- **AND** MUST NOT introduce a generic key/value configuration table
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Testimonials are managed with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text, author name, optional context, optional photo with alt text, sort order, featured flag, and `published_at`.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished testimonial is excluded
|
||||||
|
|
||||||
|
- **WHEN** a testimonial has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published testimonial is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` with required quote and author name
|
||||||
|
- **THEN** the testimonial MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage testimonials
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the testimonials Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Featured testimonials are filterable
|
||||||
|
|
||||||
|
- **WHEN** content is queried with featured filter
|
||||||
|
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
|
||||||
55
openspec/changes/build-content-cms/tasks.md
Normal file
55
openspec/changes/build-content-cms/tasks.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
## 1. Pré-requisitos e alinhamento de testes
|
||||||
|
|
||||||
|
- [x] 1.1 Confirmar `phpunit.xml` com PostgreSQL (`amare_test`) e documentar criação do banco de teste no README
|
||||||
|
- [x] 1.2 Registrar Policies de conteúdo no `AppServiceProvider` ou descoberta automática
|
||||||
|
- [x] 1.3 Criar diretório `tests/Feature/Marketing/` para testes desta change
|
||||||
|
|
||||||
|
## 2. Content media (content-media)
|
||||||
|
|
||||||
|
- [x] 2.1 Criar `app/Support/PublicImageUploadRules.php` com allowlist MIME/extensão, limite 10 MB e regra de alt text obrigatório
|
||||||
|
- [x] 2.2 Escrever teste unitário ou feature validando rejeição de MIME inválido e arquivo oversize
|
||||||
|
- [x] 2.3 Documentar `php artisan storage:link` e disco `public` no README
|
||||||
|
|
||||||
|
## 3. Site settings (site-settings / WEB-06)
|
||||||
|
|
||||||
|
- [x] 3.1 Criar migration `site_settings` conforme SPEC §8.2 (incluindo `social_links` jsonb)
|
||||||
|
- [x] 3.2 Criar model `SiteSetting` com `instance()` singleton e casts (jsonb, timestamps)
|
||||||
|
- [x] 3.3 Criar `SiteSettingPolicy` admin-only
|
||||||
|
- [x] 3.4 Criar Filament Page `ManageSiteSettings` no grupo **Conteúdo do site** com form tipado (hero, contato, meta, OG image, analytics desabilitados por padrão)
|
||||||
|
- [x] 3.5 Escrever feature tests: admin salva settings; assistant recebe 403
|
||||||
|
|
||||||
|
## 4. Service catalog (service-catalog / WEB-02)
|
||||||
|
|
||||||
|
- [x] 4.1 Criar migration `services` com índices em `slug`, `sort_order`, `is_featured`, `published_at`
|
||||||
|
- [x] 4.2 Criar model `Service` com scope `published()`, slug único e fillable conforme SPEC
|
||||||
|
- [x] 4.3 Criar `ServiceFactory` e `ServicePolicy` admin-only
|
||||||
|
- [x] 4.4 Criar `ServiceResource` Filament (form, table, confirmação de exclusão, upload de capa com alt text via PublicImageUploadRules)
|
||||||
|
- [x] 4.5 Escrever feature tests: slug único, publicação via `published_at`, assistant 403
|
||||||
|
|
||||||
|
## 5. Portfolio cases (portfolio-cases / WEB-03)
|
||||||
|
|
||||||
|
- [x] 5.1 Criar migrations `portfolio_cases` e `portfolio_images` (FK cascade, índice composto `portfolio_case_id, sort_order`)
|
||||||
|
- [x] 5.2 Criar models `PortfolioCase` e `PortfolioImage` com relação hasMany ordenada e scope `published()`
|
||||||
|
- [x] 5.3 Criar factories e `PortfolioCasePolicy` admin-only
|
||||||
|
- [x] 5.4 Criar `PortfolioCaseResource` com relation manager de galeria (reorderable, alt text, caption opcional)
|
||||||
|
- [x] 5.5 Escrever feature tests: publicação, ordem da galeria, cascade delete, assistant 403
|
||||||
|
|
||||||
|
## 6. Testimonials (testimonials / WEB-04)
|
||||||
|
|
||||||
|
- [x] 6.1 Criar migration `testimonials` conforme SPEC §8.2
|
||||||
|
- [x] 6.2 Criar model `Testimonial` com scope `published()` e `TestimonialFactory`
|
||||||
|
- [x] 6.3 Criar `TestimonialPolicy` e `TestimonialResource` Filament
|
||||||
|
- [x] 6.4 Escrever feature tests: publicação, featured filter, assistant 403
|
||||||
|
|
||||||
|
## 7. Seed de conteúdo de demonstração
|
||||||
|
|
||||||
|
- [x] 7.1 Criar `ContentSeeder` ou estender `DatabaseSeeder` com settings padrão, 3 serviços, 3 casos (com galeria), 3 depoimentos
|
||||||
|
- [x] 7.2 Adicionar imagens fixture versionadas em `tests/fixtures/images/` e referenciá-las no seed
|
||||||
|
- [x] 7.3 Garantir datas e conteúdo determinísticos para testes visuais futuros (SPEC §17.3)
|
||||||
|
|
||||||
|
## 8. Verificação final
|
||||||
|
|
||||||
|
- [x] 8.1 Executar `composer pint:check` e `composer phpstan`
|
||||||
|
- [x] 8.2 Executar `composer test:feature` contra PostgreSQL local
|
||||||
|
- [x] 8.3 Executar `composer quality` e corrigir falhas
|
||||||
|
- [x] 8.4 Reportar conclusão no formato SPEC §24 (requisitos WEB-02/03/04/06, alterações, testes, comandos, aceite, pendências)
|
||||||
14
phpunit.xml
14
phpunit.xml
@@ -28,13 +28,17 @@
|
|||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="database"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<env name="DB_CONNECTION" value="pgsql"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<env name="DB_HOST" value="127.0.0.1"/>
|
||||||
|
<env name="DB_PORT" value="5432"/>
|
||||||
|
<env name="DB_DATABASE" value="amare_test"/>
|
||||||
|
<env name="DB_USERNAME" value="amare"/>
|
||||||
|
<env name="DB_PASSWORD" value="secret"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value=""/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="database"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="database"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ class InternalAuthenticationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(Login::class)
|
Livewire::test(Login::class)
|
||||||
->fillForm([
|
->set('data.email', $admin->email)
|
||||||
'email' => $admin->email,
|
->set('data.password', 'password')
|
||||||
'password' => 'password',
|
|
||||||
])
|
|
||||||
->call('authenticate')
|
->call('authenticate')
|
||||||
->assertHasNoFormErrors()
|
->assertHasNoFormErrors()
|
||||||
->assertRedirect(route('filament.admin.pages.dashboard'));
|
->assertRedirect(route('filament.admin.pages.dashboard'));
|
||||||
@@ -41,10 +39,8 @@ class InternalAuthenticationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(Login::class)
|
Livewire::test(Login::class)
|
||||||
->fillForm([
|
->set('data.email', $assistant->email)
|
||||||
'email' => $assistant->email,
|
->set('data.password', 'password')
|
||||||
'password' => 'password',
|
|
||||||
])
|
|
||||||
->call('authenticate')
|
->call('authenticate')
|
||||||
->assertHasNoFormErrors()
|
->assertHasNoFormErrors()
|
||||||
->assertRedirect(route('filament.admin.pages.dashboard'));
|
->assertRedirect(route('filament.admin.pages.dashboard'));
|
||||||
@@ -59,10 +55,8 @@ class InternalAuthenticationTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(Login::class)
|
Livewire::test(Login::class)
|
||||||
->fillForm([
|
->set('data.email', $inactiveUser->email)
|
||||||
'email' => $inactiveUser->email,
|
->set('data.password', 'password')
|
||||||
'password' => 'password',
|
|
||||||
])
|
|
||||||
->call('authenticate')
|
->call('authenticate')
|
||||||
->assertHasFormErrors(['email']);
|
->assertHasFormErrors(['email']);
|
||||||
|
|
||||||
|
|||||||
76
tests/Feature/Marketing/PortfolioCasesTest.php
Normal file
76
tests/Feature/Marketing/PortfolioCasesTest.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\ListPortfolioCases;
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\PortfolioImage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PortfolioCasesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_published_scope_excludes_draft_cases(): void
|
||||||
|
{
|
||||||
|
PortfolioCase::factory()->create(['published_at' => null]);
|
||||||
|
$published = PortfolioCase::factory()->published()->create();
|
||||||
|
|
||||||
|
$this->assertCount(1, PortfolioCase::query()->published()->get());
|
||||||
|
$this->assertTrue(PortfolioCase::query()->published()->first()?->is($published));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_gallery_images_are_ordered_by_sort_order(): void
|
||||||
|
{
|
||||||
|
$case = PortfolioCase::factory()->create();
|
||||||
|
|
||||||
|
PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => 'content/b.jpg',
|
||||||
|
'alt_text' => 'Segunda',
|
||||||
|
'sort_order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => 'content/a.jpg',
|
||||||
|
'alt_text' => 'Primeira',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ordered = $case->fresh()->images->pluck('alt_text')->all();
|
||||||
|
|
||||||
|
$this->assertSame(['Primeira', 'Segunda'], $ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_deleting_case_cascades_gallery_images(): void
|
||||||
|
{
|
||||||
|
$case = PortfolioCase::factory()->create();
|
||||||
|
|
||||||
|
PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => 'content/a.jpg',
|
||||||
|
'alt_text' => 'Imagem',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$case->delete();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('portfolio_images', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assistant_cannot_access_portfolio_resource(): void
|
||||||
|
{
|
||||||
|
$assistant = User::factory()->assistant()->create();
|
||||||
|
|
||||||
|
$this->actingAs($assistant);
|
||||||
|
|
||||||
|
Livewire::test(ListPortfolioCases::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Feature/Marketing/ServiceCatalogTest.php
Normal file
68
tests/Feature/Marketing/ServiceCatalogTest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\Pages\ListServices;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ServiceCatalogTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_published_scope_excludes_unpublished_services(): void
|
||||||
|
{
|
||||||
|
Service::factory()->create(['published_at' => null]);
|
||||||
|
$published = Service::factory()->published()->create();
|
||||||
|
|
||||||
|
$this->assertCount(1, Service::query()->published()->get());
|
||||||
|
$this->assertTrue(Service::query()->published()->first()?->is($published));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_slug_uniqueness_is_enforced(): void
|
||||||
|
{
|
||||||
|
Service::factory()->create(['slug' => 'casamentos']);
|
||||||
|
|
||||||
|
$this->expectException(QueryException::class);
|
||||||
|
|
||||||
|
Service::factory()->create(['slug' => 'casamentos']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assistant_cannot_access_services_resource(): void
|
||||||
|
{
|
||||||
|
$assistant = User::factory()->assistant()->create();
|
||||||
|
|
||||||
|
$this->actingAs($assistant);
|
||||||
|
|
||||||
|
Livewire::test(ListServices::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_access_services_resource(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test(ListServices::class)
|
||||||
|
->assertSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_publication_uses_published_at_timestamp(): void
|
||||||
|
{
|
||||||
|
$publishedAt = Carbon::parse('2026-02-01 12:00:00');
|
||||||
|
|
||||||
|
$service = Service::factory()->create([
|
||||||
|
'published_at' => $publishedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue(Service::query()->published()->whereKey($service->id)->exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/Feature/Marketing/SiteSettingsTest.php
Normal file
54
tests/Feature/Marketing/SiteSettingsTest.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Filament\Pages\ManageSiteSettings;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SiteSettingsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_admin_can_save_site_settings(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test(ManageSiteSettings::class)
|
||||||
|
->set('data.brand_name', 'Amare Atualizada')
|
||||||
|
->set('data.hero_title', 'Novo título')
|
||||||
|
->set('data.hero_subtitle', 'Novo subtítulo')
|
||||||
|
->set('data.hero_cta_label', 'Fale conosco')
|
||||||
|
->set('data.email', 'novo@amare.local')
|
||||||
|
->set('data.phone', '(85) 98888-8888')
|
||||||
|
->set('data.default_meta_title', 'Meta title')
|
||||||
|
->set('data.default_meta_description', 'Meta description')
|
||||||
|
->set('data.analytics_enabled', false)
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('site_settings', [
|
||||||
|
'brand_name' => 'Amare Atualizada',
|
||||||
|
'hero_title' => 'Novo título',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assistant_cannot_access_site_settings(): void
|
||||||
|
{
|
||||||
|
$assistant = User::factory()->assistant()->create();
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$this->actingAs($assistant);
|
||||||
|
|
||||||
|
Livewire::test(ManageSiteSettings::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/Feature/Marketing/TestimonialsTest.php
Normal file
47
tests/Feature/Marketing/TestimonialsTest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Marketing;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TestimonialsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_published_scope_excludes_unpublished_testimonials(): void
|
||||||
|
{
|
||||||
|
Testimonial::factory()->create(['published_at' => null]);
|
||||||
|
$published = Testimonial::factory()->published()->create();
|
||||||
|
|
||||||
|
$this->assertCount(1, Testimonial::query()->published()->get());
|
||||||
|
$this->assertTrue(Testimonial::query()->published()->first()?->is($published));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_featured_testimonials_are_filterable(): void
|
||||||
|
{
|
||||||
|
Testimonial::factory()->create(['is_featured' => false]);
|
||||||
|
$featured = Testimonial::factory()->featured()->create();
|
||||||
|
|
||||||
|
$results = Testimonial::query()->where('is_featured', true)->get();
|
||||||
|
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertTrue($results->first()?->is($featured));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_assistant_cannot_access_testimonials_resource(): void
|
||||||
|
{
|
||||||
|
$assistant = User::factory()->assistant()->create();
|
||||||
|
|
||||||
|
$this->actingAs($assistant);
|
||||||
|
|
||||||
|
Livewire::test(ListTestimonials::class)
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/Unit/PublicImageUploadRulesTest.php
Normal file
53
tests/Unit/PublicImageUploadRulesTest.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PublicImageUploadRulesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_rejects_invalid_mime_type(): void
|
||||||
|
{
|
||||||
|
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['image' => $file],
|
||||||
|
['image' => PublicImageUploadRules::validationRules()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue($validator->fails());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rejects_oversized_image(): void
|
||||||
|
{
|
||||||
|
$file = UploadedFile::fake()->create(
|
||||||
|
'large.jpg',
|
||||||
|
PublicImageUploadRules::MAX_SIZE_KILOBYTES + 1,
|
||||||
|
'image/jpeg',
|
||||||
|
);
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['image' => $file],
|
||||||
|
['image' => PublicImageUploadRules::validationRules()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue($validator->fails());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_accepts_valid_image_within_limits(): void
|
||||||
|
{
|
||||||
|
$file = UploadedFile::fake()->create('photo.jpg', 512, 'image/jpeg');
|
||||||
|
|
||||||
|
$validator = Validator::make(
|
||||||
|
['image' => $file],
|
||||||
|
['image' => PublicImageUploadRules::validationRules()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse($validator->fails());
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
tests/fixtures/images/gallery.jpg
vendored
Normal file
BIN
tests/fixtures/images/gallery.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
tests/fixtures/images/og-default.jpg
vendored
Normal file
BIN
tests/fixtures/images/og-default.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
tests/fixtures/images/portfolio-cover.jpg
vendored
Normal file
BIN
tests/fixtures/images/portfolio-cover.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
tests/fixtures/images/service-cover.jpg
vendored
Normal file
BIN
tests/fixtures/images/service-cover.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
tests/fixtures/images/testimonial.jpg
vendored
Normal file
BIN
tests/fixtures/images/testimonial.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
Reference in New Issue
Block a user