From 6a260eb119e38318da84686ff8364387f19efea8 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 23 May 2026 17:38:08 -0300 Subject: [PATCH 1/7] feat(profile): UpsertProfile and ToggleAvailability actions (#253) Domain actions for profile management: - UpsertProfileDTO with fromArray() factory for Filament/test compatibility - UpsertProfile action with validation (about max 500, headline max 100, social_links enum check) - ToggleAvailability action with required start_availability when activating - Feature tests covering all BDD scenarios from the issue --- .../src/Actions/ToggleAvailability.php | 35 +++++ .../profile/src/Actions/UpsertProfile.php | 90 ++++++++++++ .../profile/src/DTOs/UpsertProfileDTO.php | 41 ++++++ .../tests/Feature/ToggleAvailabilityTest.php | 62 ++++++++ .../tests/Feature/UpsertProfileTest.php | 135 ++++++++++++++++++ 5 files changed, 363 insertions(+) create mode 100644 app-modules/profile/src/Actions/ToggleAvailability.php create mode 100644 app-modules/profile/src/Actions/UpsertProfile.php create mode 100644 app-modules/profile/src/DTOs/UpsertProfileDTO.php create mode 100644 app-modules/profile/tests/Feature/ToggleAvailabilityTest.php create mode 100644 app-modules/profile/tests/Feature/UpsertProfileTest.php diff --git a/app-modules/profile/src/Actions/ToggleAvailability.php b/app-modules/profile/src/Actions/ToggleAvailability.php new file mode 100644 index 000000000..9a2274cb4 --- /dev/null +++ b/app-modules/profile/src/Actions/ToggleAvailability.php @@ -0,0 +1,35 @@ + [__('validation.required_if', [ + 'attribute' => 'start_availability', + 'other' => 'available_for_proposals', + 'value' => 'true', + ])], + ]); + } + + $attributes = ['available_for_proposals' => $available]; + + if ($available && $startAvailability instanceof StartAvailability) { + $attributes['start_availability'] = $startAvailability; + } + + $profile->update($attributes); + + return $profile->refresh(); + } +} diff --git a/app-modules/profile/src/Actions/UpsertProfile.php b/app-modules/profile/src/Actions/UpsertProfile.php new file mode 100644 index 000000000..f6c9ed0e6 --- /dev/null +++ b/app-modules/profile/src/Actions/UpsertProfile.php @@ -0,0 +1,90 @@ +validate($dto); + + $attributes = []; + + if ($dto->nickname !== null) { + $attributes['nickname'] = $dto->nickname; + } + + if ($dto->birthdate instanceof Carbon) { + $attributes['birthdate'] = $dto->birthdate; + } + + if ($dto->about !== null) { + $attributes['about'] = $dto->about; + } + + if ($dto->headline !== null) { + $attributes['headline'] = $dto->headline; + } + + if ($dto->seniorityLevel instanceof SeniorityLevel) { + $attributes['seniority_level'] = $dto->seniorityLevel; + } + + if ($dto->yearsExperience !== null) { + $attributes['years_experience'] = $dto->yearsExperience; + } + + if ($dto->socialLinks !== null) { + $attributes['social_links'] = $dto->socialLinks; + } + + if ($attributes !== []) { + $profile->update($attributes); + } + + return $profile->refresh(); + } + + private function validate(UpsertProfileDTO $dto): void + { + $errors = []; + + if ($dto->about !== null && mb_strlen($dto->about) > 500) { + $errors['about'] = [__('validation.max.string', ['attribute' => 'about', 'max' => 500])]; + } + + if ($dto->headline !== null && mb_strlen($dto->headline) > 100) { + $errors['headline'] = [__('validation.max.string', ['attribute' => 'headline', 'max' => 100])]; + } + + if ($dto->nickname !== null && mb_strlen($dto->nickname) > 100) { + $errors['nickname'] = [__('validation.max.string', ['attribute' => 'nickname', 'max' => 100])]; + } + + if ($dto->yearsExperience !== null && ($dto->yearsExperience < 0 || $dto->yearsExperience > 50)) { + $errors['years_experience'] = [__('validation.between.numeric', ['attribute' => 'years_experience', 'min' => 0, 'max' => 50])]; + } + + if ($dto->socialLinks !== null) { + $validPlatforms = SocialPlatform::values(); + $invalidPlatforms = array_diff(array_keys($dto->socialLinks), $validPlatforms); + + if ($invalidPlatforms !== []) { + $errors['social_links'] = [sprintf('Invalid social platform keys: %s.', implode(', ', $invalidPlatforms))]; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } +} diff --git a/app-modules/profile/src/DTOs/UpsertProfileDTO.php b/app-modules/profile/src/DTOs/UpsertProfileDTO.php new file mode 100644 index 000000000..031f9924a --- /dev/null +++ b/app-modules/profile/src/DTOs/UpsertProfileDTO.php @@ -0,0 +1,41 @@ +|null */ + public ?array $socialLinks = null, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + nickname: $data['nickname'] ?? null, + birthdate: isset($data['birthdate']) ? Date::parse($data['birthdate']) : null, + about: $data['about'] ?? null, + headline: $data['headline'] ?? null, + seniorityLevel: isset($data['seniority_level']) + ? ($data['seniority_level'] instanceof SeniorityLevel ? $data['seniority_level'] : SeniorityLevel::from($data['seniority_level'])) + : null, + yearsExperience: isset($data['years_experience']) ? (int) $data['years_experience'] : null, + socialLinks: $data['social_links'] ?? null, + ); + } +} diff --git a/app-modules/profile/tests/Feature/ToggleAvailabilityTest.php b/app-modules/profile/tests/Feature/ToggleAvailabilityTest.php new file mode 100644 index 000000000..3d6e50f0d --- /dev/null +++ b/app-modules/profile/tests/Feature/ToggleAvailabilityTest.php @@ -0,0 +1,62 @@ +create([ + 'available_for_proposals' => false, + ]); + + $result = resolve(ToggleAvailability::class)->handle($profile, true, StartAvailability::Immediate); + + expect($result->available_for_proposals)->toBeTrue() + ->and($result->start_availability)->toBe(StartAvailability::Immediate); +}); + +test('activate availability without start availability throws validation error', function (): void { + $profile = Profile::factory()->create([ + 'available_for_proposals' => false, + ]); + + resolve(ToggleAvailability::class)->handle($profile, true); +})->throws(ValidationException::class); + +test('deactivate availability preserves previous start availability', function (): void { + $profile = Profile::factory()->create([ + 'available_for_proposals' => true, + 'start_availability' => StartAvailability::OneWeek, + ]); + + $result = resolve(ToggleAvailability::class)->handle($profile, false); + + expect($result->available_for_proposals)->toBeFalse() + ->and($result->start_availability)->toBe(StartAvailability::OneWeek); +}); + +test('change start availability while keeping availability active', function (): void { + $profile = Profile::factory()->create([ + 'available_for_proposals' => true, + 'start_availability' => StartAvailability::Immediate, + ]); + + $result = resolve(ToggleAvailability::class)->handle($profile, true, StartAvailability::TwoWeeks); + + expect($result->available_for_proposals)->toBeTrue() + ->and($result->start_availability)->toBe(StartAvailability::TwoWeeks); +}); + +test('deactivate already inactive availability is idempotent', function (): void { + $profile = Profile::factory()->create([ + 'available_for_proposals' => false, + 'start_availability' => null, + ]); + + $result = resolve(ToggleAvailability::class)->handle($profile, false); + + expect($result->available_for_proposals)->toBeFalse(); +}); diff --git a/app-modules/profile/tests/Feature/UpsertProfileTest.php b/app-modules/profile/tests/Feature/UpsertProfileTest.php new file mode 100644 index 000000000..0ece475da --- /dev/null +++ b/app-modules/profile/tests/Feature/UpsertProfileTest.php @@ -0,0 +1,135 @@ +create(); + $dto = UpsertProfileDTO::fromArray([ + 'nickname' => 'Dan', + 'birthdate' => '1995-03-15', + 'about' => 'Dev PHP apaixonado por Laravel', + 'headline' => 'Backend Developer', + 'seniority_level' => 'mid', + 'years_experience' => 5, + 'social_links' => [ + 'instagram' => '@danielhe4rt', + 'website' => 'https://danielheart.dev', + ], + ]); + + $result = resolve(UpsertProfile::class)->handle($profile, $dto); + + expect($result->nickname)->toBe('Dan') + ->and($result->birthdate->format('Y-m-d'))->toBe('1995-03-15') + ->and($result->about)->toBe('Dev PHP apaixonado por Laravel') + ->and($result->headline)->toBe('Backend Developer') + ->and($result->seniority_level)->toBe(SeniorityLevel::Mid) + ->and($result->years_experience)->toBe(5) + ->and($result->social_links)->toMatchArray([ + 'instagram' => '@danielhe4rt', + 'website' => 'https://danielheart.dev', + ]); +}); + +test('upsert profile updates partially without affecting other fields', function (): void { + $profile = Profile::factory()->create([ + 'nickname' => 'Dan', + 'about' => 'Dev PHP', + ]); + $dto = UpsertProfileDTO::fromArray([ + 'headline' => 'Senior Dev', + ]); + + $result = resolve(UpsertProfile::class)->handle($profile, $dto); + + expect($result->headline)->toBe('Senior Dev') + ->and($result->nickname)->toBe('Dan') + ->and($result->about)->toBe('Dev PHP'); +}); + +test('upsert profile rejects about exceeding 500 characters', function (): void { + $profile = Profile::factory()->create(); + $dto = UpsertProfileDTO::fromArray([ + 'about' => str_repeat('a', 501), + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); +})->throws(ValidationException::class); + +test('upsert profile rejects headline exceeding 100 characters', function (): void { + $profile = Profile::factory()->create(); + $dto = UpsertProfileDTO::fromArray([ + 'headline' => str_repeat('a', 101), + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); +})->throws(ValidationException::class); + +test('upsert profile rejects nickname exceeding 100 characters', function (): void { + $profile = Profile::factory()->create(); + $dto = UpsertProfileDTO::fromArray([ + 'nickname' => str_repeat('a', 101), + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); +})->throws(ValidationException::class); + +test('upsert profile rejects years_experience outside 0-50 range', function (): void { + $profile = Profile::factory()->create(); + $dto = UpsertProfileDTO::fromArray([ + 'years_experience' => 51, + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); +})->throws(ValidationException::class); + +test('upsert profile rejects invalid social platform keys', function (): void { + $profile = Profile::factory()->create(); + $dto = UpsertProfileDTO::fromArray([ + 'social_links' => ['tiktok' => '@dan'], + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); +})->throws(ValidationException::class); + +test('upsert profile accepts valid social platform keys', function (): void { + $profile = Profile::factory()->create(); + $dto = UpsertProfileDTO::fromArray([ + 'social_links' => [ + 'instagram' => '@dan', + 'website' => 'https://dan.dev', + ], + ]); + + $result = resolve(UpsertProfile::class)->handle($profile, $dto); + + expect($result->social_links)->toMatchArray([ + 'instagram' => '@dan', + 'website' => 'https://dan.dev', + ]); +}); + +test('upsert profile does not modify profile when dto has all nulls', function (): void { + $profile = Profile::factory()->create([ + 'headline' => 'Original', + ]); + $dto = UpsertProfileDTO::fromArray([]); + + $result = resolve(UpsertProfile::class)->handle($profile, $dto); + + expect($result->headline)->toBe('Original'); +}); + +test('upsert profile dto fromArray handles enum instances', function (): void { + $dto = UpsertProfileDTO::fromArray([ + 'seniority_level' => SeniorityLevel::Senior, + ]); + + expect($dto->seniorityLevel)->toBe(SeniorityLevel::Senior); +}); From 4a91a13c8dbda2f66142b7afd676aa5e9e43b31d Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 23 May 2026 17:45:33 -0300 Subject: [PATCH 2/7] refactor(profile): remove legacy Information, Address, and REST API (#254) - Delete Information and Address models, factories, actions, DTOs, exceptions - Remove UserInformationForm Filament schema - Remove information() relationship from User model - Update EditProfileCommand and IntroductionCommand to use Profile module - GitHub/LinkedIn fields removed from bot modals (now from OAuth/ExternalIdentity) - Tables user_information and user_address preserved in database as safety net --- .../src/SlashCommands/EditProfileCommand.php | 75 ++++++++----------- .../src/SlashCommands/IntroductionCommand.php | 57 ++++++-------- .../database/factories/AddressFactory.php | 29 ------- .../database/factories/InformationFactory.php | 31 -------- .../Shared/Schemas/UserInformationForm.php | 54 ------------- .../User/Actions/InformationUserAction.php | 23 ------ .../src/User/Actions/UpdateProfile.php | 43 ----------- .../src/User/DTOs/UpdateProfileDTO.php | 55 -------------- .../src/User/DTOs/UpsertInformationDTO.php | 36 --------- .../src/User/Exceptions/ProfileException.php | 16 ---- .../identity/src/User/Models/Address.php | 41 ---------- .../identity/src/User/Models/Information.php | 42 ----------- app-modules/identity/src/User/Models/User.php | 8 -- 13 files changed, 53 insertions(+), 457 deletions(-) delete mode 100644 app-modules/identity/database/factories/AddressFactory.php delete mode 100644 app-modules/identity/database/factories/InformationFactory.php delete mode 100644 app-modules/identity/src/Filament/Shared/Schemas/UserInformationForm.php delete mode 100644 app-modules/identity/src/User/Actions/InformationUserAction.php delete mode 100644 app-modules/identity/src/User/Actions/UpdateProfile.php delete mode 100644 app-modules/identity/src/User/DTOs/UpdateProfileDTO.php delete mode 100644 app-modules/identity/src/User/DTOs/UpsertInformationDTO.php delete mode 100644 app-modules/identity/src/User/Exceptions/ProfileException.php delete mode 100644 app-modules/identity/src/User/Models/Address.php delete mode 100644 app-modules/identity/src/User/Models/Information.php diff --git a/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php b/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php index 96a82cfbe..65a322459 100644 --- a/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/EditProfileCommand.php @@ -7,8 +7,9 @@ use Discord\Builders\Components\TextInput; use Discord\Helpers\Collection; use Discord\Parts\Interactions\Interaction; -use He4rt\Identity\User\Actions\UpdateProfile; -use He4rt\Identity\User\DTOs\UpdateProfileDTO; +use He4rt\Profile\Actions\UpsertProfile; +use He4rt\Profile\DTOs\UpsertProfileDTO; +use He4rt\Profile\Models\Profile; use Illuminate\Support\Facades\Date; use Throwable; @@ -61,7 +62,12 @@ class EditProfileCommand extends AbstractSlashCommand */ public function handle(Interaction $interaction): void { - if (!$this->memberProvider?->user?->information) { + $profile = Profile::query() + ->where('user_id', $this->memberProvider?->user?->id) + ->where('tenant_id', $this->memberProvider?->tenant_id) + ->first(); + + if (!$profile) { $interaction->respondWithMessage( 'Parece que você ainda não completou sua apresentação. Use o comando `/apresentar` para continuar.', true @@ -70,8 +76,6 @@ public function handle(Interaction $interaction): void return; } - $profile = $this->memberProvider->user->information; - $this->modal('Editar Perfil') ->components([ TextInput::new('Nome', TextInput::STYLE_SHORT) @@ -79,7 +83,7 @@ public function handle(Interaction $interaction): void ->setMinLength(2) ->setMaxLength(32) ->setPlaceholder('Seu nome') - ->setValue($profile->name ?? '') + ->setValue($this->memberProvider->user->name ?? '') ->setRequired(true), TextInput::new('Nickname', TextInput::STYLE_SHORT) @@ -90,26 +94,10 @@ public function handle(Interaction $interaction): void ->setValue($profile->nickname ?? '') ->setRequired(true), - TextInput::new('Git/Github (Opcional)', TextInput::STYLE_SHORT) - ->setCustomId('github_url') - ->setMinLength(0) - ->setMaxLength(60) - ->setPlaceholder('https://github.com/...') - ->setValue($profile->github_url ?? '') - ->setRequired(false), - - TextInput::new('Linkedin (Opcional)', TextInput::STYLE_SHORT) - ->setCustomId('linkedin_url') - ->setMinLength(0) - ->setMaxLength(60) - ->setPlaceholder('https://linkedin.com/in/...') - ->setValue($profile->linkedin_url ?? '') - ->setRequired(false), - TextInput::new('Nos conte um pouco sobre você', TextInput::STYLE_PARAGRAPH) ->setCustomId('about') ->setMinLength(5) - ->setMaxLength(1000) + ->setMaxLength(500) ->setPlaceholder('Fale mais sobre você...') ->setValue($profile->about ?? '') ->setRequired(true), @@ -130,37 +118,34 @@ private function persistData( Collection $components ): void { try { - $payload = UpdateProfileDTO::fromPayload([ - 'tenant_id' => $this->memberProvider->tenant_id, - 'provider' => $this->memberProvider->provider, - 'external_account_id' => $interaction->user->id, - 'name' => $components->get('custom_id', 'name')?->value, - 'nickname' => $components->get('custom_id', 'nickname')?->value, - 'linkedin_url' => $components->get('custom_id', 'linkedin_url')?->value, - 'github_url' => $components->get('custom_id', 'github_url')?->value, - 'birthdate' => $components->get('custom_id', 'birthdate')?->value, - 'about' => $components->get('custom_id', 'about')?->value, + $name = $components->get('custom_id', 'name')?->value; + $nickname = $components->get('custom_id', 'nickname')?->value; + $about = $components->get('custom_id', 'about')?->value; + + $this->memberProvider->user->update(['name' => $name]); + + $profile = Profile::query() + ->where('user_id', $this->memberProvider->user->id) + ->where('tenant_id', $this->memberProvider->tenant_id) + ->firstOrFail(); + + $dto = UpsertProfileDTO::fromArray([ + 'nickname' => $nickname, + 'about' => $about, ]); - resolve(UpdateProfile::class)->handle($payload); + resolve(UpsertProfile::class)->handle($profile, $dto); $this ->message('Perfil atualizado!') ->content('https://heartdevs.com/') ->color('800080') - ->title('Perfil '.$payload->nickname) + ->title('Perfil '.$nickname) ->thumbnailUrl($interaction->user->avatar) - ->fields([ // max 3 fields per row - 'Nome/Nickname' => $payload->nickname, - 'Sobre' => $payload->about, + ->fields([ + 'Nome/Nickname' => $nickname, + 'Sobre' => $about, ]) - ->fields( - [ // max 3 fields per row - 'Git/Github' => $payload->githubUrl ?? '-', - 'Linkedin' => $payload->linkedinUrl ?? '-', - ], - inline: false - ) ->footerIcon($interaction->guild->icon) ->footerText(Date::now()->format('Y').' © He4rt Developers') ->timestamp(now()) diff --git a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php index 62ebf1f60..b33d5cbd2 100644 --- a/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/IntroductionCommand.php @@ -11,10 +11,11 @@ use He4rt\Identity\ExternalIdentity\DTOs\ResolveUserProviderDTO; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\Tenant\Models\Tenant; -use He4rt\Identity\User\Actions\InformationUserAction; use He4rt\Identity\User\Actions\ResolveUserContext; -use He4rt\Identity\User\DTOs\UpsertInformationDTO; use He4rt\Identity\User\Models\User; +use He4rt\Profile\Actions\UpsertProfile; +use He4rt\Profile\DTOs\UpsertProfileDTO; +use He4rt\Profile\Models\Profile; use Illuminate\Support\Facades\Date; use Laracord\Commands\SlashCommand; use Throwable; @@ -75,24 +76,10 @@ public function handle(Interaction $interaction): void ->setPlaceholder('Fulano123') ->setRequired(true), - TextInput::new('Git/Github (Opcional)', TextInput::STYLE_SHORT) - ->setCustomId('github_url') - ->setMinLength(0) - ->setMaxLength(60) - ->setPlaceholder('https://github.com/YOUR_USERNAME') - ->setRequired(false), - - TextInput::new('LinkedIn (Opcional)', TextInput::STYLE_SHORT) - ->setCustomId('linkedin_url') - ->setMinLength(0) - ->setMaxLength(60) - ->setPlaceholder('https://linkedin.com/in/YOUR_USERNAME') - ->setRequired(false), - TextInput::new('Nos conte um pouco sobre você', TextInput::STYLE_PARAGRAPH) ->setCustomId('about') ->setMinLength(5) - ->setMaxLength(1000) + ->setMaxLength(500) ->setPlaceholder('Entrei de curioso e acabei gostando do servidor!') ->setRequired(true), @@ -135,35 +122,37 @@ private function persistData(Interaction $interaction, Collection $components): $userContext = resolve(ResolveUserContext::class)->handle($userDto); - $informationDto = UpsertInformationDTO::make([ - 'user' => $userContext->user, - 'name' => $components->get('custom_id', 'name')->value, - 'nickname' => $components->get('custom_id', 'nickname')->value, - 'about' => $components->get('custom_id', 'about')->value, - 'linkedin_url' => $components->get('custom_id', 'linkedin_url')?->value, - 'github_url' => $components->get('custom_id', 'github_url')?->value, - 'birthdate' => null, + $name = $components->get('custom_id', 'name')->value; + $nickname = $components->get('custom_id', 'nickname')->value; + $about = $components->get('custom_id', 'about')->value; + + $userContext->user->update(['name' => $name]); + + $profile = Profile::query() + ->where('user_id', $userContext->user->id) + ->where('tenant_id', $tenantProvider->tenant_id) + ->firstOrFail(); + + $dto = UpsertProfileDTO::fromArray([ + 'nickname' => $nickname, + 'about' => $about, ]); - $userInformation = resolve(InformationUserAction::class)->handle($informationDto); + resolve(UpsertProfile::class)->handle($profile, $dto); $this ->message('Nova apresentação') - ->title('Apresentação de '.$userInformation->nickname) + ->title('Apresentação de '.$nickname) ->thumbnailUrl($interaction->user->avatar) ->content(sprintf( '<@%s> acabou de se apresentar na comunidade.', $interaction->user->id )) ->fields([ - 'Nome' => $userInformation->name, - 'Nickname' => $userInformation->nickname, - ]) - ->fields(['Sobre' => $userInformation->about], inline: false) - ->fields([ - 'GitHub' => $userInformation->github_url ?? '-', - 'LinkedIn' => $userInformation->linkedin_url ?? '-', + 'Nome' => $name, + 'Nickname' => $nickname, ]) + ->fields(['Sobre' => $about], inline: false) ->footerIcon($interaction->guild->icon) ->footerText(Date::now()->format('Y').' © He4rt Developers') ->timestamp(now()) diff --git a/app-modules/identity/database/factories/AddressFactory.php b/app-modules/identity/database/factories/AddressFactory.php deleted file mode 100644 index 257a3bf28..000000000 --- a/app-modules/identity/database/factories/AddressFactory.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ -final class AddressFactory extends Factory -{ - protected $model = Address::class; - - public function definition(): array - { - return [ - 'id' => fake()->uuid(), - 'user_id' => User::factory(), - 'country' => fake()->countryCode(), - 'state' => fake()->randomElement(['SP', 'RJ', 'BH']), - 'city' => fake()->city(), - 'zip_code' => fake()->randomNumber(8), - ]; - } -} diff --git a/app-modules/identity/database/factories/InformationFactory.php b/app-modules/identity/database/factories/InformationFactory.php deleted file mode 100644 index 423ab35fe..000000000 --- a/app-modules/identity/database/factories/InformationFactory.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ -final class InformationFactory extends Factory -{ - protected $model = Information::class; - - public function definition(): array - { - return [ - 'id' => fake()->uuid(), - 'user_id' => User::factory(), - 'name' => fake()->name(), - 'nickname' => fake()->userName(), - 'linkedin_url' => fake()->url(), - 'github_url' => fake()->url(), - 'birthdate' => fake()->date(), - 'about' => fake()->text(), - ]; - } -} diff --git a/app-modules/identity/src/Filament/Shared/Schemas/UserInformationForm.php b/app-modules/identity/src/Filament/Shared/Schemas/UserInformationForm.php deleted file mode 100644 index 3153fa284..000000000 --- a/app-modules/identity/src/Filament/Shared/Schemas/UserInformationForm.php +++ /dev/null @@ -1,54 +0,0 @@ -components([ - Section::make('Personal Information') - ->description('Basic profile details and social links.') - ->schema([ - TextInput::make('name') - ->label('Full Name') - ->placeholder('Enter your full name') - ->maxLength(255), - - TextInput::make('nickname') - ->label('Nickname') - ->placeholder('How do you like to be called?') - ->maxLength(255), - - DatePicker::make('birthdate') - ->label('Birthdate') - ->placeholder('Select your birth date'), - - RichEditor::make('about') - ->label('About') - ->placeholder('Write a short description about yourself...') - ->columnSpanFull(), - - TextInput::make('linkedin_url') - ->label('LinkedIn URL') - ->placeholder('https://linkedin.com/in/username') - ->url(), - - TextInput::make('github_url') - ->label('GitHub URL') - ->placeholder('https://github.com/username') - ->url(), - ]) - ->columnSpanFull(), - ]); - } -} diff --git a/app-modules/identity/src/User/Actions/InformationUserAction.php b/app-modules/identity/src/User/Actions/InformationUserAction.php deleted file mode 100644 index 97068eee8..000000000 --- a/app-modules/identity/src/User/Actions/InformationUserAction.php +++ /dev/null @@ -1,23 +0,0 @@ -updateOrCreate(['user_id' => $dto->user->id], [ - 'name' => $dto->name, - 'nickname' => $dto->nickname, - 'linkedin_url' => $dto->linkedinUrl, - 'github_url' => $dto->githubUrl, - 'birthdate' => $dto->birthdate, - 'about' => $dto->about, - ]); - } -} diff --git a/app-modules/identity/src/User/Actions/UpdateProfile.php b/app-modules/identity/src/User/Actions/UpdateProfile.php deleted file mode 100644 index 70f2e3391..000000000 --- a/app-modules/identity/src/User/Actions/UpdateProfile.php +++ /dev/null @@ -1,43 +0,0 @@ - $profileDTO->externalAccountId, - 'provider' => $profileDTO->provider, - 'tenant_id' => $profileDTO->tenantId, - 'model_type' => (new User)->getMorphClass(), - ]); - $provider = $this->providerResolver->handle($providerDto); - - $informationDto = UpsertInformationDTO::make([ - 'user' => $provider->user, - 'name' => $profileDTO->name, - 'nickname' => $profileDTO->nickname, - 'linkedin_url' => $profileDTO->linkedinUrl, - 'github_url' => $profileDTO->githubUrl, - 'birthdate' => $profileDTO->birthdate, - 'about' => $profileDTO->about, - ]); - - $this->informationUserAction->handle($informationDto); - - } -} diff --git a/app-modules/identity/src/User/DTOs/UpdateProfileDTO.php b/app-modules/identity/src/User/DTOs/UpdateProfileDTO.php deleted file mode 100644 index f2d530a4a..000000000 --- a/app-modules/identity/src/User/DTOs/UpdateProfileDTO.php +++ /dev/null @@ -1,55 +0,0 @@ - $payload - */ - public static function fromPayload(array $payload): self - { - return new self( - externalAccountId: $payload['external_account_id'], - provider: $payload['provider'], - tenantId: $payload['tenant_id'], - name: $payload['name'], - nickname: $payload['nickname'], - linkedinUrl: $payload['linkedin_url'], - githubUrl: $payload['github_url'], - birthdate: $payload['birthdate'], - about: $payload['about'] - ); - } - - /** - * @return array - */ - public function toProfile(): array - { - return [ - 'name' => $this->name, - 'nickname' => $this->nickname, - 'linkedin_url' => $this->linkedinUrl, - 'github_url' => $this->githubUrl, - 'birthdate' => $this->birthdate, - 'about' => $this->about, - ]; - } -} diff --git a/app-modules/identity/src/User/DTOs/UpsertInformationDTO.php b/app-modules/identity/src/User/DTOs/UpsertInformationDTO.php deleted file mode 100644 index 31bf201c9..000000000 --- a/app-modules/identity/src/User/DTOs/UpsertInformationDTO.php +++ /dev/null @@ -1,36 +0,0 @@ - $data - */ - public static function make(array $data): self - { - return new self( - user: $data['user'], - name: $data['name'], - nickname: $data['nickname'], - about: $data['about'], - linkedinUrl: $data['linkedin_url'] ?? null, - githubUrl: $data['github_url'] ?? null, - birthdate: $data['birthdate'] ?? null, - ); - } -} diff --git a/app-modules/identity/src/User/Exceptions/ProfileException.php b/app-modules/identity/src/User/Exceptions/ProfileException.php deleted file mode 100644 index 0afc4ced1..000000000 --- a/app-modules/identity/src/User/Exceptions/ProfileException.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - use HasFactory; - use HasUuids; - - /** - * @return BelongsTo - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - protected static function newFactory(): AddressFactory - { - return AddressFactory::new(); - } -} diff --git a/app-modules/identity/src/User/Models/Information.php b/app-modules/identity/src/User/Models/Information.php deleted file mode 100644 index d0c6241e8..000000000 --- a/app-modules/identity/src/User/Models/Information.php +++ /dev/null @@ -1,42 +0,0 @@ - */ - use HasFactory; - use HasUuids; - - /** - * @return BelongsTo - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - protected static function newFactory(): InformationFactory - { - return InformationFactory::new(); - } -} diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index 94049af43..36c460435 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -53,14 +53,6 @@ public function isAdmin(): bool return in_array($this->username, str(config('he4rt.admins'))->explode(',')->toArray(), true); } - /** - * @return HasOne - */ - public function information(): HasOne - { - return $this->hasOne(Information::class); - } - /** * @return MorphMany */ From ece5f8e2dc1e61b6e4c1d415cbbfd513df3900be Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 23 May 2026 18:16:07 -0300 Subject: [PATCH 3/7] feat(bot-discord): /endereco slash command for address collection - Add $fillable to Address model for mass assignment - New /endereco command with autocomplete for Brazilian states (27 UFs) - Persists country (BRA default), state (UF), and city via User's polymorphic address - Add updateOrCreate test for address relationship --- .../src/SlashCommands/AddressCommand.php | 142 ++++++++++++++++++ app/Models/Address.php | 9 ++ tests/Feature/AddressTest.php | 25 +++ 3 files changed, 176 insertions(+) create mode 100644 app-modules/bot-discord/src/SlashCommands/AddressCommand.php diff --git a/app-modules/bot-discord/src/SlashCommands/AddressCommand.php b/app-modules/bot-discord/src/SlashCommands/AddressCommand.php new file mode 100644 index 000000000..a941966a4 --- /dev/null +++ b/app-modules/bot-discord/src/SlashCommands/AddressCommand.php @@ -0,0 +1,142 @@ + 'Acre', + 'AL' => 'Alagoas', + 'AP' => 'Amapá', + 'AM' => 'Amazonas', + 'BA' => 'Bahia', + 'CE' => 'Ceará', + 'DF' => 'Distrito Federal', + 'ES' => 'Espírito Santo', + 'GO' => 'Goiás', + 'MA' => 'Maranhão', + 'MT' => 'Mato Grosso', + 'MS' => 'Mato Grosso do Sul', + 'MG' => 'Minas Gerais', + 'PA' => 'Pará', + 'PB' => 'Paraíba', + 'PR' => 'Paraná', + 'PE' => 'Pernambuco', + 'PI' => 'Piauí', + 'RJ' => 'Rio de Janeiro', + 'RN' => 'Rio Grande do Norte', + 'RS' => 'Rio Grande do Sul', + 'RO' => 'Rondônia', + 'RR' => 'Roraima', + 'SC' => 'Santa Catarina', + 'SP' => 'São Paulo', + 'SE' => 'Sergipe', + 'TO' => 'Tocantins', + ]; + + protected $name = 'endereco'; + + protected $description = 'Defina sua localização (estado e cidade).'; + + /** @var array */ + protected $options = []; + + /** @var array */ + protected $permissions = []; + + protected $admin = false; + + protected $hidden = false; + + public function handle(Interaction $interaction): void + { + if (!$this->memberProvider?->user) { + $interaction->respondWithMessage( + 'Você precisa se apresentar primeiro. Use o comando `/apresentar`.', + true + ); + + return; + } + + try { + $state = mb_strtoupper((string) $this->value('estado')); + $city = $this->value('cidade'); + + if (!isset(self::STATES[$state])) { + $interaction->respondWithMessage('Estado inválido. Use a sigla (ex: SP, RJ).', true); + + return; + } + + $this->memberProvider->user->address()->updateOrCreate([], [ + 'country' => 'BRA', + 'state' => $state, + 'city' => $city, + ]); + + $stateName = self::STATES[$state]; + + $interaction->respondWithMessage( + sprintf('Localização atualizada para **%s, %s** 🇧🇷', $city, $stateName), + true + ); + } catch (Throwable $throwable) { + $this->logger()->error('Error AddressCommand:', [$throwable->getMessage()]); + + $interaction->respondWithMessage('Erro ao atualizar localização.', true); + } + } + + /** + * @return array + */ + public function options(): array + { + return [ + [ + 'name' => 'estado', + 'description' => 'Seu estado (UF)', + 'type' => Option::STRING, + 'required' => true, + 'autocomplete' => true, + ], + [ + 'name' => 'cidade', + 'description' => 'Sua cidade', + 'type' => Option::STRING, + 'required' => true, + 'max_length' => 100, + ], + ]; + } + + /** + * @return array + */ + public function autocomplete(): array + { + return [ + 'estado' => function (Interaction $interaction, ?string $value): array { + $states = collect(self::STATES) + ->map(fn (string $name, string $uf): string => sprintf('%s - %s', $uf, $name)); + + if ($value) { + $search = mb_strtolower($value); + $states = $states->filter( + fn (string $label, string $uf): bool => str_contains(mb_strtolower($label), $search) + || str_contains(mb_strtolower($uf), $search) + ); + } + + return $states->take(25)->all(); + }, + ]; + } +} diff --git a/app/Models/Address.php b/app/Models/Address.php index 1bfb1c991..03f268c47 100644 --- a/app/Models/Address.php +++ b/app/Models/Address.php @@ -30,6 +30,15 @@ final class Address extends Model use HasFactory; use HasUuids; + protected $fillable = [ + 'addressable_type', + 'addressable_id', + 'country', + 'state', + 'city', + 'zip_code', + ]; + /** * @return MorphTo */ diff --git a/tests/Feature/AddressTest.php b/tests/Feature/AddressTest.php index dbf317748..98590352a 100644 --- a/tests/Feature/AddressTest.php +++ b/tests/Feature/AddressTest.php @@ -42,6 +42,31 @@ expect(Address::query()->where('addressable_id', $user->id)->exists())->toBeFalse(); }); +it('updateOrCreate via relationship creates and updates address', function (): void { + $user = User::factory()->create(); + + $user->address()->updateOrCreate([], [ + 'country' => 'BRA', + 'state' => 'SP', + 'city' => 'São Paulo', + ]); + + expect($user->fresh()->address) + ->country->toBe('BRA') + ->state->toBe('SP') + ->city->toBe('São Paulo'); + + $user->address()->updateOrCreate([], [ + 'state' => 'RJ', + 'city' => 'Rio de Janeiro', + ]); + + expect($user->fresh()->address) + ->state->toBe('RJ') + ->city->toBe('Rio de Janeiro') + ->and(Address::query()->where('addressable_id', $user->id)->count())->toBe(1); +}); + it('factory cria address válido para user', function (): void { $user = User::factory()->create(); From 29ca69191c72753ad0f99e77e6ea0659bacc1ad3 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 23 May 2026 18:47:51 -0300 Subject: [PATCH 4/7] feat(panel-app): profile edit page with live preview card (#255) - Custom ProfilePage in User panel with cover/avatar media header - Cover as full-width banner with hover overlay upload, avatar overlapping - Live preview card (sticky sidebar) updates as fields change - Gamification data: level, XP progress bar, badges via computed property - Social links via Repeater with SocialPlatform enum - Availability toggle with conditional start_availability - Manual media uploads via WithFileUploads + Spatie Media Library - Added 'cover' media collection to User model - Translations in en + pt_BR - 8 Livewire tests covering form load, save, validation, social links --- app-modules/identity/src/User/Models/User.php | 5 + app-modules/panel-app/lang/en/profile.php | 50 +++ app-modules/panel-app/lang/pt_BR/profile.php | 50 +++ .../components/profile-media-header.blade.php | 149 ++++++++ .../components/profile-preview-card.blade.php | 210 +++++++++++ .../resources/views/pages/profile.blade.php | 33 ++ .../panel-app/src/Pages/ProfilePage.php | 333 ++++++++++++++++++ .../tests/Feature/ProfilePageTest.php | 127 +++++++ app/Providers/Filament/AppPanelProvider.php | 2 + 9 files changed, 959 insertions(+) create mode 100644 app-modules/panel-app/lang/en/profile.php create mode 100644 app-modules/panel-app/lang/pt_BR/profile.php create mode 100644 app-modules/panel-app/resources/views/components/profile-media-header.blade.php create mode 100644 app-modules/panel-app/resources/views/components/profile-preview-card.blade.php create mode 100644 app-modules/panel-app/resources/views/pages/profile.blade.php create mode 100644 app-modules/panel-app/src/Pages/ProfilePage.php create mode 100644 app-modules/panel-app/tests/Feature/ProfilePageTest.php diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index 36c460435..6ae1983de 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -77,6 +77,11 @@ public function getFilamentName(): string public function registerMediaCollections(): void { $this->addMediaCollection('avatar') + ->singleFile() + ->useDisk('public'); + + $this->addMediaCollection('cover') + ->singleFile() ->useDisk('public'); } diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php new file mode 100644 index 000000000..dc94a5dad --- /dev/null +++ b/app-modules/panel-app/lang/en/profile.php @@ -0,0 +1,50 @@ + [ + 'personal' => 'Personal', + 'professional' => 'Professional', + 'about' => 'About', + 'social_links' => 'Social Links', + 'availability' => 'Availability', + ], + + 'fields' => [ + 'nickname' => 'Nickname', + 'birthdate' => 'Birthdate', + 'headline' => 'Headline', + 'seniority_level' => 'Seniority Level', + 'years_experience' => 'Years of Experience', + 'about' => 'About', + 'platform' => 'Platform', + 'handle' => 'Handle / URL', + 'avatar' => 'Photo', + 'cover' => 'Cover', + 'available_for_proposals' => 'Available for proposals', + 'start_availability' => 'Start availability', + ], + + 'placeholders' => [ + 'nickname' => 'How do you like to be called?', + 'headline' => 'Your job title or role', + 'about' => 'Tell us about yourself...', + 'handle' => '@username or https://...', + ], + + 'hints' => [ + 'headline' => 'e.g. Frontend Developer, Product Designer', + 'available_for_proposals' => 'When active, recruiters will see a green badge on your profile', + ], + + 'actions' => [ + 'save' => 'Save profile', + 'add_social_link' => 'Add social link', + ], + + 'notifications' => [ + 'saved' => 'Profile saved successfully!', + 'no_profile' => 'Profile not found for this tenant.', + ], +]; diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php new file mode 100644 index 000000000..f9c56145b --- /dev/null +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -0,0 +1,50 @@ + [ + 'personal' => 'Pessoal', + 'professional' => 'Profissional', + 'about' => 'Sobre', + 'social_links' => 'Links Sociais', + 'availability' => 'Disponibilidade', + ], + + 'fields' => [ + 'nickname' => 'Apelido', + 'birthdate' => 'Data de Nascimento', + 'headline' => 'Título Profissional', + 'seniority_level' => 'Senioridade', + 'years_experience' => 'Anos de Experiência', + 'about' => 'Sobre', + 'platform' => 'Plataforma', + 'handle' => 'Handle / URL', + 'avatar' => 'Foto', + 'cover' => 'Capa', + 'available_for_proposals' => 'Disponível para propostas', + 'start_availability' => 'Disponibilidade para início', + ], + + 'placeholders' => [ + 'nickname' => 'Como você é conhecido?', + 'headline' => 'Seu cargo ou título profissional', + 'about' => 'Conte um pouco sobre você...', + 'handle' => '@usuario ou https://...', + ], + + 'hints' => [ + 'headline' => 'Ex: Frontend Developer, Product Designer', + 'available_for_proposals' => 'Quando ativo, recrutadores verão um badge verde no seu perfil', + ], + + 'actions' => [ + 'save' => 'Salvar perfil', + 'add_social_link' => 'Adicionar link social', + ], + + 'notifications' => [ + 'saved' => 'Perfil salvo com sucesso!', + 'no_profile' => 'Perfil não encontrado para este tenant.', + ], +]; diff --git a/app-modules/panel-app/resources/views/components/profile-media-header.blade.php b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php new file mode 100644 index 000000000..962087ae3 --- /dev/null +++ b/app-modules/panel-app/resources/views/components/profile-media-header.blade.php @@ -0,0 +1,149 @@ +@props ([ + 'avatarPreviewUrl' => null, + 'coverPreviewUrl' => null, + 'initials' => '', + 'name' => '' +]) + +
+ {{-- Cover --}} +
+ @if ($coverPreviewUrl) +
+ @else +
+ @endif + + + + + + @if ($coverPreviewUrl) + + @endif + +
+ +
+
+ + {{-- Bottom section: avatar + fields --}} +
+ {{-- Avatar (overlapping cover) --}} +
+
+ @if ($avatarPreviewUrl) + {{ $name }} + @else +
+ {{ $initials }} +
+ @endif + + + + + + @if ($avatarPreviewUrl) + + @endif + +
+ +
+
+
+ + {{-- Nickname + Birthdate --}} +
+
+ + +
+
+ + +
+
+
+
diff --git a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php new file mode 100644 index 000000000..57df56e04 --- /dev/null +++ b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php @@ -0,0 +1,210 @@ +@props ([ + 'data' => [], + 'user' => null, + 'character' => null, + 'initials' => '', + 'avatarPreviewUrl' => null, + 'coverPreviewUrl' => null +]) + +@php + $name = $user?->name ?? ''; + $username = $user?->username ?? ''; + $nickname = $data['nickname'] ?? null; + $headline = $data['headline'] ?? null; + $about = $data['about'] ?? null; + $yearsExperience = $data['years_experience'] ?? null; + $available = (bool) ($data['available_for_proposals'] ?? false); + + $seniorityRaw = $data['seniority_level'] ?? null; + $seniority = + $seniorityRaw instanceof \He4rt\Profile\Enums\SeniorityLevel + ? $seniorityRaw + : (is_string($seniorityRaw) + ? \He4rt\Profile\Enums\SeniorityLevel::tryFrom($seniorityRaw) + : null); + + $startRaw = $data['start_availability'] ?? null; + $startAvailability = + $startRaw instanceof \He4rt\Profile\Enums\StartAvailability + ? $startRaw + : (is_string($startRaw) + ? \He4rt\Profile\Enums\StartAvailability::tryFrom($startRaw) + : null); + + $socialLinks = collect($data['social_links'] ?? [])->filter( + fn($item) => !empty($item['platform']) && !empty($item['handle']), + ); + + $level = $character?->level ?? 1; + $experience = $character?->experience ?? 0; + $nextThreshold = \He4rt\Gamification\Character\Models\Character::LEVEL_THRESHOLDS[$level + 1] ?? $experience; + $currentThreshold = \He4rt\Gamification\Character\Models\Character::LEVEL_THRESHOLDS[$level] ?? 0; + $xpPercent = + $nextThreshold > $currentThreshold + ? round((($experience - $currentThreshold) / ($nextThreshold - $currentThreshold)) * 100) + : 100; + $badges = $character?->badges ?? collect(); +@endphp + +
+ {{-- Header gradient / cover --}} +
+ @if ($coverPreviewUrl) + + @endif + + {{-- Level badge --}} +
+ LVL {{ $level }} +
+ + {{-- Avatar --}} +
+ @if ($avatarPreviewUrl) + {{ $name }} + @else +
+ {{ $initials }} +
+ @endif +
+
+ + {{-- Content --}} +
+ {{-- Name + available badge --}} +
+
+

{{ $name }}

+ @if ($available) + + + Disponível + + @endif +
+

{{ '@' }}{{ $username }}

+
+ + {{-- Headline --}} + @if ($headline) +

{{ $headline }}

+ @endif + + {{-- Seniority + years --}} + @if ($seniority || $yearsExperience) +
+ @if ($seniority) + + $seniority === \He4rt\Profile\Enums\SeniorityLevel::Junior, + 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' => + $seniority === \He4rt\Profile\Enums\SeniorityLevel::Mid, + 'bg-purple-50 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400' => + $seniority === \He4rt\Profile\Enums\SeniorityLevel::Senior, + 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400' => + $seniority === \He4rt\Profile\Enums\SeniorityLevel::Specialist, + 'bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-400' => + $seniority === \He4rt\Profile\Enums\SeniorityLevel::Lead + ]) + > + {{ $seniority->getLabel() }} + + @endif + @if ($yearsExperience) + + {{ $yearsExperience }} {{ $yearsExperience == 1 ? 'ano' : 'anos' }} de exp. + + @endif +
+ @endif + + {{-- Bio --}} + @if ($about) +

{{ Str::limit($about, 200) }}

+ @endif + + {{-- XP bar --}} + @if ($character) +
+
+ Experiência + + {{ number_format($experience) }} + / {{ number_format($nextThreshold) }} XP + +
+
+
+
+
+ @endif + + {{-- Badges --}} + @if ($badges->isNotEmpty()) +
+ @foreach ($badges->take(5) as $badge) + + {{ $badge->name }} + + @endforeach +
+ @endif + + {{-- Social links --}} + @if ($socialLinks->isNotEmpty()) +
+ @foreach ($socialLinks as $link) + @php + $platform = + $link['platform'] instanceof \He4rt\Profile\Enums\SocialPlatform + ? $link['platform'] + : \He4rt\Profile\Enums\SocialPlatform::tryFrom($link['platform']); + @endphp + @if ($platform) + + {{ $platform->getLabel() }} {{ $link['handle'] }} + + @endif + @endforeach +
+ @endif + + {{-- Start availability --}} + @if ($available && $startAvailability) +

+ Pode iniciar: {{ $startAvailability->getLabel() }} +

+ @endif +
+ + {{-- Footer --}} +
+

Esse card aparece na listagem de membros e no seu perfil público.

+
+
diff --git a/app-modules/panel-app/resources/views/pages/profile.blade.php b/app-modules/panel-app/resources/views/pages/profile.blade.php new file mode 100644 index 000000000..2e9a66b09 --- /dev/null +++ b/app-modules/panel-app/resources/views/pages/profile.blade.php @@ -0,0 +1,33 @@ + +
+ Preencha os campos e veja seu card sendo montado em tempo real +
+ +
+ {{-- Form (left, 2/3) --}} +
+ @include ('panel-app::components.profile-media-header', + [ + 'avatarPreviewUrl' => $this->avatarPreviewUrl, + 'coverPreviewUrl' => $this->coverPreviewUrl, + 'initials' => $this->initials, + 'name' => auth()->user()->name + ]) + + {{ $this->form }} +
+ + {{-- Preview card (right, 1/3, sticky) --}} +
+ @include ('panel-app::components.profile-preview-card', + [ + 'data' => $this->data, + 'user' => auth()->user(), + 'character' => $this->character, + 'initials' => $this->initials, + 'avatarPreviewUrl' => $this->avatarPreviewUrl, + 'coverPreviewUrl' => $this->coverPreviewUrl + ]) +
+
+
diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php new file mode 100644 index 000000000..3f9d3cde6 --- /dev/null +++ b/app-modules/panel-app/src/Pages/ProfilePage.php @@ -0,0 +1,333 @@ +|null */ + public ?array $data = []; + + /** @var TemporaryUploadedFile|null */ + public $avatarUpload; + + /** @var TemporaryUploadedFile|null */ + public $coverUpload; + + protected static string|null|BackedEnum $navigationIcon = 'heroicon-o-user-circle'; + + protected static ?string $title = 'Profile'; + + protected static ?string $slug = 'profile'; + + protected static ?int $navigationSort = 2; + + protected string $view = 'panel-app::pages.profile'; + + protected Width|string|null $maxContentWidth = Width::Full; + + public function mount(): void + { + $profile = $this->getRecord(); + + $this->form->fill([ + 'nickname' => $profile->nickname, + 'birthdate' => $profile->birthdate?->format('Y-m-d'), + 'headline' => $profile->headline, + 'seniority_level' => $profile->seniority_level, + 'years_experience' => $profile->years_experience, + 'about' => $profile->about, + 'social_links' => $this->socialLinksToRepeater($profile->social_links), + 'available_for_proposals' => $profile->available_for_proposals, + 'start_availability' => $profile->start_availability, + ]); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + Form::make([ + Section::make(__('panel-app::profile.sections.professional')) + ->schema([ + Grid::make(3)->schema([ + TextInput::make('headline') + ->label(__('panel-app::profile.fields.headline')) + ->placeholder(__('panel-app::profile.placeholders.headline')) + ->maxLength(100) + ->live(onBlur: true) + ->columnSpan(1), + + Select::make('seniority_level') + ->label(__('panel-app::profile.fields.seniority_level')) + ->options(SeniorityLevel::class) + ->live() + ->columnSpan(1), + + TextInput::make('years_experience') + ->label(__('panel-app::profile.fields.years_experience')) + ->numeric() + ->minValue(0) + ->maxValue(50) + ->live(onBlur: true) + ->columnSpan(1), + Textarea::make('about') + ->label(__('panel-app::profile.fields.about')) + ->placeholder(__('panel-app::profile.placeholders.about')) + ->maxLength(500) + ->rows(4) + ->live(onBlur: true) + ->columnSpanFull(), + ]), + ]), + + Section::make(__('panel-app::profile.sections.social_links')) + ->schema([ + Repeater::make('social_links') + ->label('') + ->schema([ + Grid::make(2)->schema([ + Select::make('platform') + ->label(__('panel-app::profile.fields.platform')) + ->options(SocialPlatform::class) + ->required() + ->columnSpan(1), + + TextInput::make('handle') + ->label(__('panel-app::profile.fields.handle')) + ->placeholder(__('panel-app::profile.placeholders.handle')) + ->required() + ->columnSpan(1), + ]), + ]) + ->addActionLabel(__('panel-app::profile.actions.add_social_link')) + ->defaultItems(0) + ->reorderable(false) + ->columnSpanFull(), + ]), + + Section::make(__('panel-app::profile.sections.availability')) + ->schema([ + Toggle::make('available_for_proposals') + ->label(__('panel-app::profile.fields.available_for_proposals')) + ->hint(__('panel-app::profile.hints.available_for_proposals')) + ->live(), + + Select::make('start_availability') + ->label(__('panel-app::profile.fields.start_availability')) + ->options(StartAvailability::class) + ->live() + ->visible(fn (Get $get): bool => (bool) $get('available_for_proposals')), + ]), + ]) + ->livewireSubmitHandler('save') + ->footer([ + Actions::make([ + Action::make('save') + ->label(__('panel-app::profile.actions.save')) + ->submit('save'), + ]), + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $formData = $this->form->getState(); + $profile = $this->getRecord(); + + $socialLinks = $this->repeaterToSocialLinks($formData['social_links'] ?? []); + + $dto = UpsertProfileDTO::fromArray([ + 'nickname' => $this->data['nickname'] ?? null, + 'birthdate' => $this->data['birthdate'] ?? null, + 'about' => $formData['about'] ?? null, + 'headline' => $formData['headline'] ?? null, + 'seniority_level' => $formData['seniority_level'] ?? null, + 'years_experience' => $formData['years_experience'] ?? null, + 'social_links' => $socialLinks !== [] ? $socialLinks : null, + ]); + + resolve(UpsertProfile::class)->handle($profile, $dto); + + $available = (bool) ($formData['available_for_proposals'] ?? false); + $rawStartAvailability = $formData['start_availability'] ?? null; + $startAvailability = match (true) { + $rawStartAvailability instanceof StartAvailability => $rawStartAvailability, + is_string($rawStartAvailability) => StartAvailability::from($rawStartAvailability), + $available => StartAvailability::Negotiable, + default => null, + }; + + resolve(ToggleAvailability::class)->handle($profile, $available, $startAvailability); + + $this->saveMedia(); + + Notification::make() + ->success() + ->title(__('panel-app::profile.notifications.saved')) + ->send(); + } + + public function getRecord(): Profile + { + return Profile::query() + ->firstOrCreate( + [ + 'user_id' => auth()->id(), + 'tenant_id' => filament()->getTenant()?->getKey(), + ], + ); + } + + #[Computed] + public function character(): ?Character + { + return Character::query() + ->with('badges') + ->where('user_id', auth()->id()) + ->where('tenant_id', filament()->getTenant()?->getKey()) + ->first(); + } + + #[Computed] + public function initials(): string + { + return Str::of(auth()->user()->name) + ->explode(' ') + ->map(fn (string $part): string => Str::upper(Str::substr($part, 0, 1))) + ->take(2) + ->implode(''); + } + + #[Computed] + public function avatarPreviewUrl(): ?string + { + if ($this->avatarUpload instanceof TemporaryUploadedFile) { + return $this->avatarUpload->temporaryUrl(); + } + + return auth()->user()->getFirstMediaUrl('avatar') ?: null; + } + + #[Computed] + public function coverPreviewUrl(): ?string + { + if ($this->coverUpload instanceof TemporaryUploadedFile) { + return $this->coverUpload->temporaryUrl(); + } + + return auth()->user()->getFirstMediaUrl('cover') ?: null; + } + + public function removeAvatar(): void + { + $this->avatarUpload = null; + auth()->user()->clearMediaCollection('avatar'); + } + + public function removeCover(): void + { + $this->coverUpload = null; + auth()->user()->clearMediaCollection('cover'); + } + + private function saveMedia(): void + { + /** @var User $user */ + $user = auth()->user(); + + if ($this->avatarUpload instanceof TemporaryUploadedFile) { + $user->clearMediaCollection('avatar'); + $user->addMedia($this->avatarUpload->getRealPath()) + ->usingFileName($this->avatarUpload->getClientOriginalName()) + ->toMediaCollection('avatar'); + $this->avatarUpload = null; + } + + if ($this->coverUpload instanceof TemporaryUploadedFile) { + $user->clearMediaCollection('cover'); + $user->addMedia($this->coverUpload->getRealPath()) + ->usingFileName($this->coverUpload->getClientOriginalName()) + ->toMediaCollection('cover'); + $this->coverUpload = null; + } + } + + /** + * @param array|null $socialLinks + * @return array + */ + private function socialLinksToRepeater(?array $socialLinks): array + { + if ($socialLinks === null) { + return []; + } + + return array_values(array_map( + fn (string $handle, string $platform): array => [ + 'platform' => $platform, + 'handle' => $handle, + ], + $socialLinks, + array_keys($socialLinks), + )); + } + + /** + * @param array $repeaterData + * @return array + */ + private function repeaterToSocialLinks(array $repeaterData): array + { + $links = []; + + foreach ($repeaterData as $item) { + if (filled($item['platform']) && (isset($item['handle']) && ($item['handle'] !== '' && $item['handle'] !== '0'))) { + $platform = $item['platform'] instanceof SocialPlatform + ? $item['platform']->value + : $item['platform']; + $links[$platform] = $item['handle']; + } + } + + return $links; + } +} diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php new file mode 100644 index 000000000..eb4a43e25 --- /dev/null +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -0,0 +1,127 @@ +user = User::factory()->create(); + $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']); + $this->tenant->members()->attach($this->user); + + $this->actingAs($this->user); + + Filament::setCurrentPanel(Filament::getPanel('app')); + Filament::setTenant($this->tenant); + + $this->profile = Profile::query() + ->where('user_id', $this->user->id) + ->where('tenant_id', $this->tenant->id) + ->first(); +}); + +test('profile page renders successfully', function (): void { + $this->get(ProfilePage::getUrl()) + ->assertSuccessful(); +}); + +test('profile page loads existing profile data', function (): void { + $this->profile->update([ + 'headline' => 'Backend Developer', + 'seniority_level' => SeniorityLevel::Mid, + ]); + + livewire(ProfilePage::class) + ->assertOk() + ->assertSchemaStateSet([ + 'headline' => 'Backend Developer', + 'seniority_level' => SeniorityLevel::Mid, + ]); +}); + +test('profile page saves all fields', function (): void { + livewire(ProfilePage::class) + ->set('data.nickname', 'Dan') + ->fillForm([ + 'headline' => 'Backend Developer', + 'seniority_level' => 'mid', + 'years_experience' => 5, + 'about' => 'Dev PHP apaixonado por Laravel', + ]) + ->call('save') + ->assertNotified(); + + $this->profile->refresh(); + + expect($this->profile->nickname)->toBe('Dan') + ->and($this->profile->headline)->toBe('Backend Developer') + ->and($this->profile->seniority_level)->toBe(SeniorityLevel::Mid) + ->and($this->profile->years_experience)->toBe(5) + ->and($this->profile->about)->toBe('Dev PHP apaixonado por Laravel'); +}); + +test('profile page saves social links from repeater', function (): void { + livewire(ProfilePage::class) + ->fillForm([ + 'social_links' => [ + ['platform' => 'instagram', 'handle' => '@danielhe4rt'], + ['platform' => 'website', 'handle' => 'https://danielheart.dev'], + ], + ]) + ->call('save') + ->assertNotified(); + + $this->profile->refresh(); + + expect($this->profile->social_links)->toMatchArray([ + 'instagram' => '@danielhe4rt', + 'website' => 'https://danielheart.dev', + ]); +}); + +test('profile page saves availability toggle', function (): void { + livewire(ProfilePage::class) + ->fillForm([ + 'available_for_proposals' => true, + 'start_availability' => 'immediate', + ]) + ->call('save') + ->assertNotified(); + + $this->profile->refresh(); + + expect($this->profile->available_for_proposals)->toBeTrue() + ->and($this->profile->start_availability)->toBe(StartAvailability::Immediate); +}); + +test('profile page validates about max length', function (): void { + livewire(ProfilePage::class) + ->fillForm([ + 'about' => str_repeat('a', 501), + ]) + ->call('save') + ->assertHasFormErrors(['about']); +}); + +test('profile page validates headline max length', function (): void { + livewire(ProfilePage::class) + ->fillForm([ + 'headline' => str_repeat('a', 101), + ]) + ->call('save') + ->assertHasFormErrors(['headline']); +}); + +test('profile page does not show account fields', function (): void { + livewire(ProfilePage::class) + ->assertFormFieldDoesNotExist('email') + ->assertFormFieldDoesNotExist('password'); +}); diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 5ab629567..a3d96985e 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -14,6 +14,7 @@ use Filament\PanelProvider; use Filament\Support\Colors\Color; use He4rt\Identity\Tenant\Models\Tenant; +use He4rt\PanelApp\Pages\ProfilePage; use He4rt\PanelApp\Pages\ThreadPage; use He4rt\PanelApp\Pages\TimelinePage; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -50,6 +51,7 @@ public function panel(Panel $panel): Panel ->pages([ TimelinePage::class, ThreadPage::class, + ProfilePage::class, ]) ->middleware([ EncryptCookies::class, From 5d76db9e3631c69cd95917229f35c144e3416149 Mon Sep 17 00:00:00 2001 From: danielhe4rt Date: Sat, 23 May 2026 19:00:55 -0300 Subject: [PATCH 5/7] feat(panel-app): address section with relationship, mobile hide preview, media fixes - Address section using Filament relationship('address') for auto load/save - Country select with 18 countries (default BRA), state select with 27 UFs for BRA - State field adapts: shows Brazilian states when BRA, empty select for other countries - Location shown in preview card with map pin icon - Hide preview card on mobile (hidden xl:block) - Fix cover image using bg-cover instead of img tag - Fix birthdate format (Carbon to Y-m-d for date input) - Merge media header with personal section fields --- app-modules/panel-app/lang/en/profile.php | 4 ++ app-modules/panel-app/lang/pt_BR/profile.php | 4 ++ .../components/profile-preview-card.blade.php | 10 ++++ .../resources/views/pages/profile.blade.php | 2 +- .../panel-app/src/Pages/ProfilePage.php | 60 +++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php index dc94a5dad..12b5c6dec 100644 --- a/app-modules/panel-app/lang/en/profile.php +++ b/app-modules/panel-app/lang/en/profile.php @@ -7,6 +7,7 @@ 'personal' => 'Personal', 'professional' => 'Professional', 'about' => 'About', + 'address' => 'Location', 'social_links' => 'Social Links', 'availability' => 'Availability', ], @@ -20,6 +21,9 @@ 'about' => 'About', 'platform' => 'Platform', 'handle' => 'Handle / URL', + 'country' => 'Country (ISO)', + 'state' => 'State (UF)', + 'city' => 'City', 'avatar' => 'Photo', 'cover' => 'Cover', 'available_for_proposals' => 'Available for proposals', diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php index f9c56145b..79d91e40e 100644 --- a/app-modules/panel-app/lang/pt_BR/profile.php +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -7,6 +7,7 @@ 'personal' => 'Pessoal', 'professional' => 'Profissional', 'about' => 'Sobre', + 'address' => 'Localização', 'social_links' => 'Links Sociais', 'availability' => 'Disponibilidade', ], @@ -20,6 +21,9 @@ 'about' => 'Sobre', 'platform' => 'Plataforma', 'handle' => 'Handle / URL', + 'country' => 'País (ISO)', + 'state' => 'Estado (UF)', + 'city' => 'Cidade', 'avatar' => 'Foto', 'cover' => 'Capa', 'available_for_proposals' => 'Disponível para propostas', diff --git a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php index 57df56e04..e45836a73 100644 --- a/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php +++ b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php @@ -36,6 +36,10 @@ fn($item) => !empty($item['platform']) && !empty($item['handle']), ); + $location = collect([$data['city'] ?? null, $data['state'] ?? null, $data['country'] ?? null]) + ->filter() + ->implode(', '); + $level = $character?->level ?? 1; $experience = $character?->experience ?? 0; $nextThreshold = \He4rt\Gamification\Character\Models\Character::LEVEL_THRESHOLDS[$level + 1] ?? $experience; @@ -97,6 +101,12 @@ class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text- @endif

{{ '@' }}{{ $username }}

+ @if ($location) +

+ + {{ $location }} +

+ @endif {{-- Headline --}} diff --git a/app-modules/panel-app/resources/views/pages/profile.blade.php b/app-modules/panel-app/resources/views/pages/profile.blade.php index 2e9a66b09..d4287c0fd 100644 --- a/app-modules/panel-app/resources/views/pages/profile.blade.php +++ b/app-modules/panel-app/resources/views/pages/profile.blade.php @@ -18,7 +18,7 @@ {{-- Preview card (right, 1/3, sticky) --}} -
+