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 911d2382b..d059fb854 100644
--- a/app-modules/portal/resources/views/components/layouts/app.blade.php
+++ b/app-modules/portal/resources/views/components/layouts/app.blade.php
@@ -7,6 +7,7 @@
{{ $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
new file mode 100644
index 000000000..2f062f4cb
--- /dev/null
+++ b/app-modules/profile/resources/views/public-profile.blade.php
@@ -0,0 +1,547 @@
+
+ @push ('styles')
+
+ @vite (['app-modules/profile/resources/css/profile.css'])
+ @endpush
+
+
+
+
+
+
+
+
+
+ @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)
+
+
+
+
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 }}
+ @if ($badge->description)
+
{{ $badge->description }}
+ @endif
+
+
+ @endforeach
+ @if ($otherBadges->count() > 4)
+ @foreach ($otherBadges->skip(4) as $badge)
+
+
+ @if ($badge->getFirstMediaUrl('badge'))
+
 }})
+ @else
+
+ @endif
+
+
+
{{ $badge->name }}
+ @if ($badge->description)
+
{{ $badge->description }}
+ @endif
+
+
+ @endforeach
+ @endif
+ @if ($otherBadges->count() > 4)
+
+
+
+ @endif
+
+
+ @endif
+
+
+
+
+
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
new file mode 100644
index 000000000..f7c3ecf9a
--- /dev/null
+++ b/app-modules/profile/src/Http/ProfileController.php
@@ -0,0 +1,122 @@
+ 'https://github.com/%s',
+ 'discord' => 'https://discord.com/users/%s',
+ 'twitch' => 'https://twitch.tv/%s',
+ '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()
+ ->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' => 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) {
+ $accounts->push([
+ 'provider' => $platform,
+ 'label' => $platform,
+ 'url' => $url,
+ ]);
+ $seen[] = $platform;
+ }
+ }
+
+ foreach ($user->providers as $provider) {
+ $name = $provider->provider instanceof IdentityProvider
+ ? $provider->provider->value
+ : $provider->provider;
+
+ if (in_array($name, $seen, true)) {
+ continue;
+ }
+
+ $template = self::PROVIDER_URLS[$name] ?? null;
+
+ if ($template && $provider->external_account_id) {
+ $accounts->push([
+ 'provider' => $name,
+ 'label' => $name,
+ 'url' => sprintf($template, $provider->external_account_id),
+ ]);
+ $seen[] = $name;
+ }
+ }
+
+ return $accounts;
+ }
+}
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 d9d8af288..e67cf193f 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,14 @@ 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::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
new file mode 100644
index 000000000..a1f7e7f69
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileTest.php
@@ -0,0 +1,156 @@
+create(['active' => true, '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(['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');
+
+ $response->assertOk();
+ $response->assertSee($user->name);
+ $response->assertDontSee('null');
+ $response->assertDontSee('undefined');
+});
+
+it('returns 404 for non-existent user', function (): void {
+ Tenant::factory()->create(['active' => true, '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::factory()->create(['active' => true, 'domain' => 'test.he4rtdevs.com']);
+ User::factory()->create(['username' => 'semprofile']);
+
+ $response = $this->get('http://test.he4rtdevs.com/@semprofile');
+
+ $response->assertNotFound();
+});
+
+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,
+ 'work_types' => null,
+ ]);
+
+ $response = $this->get('http://test.he4rtdevs.com/@indisponivel');
+
+ $response->assertOk();
+ $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,
}),