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-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/bot-discord/src/SlashCommands/ProfileCommand.php b/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php index fd20cd463..0d3625a3e 100644 --- a/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php +++ b/app-modules/bot-discord/src/SlashCommands/ProfileCommand.php @@ -7,6 +7,7 @@ use Discord\Parts\Interactions\Command\Option; use Discord\Parts\Interactions\Interaction; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; +use He4rt\Profile\Models\Profile; use Illuminate\Support\Facades\Date; use Throwable; @@ -75,31 +76,39 @@ public function handle(Interaction $interaction): void try { - if (!$this->memberProvider instanceof ExternalIdentity || !$this->memberProvider->user->information) { + if (!$this->memberProvider instanceof ExternalIdentity) { $this ->message() - ->content($mentionedUser.' ainda não se apresentou! Use o comando `/introduction` primeiro.') + ->content($mentionedUser.' ainda não se apresentou! Use o comando `/apresentar` primeiro.') ->reply($interaction, true); return; } - $information = $this->memberProvider->user->information; + $profile = Profile::query() + ->where('user_id', $this->memberProvider->user->id) + ->where('tenant_id', $this->memberProvider->tenant_id) + ->first(); + + if (!$profile) { + $this + ->message() + ->content($mentionedUser.' ainda não possui um perfil.') + ->reply($interaction, true); + + return; + } $this ->message() ->content('https://heartdevs.com/') ->color('800080') - ->title('Perfil de '.($information->nickname ?? '-')) + ->title('Perfil de '.($profile->nickname ?? $this->memberProvider->user->name)) ->thumbnailUrl($mentionedUser->avatar) ->fields([ - 'Nome/Nickname' => $information->nickname ?? '-', - 'Sobre' => $information->about ?? '-', + 'Nome/Nickname' => $profile->nickname ?? '-', + 'Sobre' => $profile->about ?? '-', ]) - ->fields([ - 'Git/Github' => $information->github_url ?? '-', - 'Linkedin' => $information->linkedin_url ?? '-', - ], 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/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php index 55abae841..80a0397d5 100644 --- a/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Actions/CreateAccountByExternalIdentity.php @@ -31,8 +31,6 @@ public function handle(int $tenantId, IdentityProvider $provider, string $provid 'is_donator' => false, ]); - $user->address()->create(); - $user->information()->create(); $user->character()->create([ 'tenant_id' => $tenantId, ]); 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..d28673bc9 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -33,8 +33,10 @@ * @property string $username * @property string $email * @property bool $is_donator - * @property Carbon $created_at - * @property Carbon $updated_at + * @property Carbon|null $suspended_until + * @property Carbon|null $banned_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at */ #[ObservedBy(UserObserver::class)] #[Table(name: 'users')] @@ -53,14 +55,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 */ @@ -85,6 +79,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..12b5c6dec --- /dev/null +++ b/app-modules/panel-app/lang/en/profile.php @@ -0,0 +1,54 @@ + [ + 'personal' => 'Personal', + 'professional' => 'Professional', + 'about' => 'About', + 'address' => 'Location', + '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', + 'country' => 'Country (ISO)', + 'state' => 'State (UF)', + 'city' => 'City', + '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..79d91e40e --- /dev/null +++ b/app-modules/panel-app/lang/pt_BR/profile.php @@ -0,0 +1,54 @@ + [ + 'personal' => 'Pessoal', + 'professional' => 'Profissional', + 'about' => 'Sobre', + 'address' => 'Localização', + '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', + 'country' => 'País (ISO)', + 'state' => 'Estado (UF)', + 'city' => 'Cidade', + '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..3f8dea78d --- /dev/null +++ b/app-modules/panel-app/resources/views/components/profile-preview-card.blade.php @@ -0,0 +1,221 @@ +@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']), + ); + + $address = $data['address'] ?? []; + $location = collect([$address['city'] ?? null, $address['state'] ?? null, $address['country'] ?? null]) + ->filter() + ->implode(', '); + + $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 }}

+ @if ($location) +

+ + {{ $location }} +

+ @endif +
+ + {{-- 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..d4287c0fd --- /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) --}} + +
+
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..76907ad2d --- /dev/null +++ b/app-modules/panel-app/src/Pages/ProfilePage.php @@ -0,0 +1,399 @@ +|null */ + public ?array $data = []; + + /** @var TemporaryUploadedFile|null */ + #[Validate('nullable|image|mimes:jpg,jpeg,png,webp|max:2048')] + public $avatarUpload; + + /** @var TemporaryUploadedFile|null */ + #[Validate('nullable|image|mimes:jpg,jpeg,png,webp|max:4096')] + 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.address')) + ->relationship('address') + ->schema([ + Grid::make(3)->schema([ + Select::make('country') + ->label(__('panel-app::profile.fields.country')) + ->options([ + 'BRA' => '🇧🇷 Brasil', + 'USA' => '🇺🇸 United States', + 'PRT' => '🇵🇹 Portugal', + 'ARG' => '🇦🇷 Argentina', + 'DEU' => '🇩🇪 Deutschland', + 'CAN' => '🇨🇦 Canada', + 'GBR' => '🇬🇧 United Kingdom', + 'FRA' => '🇫🇷 France', + 'ESP' => '🇪🇸 España', + 'ITA' => '🇮🇹 Italia', + 'JPN' => '🇯🇵 Japan', + 'AUS' => '🇦🇺 Australia', + 'MEX' => '🇲🇽 México', + 'COL' => '🇨🇴 Colombia', + 'CHL' => '🇨🇱 Chile', + 'URY' => '🇺🇾 Uruguay', + 'IRL' => '🇮🇪 Ireland', + 'NLD' => '🇳🇱 Nederland', + ]) + ->default('BRA') + ->searchable() + ->live() + ->columnSpan(1), + + Select::make('state') + ->label(__('panel-app::profile.fields.state')) + ->options(fn (Get $get): array => ($get('country') ?? 'BRA') === 'BRA' ? [ + 'AC' => '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', + ] : []) + ->searchable() + ->allowHtml(false) + ->live() + ->columnSpan(1), + + TextInput::make('city') + ->label(__('panel-app::profile.fields.city')) + ->placeholder('São Paulo') + ->maxLength(100) + ->live(onBlur: true) + ->columnSpan(1), + ]), + ]), + + 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'), + ]), + ]), + ]) + ->record(auth()->user()) + ->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(); + $this->form->saveRelationships(); + + Notification::make() + ->success() + ->title(__('panel-app::profile.notifications.saved')) + ->send(); + } + + public function getRecord(): Profile + { + $tenantId = filament()->getTenant()?->getKey(); + abort_unless($tenantId, 403); + + return Profile::query() + ->firstOrCreate( + [ + 'user_id' => auth()->id(), + 'tenant_id' => $tenantId, + ], + ); + } + + #[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(Str::uuid()->toString().'.'.$this->avatarUpload->getClientOriginalExtension()) + ->toMediaCollection('avatar'); + $this->avatarUpload = null; + } + + if ($this->coverUpload instanceof TemporaryUploadedFile) { + $user->clearMediaCollection('cover'); + $user->addMedia($this->coverUpload->getRealPath()) + ->usingFileName(Str::uuid()->toString().'.'.$this->coverUpload->getClientOriginalExtension()) + ->toMediaCollection('cover'); + $this->coverUpload = null; + } + } + + /** + * @param array|null $socialLinks + * @return list + */ + private function socialLinksToRepeater(?array $socialLinks): array + { + if ($socialLinks === null) { + return []; + } + + $result = []; + + foreach ($socialLinks as $platform => $handle) { + $result[] = ['platform' => $platform, 'handle' => $handle]; + } + + return $result; + } + + /** + * @param array> $repeaterData + * @return array + */ + private function repeaterToSocialLinks(array $repeaterData): array + { + $links = []; + + foreach ($repeaterData as $item) { + $platform = $item['platform'] ?? null; + $handle = $item['handle'] ?? null; + + if (filled($platform) && filled($handle)) { + $key = $platform instanceof SocialPlatform ? $platform->value : (string) $platform; + $links[$key] = (string) $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-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/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php index 570eadc97..96933f5af 100644 --- a/app-modules/profile/src/Models/Profile.php +++ b/app-modules/profile/src/Models/Profile.php @@ -4,6 +4,7 @@ namespace He4rt\Profile\Models; +use Carbon\Carbon; use He4rt\Identity\Tenant\Models\Tenant; use He4rt\Identity\User\Models\User; use He4rt\Profile\Database\Factories\ProfileFactory; @@ -22,7 +23,17 @@ * @property string $id * @property string $user_id * @property int $tenant_id + * @property string|null $nickname + * @property Carbon|null $birthdate + * @property string|null $about + * @property string|null $headline + * @property SeniorityLevel|null $seniority_level + * @property int|null $years_experience * @property array|null $social_links + * @property bool $available_for_proposals + * @property StartAvailability|null $start_availability + * @property Carbon|null $created_at + * @property Carbon|null $updated_at */ #[Table(name: 'user_profiles')] final class Profile extends Model 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); +}); diff --git a/app/Console/Commands/ImportDiscordMembers.php b/app/Console/Commands/ImportDiscordMembers.php index 83d392d0d..19f525267 100644 --- a/app/Console/Commands/ImportDiscordMembers.php +++ b/app/Console/Commands/ImportDiscordMembers.php @@ -73,7 +73,7 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void $stats = ['created' => 0, 'existing' => 0, 'github_set' => 0, 'github_skipped' => 0, 'tenant_attached' => 0]; $csvRows = []; $isDryRun = $this->option('dry-run'); - $force = $this->option('force'); + $this->option('force'); $bar = $this->output->createProgressBar(count($members)); $bar->start(); @@ -121,31 +121,15 @@ public function handle(CreateAccountByExternalIdentity $createAccount): void $stats['tenant_attached']++; } - // Set GitHub URL if available - $githubUsername = $githubMap[$discordId] ?? null; - - if ($githubUsername && $user) { - $info = $user->information; - $currentGithub = $info?->github_url; - - if (!$currentGithub || $force) { - $githubUrl = 'https://github.com/'.$githubUsername; - $stats['github_set']++; - - if (!$isDryRun && $info) { - $info->update(['github_url' => $githubUrl]); - } - } else { - $stats['github_skipped']++; - } - } + // GitHub URL import removed — legacy information() model no longer exists. + // GitHub is now derived from ExternalIdentity (OAuth) connections. $csvRows[] = [ 'discord_id' => $discordId, 'username' => $member['username'], 'global_name' => $member['global_name'] ?? '', 'status' => $existing ? 'existing' : 'created', - 'github_username' => $githubUsername ?? '', + 'github_username' => $githubMap[$discordId] ?? '', 'joined_at' => $member['joined_at'], ]; 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/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, 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();