From 33c14efa35df5236a5a1e313a0984171d5c2ecd9 Mon Sep 17 00:00:00 2001 From: fernanduandrade Date: Sat, 15 Aug 2026 13:06:00 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(events):=20adicionar=20se=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20pr=C3=B3ximos=20eventos=20na=20landing=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app-modules/events/src/Event/Models/Event.php | 12 +- .../events/src/EventsServiceProvider.php | 12 +- .../views/components/headline.blade.php | 5 +- app-modules/panel-admin/lang/en/events.php | 1 + app-modules/panel-admin/lang/pt_BR/events.php | 1 + .../Resources/Events/Schemas/EventForm.php | 8 + .../portal/resources/views/homepage.blade.php | 1 + .../views/sections/upcoming-events.blade.php | 222 ++++++++++++++++++ .../src/Livewire/UpcomingEventsSection.php | 112 +++++++++ .../portal/src/PortalServiceProvider.php | 2 + .../Feature/UpcomingEventsSectionTest.php | 149 ++++++++++++ 11 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 app-modules/portal/resources/views/sections/upcoming-events.blade.php create mode 100644 app-modules/portal/src/Livewire/UpcomingEventsSection.php create mode 100644 app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php index 53d94b7ab..f7c14a5c8 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 55745965b..5ee19bd5b 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 a089a5ab2..30856f9d8 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 667fa3507..fedcc6f9a 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 fff2a789f..6dfdb84c5 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 f84c45d02..4e55e115f 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 0b1d7397c..c9d849c8a 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 000000000..e8a4d1cf6 --- /dev/null +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -0,0 +1,222 @@ +@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 000000000..b167e0f20 --- /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 2bcdc9d33..a372c9c01 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 000000000..1e7229c5e --- /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 a página pública do evento', function (): void { + $event = Event::factory()->published()->create([ + 'title' => 'He4rt Conf 2026', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('/app/events/'.$event->id, 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); +}); From f4745cc59ddcdbd50f62deb8d369a766a9b150b5 Mon Sep 17 00:00:00 2001 From: fernanduandrade Date: Sat, 15 Aug 2026 13:25:33 -0300 Subject: [PATCH 2/3] feat(events): adicionar share button --- .../views/sections/upcoming-events.blade.php | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app-modules/portal/resources/views/sections/upcoming-events.blade.php b/app-modules/portal/resources/views/sections/upcoming-events.blade.php index e8a4d1cf6..9e9103696 100644 --- a/app-modules/portal/resources/views/sections/upcoming-events.blade.php +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -71,7 +71,6 @@ class="events-carousel relative w-full max-w-6xl" this.moved = false; this.startX = e.clientX; this.startLeft = $refs.track.scrollLeft; - $refs.track.setPointerCapture?.(e.pointerId); }, move(e) { if (!this.dragging) return; @@ -125,6 +124,27 @@ class="flex items-stretch gap-6 overflow-x-auto px-1 py-3 scroll-auto cursor-gra
title, + $event->description, + $event->location, + $occurrence->translatedFormat('d M Y H:i'), + ])) . "\n\nSaiba mais -> " . url('/'), + ]), + copied: false, + share() { + if (navigator.share) { + navigator.share(this.shareData).catch(() => {}); + } else if (navigator.clipboard) { + navigator.clipboard.writeText(this.shareData.text); + this.copied = true; + setTimeout(() => (this.copied = false), 2000); + } + }, + }" >
@@ -163,6 +183,17 @@ class="absolute left-4 top-4 rounded-full bg-primary px-3 py-1 text-[10px] font- {{ $offline ? 'Presencial' : 'Online' }} + +