From 00fae1b59120cb1a15f1d00568ac3e07275120b9 Mon Sep 17 00:00:00 2001 From: Gusta Date: Wed, 3 Jun 2026 15:54:47 -0300 Subject: [PATCH 1/2] feat: add public profile page (#257) --- .../views/components/layouts/app.blade.php | 2 +- .../resources/views/public-profile.blade.php | 343 ++++++++++++++++++ .../profile/src/Http/ProfileController.php | 88 +++++ .../profile/src/ProfileServiceProvider.php | 6 + .../tests/Feature/PublicProfileTest.php | 79 ++++ 5 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 app-modules/profile/resources/views/public-profile.blade.php create mode 100644 app-modules/profile/src/Http/ProfileController.php create mode 100644 app-modules/profile/tests/Feature/PublicProfileTest.php diff --git a/app-modules/portal/resources/views/components/layouts/app.blade.php b/app-modules/portal/resources/views/components/layouts/app.blade.php index 911d2382b..def9e70c5 100644 --- a/app-modules/portal/resources/views/components/layouts/app.blade.php +++ b/app-modules/portal/resources/views/components/layouts/app.blade.php @@ -1,6 +1,6 @@ @props (['title' => null]) - + diff --git a/app-modules/profile/resources/views/public-profile.blade.php b/app-modules/profile/resources/views/public-profile.blade.php new file mode 100644 index 000000000..29a9ea746 --- /dev/null +++ b/app-modules/profile/resources/views/public-profile.blade.php @@ -0,0 +1,343 @@ + +
+
+
+
+
+ {{ strtoupper(substr($user->name, 0, 1)) }}{{ + strtoupper( + substr(explode(' ', $user->name)[1] ?? '', 0, 1), + ) + }} +
+
+ +
+ @if ($profile->available_for_proposals) +

{{ $profile->nickname ?? $user->name }}

+

+ @if ($profile->headline) + {{ $profile->headline }} + @elseif ($profile->seniority_level) + {{ + __( + 'profile::enums.seniority_level.' . $profile->seniority_level->value, + ) + }} + @if ($profile->years_experience) ·{{ $profile->years_experience }}anos de experiência @endif + @endif +

+ + Disponível para propostas + + + @else +

{{ $profile->nickname ?? $user->name }}

+

+ @if ($profile->headline) + {{ $profile->headline }} + @elseif ($profile->seniority_level) + {{ + __( + 'profile::enums.seniority_level.' . $profile->seniority_level->value, + ) + }} + @if ($profile->years_experience) ·{{ $profile->years_experience }}anos de experiência @endif + @endif +

+ @endif + +
+ @if ($profile->social_links) + @foreach (['github', 'linkedin'] as $platform) + @if ($url = $profile->social_links[$platform] ?? null) + + @switch ($platform) + @case ('github') + + @break + @case ('linkedin') + + @break + @endswitch + + @endif + @endforeach + @endif +
+
+
+
+ +
+
+
+ @if ($profile->about || $profile->seniority_level || $profile->years_experience) +
+

Sobre

+ @if ($profile->about) +

{{ $profile->about }}

+ @endif +
+ @endif + +
+

+ Resumo para Recrutadores + +

+
+ @if ($profile->seniority_level) +
+

Senioridade

+

{{ + __( + 'profile::enums.seniority_level.' . $profile->seniority_level->value, + ) + }}

+
+ @endif + @if ($profile->years_experience) +
+

Experiência

+

{{ $profile->years_experience }} anos

+
+ @endif + @if ($user->address) +
+

Localização

+

{{ $user->address->city }}{{ + $user->address->state + ? ', ' . $user->address->state + : '' + }}

+
+ @endif + @if ($profile->start_availability) +
+

Início

+

+ {{ + __( + 'profile::enums.start_availability.' . $profile->start_availability->value, + ) + }} +

+
+ @endif +
+
+ + @if ($user->character) +
+
+
+ +
+
+

Nível da Comunidade

+

{{ $user->character->level }}

+
+
+
+
+
+
+

{{ number_format($user->character->experience) }} XP · {{ number_format($user->character->experience_progress) }} para o próximo nível

+
+
+ @endif + + @if ($user->character && $user->character->badges->isNotEmpty()) +
+

Badges He4rt

+
+ @foreach ($user->character->badges as $badge) +
+
+ @if ($badge->getFirstMediaUrl('badge')) + {{ $badge->name }} + @else + + @endif +
+
+

{{ $badge->name }}

+ @if ($badge->description) +

{{ $badge->description }}

+ @endif +
+
+ @endforeach +
+
+ @endif + + @if ($connectedAccounts->isNotEmpty()) + + @endif +
+ + +
+
+
+
diff --git a/app-modules/profile/src/Http/ProfileController.php b/app-modules/profile/src/Http/ProfileController.php new file mode 100644 index 000000000..e51e1d4a8 --- /dev/null +++ b/app-modules/profile/src/Http/ProfileController.php @@ -0,0 +1,88 @@ + 'https://github.com/%s', + 'discord' => 'https://discord.com/users/%s', + 'twitch' => 'https://twitch.tv/%s', + 'devto' => 'https://dev.to/%s', + ]; + + public function show(Request $request, string $username): Factory|View + { + $tenant = Tenant::query() + ->where('domain', $request->getHost()) + ->firstOrFail(); + + $user = User::query() + ->where('username', $username) + ->first(); + + abort_if($user === null, 404); + + $profile = Profile::query() + ->where('user_id', $user->id) + ->where('tenant_id', $tenant->id) + ->first(); + + abort_if($profile === null, 404); + + $user->load(['character.badges', 'providers', 'address']); + + $connectedAccounts = $this->buildConnectedAccounts($profile, $user); + + return view('profile::public-profile', [ + 'user' => $user, + 'profile' => $profile, + 'tenant' => $tenant, + 'connectedAccounts' => $connectedAccounts, + ]); + } + + private function buildConnectedAccounts(Profile $profile, User $user): Collection + { + $accounts = collect(); + + if ($profile->social_links) { + foreach ($profile->social_links as $platform => $url) { + $accounts->push([ + 'provider' => $platform, + 'label' => $platform, + 'url' => $url, + ]); + } + } + + foreach ($user->providers as $provider) { + $name = $provider->provider instanceof IdentityProvider + ? $provider->provider->value + : $provider->provider; + + $template = self::PROVIDER_URLS[$name] ?? null; + + if ($template) { + $accounts->push([ + 'provider' => $name, + 'label' => $name, + 'url' => sprintf($template, $provider->external_account_id), + ]); + } + } + + return $accounts; + } +} diff --git a/app-modules/profile/src/ProfileServiceProvider.php b/app-modules/profile/src/ProfileServiceProvider.php index d9d8af288..1279e9e2a 100644 --- a/app-modules/profile/src/ProfileServiceProvider.php +++ b/app-modules/profile/src/ProfileServiceProvider.php @@ -4,8 +4,10 @@ namespace He4rt\Profile; +use He4rt\Profile\Http\ProfileController; use He4rt\Profile\Models\Profile; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; final class ProfileServiceProvider extends ServiceProvider @@ -14,9 +16,13 @@ public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->loadTranslationsFrom(__DIR__.'/../lang', 'profile'); + $this->loadViewsFrom(__DIR__.'/../resources/views', 'profile'); Relation::morphMap([ 'profile' => Profile::class, ]); + + Route::get('/@{username}', [ProfileController::class, 'show']) + ->name('profile.public'); } } diff --git a/app-modules/profile/tests/Feature/PublicProfileTest.php b/app-modules/profile/tests/Feature/PublicProfileTest.php new file mode 100644 index 000000000..d59d57748 --- /dev/null +++ b/app-modules/profile/tests/Feature/PublicProfileTest.php @@ -0,0 +1,79 @@ +create(); + $tenant->update(['domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'janedoe']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'headline' => 'Backend Dev', + ]); + + $response = $this->get('http://test.he4rtdevs.com/@janedoe'); + + $response->assertOk(); + $response->assertSee('Backend Dev'); +}); + +it('renders minimal profile without crashing', function (): void { + $tenant = Tenant::factory()->create(); + $tenant->update(['domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'novato']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + ]); + + $response = $this->get('http://test.he4rtdevs.com/@novato'); + + $response->assertOk(); + $response->assertSee($user->name); + $response->assertDontSee('null'); + $response->assertDontSee('undefined'); +}); + +it('returns 404 for non-existent user', function (): void { + $tenant = Tenant::factory()->create(); + $tenant->update(['domain' => 'test.he4rtdevs.com']); + + $response = $this->get('http://test.he4rtdevs.com/@fantasma'); + + $response->assertNotFound(); +}); + +it('returns 404 for user without profile in tenant', function (): void { + $tenant = Tenant::factory()->create(); + $tenant->update(['domain' => 'test.he4rtdevs.com']); + User::factory()->create(['username' => 'semprofile']); + + $response = $this->get('http://test.he4rtdevs.com/@semprofile'); + + $response->assertNotFound(); +}); + +it('does not show available badge when available_for_proposals is false', function (): void { + $tenant = Tenant::factory()->create(); + $tenant->update(['domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'indisponivel']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'available_for_proposals' => false, + ]); + + $response = $this->get('http://test.he4rtdevs.com/@indisponivel'); + + $response->assertOk(); + $response->assertDontSee('Disponível'); + $response->assertDontSee('Indisponível'); +}); From 21e5eb5f2e0076d142e8712c815bb84733f2a40c Mon Sep 17 00:00:00 2001 From: Gusta Date: Mon, 15 Jun 2026 19:01:55 -0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20atualiza=C3=A7=C3=A3o=20do=20perfil?= =?UTF-8?q?=20para=20remodelagem=20visual,=20moderniza=C3=A7=C3=A3o=20e=20?= =?UTF-8?q?inser=C3=A7=C3=A3o=20de=20novas=20regras=20de=20neg=C3=B3cio=20?= =?UTF-8?q?(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novos arquivos: - Models: Project.php, PullRequest.php — seções de projetos e PRs manuais - Migrations (5): add_skills, add_work_types, add_languages, create_user_projects, create_user_pull_requests - Enums (2): SkillCategory.php, Skills.php — skills free-text com ícones - Actions: UpsertProfile.php + UpsertProfileDTO.php — ação de criar/atualizar profile - CSS: profile.css — tema dark/light com dupla detecção (prefers-color-scheme + .dark class) - Testes: UpsertProfileTest.php, ProfileEnumTest.php + PublicProfileTest.php expandido para 9 testes Funcionalidades extras (fora da issue original): - Projetos — registro manual, max 3 com "Ver todos" - Pull Requests — registro manual, max 3 com "Ver todos" - Skills — free-text com chips coloridos por categoria (Frontend, Backend, Infra, Soft Skills) - Work Types + Idiomas — chips visuais - XP Bar — progresso xp / nextThreshold * 100 - H4 Verified Badge — badge especial (redeem_code H4_VERIFIED) só pra quem for verificado pela he4rt - Badge expand — max 4, "Ver todas" - Resume upload/download - Tema completo dark/light — fundo preto/off-white, cards, gradientes, chips vibrantes O que a Issue #257 pede e que foi feito: - Rota /{tenant:domain}/@username ✅ - Controller com eager-load (character, badges, providers, address) ✅ - 404 se user não existe ou sem profile no tenant ✅ - Header: avatar, nome, username, headline, badge disponível, localização ✅ - Sobre: bio, senioridade, anos de experiência ✅ - Links: social_links manuais + OAuth providers (GitHub/Discord) ✅ - Gamificação: level + badges ✅ - Disponibilidade: badge + prazo ✅ - Esconder seções vazias (sem null/placeholders) ✅ - Sem autenticação ✅ - Blade + Tailwind (não Filament) ✅ - Responsivo ✅ - Dark mode ✅ - Testes de feature (completo, vazio, 404, domain routing) ✅ - Pint passando ✅ --- .../components/profile-preview-card.blade.php | 8 +- .../tests/Feature/ProfilePageTest.php | 8 +- .../views/components/layouts/app.blade.php | 3 +- .../database/factories/ProfileFactory.php | 13 + ...0000_add_skills_to_user_profiles_table.php | 24 + ..._add_work_types_to_user_profiles_table.php | 24 + ...2_add_languages_to_user_profiles_table.php | 24 + ...6_14_000003_create_user_projects_table.php | 34 + ...000004_create_user_pull_requests_table.php | 34 + app-modules/profile/lang/en/enums.php | 26 +- app-modules/profile/lang/pt_BR/enums.php | 16 +- app-modules/profile/resources/css/profile.css | 409 +++++++++ .../resources/views/public-profile.blade.php | 776 +++++++++++------- .../profile/src/Actions/UpsertProfile.php | 12 + .../profile/src/DTOs/UpsertProfileDTO.php | 9 + .../profile/src/Enums/SeniorityLevel.php | 12 +- .../profile/src/Enums/SkillCategory.php | 33 + app-modules/profile/src/Enums/Skills.php | 135 +++ .../profile/src/Enums/SocialPlatform.php | 8 + .../profile/src/Http/ProfileController.php | 38 +- app-modules/profile/src/Models/Profile.php | 70 +- app-modules/profile/src/Models/Project.php | 81 ++ .../profile/src/Models/PullRequest.php | 100 +++ .../profile/src/ProfileServiceProvider.php | 3 +- .../tests/Feature/PublicProfileTest.php | 105 ++- .../tests/Feature/UpsertProfileTest.php | 4 +- .../profile/tests/Unit/ProfileEnumTest.php | 10 +- vite.config.js | 1 + 28 files changed, 1677 insertions(+), 343 deletions(-) create mode 100644 app-modules/profile/database/migrations/2026_06_11_000000_add_skills_to_user_profiles_table.php create mode 100644 app-modules/profile/database/migrations/2026_06_14_000001_add_work_types_to_user_profiles_table.php create mode 100644 app-modules/profile/database/migrations/2026_06_14_000002_add_languages_to_user_profiles_table.php create mode 100644 app-modules/profile/database/migrations/2026_06_14_000003_create_user_projects_table.php create mode 100644 app-modules/profile/database/migrations/2026_06_14_000004_create_user_pull_requests_table.php create mode 100644 app-modules/profile/resources/css/profile.css create mode 100644 app-modules/profile/src/Enums/SkillCategory.php create mode 100644 app-modules/profile/src/Enums/Skills.php create mode 100644 app-modules/profile/src/Models/Project.php create mode 100644 app-modules/profile/src/Models/PullRequest.php 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 3f8dea78d..724f54915 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 @@ -125,13 +125,9 @@ class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text- 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400' => $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, + $seniority === \He4rt\Profile\Enums\SeniorityLevel::Pleno, '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 === \He4rt\Profile\Enums\SeniorityLevel::Senior ]) > {{ $seniority->getLabel() }} diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php index eb4a43e25..eec1771b2 100644 --- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php +++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php @@ -36,14 +36,14 @@ test('profile page loads existing profile data', function (): void { $this->profile->update([ 'headline' => 'Backend Developer', - 'seniority_level' => SeniorityLevel::Mid, + 'seniority_level' => SeniorityLevel::Pleno, ]); livewire(ProfilePage::class) ->assertOk() ->assertSchemaStateSet([ 'headline' => 'Backend Developer', - 'seniority_level' => SeniorityLevel::Mid, + 'seniority_level' => SeniorityLevel::Pleno, ]); }); @@ -52,7 +52,7 @@ ->set('data.nickname', 'Dan') ->fillForm([ 'headline' => 'Backend Developer', - 'seniority_level' => 'mid', + 'seniority_level' => 'pleno', 'years_experience' => 5, 'about' => 'Dev PHP apaixonado por Laravel', ]) @@ -63,7 +63,7 @@ expect($this->profile->nickname)->toBe('Dan') ->and($this->profile->headline)->toBe('Backend Developer') - ->and($this->profile->seniority_level)->toBe(SeniorityLevel::Mid) + ->and($this->profile->seniority_level)->toBe(SeniorityLevel::Pleno) ->and($this->profile->years_experience)->toBe(5) ->and($this->profile->about)->toBe('Dev PHP apaixonado por Laravel'); }); diff --git a/app-modules/portal/resources/views/components/layouts/app.blade.php b/app-modules/portal/resources/views/components/layouts/app.blade.php index def9e70c5..d059fb854 100644 --- a/app-modules/portal/resources/views/components/layouts/app.blade.php +++ b/app-modules/portal/resources/views/components/layouts/app.blade.php @@ -1,12 +1,13 @@ @props (['title' => null]) - + {{ $title ? $title . ' - ' : '' }}{{ config('app.name') }} @vite (['app-modules/he4rt/resources/css/theme.css']) + @stack ('styles') @fluxAppearance diff --git a/app-modules/profile/database/factories/ProfileFactory.php b/app-modules/profile/database/factories/ProfileFactory.php index 79d644d12..ac765cbf4 100644 --- a/app-modules/profile/database/factories/ProfileFactory.php +++ b/app-modules/profile/database/factories/ProfileFactory.php @@ -33,6 +33,9 @@ public function definition(): array 'social_links' => null, 'available_for_proposals' => false, 'start_availability' => null, + 'skills' => null, + 'work_types' => null, + 'languages' => null, ]; } @@ -51,6 +54,16 @@ public function complete(): self ], 'available_for_proposals' => true, 'start_availability' => fake()->randomElement(StartAvailability::cases()), + 'skills' => [ + ['name' => 'PHP', 'category' => 'languages_frameworks'], + ['name' => 'Laravel', 'category' => 'languages_frameworks'], + ['name' => 'PostgreSQL', 'category' => 'infra_databases'], + ['name' => 'Docker', 'category' => 'infra_databases'], + ], + 'work_types' => ['immediate', 'remote'], + 'languages' => [ + ['name' => 'Português', 'level' => 'Nativo'], + ], ]); } } diff --git a/app-modules/profile/database/migrations/2026_06_11_000000_add_skills_to_user_profiles_table.php b/app-modules/profile/database/migrations/2026_06_11_000000_add_skills_to_user_profiles_table.php new file mode 100644 index 000000000..20031d3a2 --- /dev/null +++ b/app-modules/profile/database/migrations/2026_06_11_000000_add_skills_to_user_profiles_table.php @@ -0,0 +1,24 @@ +jsonb('skills')->nullable()->after('start_availability'); + }); + } + + public function down(): void + { + Schema::table('user_profiles', function (Blueprint $table): void { + $table->dropColumn('skills'); + }); + } +}; diff --git a/app-modules/profile/database/migrations/2026_06_14_000001_add_work_types_to_user_profiles_table.php b/app-modules/profile/database/migrations/2026_06_14_000001_add_work_types_to_user_profiles_table.php new file mode 100644 index 000000000..95e7001c5 --- /dev/null +++ b/app-modules/profile/database/migrations/2026_06_14_000001_add_work_types_to_user_profiles_table.php @@ -0,0 +1,24 @@ +jsonb('work_types')->nullable()->after('available_for_proposals'); + }); + } + + public function down(): void + { + Schema::table('user_profiles', function (Blueprint $table): void { + $table->dropColumn('work_types'); + }); + } +}; diff --git a/app-modules/profile/database/migrations/2026_06_14_000002_add_languages_to_user_profiles_table.php b/app-modules/profile/database/migrations/2026_06_14_000002_add_languages_to_user_profiles_table.php new file mode 100644 index 000000000..3520d8b16 --- /dev/null +++ b/app-modules/profile/database/migrations/2026_06_14_000002_add_languages_to_user_profiles_table.php @@ -0,0 +1,24 @@ +jsonb('languages')->nullable()->after('work_types'); + }); + } + + public function down(): void + { + Schema::table('user_profiles', function (Blueprint $table): void { + $table->dropColumn('languages'); + }); + } +}; diff --git a/app-modules/profile/database/migrations/2026_06_14_000003_create_user_projects_table.php b/app-modules/profile/database/migrations/2026_06_14_000003_create_user_projects_table.php new file mode 100644 index 000000000..1945a5b3b --- /dev/null +++ b/app-modules/profile/database/migrations/2026_06_14_000003_create_user_projects_table.php @@ -0,0 +1,34 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUuid('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('url')->nullable(); + $table->jsonb('tags')->nullable(); + $table->unsignedInteger('stars')->nullable(); + $table->unsignedInteger('forks')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['user_id', 'tenant_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_projects'); + } +}; diff --git a/app-modules/profile/database/migrations/2026_06_14_000004_create_user_pull_requests_table.php b/app-modules/profile/database/migrations/2026_06_14_000004_create_user_pull_requests_table.php new file mode 100644 index 000000000..40ce113c0 --- /dev/null +++ b/app-modules/profile/database/migrations/2026_06_14_000004_create_user_pull_requests_table.php @@ -0,0 +1,34 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUuid('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('title'); + $table->string('repo'); + $table->enum('status', ['open', 'merged', 'closed']); + $table->unsignedInteger('number'); + $table->string('url')->nullable(); + $table->timestamp('pr_created_at')->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->index(['user_id', 'tenant_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_pull_requests'); + } +}; diff --git a/app-modules/profile/lang/en/enums.php b/app-modules/profile/lang/en/enums.php index 4f22d4179..c8ea27efe 100644 --- a/app-modules/profile/lang/en/enums.php +++ b/app-modules/profile/lang/en/enums.php @@ -5,10 +5,8 @@ return [ 'seniority_level' => [ 'junior' => 'Junior', - 'mid' => 'Mid-Level', + 'pleno' => 'Pleno', 'senior' => 'Senior', - 'specialist' => 'Specialist', - 'lead' => 'Lead', ], 'social_platform' => [ @@ -17,15 +15,27 @@ 'website' => 'Website', 'youtube' => 'YouTube', 'bluesky' => 'Bluesky', + 'whatsapp' => 'WhatsApp', + 'linkedin' => 'LinkedIn', + 'github' => 'GitHub', + 'devto' => 'Dev.to', ], 'start_availability' => [ 'immediate' => 'Immediate', - '1_week' => '1 Week', - '2_weeks' => '2 Weeks', - '3_weeks' => '3 Weeks', - '1_month' => '1 Month', - '2_months' => '2 Months', + '1_week' => '1 week', + '2_weeks' => '2 weeks', + '3_weeks' => '3 weeks', + '1_month' => '1 month', + '2_months' => '2 months', 'negotiable' => 'Negotiable', ], + + 'work_type' => [ + 'immediate' => 'Immediate start', + 'remote' => 'Remote', + 'clt' => 'CLT', + 'pj' => 'PJ', + 'freelance' => 'Freelance', + ], ]; diff --git a/app-modules/profile/lang/pt_BR/enums.php b/app-modules/profile/lang/pt_BR/enums.php index 337dc4e53..ca896b206 100644 --- a/app-modules/profile/lang/pt_BR/enums.php +++ b/app-modules/profile/lang/pt_BR/enums.php @@ -5,10 +5,8 @@ return [ 'seniority_level' => [ 'junior' => 'Júnior', - 'mid' => 'Pleno', + 'pleno' => 'Pleno', 'senior' => 'Sênior', - 'specialist' => 'Especialista', - 'lead' => 'Lead', ], 'social_platform' => [ @@ -17,6 +15,10 @@ 'website' => 'Website', 'youtube' => 'YouTube', 'bluesky' => 'Bluesky', + 'whatsapp' => 'WhatsApp', + 'linkedin' => 'LinkedIn', + 'github' => 'GitHub', + 'devto' => 'Dev.to', ], 'start_availability' => [ @@ -28,4 +30,12 @@ '2_months' => '2 meses', 'negotiable' => 'Negociável', ], + + 'work_type' => [ + 'immediate' => 'Início imediato', + 'remote' => 'Remoto', + 'clt' => 'CLT', + 'pj' => 'PJ', + 'freelance' => 'Freelance', + ], ]; diff --git a/app-modules/profile/resources/css/profile.css b/app-modules/profile/resources/css/profile.css new file mode 100644 index 000000000..a6200da3f --- /dev/null +++ b/app-modules/profile/resources/css/profile.css @@ -0,0 +1,409 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --primary: #9b5de5; + --primary2: #782bf1; + /* Light mode (default) */ + --bg: #f5f5f7; + --card: #ffffff; + --e2: #ede8f5; + --ol: rgba(124, 58, 237, 0.18); + --tm: #3d3654; + --tl: #706888; + --text-main: #1e1833; + --text-heading: #130f24; + --chip-purple-bg: rgba(124, 58, 237, 0.14); + --chip-purple-border: rgba(124, 58, 237, 0.35); + --chip-purple-text: #6d28d9; + --chip-cyan-bg: rgba(6, 182, 212, 0.12); + --chip-cyan-border: rgba(6, 182, 212, 0.35); + --chip-cyan-text: #0891b2; + --chip-gray-bg: #ede8f5; + --chip-gray-border: rgba(124, 58, 237, 0.2); + --chip-gray-text: #3d3654; + --badge-bg: rgba(124, 58, 237, 0.1); + --icon-hover-bg: rgba(124, 58, 237, 0.12); + --divider: rgba(124, 58, 237, 0.14); + --status-green-bg: rgba(34, 197, 94, 0.12); + --status-green-border: rgba(34, 197, 94, 0.35); + --status-green-text: #16a34a; + --skill-chip-hover-border: #7c3aed; + --overlay: rgba(255, 255, 255, 0.85); +} + +:root.dark { + --bg: #000000; + --card: #0a0a0a; + --e2: #111111; + --ol: rgba(155, 93, 229, 0.16); + --tm: #a8a0c0; + --tl: #6e6590; + --text-main: #e8e4f0; + --text-heading: #f0ecf8; + --chip-purple-bg: rgba(120, 43, 241, 0.12); + --chip-purple-border: rgba(120, 43, 241, 0.25); + --chip-purple-text: #c4a3f0; + --chip-cyan-bg: rgba(6, 182, 212, 0.12); + --chip-cyan-border: rgba(6, 182, 212, 0.25); + --chip-cyan-text: #67d8ef; + --chip-gray-bg: #111111; + --chip-gray-border: rgba(155, 93, 229, 0.16); + --chip-gray-text: #a8a0c0; + --badge-bg: rgba(155, 93, 229, 0.1); + --icon-hover-bg: rgba(155, 93, 229, 0.12); + --divider: rgba(155, 93, 229, 0.16); + --status-green-bg: rgba(34, 197, 94, 0.1); + --status-green-border: rgba(34, 197, 94, 0.3); + --status-green-text: #86efac; + --skill-chip-hover-border: #9b5de5; + --overlay: rgba(0, 0, 0, 0.6); +} + +/* Light body — default */ +body:has(.profile-page) { + background: #f5f5f7 !important; + color: var(--text-main) !important; +} + +/* Dark body via .dark class (Flux sets this based on OS) */ +.dark body:has(.profile-page) { + background: + radial-gradient(ellipse at 15% 0%, rgba(120, 43, 241, 0.08), transparent 50%), + radial-gradient(ellipse at 85% 20%, rgba(88, 28, 180, 0.05), transparent 45%), #000000 !important; + color: var(--text-main) !important; +} + +/* Dark body via prefers-color-scheme (native OS detection) */ +@media (prefers-color-scheme: dark) { + :root { + --bg: #000000; + --card: #0a0a0a; + --e2: #111111; + --ol: rgba(155, 93, 229, 0.16); + --tm: #a8a0c0; + --tl: #6e6590; + --text-main: #e8e4f0; + --text-heading: #f0ecf8; + --chip-purple-bg: rgba(120, 43, 241, 0.12); + --chip-purple-border: rgba(120, 43, 241, 0.25); + --chip-purple-text: #c4a3f0; + --chip-cyan-bg: rgba(6, 182, 212, 0.12); + --chip-cyan-border: rgba(6, 182, 212, 0.25); + --chip-cyan-text: #67d8ef; + --chip-gray-bg: #111111; + --chip-gray-border: rgba(155, 93, 229, 0.16); + --chip-gray-text: #a8a0c0; + --badge-bg: rgba(155, 93, 229, 0.1); + --icon-hover-bg: rgba(155, 93, 229, 0.12); + --divider: rgba(155, 93, 229, 0.16); + --status-green-bg: rgba(34, 197, 94, 0.1); + --status-green-border: rgba(34, 197, 94, 0.3); + --status-green-text: #86efac; + --skill-chip-hover-border: #9b5de5; + --overlay: rgba(0, 0, 0, 0.6); + } + + body:has(.profile-page) { + background: + radial-gradient(ellipse at 15% 0%, rgba(120, 43, 241, 0.08), transparent 50%), + radial-gradient(ellipse at 85% 20%, rgba(88, 28, 180, 0.05), transparent 45%), #000000 !important; + color: var(--text-main) !important; + } +} + +.profile-page { + min-height: 100vh; + color: var(--text-main); +} + +/* ============================================ + TAILWIND UTILITY CLASSES (CSS var driven) + ============================================ */ + +.font-display { + font-family: 'Outfit', sans-serif; +} + +.bg-bg { + background-color: var(--bg); +} +.bg-card { + background-color: var(--card); +} +.bg-e2 { + background-color: var(--e2); +} +.bg-primary\/10 { + background-color: var(--badge-bg); +} +.bg-primary\/20 { + background-color: var(--icon-hover-bg); +} +.bg-primary2 { + background-color: var(--primary2); +} + +.text-primary { + color: var(--primary); +} +.text-tm { + color: var(--tm); +} +.text-tl { + color: var(--tl); +} + +.border-ol { + border-color: var(--ol); +} + +/* ============================================ + OVERRIDES + ============================================ */ + +body:has(.profile-page) .hp-button-outline { + color: var(--tm) !important; + border-color: var(--ol) !important; +} + +body:has(.profile-page) .hp-button-outline:hover { + color: var(--primary) !important; + border-color: var(--primary2) !important; + background: var(--icon-hover-bg) !important; +} + +body:has(.profile-page) .hp-button-solid { + color: #fff !important; +} + +/* ============================================ + AVATAR NEON RING + ============================================ */ + +.avatar-wrap { + position: relative; + width: 11rem; + height: 11rem; +} + +.avatar-wrap::before { + content: ''; + position: absolute; + inset: -10px; + border-radius: 9999px; + background: conic-gradient( + from 0deg, + transparent 0deg, + #8b5cf6 8deg, + #8b5cf6 58deg, + #5b3a8c 108deg, + transparent 113deg, + transparent 117deg, + transparent 117deg, + #8b5cf6 125deg, + #8b5cf6 178deg, + #5b3a8c 228deg, + transparent 233deg, + transparent 237deg, + transparent 237deg, + #8b5cf6 245deg, + #8b5cf6 298deg, + #5b3a8c 348deg, + transparent 353deg, + transparent 357deg + ); + animation: spin 18s linear infinite; + z-index: 0; + -webkit-mask: radial-gradient( + farthest-side, + transparent calc(100% - 1px), + #000 calc(100% - 1px), + #000 100%, + transparent 100% + ); + mask: radial-gradient( + farthest-side, + transparent calc(100% - 1px), + #000 calc(100% - 1px), + #000 100%, + transparent 100% + ); +} + +.avatar-wrap::after { + content: ''; + position: absolute; + inset: -10px; + border-radius: 9999px; + background: conic-gradient( + from 0deg, + transparent 0deg, + #8b5cf6 8deg, + #8b5cf6 58deg, + #5b3a8c 108deg, + transparent 113deg, + transparent 117deg, + transparent 117deg, + #8b5cf6 125deg, + #8b5cf6 178deg, + #5b3a8c 228deg, + transparent 233deg, + transparent 237deg, + transparent 237deg, + #8b5cf6 245deg, + #8b5cf6 298deg, + #5b3a8c 348deg, + transparent 353deg, + transparent 357deg + ); + filter: blur(3px); + opacity: 0.6; + animation: spin 18s linear infinite; + z-index: 0; + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 1px), transparent 100%); + mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 1px), transparent 100%); +} + +.avatar-ring-glow { + position: absolute; + inset: -10px; + border-radius: 9999px; + background: conic-gradient( + from 0deg, + transparent 0deg, + #8b5cf6 8deg, + #8b5cf6 58deg, + #5b3a8c 108deg, + transparent 113deg, + transparent 117deg, + transparent 117deg, + #8b5cf6 125deg, + #8b5cf6 178deg, + #5b3a8c 228deg, + transparent 233deg, + transparent 237deg, + transparent 237deg, + #8b5cf6 245deg, + #8b5cf6 298deg, + #5b3a8c 348deg, + transparent 353deg, + transparent 357deg + ); + filter: blur(9px); + opacity: 0.35; + animation: spin 18s linear infinite; + z-index: 0; + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 6px), #000 calc(100% - 1px), transparent 100%); + mask: radial-gradient(farthest-side, transparent calc(100% - 6px), #000 calc(100% - 1px), transparent 100%); +} + +.avatar-core { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + border-radius: 9999px; + background: linear-gradient(135deg, #782bf1, #9b5de5); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Outfit', sans-serif; + font-weight: 700; + font-size: 3rem; + color: #fff; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .avatar-wrap::before, + .avatar-wrap::after, + .avatar-ring-glow { + animation: none; + } +} + +/* ============================================ + GLOW RULE (underline under name) + ============================================ */ + +.glow-rule { + height: 2px; + width: 48px; + background: linear-gradient(90deg, #782bf1, #00f0ff); + border-radius: 2px; +} + +/* ============================================ + SKILL DIVIDER + ============================================ */ + +.skill-divider { + height: 1px; + width: 100%; + background: var(--divider); + border-radius: 1px; +} + +/* ============================================ + SKILL CHIP HOVER + ============================================ */ + +.skill-chip { + transition: + transform 0.15s ease, + border-color 0.15s ease; +} + +.skill-chip:hover { + transform: translateY(-2px); + border-color: var(--skill-chip-hover-border); +} + +.skill-chip-lang { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + background: var(--chip-gray-bg); + border: 1px solid var(--chip-gray-border); + color: var(--chip-gray-text); +} + +/* ============================================ + CARD + ============================================ */ + +.card { + background: var(--card); + border: 1px solid var(--ol); + border-radius: 0.9rem; +} + +/* ============================================ + XP PROGRESS BAR + ============================================ */ + +.xp-track { + background: var(--ol); +} + +.xp-bar { + background: linear-gradient(90deg, #782bf1, #9b5de5); + transition: width 0.6s ease; +} + +/* ============================================ + STATUS DOT + ============================================ */ + +.status-dot { + box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.15); +} diff --git a/app-modules/profile/resources/views/public-profile.blade.php b/app-modules/profile/resources/views/public-profile.blade.php index 29a9ea746..2f062f4cb 100644 --- a/app-modules/profile/resources/views/public-profile.blade.php +++ b/app-modules/profile/resources/views/public-profile.blade.php @@ -1,186 +1,484 @@ -
-
-
-
-
- {{ strtoupper(substr($user->name, 0, 1)) }}{{ - strtoupper( - substr(explode(' ', $user->name)[1] ?? '', 0, 1), - ) - }} + @push ('styles') + + @vite (['app-modules/profile/resources/css/profile.css']) + @endpush + +
+
+
+ +
-
- @if ($profile->available_for_proposals) -

{{ $profile->nickname ?? $user->name }}

-

- @if ($profile->headline) - {{ $profile->headline }} - @elseif ($profile->seniority_level) - {{ - __( - 'profile::enums.seniority_level.' . $profile->seniority_level->value, - ) - }} - @if ($profile->years_experience) ·{{ $profile->years_experience }}anos de experiência @endif +

+

+ {{ $user->name }} + @if ($user->character && $user->character->badges->contains('redeem_code', 'H4_VERIFIED')) + + H4 + + + + + @endif -

- - Disponível para propostas - - - @else -

{{ $profile->nickname ?? $user->name }}

-

- @if ($profile->headline) - {{ $profile->headline }} - @elseif ($profile->seniority_level) - {{ - __( - 'profile::enums.seniority_level.' . $profile->seniority_level->value, - ) - }} - @if ($profile->years_experience) ·{{ $profile->years_experience }}anos de experiência @endif + +

+
+ + @if ($profile->headline || $profile->seniority_level) +
+

{{ + $profile->headline ?? + __('profile::enums.seniority_level.' . $profile->seniority_level->value, [], 'pt_BR') + }}

+ @if ($profile->seniority_level) + + {{ + __( + 'profile::enums.seniority_level.' . $profile->seniority_level->value, + [], + 'pt_BR', + ) + }} + @endif -

+
+ @endif + + @if ($user->address) +
+ + {{ $user->address->city }}{{ + $user->address->state + ? ', ' . $user->address->state + : '' + }} +
+ @endif + + @if ($profile->work_types && count($profile->work_types) > 0) +
+ @foreach ($profile->work_types as $workType) + + + {{ + __( + 'profile::enums.work_type.' . $workType, + [], + 'pt_BR', + ) + }} + + @endforeach +
@endif -
- @if ($profile->social_links) - @foreach (['github', 'linkedin'] as $platform) - @if ($url = $profile->social_links[$platform] ?? null) + @if ($profile->social_links && count($profile->social_links) > 0) +
+ @foreach ($profile->social_links as $platform => $url) + @if ($url) - @switch ($platform) - @case ('github') - - @break - @case ('linkedin') - - @break - @endswitch + @endif @endforeach - @endif -
-
-
-
- -
-
-
- @if ($profile->about || $profile->seniority_level || $profile->years_experience) -
-

Sobre

- @if ($profile->about) -

{{ $profile->about }}

- @endif
@endif -
-

- Resumo para Recrutadores - -

-
- @if ($profile->seniority_level) -
-

Senioridade

-

{{ - __( - 'profile::enums.seniority_level.' . $profile->seniority_level->value, - ) - }}

-
- @endif - @if ($profile->years_experience) -
-

Experiência

-

{{ $profile->years_experience }} anos

-
- @endif - @if ($user->address) -
-

Localização

-

{{ $user->address->city }}{{ - $user->address->state - ? ', ' . $user->address->state - : '' - }}

-
- @endif - @if ($profile->start_availability) -
-

Início

-

- {{ - __( - 'profile::enums.start_availability.' . $profile->start_availability->value, - ) - }} -

-
- @endif -
-
+ @if ($resumeUrl) + + + Download do currículo + + @endif @if ($user->character) -
+
-
- +
+
-

Nível da Comunidade

-

{{ $user->character->level }}

+

Nível da Comunidade

+

{{ $user->character->level }}

-
-
+
+ @php + $xpBarLevel = $user->character->level; + $xpBarNext = \He4rt\Gamification\Character\Models\Character::LEVEL_THRESHOLDS[$xpBarLevel + 1] ?? 1; + $xpBarPercent = $xpBarNext > 0 ? round(($user->character->experience / $xpBarNext) * 100, 1) : 100; + @endphp +
-

{{ number_format($user->character->experience) }} XP · {{ number_format($user->character->experience_progress) }} para o próximo nível

+

{{ number_format($user->character->experience) }} XP · {{ number_format($user->character->experience_progress) }} para o próximo nível

@endif + - @if ($user->character && $user->character->badges->isNotEmpty()) -
-

Badges He4rt

-
- @foreach ($user->character->badges as $badge) -
+
+ @if ($profile->about) +
+

Sobre

+

{{ $profile->about }}

+
+ @endif + + @php + $allSkills = collect($profile->skillsByCategory()); + $hasLanguages = $profile->languages && count($profile->languages) > 0; + @endphp + @if ($allSkills->isNotEmpty() || $hasLanguages) +
+

Stack & Skills

+
+ @php + $chipPurple = 'skill-chip inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium'; + $chipPurpleStyle = + 'background:var(--chip-purple-bg);border:1px solid var(--chip-purple-border);color:var(--chip-purple-text)'; + $chipCyan = 'skill-chip inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium'; + $chipCyanStyle = 'background:var(--chip-cyan-bg);border:1px solid var(--chip-cyan-border);color:var(--chip-cyan-text)'; + $chipGray = 'skill-chip inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-medium'; + $chipGrayStyle = 'background:var(--chip-gray-bg);border:1px solid var(--chip-gray-border);color:var(--chip-gray-text)'; + @endphp + + @php + $langFw = $allSkills->where('category', 'languages_frameworks'); + @endphp + @if ($langFw->isNotEmpty()) +
+ @foreach ($langFw as $skill) + + @if ($skill['icon']) + + @endif + {{ $skill['name'] }} + + @endforeach +
+ @endif + + @php + $infraDb = $allSkills->where('category', 'infra_databases'); + @endphp + @if ($infraDb->isNotEmpty()) +
+
+ @foreach ($infraDb as $skill) + + @if ($skill['icon']) + + @endif + {{ $skill['name'] }} + + @endforeach +
+ @endif + + @php + $softTools = $allSkills->where('category', 'softskills_tools'); + @endphp + @if ($softTools->isNotEmpty()) +
+
+ @foreach ($softTools as $skill) + + @if ($skill['icon']) + + @endif + {{ $skill['name'] }} + + @endforeach +
+ @endif + + @if ($profile->languages && count($profile->languages) > 0) +
+
+ @foreach ($profile->languages as $lang) + + + {{ $lang['name'] }} · {{ $lang['level'] }} + + @endforeach +
+ @endif +
+
+ @endif + + + @if ($projects->isNotEmpty()) +
+

Projetos

+ @foreach ($projects->take(3) as $project) +
+
+
+

{{ $project->name }}

+

{{ $project->description }}

+
+ @if ($project->url) + + + + @endif +
+ @if ($project->tags) +
+ @foreach ($project->tags as $tag) + {{ $tag }} + @endforeach +
+ @endif + @if ($project->stars || $project->forks) +
+ @if ($project->stars) + + {{ $project->stars }} + @endif + @if ($project->forks) + + {{ $project->forks }} + @endif +
+ @endif +
+ @endforeach + @if ($projects->count() > 3) +
+ @foreach ($projects->skip(3) as $project) +
+
+
+

{{ $project->name }}

+

{{ $project->description }}

+
+ @if ($project->url) + + + + @endif +
+ @if ($project->tags) +
+ @foreach ($project->tags as $tag) + {{ $tag }} + @endforeach +
+ @endif + @if ($project->stars || $project->forks) +
+ @if ($project->stars) + + {{ $project->stars }} + @endif + @if ($project->forks) + + {{ $project->forks }} + @endif +
+ @endif +
+ @endforeach +
+
+ +
+ @endif +
+ @endif + + + @if ($pullRequests->isNotEmpty()) +
+

PRs na Comunidade

+
+ @foreach ($pullRequests->take(3) as $pr) +
+
+
+

{{ $pr->title }}

+

{{ $pr->repo }}

+
+ + {{ $pr->status_label }} + +
+
+ + @if ($pr->status === 'merged') + + @else + + @endif + #{{ $pr->number }} + + @if ($pr->pr_created_at) + {{ $pr->pr_created_at->diffForHumans() }} + @endif +
+
+ @endforeach + @if ($pullRequests->count() > 3) +
+ @foreach ($pullRequests->skip(3) as $pr) +
+
+
+

{{ $pr->title }}

+

{{ $pr->repo }}

+
+ + {{ $pr->status_label }} + +
+
+ + @if ($pr->status === 'merged') + + @else + + @endif + #{{ $pr->number }} + + @if ($pr->pr_created_at) + {{ $pr->pr_created_at->diffForHumans() }} + @endif +
+
+ @endforeach +
+ @endif +
+ @if ($pullRequests->count() > 3) +
+ +
+ @endif +
+ @endif + + + @php + $allBadges = $user->character?->badges ?? collect(); + $h4Badge = $allBadges->firstWhere('redeem_code', 'H4_VERIFIED'); + $otherBadges = $allBadges->reject(fn($b) => $b->redeem_code === 'H4_VERIFIED'); + @endphp + @if ($h4Badge || $otherBadges->isNotEmpty()) +
+

Badges He4rt

+
+ @if ($h4Badge) +
+ H4 + + + + +
+
+

Perfil Certificado pela Comunidade

+

Certificação oficial da He4rt Devs

+
+
+ @endif + @foreach ($otherBadges->take(4) as $badge) +
+
@if ($badge->getFirstMediaUrl('badge')) @else - + @endif
-

{{ $badge->name }}

+

{{ $badge->name }}

@if ($badge->description) -

{{ $badge->description }}

+

{{ $badge->description }}

@endif
@endforeach -
-
- @endif - - @if ($connectedAccounts->isNotEmpty()) - - - +
diff --git a/app-modules/profile/src/Actions/UpsertProfile.php b/app-modules/profile/src/Actions/UpsertProfile.php index f6c9ed0e6..f971065ed 100644 --- a/app-modules/profile/src/Actions/UpsertProfile.php +++ b/app-modules/profile/src/Actions/UpsertProfile.php @@ -47,6 +47,18 @@ public function handle(Profile $profile, UpsertProfileDTO $dto): Profile $attributes['social_links'] = $dto->socialLinks; } + if ($dto->workTypes !== null) { + $attributes['work_types'] = $dto->workTypes; + } + + if ($dto->languages !== null) { + $attributes['languages'] = $dto->languages; + } + + if ($dto->skills !== null) { + $attributes['skills'] = $dto->skills; + } + if ($attributes !== []) { $profile->update($attributes); } diff --git a/app-modules/profile/src/DTOs/UpsertProfileDTO.php b/app-modules/profile/src/DTOs/UpsertProfileDTO.php index 031f9924a..499c9fc9a 100644 --- a/app-modules/profile/src/DTOs/UpsertProfileDTO.php +++ b/app-modules/profile/src/DTOs/UpsertProfileDTO.php @@ -19,6 +19,12 @@ public function __construct( public ?int $yearsExperience = null, /** @var array|null */ public ?array $socialLinks = null, + /** @var array|null */ + public ?array $workTypes = null, + /** @var array|null */ + public ?array $languages = null, + /** @var array|null */ + public ?array $skills = null, ) {} /** @@ -36,6 +42,9 @@ public static function fromArray(array $data): self : null, yearsExperience: isset($data['years_experience']) ? (int) $data['years_experience'] : null, socialLinks: $data['social_links'] ?? null, + workTypes: $data['work_types'] ?? null, + languages: $data['languages'] ?? null, + skills: $data['skills'] ?? null, ); } } diff --git a/app-modules/profile/src/Enums/SeniorityLevel.php b/app-modules/profile/src/Enums/SeniorityLevel.php index 27251263a..f5536ef2e 100644 --- a/app-modules/profile/src/Enums/SeniorityLevel.php +++ b/app-modules/profile/src/Enums/SeniorityLevel.php @@ -13,10 +13,8 @@ enum SeniorityLevel: string implements HasColor, HasIcon, HasLabel { case Junior = 'junior'; - case Mid = 'mid'; + case Pleno = 'pleno'; case Senior = 'senior'; - case Specialist = 'specialist'; - case Lead = 'lead'; public function getLabel(): string { @@ -27,10 +25,8 @@ public function getColor(): array { return match ($this) { self::Junior => Color::Green, - self::Mid => Color::Blue, + self::Pleno => Color::Blue, self::Senior => Color::Purple, - self::Specialist => Color::Amber, - self::Lead => Color::Red, }; } @@ -38,10 +34,8 @@ public function getIcon(): Heroicon { return match ($this) { self::Junior => Heroicon::AcademicCap, - self::Mid => Heroicon::CodeBracket, + self::Pleno => Heroicon::CodeBracket, self::Senior => Heroicon::Star, - self::Specialist => Heroicon::Beaker, - self::Lead => Heroicon::Flag, }; } } diff --git a/app-modules/profile/src/Enums/SkillCategory.php b/app-modules/profile/src/Enums/SkillCategory.php new file mode 100644 index 000000000..9e6cc2aab --- /dev/null +++ b/app-modules/profile/src/Enums/SkillCategory.php @@ -0,0 +1,33 @@ + 'Linguagens & Frameworks', + self::InfraDatabases => 'Infraestrutura & Databases', + self::SoftSkillsTools => 'Soft Skills & Ferramentas', + self::Idiomas => 'Idiomas', + }; + } + + public function limit(): int + { + return match ($this) { + self::LanguagesFrameworks => 6, + self::InfraDatabases => 6, + self::SoftSkillsTools => 15, + self::Idiomas => PHP_INT_MAX, + }; + } +} diff --git a/app-modules/profile/src/Enums/Skills.php b/app-modules/profile/src/Enums/Skills.php new file mode 100644 index 000000000..a904097df --- /dev/null +++ b/app-modules/profile/src/Enums/Skills.php @@ -0,0 +1,135 @@ +}> */ + public const array ICON_MAP = [ + // Languages & Frameworks + 'php' => ['icon' => 'fab fa-php', 'keywords' => ['php']], + 'javascript' => ['icon' => 'fab fa-js', 'keywords' => ['javascript', 'js']], + 'typescript' => ['icon' => 'fab fa-js', 'keywords' => ['typescript', 'ts']], + 'python' => ['icon' => 'fab fa-python', 'keywords' => ['python']], + 'java' => ['icon' => 'fab fa-java', 'keywords' => ['java']], + 'csharp' => ['icon' => 'fas fa-hashtag', 'keywords' => ['c#', 'csharp', 'c sharp', '.net']], + 'go' => ['icon' => 'fas fa-code', 'keywords' => ['go', 'golang']], + 'rust' => ['icon' => 'fas fa-cog', 'keywords' => ['rust']], + 'ruby' => ['icon' => 'fas fa-gem', 'keywords' => ['ruby', 'rails', 'ruby on rails']], + 'swift' => ['icon' => 'fas fa-bolt', 'keywords' => ['swift']], + 'kotlin' => ['icon' => 'fas fa-code', 'keywords' => ['kotlin']], + 'dart' => ['icon' => 'fas fa-feather', 'keywords' => ['dart', 'flutter']], + 'laravel' => ['icon' => 'fab fa-laravel', 'keywords' => ['laravel']], + 'react' => ['icon' => 'fab fa-react', 'keywords' => ['react', 'reactjs', 'react.js']], + 'vuejs' => ['icon' => 'fab fa-vuejs', 'keywords' => ['vue', 'vuejs', 'vue.js']], + 'angular' => ['icon' => 'fab fa-angular', 'keywords' => ['angular']], + 'nodejs' => ['icon' => 'fab fa-node-js', 'keywords' => ['node', 'nodejs', 'node.js']], + 'spring' => ['icon' => 'fas fa-leaf', 'keywords' => ['spring', 'spring boot']], + 'flutter' => ['icon' => 'fas fa-mobile', 'keywords' => ['flutter']], + 'tailwind' => ['icon' => 'fab fa-css3-alt', 'keywords' => ['tailwind', 'tailwindcss', 'tailwind css']], + 'nextjs' => ['icon' => 'fas fa-angle-right', 'keywords' => ['next', 'nextjs', 'next.js']], + 'nuxtjs' => ['icon' => 'fas fa-angle-right', 'keywords' => ['nuxt', 'nuxtjs', 'nuxt.js']], + 'django' => ['icon' => 'fas fa-leaf', 'keywords' => ['django']], + 'rails' => ['icon' => 'fas fa-gem', 'keywords' => ['rails']], + 'express' => ['icon' => 'fas fa-server', 'keywords' => ['express', 'expressjs']], + 'fastapi' => ['icon' => 'fas fa-bolt', 'keywords' => ['fastapi', 'fast api']], + 'livewire' => ['icon' => 'fas fa-bolt', 'keywords' => ['livewire']], + + // Infra & Databases + 'postgresql' => ['icon' => 'fas fa-database', 'keywords' => ['postgresql', 'postgres']], + 'mysql' => ['icon' => 'fas fa-database', 'keywords' => ['mysql']], + 'mongodb' => ['icon' => 'fas fa-database', 'keywords' => ['mongodb', 'mongo']], + 'redis' => ['icon' => 'fas fa-database', 'keywords' => ['redis']], + 'sqlite' => ['icon' => 'fas fa-database', 'keywords' => ['sqlite']], + 'elasticsearch' => ['icon' => 'fas fa-search', 'keywords' => ['elasticsearch', 'elastic']], + 'docker' => ['icon' => 'fab fa-docker', 'keywords' => ['docker']], + 'kubernetes' => ['icon' => 'fas fa-dharmachakra', 'keywords' => ['kubernetes', 'k8s']], + 'aws' => ['icon' => 'fab fa-aws', 'keywords' => ['aws', 'amazon web services']], + 'gcp' => ['icon' => 'fab fa-google', 'keywords' => ['gcp', 'google cloud']], + 'azure' => ['icon' => 'fab fa-microsoft', 'keywords' => ['azure']], + 'linux' => ['icon' => 'fab fa-linux', 'keywords' => ['linux']], + 'git' => ['icon' => 'fab fa-git-alt', 'keywords' => ['git']], + 'cicd' => ['icon' => 'fas fa-arrows-rotate', 'keywords' => ['ci/cd', 'cicd', 'ci cd', 'pipeline']], + 'github-actions' => ['icon' => 'fab fa-github', 'keywords' => ['github actions', 'gh actions']], + + // Soft Skills & Tools + 'figma' => ['icon' => 'fab fa-figma', 'keywords' => ['figma']], + 'photoshop' => ['icon' => 'fas fa-image', 'keywords' => ['photoshop']], + 'scrum' => ['icon' => 'fas fa-users', 'keywords' => ['scrum']], + 'kanban' => ['icon' => 'fas fa-columns', 'keywords' => ['kanban']], + 'design-thinking' => ['icon' => 'fas fa-lightbulb', 'keywords' => ['design thinking']], + 'notion' => ['icon' => 'fas fa-book', 'keywords' => ['notion']], + 'framer' => ['icon' => 'fas fa-shapes', 'keywords' => ['framer']], + 'miro' => ['icon' => 'fas fa-chalkboard', 'keywords' => ['miro']], + 'xd' => ['icon' => 'fas fa-pen-nib', 'keywords' => ['adobe xd', 'xd']], + 'sass' => ['icon' => 'fab fa-sass', 'keywords' => ['sass', 'scss']], + 'webpack' => ['icon' => 'fas fa-box', 'keywords' => ['webpack']], + 'vite' => ['icon' => 'fas fa-bolt', 'keywords' => ['vite']], + 'leadership' => ['icon' => 'fas fa-users', 'keywords' => ['liderança', 'lideranca', 'leadership', 'líder']], + 'mentoria' => ['icon' => 'fas fa-chalkboard-user', 'keywords' => ['mentoria', 'mentoring', 'mentor']], + 'comunicacao' => ['icon' => 'fas fa-comments', 'keywords' => ['comunicação', 'comunicacao', 'communication']], + 'trabalho-em-equipe' => ['icon' => 'fas fa-people-group', 'keywords' => ['trabalho em equipe', 'team work', 'teamwork']], + 'resolucao-de-problemas' => ['icon' => 'fas fa-puzzle-piece', 'keywords' => ['resolução de problemas', 'problem solving']], + ]; + + /** + * Tenta casar o nome da skill com o catálogo para retornar um ícone. + * + * @return array{icon: string}|null + */ + public static function matchIcon(string $skillName): ?array + { + $normalized = mb_strtolower(mb_trim($skillName)); + + foreach (self::ICON_MAP as $entry) { + foreach ($entry['keywords'] as $keyword) { + if ($normalized === $keyword || str_contains($normalized, $keyword) || str_contains($keyword, $normalized)) { + return ['icon' => $entry['icon']]; + } + } + } + + return null; + } + + /** + * Valida e limita skills por categoria. + * + * @param array $skills + * @return array + */ + public static function validateAndLimit(array $skills): array + { + $counts = []; + $result = []; + + foreach ($skills as $skill) { + $category = $skill['category'] ?? ''; + $categoryEnum = SkillCategory::tryFrom($category); + + if ($categoryEnum === null) { + continue; + } + + $counts[$category] = ($counts[$category] ?? 0) + 1; + + if ($counts[$category] <= $categoryEnum->limit()) { + $matched = self::matchIcon($skill['name']); + $result[] = [ + 'name' => $skill['name'], + 'category' => $category, + 'icon' => $matched['icon'] ?? null, + ]; + } + } + + return $result; + } +} diff --git a/app-modules/profile/src/Enums/SocialPlatform.php b/app-modules/profile/src/Enums/SocialPlatform.php index 8dccf0275..7d50d48f8 100644 --- a/app-modules/profile/src/Enums/SocialPlatform.php +++ b/app-modules/profile/src/Enums/SocialPlatform.php @@ -15,6 +15,10 @@ enum SocialPlatform: string implements HasIcon, HasLabel case Website = 'website'; case YouTube = 'youtube'; case Bluesky = 'bluesky'; + case WhatsApp = 'whatsapp'; + case LinkedIn = 'linkedin'; + case GitHub = 'github'; + case DevTo = 'devto'; /** * @return array @@ -40,6 +44,10 @@ public function getIcon(): Heroicon self::Website => Heroicon::GlobeAlt, self::YouTube => Heroicon::PlayCircle, self::Bluesky => Heroicon::Cloud, + self::WhatsApp => Heroicon::ChatBubbleLeftEllipsis, + self::LinkedIn => Heroicon::Briefcase, + self::GitHub => Heroicon::CodeBracket, + self::DevTo => Heroicon::CommandLine, }; } } diff --git a/app-modules/profile/src/Http/ProfileController.php b/app-modules/profile/src/Http/ProfileController.php index e51e1d4a8..f7c3ecf9a 100644 --- a/app-modules/profile/src/Http/ProfileController.php +++ b/app-modules/profile/src/Http/ProfileController.php @@ -22,10 +22,23 @@ final class ProfileController 'devto' => 'https://dev.to/%s', ]; + private const array SOCIAL_ICONS = [ + 'whatsapp' => 'fa-brands fa-whatsapp', + 'linkedin' => 'fa-brands fa-linkedin-in', + 'github' => 'fa-brands fa-github', + 'devto' => 'fa-brands fa-dev', + 'instagram' => 'fa-brands fa-instagram', + 'twitter' => 'fa-brands fa-twitter', + 'youtube' => 'fa-brands fa-youtube', + 'website' => 'fas fa-globe', + 'bluesky' => 'fa-brands fa-bluesky', + ]; + public function show(Request $request, string $username): Factory|View { $tenant = Tenant::query() ->where('domain', $request->getHost()) + ->where('active', true) ->firstOrFail(); $user = User::query() @@ -41,21 +54,36 @@ public function show(Request $request, string $username): Factory|View abort_if($profile === null, 404); - $user->load(['character.badges', 'providers', 'address']); + $user->load([ + 'character.badges', + 'providers' => fn ($query) => $query->where('tenant_id', $tenant->id), + 'address', + ]); $connectedAccounts = $this->buildConnectedAccounts($profile, $user); + $resumeUrl = $profile->getFirstMediaUrl('resume') ?: null; + + $projects = $profile->projects()->orderBy('sort_order')->get(); + + $pullRequests = $profile->pullRequests()->latest()->get(); + return view('profile::public-profile', [ 'user' => $user, 'profile' => $profile, 'tenant' => $tenant, 'connectedAccounts' => $connectedAccounts, + 'socialIcons' => self::SOCIAL_ICONS, + 'projects' => $projects, + 'pullRequests' => $pullRequests, + 'resumeUrl' => $resumeUrl, ]); } private function buildConnectedAccounts(Profile $profile, User $user): Collection { $accounts = collect(); + $seen = []; if ($profile->social_links) { foreach ($profile->social_links as $platform => $url) { @@ -64,6 +92,7 @@ private function buildConnectedAccounts(Profile $profile, User $user): Collectio 'label' => $platform, 'url' => $url, ]); + $seen[] = $platform; } } @@ -72,14 +101,19 @@ private function buildConnectedAccounts(Profile $profile, User $user): Collectio ? $provider->provider->value : $provider->provider; + if (in_array($name, $seen, true)) { + continue; + } + $template = self::PROVIDER_URLS[$name] ?? null; - if ($template) { + if ($template && $provider->external_account_id) { $accounts->push([ 'provider' => $name, 'label' => $name, 'url' => sprintf($template, $provider->external_account_id), ]); + $seen[] = $name; } } diff --git a/app-modules/profile/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php index 135e9ad48..cae75b285 100644 --- a/app-modules/profile/src/Models/Profile.php +++ b/app-modules/profile/src/Models/Profile.php @@ -9,6 +9,7 @@ use He4rt\Identity\User\Models\User; use He4rt\Profile\Database\Factories\ProfileFactory; use He4rt\Profile\Enums\SeniorityLevel; +use He4rt\Profile\Enums\Skills; use He4rt\Profile\Enums\SocialPlatform; use He4rt\Profile\Enums\StartAvailability; use Illuminate\Database\Eloquent\Attributes\Table; @@ -17,7 +18,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use InvalidArgumentException; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; /** * @property string $id @@ -32,15 +36,19 @@ * @property array|null $social_links * @property bool $available_for_proposals * @property StartAvailability|null $start_availability + * @property array|null $skills + * @property array|null $work_types + * @property array|null $languages * @property Carbon|null $created_at * @property Carbon|null $updated_at */ #[Table(name: 'user_profiles')] -final class Profile extends Model +final class Profile extends Model implements HasMedia { /** @use HasFactory */ use HasFactory; use HasUuids; + use InteractsWithMedia; protected $fillable = [ 'user_id', @@ -54,6 +62,9 @@ final class Profile extends Model 'social_links', 'available_for_proposals', 'start_availability', + 'skills', + 'work_types', + 'languages', ]; /** @@ -72,6 +83,44 @@ public function tenant(): BelongsTo return $this->belongsTo(Tenant::class); } + /** + * @return HasMany + */ + public function projects(): HasMany + { + return $this->hasMany(Project::class, 'user_id', 'user_id') + ->where('tenant_id', $this->tenant_id) + ->orderBy('sort_order'); + } + + /** + * @return HasMany + */ + public function pullRequests(): HasMany + { + return $this->hasMany(PullRequest::class, 'user_id', 'user_id') + ->where('tenant_id', $this->tenant_id)->latest(); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('resume')->singleFile(); + } + + /** + * Retorna skills validadas com ícones resolvidos. + * + * @return array + */ + public function skillsByCategory(): array + { + if ($this->skills === null) { + return []; + } + + return Skills::validateAndLimit($this->skills); + } + protected static function newFactory(): ProfileFactory { return ProfileFactory::new(); @@ -101,11 +150,30 @@ protected function socialLinks(): Attribute }); } + /** + * @return Attribute|null, array|null> + */ + protected function skills(): Attribute + { + return Attribute::set(function (?array $value): ?string { + if ($value === null) { + return null; + } + + $validated = Skills::validateAndLimit($value); + + return $validated !== [] ? json_encode($validated, JSON_THROW_ON_ERROR) : null; + }); + } + protected function casts(): array { return [ 'birthdate' => 'date', 'social_links' => 'array', + 'skills' => 'array', + 'work_types' => 'array', + 'languages' => 'array', 'available_for_proposals' => 'boolean', 'seniority_level' => SeniorityLevel::class, 'start_availability' => StartAvailability::class, diff --git a/app-modules/profile/src/Models/Project.php b/app-modules/profile/src/Models/Project.php new file mode 100644 index 000000000..b24b10d55 --- /dev/null +++ b/app-modules/profile/src/Models/Project.php @@ -0,0 +1,81 @@ +|null $tags + * @property int|null $stars + * @property int|null $forks + * @property int $sort_order + * @property Carbon $created_at + * @property Carbon $updated_at + */ +#[Table(name: 'user_projects')] +final class Project extends Model +{ + use HasFactory; + use HasUuids; + + protected $fillable = [ + 'user_id', + 'tenant_id', + 'name', + 'description', + 'url', + 'tags', + 'stars', + 'forks', + 'sort_order', + ]; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function profile(): BelongsTo + { + return $this->belongsTo(Profile::class); + } + + protected function casts(): array + { + return [ + 'tags' => 'array', + 'stars' => 'integer', + 'forks' => 'integer', + 'sort_order' => 'integer', + ]; + } +} diff --git a/app-modules/profile/src/Models/PullRequest.php b/app-modules/profile/src/Models/PullRequest.php new file mode 100644 index 000000000..3c5741efa --- /dev/null +++ b/app-modules/profile/src/Models/PullRequest.php @@ -0,0 +1,100 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * @return BelongsTo + */ + public function profile(): BelongsTo + { + return $this->belongsTo(Profile::class); + } + + protected function getStatusClassAttribute(): string + { + return match ($this->status) { + 'merged' => 'bg-green-500/20 text-green-400', + 'open' => 'bg-[#782bf1]/20 text-purple-400', + 'closed' => 'bg-red-500/20 text-red-400', + default => 'bg-gray-500/20 text-gray-400', + }; + } + + protected function getStatusLabelAttribute(): string + { + return match ($this->status) { + 'merged' => 'Merged', + 'open' => 'Open', + 'closed' => 'Closed', + default => $this->status, + }; + } + + protected function casts(): array + { + return [ + 'number' => 'integer', + 'pr_created_at' => 'datetime', + 'sort_order' => 'integer', + ]; + } +} diff --git a/app-modules/profile/src/ProfileServiceProvider.php b/app-modules/profile/src/ProfileServiceProvider.php index 1279e9e2a..e67cf193f 100644 --- a/app-modules/profile/src/ProfileServiceProvider.php +++ b/app-modules/profile/src/ProfileServiceProvider.php @@ -22,7 +22,8 @@ public function boot(): void 'profile' => Profile::class, ]); - Route::get('/@{username}', [ProfileController::class, 'show']) + Route::middleware('web') + ->get('/@{username}', [ProfileController::class, 'show']) ->name('profile.public'); } } diff --git a/app-modules/profile/tests/Feature/PublicProfileTest.php b/app-modules/profile/tests/Feature/PublicProfileTest.php index d59d57748..a1f7e7f69 100644 --- a/app-modules/profile/tests/Feature/PublicProfileTest.php +++ b/app-modules/profile/tests/Feature/PublicProfileTest.php @@ -7,8 +7,7 @@ use He4rt\Profile\Models\Profile; it('returns 200 for existing user with complete profile', function (): void { - $tenant = Tenant::factory()->create(); - $tenant->update(['domain' => 'test.he4rtdevs.com']); + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); $user = User::factory()->create(['username' => 'janedoe']); Profile::factory()->create([ @@ -24,13 +23,18 @@ }); it('renders minimal profile without crashing', function (): void { - $tenant = Tenant::factory()->create(); - $tenant->update(['domain' => 'test.he4rtdevs.com']); + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); $user = User::factory()->create(['username' => 'novato']); Profile::factory()->create([ 'user_id' => $user->id, 'tenant_id' => $tenant->id, + 'headline' => null, + 'about' => null, + 'skills' => null, + 'work_types' => null, + 'languages' => null, + 'social_links' => null, ]); $response = $this->get('http://test.he4rtdevs.com/@novato'); @@ -42,8 +46,7 @@ }); it('returns 404 for non-existent user', function (): void { - $tenant = Tenant::factory()->create(); - $tenant->update(['domain' => 'test.he4rtdevs.com']); + Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); $response = $this->get('http://test.he4rtdevs.com/@fantasma'); @@ -51,8 +54,7 @@ }); it('returns 404 for user without profile in tenant', function (): void { - $tenant = Tenant::factory()->create(); - $tenant->update(['domain' => 'test.he4rtdevs.com']); + Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); User::factory()->create(['username' => 'semprofile']); $response = $this->get('http://test.he4rtdevs.com/@semprofile'); @@ -60,20 +62,95 @@ $response->assertNotFound(); }); -it('does not show available badge when available_for_proposals is false', function (): void { - $tenant = Tenant::factory()->create(); - $tenant->update(['domain' => 'test.he4rtdevs.com']); +it('does not show work type tags when work_types is empty', function (): void { + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); $user = User::factory()->create(['username' => 'indisponivel']); Profile::factory()->create([ 'user_id' => $user->id, 'tenant_id' => $tenant->id, - 'available_for_proposals' => false, + 'work_types' => null, ]); $response = $this->get('http://test.he4rtdevs.com/@indisponivel'); $response->assertOk(); - $response->assertDontSee('Disponível'); - $response->assertDontSee('Indisponível'); + $response->assertDontSee('Início imediato'); +}); + +it('renders work type tags when work_types are present', function (): void { + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'disponivel']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'work_types' => ['immediate', 'remote'], + ]); + + $response = $this->get('http://test.he4rtdevs.com/@disponivel'); + + $response->assertOk(); + $response->assertSee('Início imediato'); + $response->assertSee('Remoto'); +}); + +it('renders skills section when skills are present', function (): void { + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'devskill']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'skills' => [ + ['name' => 'PHP', 'category' => 'languages_frameworks'], + ['name' => 'Laravel', 'category' => 'languages_frameworks'], + ['name' => 'PostgreSQL', 'category' => 'infra_databases'], + ], + ]); + + $response = $this->get('http://test.he4rtdevs.com/@devskill'); + + $response->assertOk(); + $response->assertSee('Stack & Skills', false); + $response->assertSee('PHP'); + $response->assertSee('Laravel'); + $response->assertSee('PostgreSQL'); +}); + +it('does not render skills section when skills are null', function (): void { + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'noskills']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'skills' => null, + ]); + + $response = $this->get('http://test.he4rtdevs.com/@noskills'); + + $response->assertOk(); + $response->assertDontSee('Stack'); +}); + +it('renders languages when present', function (): void { + $tenant = Tenant::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']); + + $user = User::factory()->create(['username' => 'polyglot']); + Profile::factory()->create([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'languages' => [ + ['name' => 'Português', 'level' => 'Nativo'], + ['name' => 'Inglês', 'level' => 'Intermediário'], + ], + ]); + + $response = $this->get('http://test.he4rtdevs.com/@polyglot'); + + $response->assertOk(); + $response->assertSee('Português'); + $response->assertSee('Nativo'); + $response->assertSee('Inglês'); }); diff --git a/app-modules/profile/tests/Feature/UpsertProfileTest.php b/app-modules/profile/tests/Feature/UpsertProfileTest.php index 0ece475da..4c717d5dd 100644 --- a/app-modules/profile/tests/Feature/UpsertProfileTest.php +++ b/app-modules/profile/tests/Feature/UpsertProfileTest.php @@ -15,7 +15,7 @@ 'birthdate' => '1995-03-15', 'about' => 'Dev PHP apaixonado por Laravel', 'headline' => 'Backend Developer', - 'seniority_level' => 'mid', + 'seniority_level' => 'pleno', 'years_experience' => 5, 'social_links' => [ 'instagram' => '@danielhe4rt', @@ -29,7 +29,7 @@ ->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->seniority_level)->toBe(SeniorityLevel::Pleno) ->and($result->years_experience)->toBe(5) ->and($result->social_links)->toMatchArray([ 'instagram' => '@danielhe4rt', diff --git a/app-modules/profile/tests/Unit/ProfileEnumTest.php b/app-modules/profile/tests/Unit/ProfileEnumTest.php index 374a8f619..9ea72460e 100644 --- a/app-modules/profile/tests/Unit/ProfileEnumTest.php +++ b/app-modules/profile/tests/Unit/ProfileEnumTest.php @@ -16,10 +16,8 @@ ->toBeInstanceOf(HasIcon::class); expect(SeniorityLevel::Junior->getLabel())->toBeString()->not->toBeEmpty() - ->and(SeniorityLevel::Mid->getLabel())->toBeString()->not->toBeEmpty() - ->and(SeniorityLevel::Senior->getLabel())->toBeString()->not->toBeEmpty() - ->and(SeniorityLevel::Specialist->getLabel())->toBeString()->not->toBeEmpty() - ->and(SeniorityLevel::Lead->getLabel())->toBeString()->not->toBeEmpty(); + ->and(SeniorityLevel::Pleno->getLabel())->toBeString()->not->toBeEmpty() + ->and(SeniorityLevel::Senior->getLabel())->toBeString()->not->toBeEmpty(); }); test('social platform implements filament enum interfaces', function (): void { @@ -49,6 +47,6 @@ $colors = array_map(fn (SeniorityLevel $level) => $level->getColor(), SeniorityLevel::cases()); $icons = array_map(fn (SeniorityLevel $level) => $level->getIcon(), SeniorityLevel::cases()); - expect($colors)->toHaveCount(5) - ->and($icons)->toHaveCount(5); + expect($colors)->toHaveCount(3) + ->and($icons)->toHaveCount(3); }); diff --git a/vite.config.js b/vite.config.js index b9201b1c7..34578e832 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,6 +13,7 @@ export default defineConfig({ 'app-modules/he4rt/resources/css/theme.css', 'app-modules/he4rt/resources/css/themes/3pontos/theme.css', 'app-modules/docs/resources/css/theme.css', + 'app-modules/profile/resources/css/profile.css', ], refresh: true, }),