diff --git a/app-modules/panel-app/lang/en/profile.php b/app-modules/panel-app/lang/en/profile.php
index 39a63f38..c8461e68 100644
--- a/app-modules/panel-app/lang/en/profile.php
+++ b/app-modules/panel-app/lang/en/profile.php
@@ -87,4 +87,13 @@
'page' => [
'subtitle' => 'Fill in the fields and watch your card being built in real time',
],
+
+ 'preview' => [
+ 'available' => 'Available',
+ 'years_experience' => ':count year of exp.|:count years of exp.',
+ 'skills' => 'Skills',
+ 'experience' => 'Experience',
+ 'can_start' => 'Can start:',
+ 'footer' => 'This card appears in the member listing and on your public profile.',
+ ],
];
diff --git a/app-modules/panel-app/lang/pt_BR/profile.php b/app-modules/panel-app/lang/pt_BR/profile.php
index 413407ba..57671c1e 100644
--- a/app-modules/panel-app/lang/pt_BR/profile.php
+++ b/app-modules/panel-app/lang/pt_BR/profile.php
@@ -87,4 +87,13 @@
'page' => [
'subtitle' => 'Preencha os campos e veja seu card sendo montado em tempo real',
],
+
+ 'preview' => [
+ 'available' => 'Disponível',
+ 'years_experience' => ':count ano de exp.|:count anos de exp.',
+ 'skills' => 'Skills',
+ 'experience' => 'Experiência',
+ 'can_start' => 'Pode iniciar:',
+ 'footer' => 'Esse card aparece na listagem de membros e no seu perfil público.',
+ ],
];
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 5386dac5..f10c5700 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
@@ -123,7 +123,7 @@ class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-w
class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-500/10 dark:text-green-400"
>
- Disponível
+ {{ __('panel-app::profile.preview.available') }}
@endif
@@ -165,7 +165,7 @@ class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-
@endif
@if ($yearsExperience)
- {{ $yearsExperience }} {{ $yearsExperience == 1 ? 'ano' : 'anos' }} de exp.
+ {{ trans_choice('panel-app::profile.preview.years_experience', $yearsExperience, ['count' => $yearsExperience]) }}
@endif
@@ -180,7 +180,7 @@ class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-
@if ($skills->isNotEmpty())
- Skills
+ {{ __('panel-app::profile.preview.skills') }}
@foreach ($skills as $skill)
@@ -203,7 +203,7 @@ class="inline-flex items-center gap-1 rounded-md bg-purple-50 px-2 py-0.5 text-x
@@ -257,13 +266,13 @@ class="inline-block rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5
{{-- Start availability --}}
@if ($available && $startAvailability)
- Pode iniciar: {{ $startAvailability->getLabel() }}
+ {{ __('panel-app::profile.preview.can_start') }} {{ $startAvailability->getLabel() }}
@endif
{{-- Footer --}}
-
Esse card aparece na listagem de membros e no seu perfil público.
+
{{ __('panel-app::profile.preview.footer') }}
diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php
index 350b4d02..5c7c6a93 100644
--- a/app-modules/panel-app/src/Pages/ProfilePage.php
+++ b/app-modules/panel-app/src/Pages/ProfilePage.php
@@ -147,13 +147,21 @@ public function form(Schema $schema): Schema
->getOptionLabelUsing(fn (?string $value): ?string => $value === null ? null : (Skill::labelsById()[$value] ?? null))
->optionsLimit(50)
->distinct()
- ->required()
+ ->required(fn (Get $get): bool => filled($get('proficiency')))
->columnSpan(1),
Select::make('proficiency')
->label(__('panel-app::profile.fields.proficiency'))
->options(SkillProficiency::class)
- ->required()
+ ->required(fn (Get $get): bool => filled($get('skill_id')))
+ ->live()
+ ->afterStateUpdated(function (Get $get, Select $component): void {
+ if (blank($get('skill_id')) || blank($get('proficiency'))) {
+ return;
+ }
+
+ $this->appendEmptyRepeaterItemIfLastRow($component);
+ })
->columnSpan(1),
TextInput::make('years_experience')
@@ -221,13 +229,21 @@ public function form(Schema $schema): Schema
Select::make('platform')
->label(__('panel-app::profile.fields.platform'))
->options(SocialPlatform::class)
- ->required()
+ ->required(fn (Get $get): bool => filled($get('handle')))
->columnSpan(1),
TextInput::make('handle')
->label(__('panel-app::profile.fields.handle'))
->placeholder(__('panel-app::profile.placeholders.handle'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('platform')))
+ ->live(onBlur: true)
+ ->afterStateUpdated(function (Get $get, TextInput $component): void {
+ if (blank($get('platform')) || blank($get('handle'))) {
+ return;
+ }
+
+ $this->appendEmptyRepeaterItemIfLastRow($component);
+ })
->columnSpan(1),
]),
])
@@ -301,18 +317,18 @@ public function form(Schema $schema): Schema
Grid::make(2)->schema([
TextInput::make('company_name')
->label(__('panel-app::profile.fields.company_name'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('position')) || filled($get('description')) || filled($get('start_date')))
->maxLength(255)
->columnSpan(1),
TextInput::make('position')
->label(__('panel-app::profile.fields.position'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('company_name')) || filled($get('description')) || filled($get('start_date')))
->maxLength(255)
->columnSpan(1),
]),
Textarea::make('description')
->label(__('panel-app::profile.fields.experience_description'))
- ->required()
+ ->required(fn (Get $get): bool => filled($get('company_name')) || filled($get('position')) || filled($get('start_date')))
->rows(3)
->maxLength(2_000)
->columnSpanFull(),
@@ -323,7 +339,7 @@ public function form(Schema $schema): Schema
->displayFormat('M Y')
->format('Y-m-d')
->maxDate(now())
- ->required()
+ ->required(fn (Get $get): bool => filled($get('company_name')) || filled($get('position')) || filled($get('description')))
->columnSpan(1),
DatePicker::make('end_date')
->label(__('panel-app::profile.fields.end_date'))
@@ -332,7 +348,7 @@ public function form(Schema $schema): Schema
->format('Y-m-d')
->maxDate(now())
->afterOrEqual('start_date')
- ->required(fn (Get $get): bool => !$get('is_currently_working_here'))
+ ->required(fn (Get $get): bool => !$get('is_currently_working_here') && (filled($get('company_name')) || filled($get('position')) || filled($get('description')) || filled($get('start_date'))))
->hidden(fn (Get $get): bool => (bool) $get('is_currently_working_here'))
->columnSpan(1),
]),
@@ -606,10 +622,18 @@ private function repeaterToSocialLinks(array $repeaterData): array
/**
* @param array
$data
- * @return array
+ * @return array|null null tells Filament to skip creating/saving this row
*/
- private function normalizeWorkExperienceData(array $data): array
+ private function normalizeWorkExperienceData(array $data): ?array
{
+ $keyFields = ['company_name', 'position', 'description', 'start_date'];
+
+ $hasAnyData = collect($keyFields)->contains(fn (string $field): bool => filled($data[$field] ?? null));
+
+ if (!$hasAnyData) {
+ return null;
+ }
+
if ($data['is_currently_working_here'] ?? false) {
$data['end_date'] = null;
}
@@ -635,6 +659,53 @@ private function skillsToRepeater(Profile $profile): array
return $skills;
}
+ private function appendEmptyRepeaterItemIfLastRow(Select|TextInput $component): void
+ {
+ $repeater = $component->getParentRepeater();
+
+ if (!$repeater instanceof Repeater) {
+ return;
+ }
+
+ $items = $repeater->getRawState();
+
+ if (!is_array($items) || blank($items)) {
+ return;
+ }
+
+ $repeaterPath = $repeater->getStatePath();
+ $componentPath = $component->getStatePath();
+
+ if ($repeaterPath === null || $componentPath === null) {
+ return;
+ }
+
+ $currentKey = explode('.', mb_substr($componentPath, mb_strlen($repeaterPath) + 1))[0];
+
+ if ($currentKey !== array_key_last($items)) {
+ return;
+ }
+
+ $newUuid = $repeater->generateUuid();
+
+ if ($newUuid) {
+ $items[$newUuid] = [];
+ } else {
+ $items[] = [];
+ }
+
+ $repeater->rawState($items);
+
+ $childSchema = $repeater->getChildSchema($newUuid ?? array_key_last($items));
+
+ if ($childSchema instanceof Schema) {
+ $childSchema->fill();
+ }
+
+ $repeater->collapsed(condition: false, shouldMakeComponentCollapsible: false);
+ $repeater->callAfterStateUpdated();
+ }
+
/**
* Skill ids already chosen in the other rows of the skills repeater, so the
* search can omit them and each skill is only pickable once.
@@ -644,13 +715,17 @@ private function skillsToRepeater(Profile $profile): array
private function skillIdsInSiblingRows(Select $component): array
{
$repeater = $component->getParentRepeater();
- if ($repeater === null) {
+
+ if (!$repeater instanceof Repeater) {
return [];
}
- /** @var array> $rows */
$rows = $repeater->getRawState();
+ if (!is_array($rows)) {
+ return [];
+ }
+
return array_values(
collect($rows)
->pluck('skill_id')
diff --git a/app-modules/profile/lang/en/enums.php b/app-modules/profile/lang/en/enums.php
index 68548963..3ffd48b7 100644
--- a/app-modules/profile/lang/en/enums.php
+++ b/app-modules/profile/lang/en/enums.php
@@ -17,6 +17,7 @@
'website' => 'Website',
'youtube' => 'YouTube',
'bluesky' => 'Bluesky',
+ 'linkedin' => 'LinkedIn',
],
'skill_category' => [
diff --git a/app-modules/profile/lang/pt_BR/enums.php b/app-modules/profile/lang/pt_BR/enums.php
index e01c86c7..1b1fa3b9 100644
--- a/app-modules/profile/lang/pt_BR/enums.php
+++ b/app-modules/profile/lang/pt_BR/enums.php
@@ -17,6 +17,7 @@
'website' => 'Website',
'youtube' => 'YouTube',
'bluesky' => 'Bluesky',
+ 'linkedin' => 'LinkedIn',
],
'skill_category' => [
diff --git a/app-modules/profile/src/Enums/SocialPlatform.php b/app-modules/profile/src/Enums/SocialPlatform.php
index 8dccf027..db6eaf65 100644
--- a/app-modules/profile/src/Enums/SocialPlatform.php
+++ b/app-modules/profile/src/Enums/SocialPlatform.php
@@ -15,6 +15,7 @@ enum SocialPlatform: string implements HasIcon, HasLabel
case Website = 'website';
case YouTube = 'youtube';
case Bluesky = 'bluesky';
+ case LinkedIn = 'linkedin';
/**
* @return array
@@ -40,6 +41,25 @@ public function getIcon(): Heroicon
self::Website => Heroicon::GlobeAlt,
self::YouTube => Heroicon::PlayCircle,
self::Bluesky => Heroicon::Cloud,
+ self::LinkedIn => Heroicon::Link
+ };
+ }
+
+ public function getUrl(string $handle): string
+ {
+ if (str_starts_with($handle, 'http://') || str_starts_with($handle, 'https://')) {
+ return $handle;
+ }
+
+ $slug = mb_ltrim($handle, '@');
+
+ return match ($this) {
+ self::Instagram => 'https://instagram.com/'.$slug,
+ self::Twitter => 'https://x.com/'.$slug,
+ self::LinkedIn => 'https://linkedin.com/in/'.$slug,
+ self::YouTube => 'https://youtube.com/@'.$slug,
+ self::Bluesky => 'https://bsky.app/profile/'.$slug,
+ self::Website => 'https://'.$slug,
};
}
}
diff --git a/app-modules/profile/tests/Unit/SocialPlatformTest.php b/app-modules/profile/tests/Unit/SocialPlatformTest.php
new file mode 100644
index 00000000..d7da83f3
--- /dev/null
+++ b/app-modules/profile/tests/Unit/SocialPlatformTest.php
@@ -0,0 +1,59 @@
+ ['johndoe'],
+ 'username with @' => ['@johndoe'],
+]);
+
+test('instagram builds correct url from handle', function (string $handle): void {
+ expect(SocialPlatform::Instagram->getUrl($handle))
+ ->toBe('https://instagram.com/johndoe');
+})->with('username handles');
+
+test('twitter builds correct url from handle', function (string $handle): void {
+ expect(SocialPlatform::Twitter->getUrl($handle))
+ ->toBe('https://x.com/johndoe');
+})->with('username handles');
+
+test('linkedin builds correct url from handle', function (string $handle): void {
+ expect(SocialPlatform::LinkedIn->getUrl($handle))
+ ->toBe('https://linkedin.com/in/johndoe');
+})->with('username handles');
+
+test('youtube builds correct url from handle', function (string $handle): void {
+ expect(SocialPlatform::YouTube->getUrl($handle))
+ ->toBe('https://youtube.com/@johndoe');
+})->with('username handles');
+
+test('bluesky builds correct url from handle', function (string $handle): void {
+ expect(SocialPlatform::Bluesky->getUrl($handle))
+ ->toBe('https://bsky.app/profile/johndoe');
+})->with('username handles');
+
+test('website prepends https when no scheme is provided', function (): void {
+ expect(SocialPlatform::Website->getUrl('example.dev'))
+ ->toBe('https://example.dev');
+});
+
+test('full https url is returned as-is for any platform', function (): void {
+ $url = 'https://www.linkedin.com/in/johndoe/';
+
+ expect(SocialPlatform::LinkedIn->getUrl($url))->toBe($url);
+});
+
+test('full http url is returned as-is for any platform', function (): void {
+ $url = 'http://example.dev';
+
+ expect(SocialPlatform::Website->getUrl($url))->toBe($url);
+});
+
+test('full url is not double-prefixed for all platforms', function (): void {
+ foreach (SocialPlatform::cases() as $platform) {
+ $url = 'https://example.com/foo';
+ expect($platform->getUrl($url))->toBe($url);
+ }
+});