diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php index 53d94b7a..f7c14a5c 100644 --- a/app-modules/events/src/Event/Models/Event.php +++ b/app-modules/events/src/Event/Models/Event.php @@ -18,6 +18,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; /** * @property string $id @@ -33,11 +35,12 @@ * @property Carbon $updated_at */ #[Table(name: 'events')] -final class Event extends Model +final class Event extends Model implements HasMedia { /** @use HasFactory */ use HasFactory; use HasUuids; + use InteractsWithMedia; protected $fillable = [ 'slug', @@ -72,6 +75,13 @@ public function checkInCodes(): HasMany return $this->hasMany(CheckInCode::class); } + public function registerMediaCollections(): void + { + $this->addMediaCollection('cover') + ->singleFile() + ->useDisk('public'); + } + public function isPast(): bool { return $this->ends_at->isPast(); diff --git a/app-modules/events/src/EventsServiceProvider.php b/app-modules/events/src/EventsServiceProvider.php index 55745965..5ee19bd5 100644 --- a/app-modules/events/src/EventsServiceProvider.php +++ b/app-modules/events/src/EventsServiceProvider.php @@ -9,9 +9,11 @@ use He4rt\Events\CheckIn\Listeners\HandleBotCheckIn; use He4rt\Events\Console\Commands\ClosePendingEventsCommand; use He4rt\Events\Enrollment\Events\EnrollmentConfirmed; +use He4rt\Events\Event\Models\Event; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Support\Facades\Event; +use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Facades\Event as EventFacade; use Illuminate\Support\ServiceProvider; class EventsServiceProvider extends ServiceProvider @@ -25,8 +27,12 @@ public function boot(): void $this->loadViewsFrom(__DIR__.'/../resources/views', 'events'); $this->loadTranslationsFrom(__DIR__.'/../lang', 'events'); - Event::listen(EnrollmentConfirmed::class, GenerateQrTokenOnConfirmed::class); - Event::listen(CheckInRequested::class, HandleBotCheckIn::class); + Relation::morphMap([ + 'event' => Event::class, + ]); + + EventFacade::listen(EnrollmentConfirmed::class, GenerateQrTokenOnConfirmed::class); + EventFacade::listen(CheckInRequested::class, HandleBotCheckIn::class); if ($this->app->runningInConsole()) { $this->app->make(Schedule::class) diff --git a/app-modules/he4rt/resources/views/components/headline.blade.php b/app-modules/he4rt/resources/views/components/headline.blade.php index a089a5ab..30856f9d 100644 --- a/app-modules/he4rt/resources/views/components/headline.blade.php +++ b/app-modules/he4rt/resources/views/components/headline.blade.php @@ -4,6 +4,7 @@ "size" => "lg", "animation" => "fade-up", "keywords" => [], + "titleTag" => "h1", ]) @php @@ -64,7 +65,7 @@ class="hp-headline-badge"
@isset($title) -

attributes->class(["hp-headline-title", "delay-100" => $animate]) }} @if ($animate) x-show="shown" @@ -85,7 +86,7 @@ class="hp-headline-badge" {{ " " }} @endunless @endforeach -

+ @endisset @isset($subtitle) diff --git a/app-modules/panel-admin/lang/en/events.php b/app-modules/panel-admin/lang/en/events.php index 667fa350..fedcc6f9 100644 --- a/app-modules/panel-admin/lang/en/events.php +++ b/app-modules/panel-admin/lang/en/events.php @@ -12,6 +12,7 @@ 'type' => 'Type', 'location' => 'Location', 'description' => 'Description', + 'cover' => 'Cover', 'starts_at' => 'Starts At', 'ends_at' => 'Ends At', 'status' => 'Status', diff --git a/app-modules/panel-admin/lang/pt_BR/events.php b/app-modules/panel-admin/lang/pt_BR/events.php index fff2a789..6dfdb84c 100644 --- a/app-modules/panel-admin/lang/pt_BR/events.php +++ b/app-modules/panel-admin/lang/pt_BR/events.php @@ -12,6 +12,7 @@ 'type' => 'Tipo', 'location' => 'Local', 'description' => 'Descrição', + 'cover' => 'Capa', 'starts_at' => 'Início', 'ends_at' => 'Término', 'status' => 'Status', diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php index f84c45d0..4e55e115 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php @@ -8,6 +8,7 @@ use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; +use Filament\Forms\Components\SpatieMediaLibraryFileUpload; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -61,6 +62,13 @@ public static function configure(Schema $schema): Schema ->nullable() ->columnSpanFull(), + SpatieMediaLibraryFileUpload::make('cover') + ->label(__('panel-admin::events.columns.cover')) + ->collection('cover') + ->image() + ->imageEditor() + ->columnSpanFull(), + DateTimePicker::make('starts_at') ->label(__('panel-admin::events.columns.starts_at')) ->required(), diff --git a/app-modules/portal/resources/views/homepage.blade.php b/app-modules/portal/resources/views/homepage.blade.php index 0b1d7397..c9d849c8 100644 --- a/app-modules/portal/resources/views/homepage.blade.php +++ b/app-modules/portal/resources/views/homepage.blade.php @@ -1,5 +1,6 @@
+ {{-- --}} {{-- --}} {{-- --}} diff --git a/app-modules/portal/resources/views/sections/upcoming-events.blade.php b/app-modules/portal/resources/views/sections/upcoming-events.blade.php new file mode 100644 index 00000000..ffa4e95f --- /dev/null +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -0,0 +1,254 @@ +@php + $events = $this->upcomingEvents; +@endphp + +
+
+ @if ($events->isNotEmpty()) + + @endif + +
+ + Próximos eventos da comunidade He4rt + + + Eventos semanais e gratuitos de tecnologia para todos os níveis, de quem está começando agora a quem já trabalha na área. Participe de encontros, aulas, presencialmente ou online. Aprenda a programar, tire suas dúvidas e conecte-se com uma comunidade de desenvolvedores em crescimento. + + + + @if ($events->isEmpty()) +
+
+ +
+

+ Nenhum evento agendado no momento +

+

+ A comunidade He4rt está sempre criando novos eventos, aulas e encontros. Fique de olho no nosso + Discord para saber quando o próximo for anunciado. +

+ + Entrar no Discord + +
+ @else + + @endif +
+
+
\ No newline at end of file diff --git a/app-modules/portal/src/Livewire/UpcomingEventsSection.php b/app-modules/portal/src/Livewire/UpcomingEventsSection.php new file mode 100644 index 00000000..b167e0f2 --- /dev/null +++ b/app-modules/portal/src/Livewire/UpcomingEventsSection.php @@ -0,0 +1,112 @@ + + */ + #[Computed] + public function upcomingEvents(): Collection + { + return $this->fetchUpcomingEvents(); + } + + /** + * @return array + */ + #[Computed] + public function schemaOrg(): array + { + $events = $this->upcomingEvents() + ->map(function (array $item): array { + $event = $item['event']; + $occurrence = $item['occurrence']; + + return [ + '@type' => 'Event', + 'name' => $event->title, + 'description' => $event->description, + 'startDate' => $occurrence->toIso8601String(), + 'endDate' => $event->ends_at->toIso8601String(), + 'eventStatus' => 'https://schema.org/EventScheduled', + 'eventAttendanceMode' => $this->isOffline($event) + ? 'https://schema.org/OfflineEventAttendanceMode' + : 'https://schema.org/OnlineEventAttendanceMode', + 'location' => $this->isOffline($event) + ? ['@type' => 'Place', 'name' => $event->location] + : ['@type' => 'VirtualLocation', 'url' => url('/app/events/'.$event->id)], + 'url' => url('/app/events/'.$event->id), + ...($event->getFirstMediaUrl('cover') ? ['image' => $event->getFirstMediaUrl('cover')] : []), + ]; + }) + ->values(); + + return [ + '@context' => 'https://schema.org', + '@type' => 'ItemList', + 'itemListElement' => $events + ->map(fn (array $event, int $index): array => [ + '@type' => 'ListItem', + 'position' => $index + 1, + 'item' => $event, + ]) + ->all(), + ]; + } + + public function render(): View + { + return view('portal::sections.upcoming-events'); + } + + /** + * @return Collection + */ + private function activeEvents(): Collection + { + $query = fn (): Collection => Event::query() + ->published() + ->upcoming() + ->orderBy('starts_at') + ->get(); + + if (!app()->isProduction()) { + return $query(); + } + + return Cache::remember('portal:upcoming-events', now()->addHour(), $query); + } + + /** + * @return Collection + */ + private function fetchUpcomingEvents(): Collection + { + return $this->activeEvents() + ->map(fn (Event $event): array => [ + 'event' => $event, + 'occurrence' => $event->starts_at, + ]); + } + + private function isOffline(Event $event): bool + { + if ($event->location === null) { + return false; + } + + return !str_contains(mb_strtolower((string) $event->location), 'online'); + } +} diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 2bcdc9d3..a372c9c0 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -8,6 +8,7 @@ use He4rt\Portal\Livewire\HeroSection; use He4rt\Portal\Livewire\Homepage; use He4rt\Portal\Livewire\SocialLinksPage; +use He4rt\Portal\Livewire\UpcomingEventsSection; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; @@ -23,5 +24,6 @@ public function boot(): void Route::get('/comunidade/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective'); Livewire::component('hero-section', HeroSection::class); + Livewire::component('upcoming-events-section', UpcomingEventsSection::class); } } diff --git a/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php new file mode 100644 index 00000000..a30d1041 --- /dev/null +++ b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php @@ -0,0 +1,149 @@ +withoutVite(); + app()->setLocale('pt_BR'); +}); + +it('exibe apenas eventos publicados com data futura, ordenados por início', function (): void { + Event::factory()->published()->create([ + 'title' => 'He4rt Meetup #42', + 'location' => 'Sede He4rt — Sala 1', + ]); + + Event::factory()->published()->create([ + 'title' => 'Workshop: Rust do Zero ao Deploy', + 'location' => 'Online — Discord He4rt', + ]); + + Event::factory()->create([ + 'title' => 'Bootcamp Laravel', + ]); + + Event::factory()->published()->past()->create([ + 'title' => 'Live: PHP 8.5', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('He4rt Meetup #42') + ->assertSee('Workshop: Rust do Zero ao Deploy') + ->assertDontSee('Bootcamp Laravel') + ->assertDontSee('Live: PHP 8.5'); +}); + +it('ordena os eventos pela data de início', function (): void { + Event::factory()->published()->create([ + 'title' => 'Workshop: Docker para Desenvolvedores', + 'starts_at' => now()->addDays(20)->setTime(9, 0), + 'ends_at' => now()->addDays(20)->setTime(18, 0), + ]); + + Event::factory()->published()->create([ + 'title' => 'He4rt Conf 2026', + 'starts_at' => now()->addDays(5)->setTime(9, 0), + 'ends_at' => now()->addDays(5)->setTime(18, 0), + ]); + + $component = livewire(UpcomingEventsSection::class); + + /** @var Collection $events */ + $events = $component->get('upcomingEvents'); + + expect($events)->toHaveCount(2) + ->and($events->pluck('event.title')->values()->all())->toBe(['He4rt Conf 2026', 'Workshop: Docker para Desenvolvedores']); +}); + +it('renderiza a mensagem de fallback quando não há eventos futuros', function (): void { + Event::factory()->published()->past()->create(); + + livewire(UpcomingEventsSection::class) + ->assertSee('Nenhum evento agendado no momento') + ->assertDontSee('events-carousel'); +}); + +it('renderiza badge de data e placeholder He4rt quando o evento não tem capa', function (): void { + Event::factory()->published()->create([ + 'title' => 'Workshop: Rust do Zero ao Deploy', + 'location' => null, + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Workshop: Rust do Zero ao Deploy') + ->assertSee('landingLogo.svg', escape: false) + ->assertSee('Online') + ->assertSee('id="agenda"', escape: false); +}); + +it('exibe badge Presencial quando o evento tem local presencial', function (): void { + Event::factory()->published()->create([ + 'title' => 'He4rt Meetup #42', + 'location' => 'Sede He4rt — Sala 1', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Presencial') + ->assertDontSee('Online'); +}); + +it('exibe badge Online quando o local é online', function (): void { + Event::factory()->published()->create([ + 'title' => 'Workshop: Rust do Zero ao Deploy', + 'location' => 'Online — Discord He4rt', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Online') + ->assertDontSee('Presencial'); +}); + +it('aponta o botão Participar para o Discord da comunidade', function (): void { + Event::factory()->published()->create([ + 'title' => 'He4rt Conf 2026', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('href="https://discord.gg/he4rt"', escape: false) + ->assertSee('Participar'); +}); + +it('inclui dados estruturados JSON-LD na home quando existem eventos', function (): void { + Event::factory()->published()->create([ + 'title' => 'He4rt Meetup #42', + ]); + + get('/') + ->assertOk() + ->assertSee('application/ld+json', escape: false) + ->assertSee('He4rt Meetup #42'); +}); + +it('renderiza a capa do evento no card com atributos de SEO', function (): void { + Storage::fake('public'); + + $event = Event::factory()->published()->create([ + 'title' => 'Hacktoberfest 2026', + ]); + + $event->addMediaFromString('fake cover bytes') + ->usingFileName('cover.png') + ->usingName('Hacktoberfest 2026') + ->toMediaCollection('cover'); + + livewire(UpcomingEventsSection::class) + ->assertSee('Capa do evento Hacktoberfest 2026') + ->assertSee('loading="lazy"', escape: false) + ->assertSee('fetchpriority="low"', escape: false) + ->assertSee('assertSee('"image"', escape: false) + ->assertSee('"url"', escape: false); +});