Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app-modules/panel-app/lang/en/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
],
];
9 changes: 9 additions & 0 deletions app-modules/panel-app/lang/pt_BR/profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
],
];
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Disponível
{{ __('panel-app::profile.preview.available') }}
</span>
@endif
</div>
Expand Down Expand Up @@ -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)
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $yearsExperience }} {{ $yearsExperience == 1 ? 'ano' : 'anos' }} de exp.
{{ trans_choice('panel-app::profile.preview.years_experience', $yearsExperience, ['count' => $yearsExperience]) }}
</span>
@endif
</div>
Expand All @@ -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())
<div>
<span class="mb-1.5 block text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">
Skills
{{ __('panel-app::profile.preview.skills') }}
</span>
<div class="flex flex-wrap gap-1.5">
@foreach ($skills as $skill)
Expand All @@ -203,7 +203,7 @@ class="inline-flex items-center gap-1 rounded-md bg-purple-50 px-2 py-0.5 text-x
<div>
<div class="mb-1 flex items-center justify-between">
<span class="text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400"
>Experiência</span
>{{ __('panel-app::profile.preview.experience') }}</span
>
<span class="text-xs text-gray-500 dark:text-gray-400">
<span
Expand Down Expand Up @@ -246,9 +246,18 @@ class="inline-block rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5
: \He4rt\Profile\Enums\SocialPlatform::tryFrom($link['platform']);
@endphp
@if ($platform)
<span class="text-xs font-medium text-purple-600 dark:text-purple-400">
{{ $platform->getLabel() }} {{ $link['handle'] }}
</span>
<a
href="{{ $platform->getUrl($link['handle']) }}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-200 transition-colors"
>
<x-dynamic-component :component="'heroicon-m-'.\Illuminate\Support\Str::kebab($platform->getIcon()->name)" class="h-3.5 w-3.5 shrink-0" />
{{ $platform->getLabel() }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.5 9.5 9.5 2.5M5 2.5h4.5V7" />
</svg>
</a>
@endif
@endforeach
</div>
Expand All @@ -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)
<p class="text-xs font-medium text-green-600 dark:text-green-400">
Pode iniciar: {{ $startAvailability->getLabel() }}
{{ __('panel-app::profile.preview.can_start') }} {{ $startAvailability->getLabel() }}
</p>
@endif
</div>

{{-- Footer --}}
<div class="border-t border-gray-100 px-6 py-3 dark:border-white/5">
<p class="text-center text-xs text-gray-400 dark:text-gray-500">Esse card aparece na listagem de membros e no seu perfil público.</p>
<p class="text-center text-xs text-gray-400 dark:text-gray-500">{{ __('panel-app::profile.preview.footer') }}</p>
</div>
</div>
101 changes: 88 additions & 13 deletions app-modules/panel-app/src/Pages/ProfilePage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')))
Comment thread
fernanduandrade marked this conversation as resolved.
->live()
->afterStateUpdated(function (Get $get, Select $component): void {
if (blank($get('skill_id')) || blank($get('proficiency'))) {
return;
}

$this->appendEmptyRepeaterItemIfLastRow($component);
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
->columnSpan(1),

TextInput::make('years_experience')
Expand Down Expand Up @@ -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),
]),
])
Expand Down Expand Up @@ -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(),
Expand All @@ -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'))
Expand All @@ -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),
]),
Expand Down Expand Up @@ -606,10 +622,18 @@ private function repeaterToSocialLinks(array $repeaterData): array

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
* @return array<string, mixed>|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;
}
Expand All @@ -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.
Expand All @@ -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<int|string, array<string, mixed>> $rows */
$rows = $repeater->getRawState();

if (!is_array($rows)) {
return [];
}

return array_values(
collect($rows)
->pluck('skill_id')
Expand Down
1 change: 1 addition & 0 deletions app-modules/profile/lang/en/enums.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
'website' => 'Website',
'youtube' => 'YouTube',
'bluesky' => 'Bluesky',
'linkedin' => 'LinkedIn',
],

'skill_category' => [
Expand Down
1 change: 1 addition & 0 deletions app-modules/profile/lang/pt_BR/enums.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
'website' => 'Website',
'youtube' => 'YouTube',
'bluesky' => 'Bluesky',
'linkedin' => 'LinkedIn',
],

'skill_category' => [
Expand Down
20 changes: 20 additions & 0 deletions app-modules/profile/src/Enums/SocialPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum SocialPlatform: string implements HasIcon, HasLabel
case Website = 'website';
case YouTube = 'youtube';
case Bluesky = 'bluesky';
case LinkedIn = 'linkedin';

/**
* @return array<int, string>
Expand All @@ -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,
};
}
}
59 changes: 59 additions & 0 deletions app-modules/profile/tests/Unit/SocialPlatformTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

use He4rt\Profile\Enums\SocialPlatform;

dataset('username handles', [
'plain username' => ['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);
}
});