diff --git a/app-modules/character/tests/Feature/ClaimCharacterBadgeTest.php b/app-modules/character/tests/Feature/ClaimCharacterBadgeTest.php index 770cf59b7..300fad0a9 100644 --- a/app-modules/character/tests/Feature/ClaimCharacterBadgeTest.php +++ b/app-modules/character/tests/Feature/ClaimCharacterBadgeTest.php @@ -46,6 +46,5 @@ 'tenant_id' => $tenant->getKey(), 'character_id' => $user->character->id, 'badge_id' => $badge->id, - 'claimed_at' => now(), ]); }); diff --git a/app-modules/user/src/Filament/User/Pages/UserProfile.php b/app-modules/user/src/Filament/User/Pages/UserProfile.php index f45b13bc1..d43103881 100644 --- a/app-modules/user/src/Filament/User/Pages/UserProfile.php +++ b/app-modules/user/src/Filament/User/Pages/UserProfile.php @@ -11,6 +11,8 @@ use Filament\Auth\Notifications\NoticeOfEmailChangeRequest; use Filament\Auth\Notifications\VerifyEmailChange; use Filament\Facades\Filament; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification as FilamentNotification; use Filament\Pages\Concerns\CanUseDatabaseTransactions; @@ -42,6 +44,7 @@ use Illuminate\Validation\Rules\Password; use League\Uri\Components\Query; use LogicException; +use OtavioAraujo\FilamentSmartCep\Forms\Components\SmartCep; use Throwable; /** @@ -58,6 +61,10 @@ final class UserProfile extends Page */ public ?array $data = []; + public ?array $informationData = []; + + public ?array $addressData = []; + protected static bool $isDiscovered = false; protected string $view; @@ -100,13 +107,19 @@ public function getView(): string public function mount(): void { $this->fillForm(); + + $user = $this->getUser(); + + $this->informationData = $user->information?->toArray() ?? []; + $this->addressData = $user->address?->toArray() ?? []; } public function getUser(): Authenticatable&Model { $user = Filament::auth()->user(); - throw_unless($user instanceof Model, LogicException::class, 'The authenticated user object must be an Eloquent model to allow the profile page to update it.'); + throw_unless($user instanceof Model, LogicException::class, + 'The authenticated user object must be an Eloquent model to allow the profile page to update it.'); return $user; } @@ -159,6 +172,56 @@ public function save(): void } } + public function saveInformation(): void + { + $this->validate([ + 'informationData.name' => ['required', 'string', 'max:255'], + 'informationData.nickname' => ['nullable', 'string', 'max:255'], + 'informationData.linkedin_url' => ['nullable', 'string', 'url', 'max:255'], + 'informationData.github_url' => ['nullable', 'string', 'url', 'max:255'], + 'informationData.birthdate' => ['nullable', 'date'], + 'informationData.about' => ['nullable', 'string', 'max:1000'], + ]); + + $user = $this->getUser(); + + $user->information()->updateOrCreate( + ['user_id' => $user->id], + $this->informationData + ); + + FilamentNotification::make() + ->title('Information updated successfully.') + ->success() + ->send(); + } + + public function saveAddress(): void + { + $this->validate([ + 'addressData.zip_code' => [ + 'required', + 'string', + 'regex:/^\d{5}-\d{3}$/', + ], + 'addressData.country' => ['nullable', 'string', 'max:255'], + 'addressData.state' => ['nullable', 'string', 'max:255'], + 'addressData.city' => ['nullable', 'string', 'max:255'], + ]); + + $user = $this->getUser(); + + $user->address()->updateOrCreate( + ['user_id' => $user->id], + $this->addressData + ); + + FilamentNotification::make() + ->title('Address updated successfully.') + ->success() + ->send(); + } + public function defaultForm(Schema $schema): Schema { return $schema @@ -231,6 +294,96 @@ public function content(Schema $schema): Schema ->schema([ Livewire::make(ConnectionHub::class), ]), + Tab::make('Information') + ->schema([ + Section::make('Personal Information') + ->description('Basic profile details and social links.') + ->schema([ + TextInput::make('informationData.name') + ->label('Full Name') + ->placeholder('Enter your full name') + ->required(), + + TextInput::make('informationData.nickname') + ->label('Nickname') + ->placeholder('How do you like to be called?'), + + DatePicker::make('informationData.birthdate') + ->label('Birthdate') + ->placeholder('Select your birth date'), + + Textarea::make('informationData.about') + ->label('About') + ->placeholder('Write a short description about yourself...') + ->rows(4) + ->columnSpanFull(), + + TextInput::make('informationData.linkedin_url') + ->label('LinkedIn URL') + ->placeholder('https://linkedin.com/in/username') + ->url(), + + TextInput::make('informationData.github_url') + ->label('GitHub URL') + ->placeholder('https://github.com/username') + ->url(), + ]) + ->columns([ + 'sm' => 2, + 'md' => 3, + ]) + ->footerActions([ + Action::make('saveInformation') + ->label('Save Information') + ->action(fn () => $this->saveInformation()) + ->color('primary') + ->icon('heroicon-o-check'), + ]), + ]), + + Tab::make('Address') + ->schema([ + Section::make('Address Information') + ->description('Fill in your current address. The ZIP Code will automatically fetch your city and state.') + ->schema([ + SmartCep::make('addressData.zip_code') + ->label('ZIP Code') + ->placeholder('Enter your ZIP Code (e.g., 13000-000)') + ->mask('99999-999') + ->required() + ->bindCityField('addressData.city') + ->bindStateField('addressData.state') + ->bindCountryField('addressData.country') + ->live() + ->columnSpan(1), + + TextInput::make('addressData.country') + ->label('Country') + ->placeholder('Brazil') + ->columnSpan(1), + + TextInput::make('addressData.state') + ->label('State') + ->placeholder('São Paulo') + ->columnSpan(1), + + TextInput::make('addressData.city') + ->label('City') + ->placeholder('Campinas') + ->columnSpan(1), + ]) + ->columns([ + 'sm' => 2, + 'md' => 4, + ]) + ->footerActions([ + Action::make('saveAddress') + ->label('Save Address') + ->action(fn () => $this->saveAddress()) + ->color('primary') + ->icon('heroicon-o-map-pin'), + ]), + ]), ]), ...Arr::wrap($this->getMultiFactorAuthenticationContentComponent()), ]); @@ -264,8 +417,10 @@ public function getMultiFactorAuthenticationContentComponent(): ?Component ->divided() ->secondary() ->schema(collect(Filament::getMultiFactorAuthenticationProviders()) - ->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider): int => $multiFactorAuthenticationProvider->isEnabled($user) ? 0 : 1) - ->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider): Component => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents()) + ->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider + ): int => $multiFactorAuthenticationProvider->isEnabled($user) ? 0 : 1) + ->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider + ): Component => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents()) ->statePath($multiFactorAuthenticationProvider->getId())) ->all()); } @@ -339,8 +494,10 @@ private function sendEmailChangeVerification(Model $record, string $newEmail): v cache()->put($verificationSignature, true, ttl: now()->addHour()); - $record->notify(app(NoticeOfEmailChangeRequest::class, [/** @phpstan-ignore-line */ - 'blockVerificationUrl' => Filament::getBlockEmailChangeVerificationUrl($record, $newEmail, $verificationSignature), + $record->notify(app(NoticeOfEmailChangeRequest::class, [ + /** @phpstan-ignore-line */ + 'blockVerificationUrl' => Filament::getBlockEmailChangeVerificationUrl($record, $newEmail, + $verificationSignature), 'newEmail' => $newEmail, ])); @@ -369,8 +526,10 @@ private function getEmailChangeVerificationSentNotification(string $newEmail): F { return FilamentNotification::make() ->success() - ->title(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.title', ['email' => $newEmail])) - ->body(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.body', ['email' => $newEmail])); + ->title(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.title', + ['email' => $newEmail])) + ->body(__('filament-panels::auth/pages/edit-profile.notifications.email_change_verification_sent.body', + ['email' => $newEmail])); } private function getSavedNotificationTitle(): ?string @@ -443,7 +602,8 @@ private function getCurrentPasswordFormComponent(): Component ->currentPassword(guard: Filament::getAuthGuard()) ->revealable(filament()->arePasswordsRevealable()) ->required() - ->visible(fn (Get $get): bool => filled($get('password')) || ($get('email') !== $this->getUser()->getAttributeValue('email'))) + ->visible(fn (Get $get + ): bool => filled($get('password')) || ($get('email') !== $this->getUser()->getAttributeValue('email'))) ->dehydrated(false); } diff --git a/app-modules/user/tests/Feature/Filament/User/Pages/UserProfileTest.php b/app-modules/user/tests/Feature/Filament/User/Pages/UserProfileTest.php new file mode 100644 index 000000000..3baf63b20 --- /dev/null +++ b/app-modules/user/tests/Feature/Filament/User/Pages/UserProfileTest.php @@ -0,0 +1,126 @@ +user = User::factory()->create(); + $this->tenant = Tenant::factory()->create([ + 'owner_id' => $this->user->id, + 'slug' => 'he4rt', + ]); + + $this->actingAs($this->user); + Filament::setCurrentPanel('user'); + Filament::setTenant($this->tenant); +}); +it('renders the user profile page successfully for a tenant', function (): void { + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->assertStatus(200) + ->assertSee('Information') + ->assertSee('Address'); +}); + +it('saves user information correctly for tenant', function (): void { + $page = Livewire::test(UserProfile::class, ['tenant' => $this->tenant]); + + $page->set('informationData', [ + 'name' => 'John Doe', + 'nickname' => 'Johnny', + 'birthdate' => '1995-03-14', + 'about' => 'Software developer.', + 'linkedin_url' => 'https://linkedin.com/in/johnny', + 'github_url' => 'https://github.com/johnny', + ]); + + $page->call('saveInformation'); + + $this->assertDatabaseHas('user_information', [ + 'user_id' => $this->user->id, + 'name' => 'John Doe', + 'nickname' => 'Johnny', + ]); +}); + +it('saves address correctly for tenant', function (): void { + $page = Livewire::test(UserProfile::class, ['tenant' => $this->tenant]); + + $page->set('addressData', [ + 'zip_code' => '13000-000', + 'country' => 'Brazil', + 'state' => 'São Paulo', + 'city' => 'Campinas', + ]); + + $page->call('saveAddress'); + + $this->assertDatabaseHas('user_address', [ + 'user_id' => $this->user->id, + 'city' => 'Campinas', + 'zip_code' => '13000-000', + ]); +}); + +it('requires a name in information form', function (): void { + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->set('informationData', [ + 'name' => '', + 'nickname' => 'Clint', + ]) + ->call('saveInformation') + ->assertHasErrors(['informationData.name' => 'required']); +}); + +it('validates nickname max length', function (): void { + $tooLong = str_repeat('N', 300); + + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->set('informationData.nickname', $tooLong) + ->call('saveInformation') + ->assertHasErrors(['informationData.nickname' => 'max']); +}); + +it('validates linkedin and github urls', function (): void { + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->set('informationData.linkedin_url', 'not-a-url') + ->set('informationData.github_url', '1234') + ->call('saveInformation') + ->assertHasErrors([ + 'informationData.linkedin_url' => 'url', + 'informationData.github_url' => 'url', + ]); +}); + +it('validates birthdate as a valid date', function (): void { + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->set('informationData.birthdate', 'not-a-date') + ->call('saveInformation') + ->assertHasErrors(['informationData.birthdate' => 'date']); +}); + +it('validates about field max 1000 characters', function (): void { + $tooLong = str_repeat('X', 1100); + + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->set('informationData.about', $tooLong) + ->call('saveInformation') + ->assertHasErrors(['informationData.about' => 'max']); +}); + +it('saves valid information successfully', function (): void { + Livewire::test(UserProfile::class, ['tenant' => $this->tenant]) + ->set('informationData', [ + 'name' => 'John Doe', + 'nickname' => 'JD', + 'linkedin_url' => 'https://linkedin.com/in/johndoe', + 'github_url' => 'https://github.com/johndoe', + 'birthdate' => '1990-01-01', + 'about' => 'Senior developer at Example Inc.', + ]) + ->call('saveInformation') + ->assertHasNoErrors(); +}); diff --git a/app/Providers/Filament/PartnerPanelProvider.php b/app/Providers/Filament/PartnerPanelProvider.php index 066b2b6da..60915218e 100644 --- a/app/Providers/Filament/PartnerPanelProvider.php +++ b/app/Providers/Filament/PartnerPanelProvider.php @@ -4,6 +4,7 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Login; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -29,7 +30,7 @@ public function panel(Panel $panel): Panel ->default() ->id('partner') ->path('partner') - ->login() + ->login(Login::class) ->colors([ 'primary' => Color::Amber, ]) diff --git a/app/Providers/Filament/UserPanelProvider.php b/app/Providers/Filament/UserPanelProvider.php index b60ccc92f..4bc16727d 100644 --- a/app/Providers/Filament/UserPanelProvider.php +++ b/app/Providers/Filament/UserPanelProvider.php @@ -4,6 +4,7 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Login; use Filament\Actions\Action; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; @@ -30,7 +31,7 @@ public function panel(Panel $panel): Panel ->default() ->id('user') ->path('app') - ->login() + ->login(Login::class) ->sidebarCollapsibleOnDesktop() ->viteTheme('resources/css/filament/admin/theme.css') ->tenant(Tenant::class, 'slug', 'ownedTenants') diff --git a/composer.json b/composer.json index 0b2f30c7e..92dbeb510 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "laravel/sanctum": "^4.2.0", "laravel/tinker": "^2.10.1", "marvinlabs/laravel-discord-logger": "^1.4.3", + "otavio-araujo/filament-smart-cep": "^4.0", "owenvoke/blade-fontawesome": "^3.0", "predis/predis": "^2.4.1", "spatie/laravel-medialibrary": "^11.17.4" diff --git a/composer.lock b/composer.lock index 413993748..ebdc4e838 100644 --- a/composer.lock +++ b/composer.lock @@ -5614,6 +5614,84 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "otavio-araujo/filament-smart-cep", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/otavio-araujo/filament-smart-cep.git", + "reference": "4bf7bf64ed7d619112858e5eb7cfbd34ce7ac252" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/otavio-araujo/filament-smart-cep/zipball/4bf7bf64ed7d619112858e5eb7cfbd34ce7ac252", + "reference": "4bf7bf64ed7d619112858e5eb7cfbd34ce7ac252", + "shasum": "" + }, + "require": { + "filament/forms": "^4.0", + "php": "^8.2|^8.3", + "spatie/laravel-package-tools": "^1.15.0" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^8.0", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-arch": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "pestphp/pest-plugin-livewire": "^3.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "rector/rector": "^2.0", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "OtavioAraujo\\FilamentSmartCep\\FilamentSmartCepServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "OtavioAraujo\\FilamentSmartCep\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Otávio Araújo", + "email": "otavio_araujo@hotmail.com", + "role": "Developer" + } + ], + "description": "A FilamentPHP plugin that validates Brazilian postal codes (CEP) and automatically fills address fields (street, neighborhood, city, state, country) using ViaCEP as the primary source, with multiple API fallbacks for reliability.", + "homepage": "https://github.com/otavio-araujo/filament-smart-cep", + "keywords": [ + "brazil", + "cep", + "filament", + "laravel" + ], + "support": { + "issues": "https://github.com/otavio-araujo/filament-smart-cep/issues", + "source": "https://github.com/otavio-araujo/filament-smart-cep" + }, + "funding": [ + { + "url": "https://github.com/otavio-araujo", + "type": "github" + } + ], + "time": "2025-08-27T14:59:38+00:00" + }, { "name": "owenvoke/blade-fontawesome", "version": "v3.0.0", @@ -15015,12 +15093,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.3" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" }