From ba1022dbe7f713a2681703fec9f96cbf0f609c84 Mon Sep 17 00:00:00 2001 From: fernanduandrade Date: Thu, 13 Aug 2026 07:56:47 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(up=20coming=20events):=20adicionar=20s?= =?UTF-8?q?e=C3=A7=C3=A3o=20de=20pr=C3=B3ximos=20eventos=20da=20comunidade?= =?UTF-8?q?=20na=20landing=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../factories/UpcomingEventFactory.php | 40 ++++++ ...12_000001_create_upcoming_events_table.php | 36 ++++++ .../src/CommunityServiceProvider.php | 6 + .../Enums/UpcomingEventCategory.php | 31 +++++ .../UpcomingEvent/Models/UpcomingEvent.php | 97 +++++++++++++++ .../tests/Unit/UpcomingEventTest.php | 64 ++++++++++ .../he4rt/resources/css/components/card.css | 12 ++ .../resources/views/components/card.blade.php | 7 ++ .../views/components/headline.blade.php | 5 +- app-modules/panel-admin/lang/en/agenda.php | 49 ++++++++ app-modules/panel-admin/lang/pt_BR/agenda.php | 49 ++++++++ .../panel-admin/src/Agenda/AgendaCluster.php | 30 +++++ .../Resources/UpcomingEventResource.php | 74 +++++++++++ .../Pages/CreateUpcomingEvent.php | 13 ++ .../Pages/EditUpcomingEvent.php | 25 ++++ .../Pages/ListUpcomingEvents.php | 24 ++++ .../Schemas/UpcomingEventForm.php | 114 +++++++++++++++++ .../Tables/UpcomingEventsTable.php | 70 +++++++++++ .../src/PanelAdminServiceProvider.php | 19 +++ .../portal/resources/views/homepage.blade.php | 1 + .../views/sections/upcoming-events.blade.php | 106 ++++++++++++++++ .../src/Livewire/UpcomingEventsSection.php | 101 +++++++++++++++ .../portal/src/PortalServiceProvider.php | 2 + .../Feature/UpcomingEventsSectionTest.php | 117 ++++++++++++++++++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/UpcomingEventSeeder.php | 66 ++++++++++ package-lock.json | 7 +- tests/Feature/LoginFlowProbeTest.php | 54 ++++++++ 28 files changed, 1217 insertions(+), 3 deletions(-) create mode 100644 app-modules/community/database/factories/UpcomingEventFactory.php create mode 100644 app-modules/community/database/migrations/2026_08_12_000001_create_upcoming_events_table.php create mode 100644 app-modules/community/src/UpcomingEvent/Enums/UpcomingEventCategory.php create mode 100644 app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php create mode 100644 app-modules/community/tests/Unit/UpcomingEventTest.php create mode 100644 app-modules/panel-admin/lang/en/agenda.php create mode 100644 app-modules/panel-admin/lang/pt_BR/agenda.php create mode 100644 app-modules/panel-admin/src/Agenda/AgendaCluster.php create mode 100644 app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource.php create mode 100644 app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/CreateUpcomingEvent.php create mode 100644 app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/EditUpcomingEvent.php create mode 100644 app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/ListUpcomingEvents.php create mode 100644 app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php create mode 100644 app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php 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 create mode 100644 database/seeders/UpcomingEventSeeder.php create mode 100644 tests/Feature/LoginFlowProbeTest.php diff --git a/app-modules/community/database/factories/UpcomingEventFactory.php b/app-modules/community/database/factories/UpcomingEventFactory.php new file mode 100644 index 000000000..d2a580a60 --- /dev/null +++ b/app-modules/community/database/factories/UpcomingEventFactory.php @@ -0,0 +1,40 @@ + + */ +final class UpcomingEventFactory extends Factory +{ + protected $model = UpcomingEvent::class; + + public function definition(): array + { + return [ + 'title' => fake()->words(3, asText: true), + 'description' => fake()->sentence(), + 'category' => UpcomingEventCategory::ReuniaoSemanal, + 'week_day' => fake()->numberBetween(0, 6), + 'time' => fake()->time('H:i'), + 'is_active' => true, + 'skip_next_occurrence' => false, + 'sort_order' => 0, + ]; + } + + public function oneOff(): self + { + return $this->state(fn () => [ + 'week_day' => null, + 'time' => null, + 'event_at' => now()->addDays(fake()->numberBetween(1, 30)), + ]); + } +} diff --git a/app-modules/community/database/migrations/2026_08_12_000001_create_upcoming_events_table.php b/app-modules/community/database/migrations/2026_08_12_000001_create_upcoming_events_table.php new file mode 100644 index 000000000..f122efd09 --- /dev/null +++ b/app-modules/community/database/migrations/2026_08_12_000001_create_upcoming_events_table.php @@ -0,0 +1,36 @@ +uuid('id')->primary(); + $table->string('title', 200); + $table->text('description')->nullable(); + $table->string('category', 30); + $table->unsignedTinyInteger('week_day')->nullable(); + $table->time('time')->nullable(); + $table->timestampTz('event_at')->nullable(); + $table->string('location', 255)->nullable(); + $table->string('external_url', 255)->nullable(); + $table->boolean('is_active')->default(value: true); + $table->boolean('skip_next_occurrence')->default(value: false); + $table->unsignedInteger('sort_order')->default(0); + $table->timestampsTz(); + + $table->index(['is_active', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('upcoming_events'); + } +}; diff --git a/app-modules/community/src/CommunityServiceProvider.php b/app-modules/community/src/CommunityServiceProvider.php index c01dfc98b..9cd48af09 100644 --- a/app-modules/community/src/CommunityServiceProvider.php +++ b/app-modules/community/src/CommunityServiceProvider.php @@ -4,6 +4,8 @@ namespace He4rt\Community; +use He4rt\Community\UpcomingEvent\Models\UpcomingEvent; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; class CommunityServiceProvider extends ServiceProvider @@ -13,5 +15,9 @@ public function register(): void {} public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + + Relation::morphMap([ + 'upcoming_event' => UpcomingEvent::class, + ]); } } diff --git a/app-modules/community/src/UpcomingEvent/Enums/UpcomingEventCategory.php b/app-modules/community/src/UpcomingEvent/Enums/UpcomingEventCategory.php new file mode 100644 index 000000000..8f299c2e8 --- /dev/null +++ b/app-modules/community/src/UpcomingEvent/Enums/UpcomingEventCategory.php @@ -0,0 +1,31 @@ + 'Reunião Semanal', + self::Aula => 'Aula Livre', + self::AulaIngles => 'Aula de Inglês', + self::Onboarding => 'Onboarding', + self::Networking => 'Networking', + }; + } +} diff --git a/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php new file mode 100644 index 000000000..479ce7937 --- /dev/null +++ b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php @@ -0,0 +1,97 @@ + */ + use HasFactory; + use HasUuids; + use InteractsWithMedia; + + public function registerMediaCollections(): void + { + $this->addMediaCollection('cover') + ->singleFile() + ->useDisk('public'); + } + + public function nextOccurrence(): ?CarbonInterface + { + if ($this->event_at instanceof CarbonInterface) { + return $this->event_at; + } + + if ($this->week_day === null || $this->time === null) { + return null; + } + + $occurrence = $this->resolveRecurringOccurrence(); + + if ($this->skip_next_occurrence) { + return $occurrence->addWeek(); + } + + return $occurrence; + } + + protected static function newFactory(): UpcomingEventFactory + { + return UpcomingEventFactory::new(); + } + + protected function casts(): array + { + return [ + 'category' => UpcomingEventCategory::class, + 'week_day' => 'integer', + 'is_active' => 'boolean', + 'skip_next_occurrence' => 'boolean', + 'event_at' => 'datetime', + 'sort_order' => 'integer', + ]; + } + + private function resolveRecurringOccurrence(): CarbonInterface + { + $weekDay = (int) $this->week_day; + $time = (string) $this->time; + + $now = now(); + + $base = $now->dayOfWeek === $weekDay && $now->format('H:i') < $time + ? $now + : $now->copy()->next($weekDay); + + return $base->setTimeFromTimeString($time); + } +} diff --git a/app-modules/community/tests/Unit/UpcomingEventTest.php b/app-modules/community/tests/Unit/UpcomingEventTest.php new file mode 100644 index 000000000..14b2cda07 --- /dev/null +++ b/app-modules/community/tests/Unit/UpcomingEventTest.php @@ -0,0 +1,64 @@ +create([ + 'week_day' => 3, + 'time' => '19:00', + ]); + + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-12 19:00:00'); +}); + +it('avança para a próxima semana quando a ocorrência do dia já passou', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 20:00:00')); + + $event = UpcomingEvent::factory()->create([ + 'week_day' => 3, + 'time' => '19:00', + ]); + + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-19 19:00:00'); +}); + +it('retorna o próximo dia da semana para eventos recorrentes', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 10:00:00')); + + $event = UpcomingEvent::factory()->create([ + 'week_day' => 1, + 'time' => '21:00', + ]); + + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-17 21:00:00'); +}); + +it('pula uma semana quando skip_next_occurrence está ativo', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 10:00:00')); + + $event = UpcomingEvent::factory()->create([ + 'week_day' => 3, + 'time' => '19:00', + 'skip_next_occurrence' => true, + ]); + + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-19 19:00:00'); +}); + +it('usa event_at para eventos pontuais', function (): void { + $event = UpcomingEvent::factory()->oneOff()->create([ + 'category' => UpcomingEventCategory::Networking, + 'event_at' => CarbonImmutable::parse('2026-08-28 20:00:00'), + ]); + + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-28 20:00:00'); +}); diff --git a/app-modules/he4rt/resources/css/components/card.css b/app-modules/he4rt/resources/css/components/card.css index 31c20a2a0..8c86e527f 100644 --- a/app-modules/he4rt/resources/css/components/card.css +++ b/app-modules/he4rt/resources/css/components/card.css @@ -17,6 +17,18 @@ @apply hover:border-primary transition-all duration-500 hover:scale-[1.02]; } +.hp-card-cover { + @apply -mx-6 -mt-6 mb-1 overflow-hidden; + @apply rounded-t-lg; + @apply aspect-[3/1]; + @apply bg-elevation-surface/32; +} + +.hp-card-cover img { + @apply h-full w-full; + @apply object-cover; +} + .hp-card-icon { @apply flex w-full items-start justify-start; } diff --git a/app-modules/he4rt/resources/views/components/card.blade.php b/app-modules/he4rt/resources/views/components/card.blade.php index dff796657..4d1a34e88 100644 --- a/app-modules/he4rt/resources/views/components/card.blade.php +++ b/app-modules/he4rt/resources/views/components/card.blade.php @@ -44,6 +44,13 @@ @endphp <{{ $tag }} {{ $attributes->merge(['class' => $classes])->merge($linkAttrs) }}> + {{-- Slot da Capa --}} + @isset($cover) +
attributes->class('hp-card-cover') }}> + {{ $cover }} +
+ @endisset + {{-- Slot do Ícone --}} @isset($icon)
attributes->class('hp-card-icon') }}> 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/agenda.php b/app-modules/panel-admin/lang/en/agenda.php new file mode 100644 index 000000000..dd5bde8b2 --- /dev/null +++ b/app-modules/panel-admin/lang/en/agenda.php @@ -0,0 +1,49 @@ + [ + 'cluster' => 'Agenda', + 'cluster_breadcrumb' => 'Agenda', + 'back_to_admin' => 'Back to Admin', + 'group' => 'Agenda', + 'upcoming_events' => 'Upcoming Events', + ], + 'resource' => [ + 'label' => 'Event', + 'plural' => 'Events', + ], + 'form' => [ + 'title' => 'Title', + 'description' => 'Description', + 'category' => 'Category', + 'cover' => 'Cover image', + 'cover_hint' => 'Image shown on top of the event card on the landing page. Landscape format recommended.', + 'week_day' => 'Week day', + 'time' => 'Time', + 'event_at' => 'Event date', + 'location' => 'Location', + 'external_url' => 'External link', + 'is_active' => 'Show on landing', + 'skip_next_occurrence' => 'Skip next occurrence', + 'section_recurring' => 'Recurrence', + 'section_event' => 'Event details', + 'week_day_hint' => 'For weekly recurring events.', + 'time_hint' => 'Start time.', + 'event_at_hint' => 'For one-off events (e.g. pub meetup).', + 'skip_hint' => 'Hides only the next occurrence, without disabling the event.', + ], + 'table' => [ + 'next_occurrence' => 'Next occurrence', + ], + 'weekdays' => [ + 0 => 'Sunday', + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + ], +]; diff --git a/app-modules/panel-admin/lang/pt_BR/agenda.php b/app-modules/panel-admin/lang/pt_BR/agenda.php new file mode 100644 index 000000000..394d32b14 --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/agenda.php @@ -0,0 +1,49 @@ + [ + 'cluster' => 'Agenda', + 'cluster_breadcrumb' => 'Agenda', + 'back_to_admin' => 'Voltar pro Admin', + 'group' => 'Agenda', + 'upcoming_events' => 'Próximos Eventos', + ], + 'resource' => [ + 'label' => 'Evento', + 'plural' => 'Eventos', + ], + 'form' => [ + 'title' => 'Título', + 'description' => 'Descrição', + 'category' => 'Categoria', + 'cover' => 'Imagem de capa', + 'cover_hint' => 'Imagem exibida no topo do card do evento na landing. Formato paisagem recomendado.', + 'week_day' => 'Dia da semana', + 'time' => 'Horário', + 'event_at' => 'Data do evento', + 'location' => 'Local', + 'external_url' => 'Link externo', + 'is_active' => 'Exibir na landing', + 'skip_next_occurrence' => 'Ocultar próxima ocorrência', + 'section_recurring' => 'Recorrência', + 'section_event' => 'Detalhes do evento', + 'week_day_hint' => 'Para eventos recorrentes semanais.', + 'time_hint' => 'Horário de início.', + 'event_at_hint' => 'Para eventos pontuais (ex.: encontro de pub).', + 'skip_hint' => 'Oculta apenas a próxima ocorrência, sem desativar o evento.', + ], + 'table' => [ + 'next_occurrence' => 'Próxima ocorrência', + ], + 'weekdays' => [ + 0 => 'Domingo', + 1 => 'Segunda', + 2 => 'Terça', + 3 => 'Quarta', + 4 => 'Quinta', + 5 => 'Sexta', + 6 => 'Sábado', + ], +]; diff --git a/app-modules/panel-admin/src/Agenda/AgendaCluster.php b/app-modules/panel-admin/src/Agenda/AgendaCluster.php new file mode 100644 index 000000000..5266cc15b --- /dev/null +++ b/app-modules/panel-admin/src/Agenda/AgendaCluster.php @@ -0,0 +1,30 @@ + + */ + public static function getPages(): array + { + return [ + 'index' => ListUpcomingEvents::route('/'), + 'create' => CreateUpcomingEvent::route('/create'), + 'edit' => EditUpcomingEvent::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/CreateUpcomingEvent.php b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/CreateUpcomingEvent.php new file mode 100644 index 000000000..b53b8774c --- /dev/null +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/CreateUpcomingEvent.php @@ -0,0 +1,13 @@ +components([ + Section::make() + ->schema([ + TextInput::make('title') + ->label(__('panel-admin::agenda.form.title')) + ->required() + ->maxLength(200), + + Textarea::make('description') + ->label(__('panel-admin::agenda.form.description')) + ->rows(3), + + Select::make('category') + ->label(__('panel-admin::agenda.form.category')) + ->options(UpcomingEventCategory::class) + ->required(), + ]) + ->columns(2), + + Section::make(__('panel-admin::agenda.form.cover')) + ->schema([ + SpatieMediaLibraryFileUpload::make('cover') + ->collection('cover') + ->label(__('panel-admin::agenda.form.cover')) + ->hint(__('panel-admin::agenda.form.cover_hint')) + ->image() + ->imageEditor() + ->panelAspectRatio('3:1') + ->imageAspectRatio('3:1') + ->imageEditorAspectRatioOptions(['3:1', '16:9']) + ->imageResizeMode('cover') + ->imageResizeTargetWidth('1200') + ->imageResizeTargetHeight('400') + ->maxSize(4_096), + ]), + + Section::make(__('panel-admin::agenda.form.section_recurring')) + ->description(__('panel-admin::agenda.form.week_day_hint')) + ->schema([ + Select::make('week_day') + ->label(__('panel-admin::agenda.form.week_day')) + ->options(collect(range(0, 6))->mapWithKeys( + fn (int $day) => [$day => __('panel-admin::agenda.weekdays.'.$day)] + )) + ->requiredWithout('event_at'), + + TimePicker::make('time') + ->label(__('panel-admin::agenda.form.time')) + ->hint(__('panel-admin::agenda.form.time_hint')) + ->native(condition: false) + ->seconds(condition: false) + ->format('H:i') + ->requiredWithout('event_at'), + ]) + ->columns(2) + ->collapsible(), + + Section::make(__('panel-admin::agenda.form.section_event')) + ->schema([ + DateTimePicker::make('event_at') + ->label(__('panel-admin::agenda.form.event_at')) + ->hint(__('panel-admin::agenda.form.event_at_hint')) + ->native(condition: false) + ->seconds(condition: false) + ->requiredWithout('week_day'), + + TextInput::make('location') + ->label(__('panel-admin::agenda.form.location')) + ->maxLength(255), + + TextInput::make('external_url') + ->label(__('panel-admin::agenda.form.external_url')) + ->url() + ->maxLength(255), + ]) + ->columns(2) + ->collapsible(), + + Section::make() + ->schema([ + Toggle::make('is_active') + ->label(__('panel-admin::agenda.form.is_active')) + ->default(state: true), + + Toggle::make('skip_next_occurrence') + ->label(__('panel-admin::agenda.form.skip_next_occurrence')) + ->hint(__('panel-admin::agenda.form.skip_hint')), + ]) + ->columns(2), + ]); + } +} diff --git a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php new file mode 100644 index 000000000..ec2493d13 --- /dev/null +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php @@ -0,0 +1,70 @@ +defaultSort('sort_order') + ->columns([ + TextColumn::make('title') + ->label(__('panel-admin::agenda.form.title')) + ->searchable() + ->sortable(), + + TextColumn::make('category') + ->label(__('panel-admin::agenda.form.category')) + ->badge() + ->formatStateUsing(fn (UpcomingEventCategory $state): string => $state->getLabel()) + ->color(fn (UpcomingEventCategory $state): string => match ($state) { + UpcomingEventCategory::ReuniaoSemanal => 'primary', + UpcomingEventCategory::Aula => 'info', + UpcomingEventCategory::AulaIngles => 'success', + UpcomingEventCategory::Onboarding => 'warning', + UpcomingEventCategory::Networking => 'danger', + }), + + TextColumn::make('week_day') + ->label(__('panel-admin::agenda.form.week_day')) + ->formatStateUsing(fn (?int $state): string => $state === null + ? '-' + : __('panel-admin::agenda.weekdays.'.$state)), + + TextColumn::make('time') + ->label(__('panel-admin::agenda.form.time')) + ->formatStateUsing(fn (?string $state): string => $state ?? '-'), + + TextColumn::make('next_occurrence') + ->label(__('panel-admin::agenda.table.next_occurrence')) + ->state(fn (UpcomingEvent $record): ?CarbonInterface => $record->nextOccurrence()) + ->dateTime('d/m/Y H:i'), + + TextColumn::make('sort_order') + ->label('Sort') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + IconColumn::make('is_active') + ->label(__('panel-admin::agenda.form.is_active')) + ->boolean() + ->sortable(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make(), + ]); + } +} diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index c379aa647..5d84d6414 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -7,6 +7,7 @@ use Filament\Navigation\NavigationBuilder; use Filament\Navigation\NavigationItem; use Filament\Panel; +use He4rt\PanelAdmin\Agenda\AgendaCluster; use He4rt\PanelAdmin\Discord\DiscordCluster; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; use He4rt\PanelAdmin\Github\GithubCluster; @@ -38,11 +39,16 @@ public function register(): void TwitchCluster::class, GithubCluster::class, DiscordCluster::class, + AgendaCluster::class, ]) ->navigation($this->buildNavigation(...)) ->resources([ ExternalIdentityResource::class, ]) + ->discoverResources( + in: __DIR__.'/Agenda/Resources', + for: 'He4rt\\PanelAdmin\\Agenda\\Resources', + ) ->discoverResources( in: __DIR__.'/Moderation/Resources', for: 'He4rt\\PanelAdmin\\Moderation\\Resources', @@ -111,6 +117,7 @@ private function buildNavigation(NavigationBuilder $builder): NavigationBuilder $requestPath->contains('marketing/') => $this->marketingNavigation($builder), $requestPath->contains('twitch/') => $this->twitchNavigation($builder), $requestPath->contains('discord/') => $this->discordNavigation($builder), + $requestPath->contains('agenda/') => $this->agendaNavigation($builder), default => $this->defaultNavigation($builder), }; @@ -126,6 +133,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ...GithubCluster::getNavigationItems(), ...ExternalIdentityResource::getNavigationItems(), ...DiscordCluster::getNavigationItems(), + ...AgendaCluster::getNavigationItems(), ]); } @@ -172,4 +180,15 @@ private function twitchNavigation(NavigationBuilder $builder): NavigationBuilder ])->groups(resolve(TwitchCluster::class)->getCachedSubNavigation()); } + + private function agendaNavigation(NavigationBuilder $builder): NavigationBuilder + { + return $builder->items([ + NavigationItem::make(__('panel-admin::agenda.navigation.back_to_admin')) + ->sort(0) + ->icon('heroicon-o-arrow-left') + ->url(Dashboard::getUrl()), + + ])->groups(resolve(AgendaCluster::class)->getCachedSubNavigation()); + } } 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..4e6fc9e2f --- /dev/null +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -0,0 +1,106 @@ +@php + $events = $this->upcomingEvents; +@endphp + +
+ @if ($events->isNotEmpty()) +
+ + +
+ + Próximos eventos da comunidade He4rt + + + Reuniões semanais, aulas e encontros gratuitos e abertos para quem está começando ou já + atua na área. Participe ao vivo, aprenda programação, tire suas dúvidas e faça networking + com outros desenvolvedores — presencial ou online. + + + +
+ @foreach ($events as $item) + @php + $event = $item['event']; + $occurrence = $item['occurrence']; + $cover = $event->getFirstMedia('cover'); + @endphp + + + @if ($cover) + +
+ Capa do evento {{ $event->title }}width) width="{{ $cover->width }}" @endif + @if ($cover->height) height="{{ $cover->height }}" @endif + loading="lazy" + fetchpriority="low" + class="h-full w-full object-cover opacity-100 transition-opacity duration-1000 ease-in" + :class="shown ? 'opacity-100' : 'opacity-0'" + /> +
+
+ @endif + + + + + {{ $event->title }} + + + + + @if ($event->description) + {{ $event->description }} + @endif + + + + + {{ $event->category->getLabel() }} + + + @if ($event->location) + + + {{ $event->location }} + + @endif + + + + + Participar + + +
+ @endforeach +
+
+
+ @endif +
diff --git a/app-modules/portal/src/Livewire/UpcomingEventsSection.php b/app-modules/portal/src/Livewire/UpcomingEventsSection.php new file mode 100644 index 000000000..49cc6f13a --- /dev/null +++ b/app-modules/portal/src/Livewire/UpcomingEventsSection.php @@ -0,0 +1,101 @@ + + */ + #[Computed] + public function upcomingEvents(): Collection + { + if (!app()->isProduction()) { + return $this->fetchUpcomingEvents(); + } + + return Cache::remember('portal:upcoming-events', now()->addHour(), fn () => $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(), + 'eventStatus' => 'https://schema.org/EventScheduled', + 'eventAttendanceMode' => $event->location + ? 'https://schema.org/OfflineEventAttendanceMode' + : 'https://schema.org/OnlineEventAttendanceMode', + 'location' => $event->location + ? ['@type' => 'Place', 'name' => $event->location] + : ['@type' => 'VirtualLocation', 'url' => $event->external_url ?? 'https://discord.gg/he4rt'], + 'url' => $event->external_url, + ...($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 fetchUpcomingEvents(): Collection + { + $events = []; + + foreach (UpcomingEvent::query()->where('is_active', operator: true)->orderBy('sort_order')->get() as $event) { + $occurrence = $event->nextOccurrence(); + if ($occurrence === null) { + continue; + } + + if ($occurrence->isPast()) { + continue; + } + + $events[] = ['event' => $event, 'occurrence' => $occurrence]; + } + + usort($events, static fn (array $a, array $b): int => $a['occurrence']->getTimestamp() <=> $b['occurrence']->getTimestamp()); + + return collect($events); + } +} 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..73e2f2b39 --- /dev/null +++ b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php @@ -0,0 +1,117 @@ +withoutVite(); +}); + +it('exibe apenas eventos ativos com próxima ocorrência futura, ordenados por data', function (): void { + UpcomingEvent::factory()->create([ + 'title' => 'Reunião Semanal', + 'category' => UpcomingEventCategory::ReuniaoSemanal, + 'week_day' => 1, + 'time' => '21:00', + ]); + + UpcomingEvent::factory()->oneOff()->create([ + 'title' => 'Encontro de Pub', + 'category' => UpcomingEventCategory::Networking, + 'event_at' => CarbonImmutable::now()->addDays(10), + 'location' => 'Pub', + ]); + + UpcomingEvent::factory()->create([ + 'title' => 'Evento Inativo', + 'is_active' => false, + ]); + + UpcomingEvent::factory()->oneOff()->create([ + 'title' => 'Evento Passado', + 'event_at' => CarbonImmutable::now()->subDay(), + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Reunião Semanal') + ->assertSee('Encontro de Pub') + ->assertDontSee('Evento Inativo') + ->assertDontSee('Evento Passado'); +}); + +it('ordena os eventos pela próxima ocorrência', function (): void { + UpcomingEvent::factory()->oneOff()->create([ + 'title' => 'Encontro de Pub', + 'category' => UpcomingEventCategory::Networking, + 'event_at' => CarbonImmutable::now()->addDays(20), + ]); + + UpcomingEvent::factory()->create([ + 'title' => 'Aula de Inglês', + 'category' => UpcomingEventCategory::AulaIngles, + 'week_day' => 6, + 'time' => '15:00', + ]); + + $component = livewire(UpcomingEventsSection::class); + + /** @var Collection $events */ + $events = $component->get('upcomingEvents'); + + expect($events)->toHaveCount(2) + ->and($events->pluck('event.title')->values()->all())->toBe(['Aula de Inglês', 'Encontro de Pub']); +}); + +it('não renderiza a seção quando não há eventos futuros', function (): void { + UpcomingEvent::factory()->oneOff()->create([ + 'event_at' => CarbonImmutable::now()->subDay(), + ]); + + livewire(UpcomingEventsSection::class) + ->assertDontSee('Próximos eventos da comunidade'); +}); + +it('inclui dados estruturados JSON-LD na home quando existem eventos', function (): void { + UpcomingEvent::factory()->create([ + 'title' => 'Reunião Semanal', + 'week_day' => 1, + 'time' => '21:00', + ]); + + get('/') + ->assertOk() + ->assertSee('application/ld+json', escape: false) + ->assertSee('Reunião Semanal'); +}); + +it('renderiza a capa do evento no card com atributos de SEO', function (): void { + Storage::fake('public'); + + $event = UpcomingEvent::factory()->create([ + 'title' => 'Hacktoberfest 2026', + 'week_day' => 3, + 'time' => '20:00', + ]); + + $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); +}); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0f56a1814..c8fd9c10a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ public function run(): void { $this->call([ BaseSeeder::class, + UpcomingEventSeeder::class, ]); } } diff --git a/database/seeders/UpcomingEventSeeder.php b/database/seeders/UpcomingEventSeeder.php new file mode 100644 index 000000000..ccc7ba928 --- /dev/null +++ b/database/seeders/UpcomingEventSeeder.php @@ -0,0 +1,66 @@ + 'Reunião Semanal', + 'description' => 'Encontro semanal da comunidade para tirar dúvidas, apresentar projetos e trocar ideias.', + 'category' => UpcomingEventCategory::ReuniaoSemanal, + 'week_day' => 1, + 'time' => '21:00', + 'sort_order' => 1, + ], + [ + 'title' => 'Aula Livre', + 'description' => 'Aula aberta toda quarta-feira sobre temas escolhidos pela comunidade.', + 'category' => UpcomingEventCategory::Aula, + 'week_day' => 3, + 'time' => '19:00', + 'sort_order' => 2, + ], + [ + 'title' => 'Aula de Inglês', + 'description' => 'Aula de inglês para devs, todo sábado às 15h.', + 'category' => UpcomingEventCategory::AulaIngles, + 'week_day' => 6, + 'time' => '15:00', + 'sort_order' => 3, + ], + [ + 'title' => 'Aula de Onboarding', + 'description' => 'Boas-vindas e apresentação da comunidade para novos membros, todo início de mês.', + 'category' => UpcomingEventCategory::Onboarding, + 'event_at' => now()->addMonth()->startOfMonth()->setTime(19, 0), + 'sort_order' => 4, + ], + [ + 'title' => 'Encontro Presencial de Pub', + 'description' => 'Encontro presencial da galera para networking e resenha num pub.', + 'category' => UpcomingEventCategory::Networking, + 'event_at' => CarbonImmutable::parse('2026-08-28 20:00:00'), + 'location' => 'Pub', + 'external_url' => 'https://discord.gg/he4rt', + 'sort_order' => 5, + ], + ]; + + foreach ($events as $event) { + UpcomingEvent::query()->updateOrCreate( + ['title' => $event['title']], + $event, + ); + } + } +} diff --git a/package-lock.json b/package-lock.json index b0a3b35fd..29ed7abc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" @@ -45,6 +46,7 @@ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1566,7 +1568,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.3", @@ -1588,6 +1591,7 @@ "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -1668,6 +1672,7 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", diff --git a/tests/Feature/LoginFlowProbeTest.php b/tests/Feature/LoginFlowProbeTest.php new file mode 100644 index 000000000..7045f444d --- /dev/null +++ b/tests/Feature/LoginFlowProbeTest.php @@ -0,0 +1,54 @@ +create([ + 'email' => 'admin@admin.com', + 'password' => 'admin', + 'username' => 'danielhe4rt', + ]); + + $this->get('/admin/login')->assertOk(); + + Filament::setCurrentPanel(Filament::getPanel('admin')); + + livewire(Login::class) + ->fillForm([ + 'email' => 'admin@admin.com', + 'password' => 'admin', + ]) + ->call('authenticate') + ->assertHasNoErrors(); + + expect(auth()->check())->toBeTrue() + ->and(auth()->id())->not->toBeNull(); +}); + +test('wrong password stays on login with error', function (): void { + User::factory()->create([ + 'email' => 'admin@admin.com', + 'password' => 'admin', + 'username' => 'danielhe4rt', + ]); + + $this->get('/admin/login')->assertOk(); + + Filament::setCurrentPanel(Filament::getPanel('admin')); + + livewire(Login::class) + ->fillForm([ + 'email' => 'admin@admin.com', + 'password' => 'senha-errada', + ]) + ->call('authenticate') + ->assertHasErrors(); + + expect(auth()->check())->toBeFalse(); +}); From b40a7fbd082a68a0b4ef8e616cd94695fb0bc2f2 Mon Sep 17 00:00:00 2001 From: fernanduandrade Date: Thu, 13 Aug 2026 19:02:18 -0300 Subject: [PATCH 2/5] fix(up-coming): ajustar design dos cards --- .../factories/UpcomingEventFactory.php | 4 + ...0001_add_host_to_upcoming_events_table.php | 25 ++ .../UpcomingEvent/Models/UpcomingEvent.php | 2 + .../he4rt/resources/css/components/card.css | 12 - .../resources/views/components/card.blade.php | 7 - app-modules/panel-admin/lang/en/agenda.php | 9 + app-modules/panel-admin/lang/pt_BR/agenda.php | 9 + .../Schemas/UpcomingEventForm.php | 204 +++++++----- .../views/sections/upcoming-events.blade.php | 298 +++++++++++++----- .../Feature/UpcomingEventsSectionTest.php | 70 +++- 10 files changed, 456 insertions(+), 184 deletions(-) create mode 100644 app-modules/community/database/migrations/2026_08_13_000001_add_host_to_upcoming_events_table.php diff --git a/app-modules/community/database/factories/UpcomingEventFactory.php b/app-modules/community/database/factories/UpcomingEventFactory.php index d2a580a60..11bcb02cf 100644 --- a/app-modules/community/database/factories/UpcomingEventFactory.php +++ b/app-modules/community/database/factories/UpcomingEventFactory.php @@ -23,6 +23,10 @@ public function definition(): array 'category' => UpcomingEventCategory::ReuniaoSemanal, 'week_day' => fake()->numberBetween(0, 6), 'time' => fake()->time('H:i'), + 'location' => null, + 'external_url' => null, + 'host_name' => fake()->name(), + 'host_role' => fake()->jobTitle(), 'is_active' => true, 'skip_next_occurrence' => false, 'sort_order' => 0, diff --git a/app-modules/community/database/migrations/2026_08_13_000001_add_host_to_upcoming_events_table.php b/app-modules/community/database/migrations/2026_08_13_000001_add_host_to_upcoming_events_table.php new file mode 100644 index 000000000..d36c870a5 --- /dev/null +++ b/app-modules/community/database/migrations/2026_08_13_000001_add_host_to_upcoming_events_table.php @@ -0,0 +1,25 @@ +string('host_name', 255)->nullable()->after('external_url'); + $table->string('host_role', 255)->nullable()->after('host_name'); + }); + } + + public function down(): void + { + Schema::table('upcoming_events', static function (Blueprint $table): void { + $table->dropColumn(['host_name', 'host_role']); + }); + } +}; diff --git a/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php index 479ce7937..66439d514 100644 --- a/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php +++ b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php @@ -24,6 +24,8 @@ * @property CarbonInterface|null $event_at * @property string|null $location * @property string|null $external_url + * @property string|null $host_name + * @property string|null $host_role * @property bool $is_active * @property bool $skip_next_occurrence * @property int $sort_order diff --git a/app-modules/he4rt/resources/css/components/card.css b/app-modules/he4rt/resources/css/components/card.css index 8c86e527f..31c20a2a0 100644 --- a/app-modules/he4rt/resources/css/components/card.css +++ b/app-modules/he4rt/resources/css/components/card.css @@ -17,18 +17,6 @@ @apply hover:border-primary transition-all duration-500 hover:scale-[1.02]; } -.hp-card-cover { - @apply -mx-6 -mt-6 mb-1 overflow-hidden; - @apply rounded-t-lg; - @apply aspect-[3/1]; - @apply bg-elevation-surface/32; -} - -.hp-card-cover img { - @apply h-full w-full; - @apply object-cover; -} - .hp-card-icon { @apply flex w-full items-start justify-start; } diff --git a/app-modules/he4rt/resources/views/components/card.blade.php b/app-modules/he4rt/resources/views/components/card.blade.php index 4d1a34e88..dff796657 100644 --- a/app-modules/he4rt/resources/views/components/card.blade.php +++ b/app-modules/he4rt/resources/views/components/card.blade.php @@ -44,13 +44,6 @@ @endphp <{{ $tag }} {{ $attributes->merge(['class' => $classes])->merge($linkAttrs) }}> - {{-- Slot da Capa --}} - @isset($cover) -
attributes->class('hp-card-cover') }}> - {{ $cover }} -
- @endisset - {{-- Slot do Ícone --}} @isset($icon)
attributes->class('hp-card-icon') }}> diff --git a/app-modules/panel-admin/lang/en/agenda.php b/app-modules/panel-admin/lang/en/agenda.php index dd5bde8b2..b2e511634 100644 --- a/app-modules/panel-admin/lang/en/agenda.php +++ b/app-modules/panel-admin/lang/en/agenda.php @@ -24,11 +24,20 @@ 'time' => 'Time', 'event_at' => 'Event date', 'location' => 'Location', + 'location_hint' => 'If empty, the event is shown as online on the landing page.', 'external_url' => 'External link', + 'host_name' => 'Host name', + 'host_name_hint' => 'Shown on the event card on the landing page.', + 'host_role' => 'Host role', 'is_active' => 'Show on landing', 'skip_next_occurrence' => 'Skip next occurrence', 'section_recurring' => 'Recurrence', 'section_event' => 'Event details', + 'section_info' => 'Event information', + 'section_date_location' => 'Date & location', + 'section_date_location_hint' => 'Fill in either the weekly recurrence or the one-off event date.', + 'section_host' => 'Host', + 'section_publish' => 'Publication', 'week_day_hint' => 'For weekly recurring events.', 'time_hint' => 'Start time.', 'event_at_hint' => 'For one-off events (e.g. pub meetup).', diff --git a/app-modules/panel-admin/lang/pt_BR/agenda.php b/app-modules/panel-admin/lang/pt_BR/agenda.php index 394d32b14..32bf2a8e8 100644 --- a/app-modules/panel-admin/lang/pt_BR/agenda.php +++ b/app-modules/panel-admin/lang/pt_BR/agenda.php @@ -24,11 +24,20 @@ 'time' => 'Horário', 'event_at' => 'Data do evento', 'location' => 'Local', + 'location_hint' => 'Se vazio, o evento é exibido como online na landing.', 'external_url' => 'Link externo', + 'host_name' => 'Nome do anfitrião', + 'host_name_hint' => 'Exibido no card do evento na landing.', + 'host_role' => 'Cargo do anfitrião', 'is_active' => 'Exibir na landing', 'skip_next_occurrence' => 'Ocultar próxima ocorrência', 'section_recurring' => 'Recorrência', 'section_event' => 'Detalhes do evento', + 'section_info' => 'Informações do evento', + 'section_date_location' => 'Data e local', + 'section_date_location_hint' => 'Preencha a recorrência semanal ou a data pontual do evento.', + 'section_host' => 'Anfitrião', + 'section_publish' => 'Publicação', 'week_day_hint' => 'Para eventos recorrentes semanais.', 'time_hint' => 'Horário de início.', 'event_at_hint' => 'Para eventos pontuais (ex.: encontro de pub).', diff --git a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php index 4e725da24..cd1dd2a11 100644 --- a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php @@ -11,6 +11,8 @@ use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TimePicker; use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use He4rt\Community\UpcomingEvent\Enums\UpcomingEventCategory; @@ -21,94 +23,124 @@ public static function configure(Schema $schema): Schema { return $schema ->components([ - Section::make() + Grid::make(3) ->schema([ - TextInput::make('title') - ->label(__('panel-admin::agenda.form.title')) - ->required() - ->maxLength(200), - - Textarea::make('description') - ->label(__('panel-admin::agenda.form.description')) - ->rows(3), - - Select::make('category') - ->label(__('panel-admin::agenda.form.category')) - ->options(UpcomingEventCategory::class) - ->required(), - ]) - ->columns(2), - - Section::make(__('panel-admin::agenda.form.cover')) - ->schema([ - SpatieMediaLibraryFileUpload::make('cover') - ->collection('cover') - ->label(__('panel-admin::agenda.form.cover')) - ->hint(__('panel-admin::agenda.form.cover_hint')) - ->image() - ->imageEditor() - ->panelAspectRatio('3:1') - ->imageAspectRatio('3:1') - ->imageEditorAspectRatioOptions(['3:1', '16:9']) - ->imageResizeMode('cover') - ->imageResizeTargetWidth('1200') - ->imageResizeTargetHeight('400') - ->maxSize(4_096), - ]), + Grid::make() + ->columnSpan(2) + ->schema([ + Section::make(__('panel-admin::agenda.form.section_info')) + ->schema([ + TextInput::make('title') + ->label(__('panel-admin::agenda.form.title')) + ->required() + ->maxLength(200) + ->columnSpanFull(), - Section::make(__('panel-admin::agenda.form.section_recurring')) - ->description(__('panel-admin::agenda.form.week_day_hint')) - ->schema([ - Select::make('week_day') - ->label(__('panel-admin::agenda.form.week_day')) - ->options(collect(range(0, 6))->mapWithKeys( - fn (int $day) => [$day => __('panel-admin::agenda.weekdays.'.$day)] - )) - ->requiredWithout('event_at'), - - TimePicker::make('time') - ->label(__('panel-admin::agenda.form.time')) - ->hint(__('panel-admin::agenda.form.time_hint')) - ->native(condition: false) - ->seconds(condition: false) - ->format('H:i') - ->requiredWithout('event_at'), - ]) - ->columns(2) - ->collapsible(), - - Section::make(__('panel-admin::agenda.form.section_event')) - ->schema([ - DateTimePicker::make('event_at') - ->label(__('panel-admin::agenda.form.event_at')) - ->hint(__('panel-admin::agenda.form.event_at_hint')) - ->native(condition: false) - ->seconds(condition: false) - ->requiredWithout('week_day'), - - TextInput::make('location') - ->label(__('panel-admin::agenda.form.location')) - ->maxLength(255), - - TextInput::make('external_url') - ->label(__('panel-admin::agenda.form.external_url')) - ->url() - ->maxLength(255), - ]) - ->columns(2) - ->collapsible(), - - Section::make() - ->schema([ - Toggle::make('is_active') - ->label(__('panel-admin::agenda.form.is_active')) - ->default(state: true), - - Toggle::make('skip_next_occurrence') - ->label(__('panel-admin::agenda.form.skip_next_occurrence')) - ->hint(__('panel-admin::agenda.form.skip_hint')), - ]) - ->columns(2), + Select::make('category') + ->label(__('panel-admin::agenda.form.category')) + ->options(UpcomingEventCategory::class) + ->required(), + + Textarea::make('description') + ->label(__('panel-admin::agenda.form.description')) + ->rows(3) + ->columnSpanFull(), + ]) + ->columns(2), + + Section::make(__('panel-admin::agenda.form.section_date_location')) + ->description(__('panel-admin::agenda.form.section_date_location_hint')) + ->schema([ + Fieldset::make(__('panel-admin::agenda.form.section_recurring')) + ->schema([ + Select::make('week_day') + ->label(__('panel-admin::agenda.form.week_day')) + ->options(collect(range(0, 6))->mapWithKeys( + fn (int $day) => [$day => __('panel-admin::agenda.weekdays.'.$day)] + )) + ->requiredWithout('event_at'), + + TimePicker::make('time') + ->label(__('panel-admin::agenda.form.time')) + ->hint(__('panel-admin::agenda.form.time_hint')) + ->native(condition: false) + ->seconds(condition: false) + ->format('H:i') + ->requiredWithout('event_at'), + ]) + ->columnSpanFull() + ->columns(2), + + Fieldset::make(__('panel-admin::agenda.form.section_event')) + ->schema([ + DateTimePicker::make('event_at') + ->label(__('panel-admin::agenda.form.event_at')) + ->hint(__('panel-admin::agenda.form.event_at_hint')) + ->native(condition: false) + ->seconds(condition: false) + ->requiredWithout('week_day'), + + TextInput::make('location') + ->label(__('panel-admin::agenda.form.location')) + ->hint(__('panel-admin::agenda.form.location_hint')) + ->maxLength(255), + + TextInput::make('external_url') + ->label(__('panel-admin::agenda.form.external_url')) + ->url() + ->maxLength(255) + ->columnSpanFull(), + ]) + ->columnSpanFull() + ->columns(2), + ]) + ->columns(2), + ]), + + Grid::make() + ->columnSpan(1) + ->schema([ + Section::make(__('panel-admin::agenda.form.cover')) + ->schema([ + SpatieMediaLibraryFileUpload::make('cover') + ->collection('cover') + ->label(__('panel-admin::agenda.form.cover')) + ->hint(__('panel-admin::agenda.form.cover_hint')) + ->image() + ->imageEditor() + ->panelAspectRatio('3:1') + ->imageAspectRatio('3:1') + ->imageEditorAspectRatioOptions(['3:1', '16:9']) + ->imageResizeMode('cover') + ->imageResizeTargetWidth('1200') + ->imageResizeTargetHeight('400') + ->maxSize(4_096), + ]), + + Section::make(__('panel-admin::agenda.form.section_host')) + ->schema([ + TextInput::make('host_name') + ->label(__('panel-admin::agenda.form.host_name')) + ->hint(__('panel-admin::agenda.form.host_name_hint')) + ->maxLength(255), + + TextInput::make('host_role') + ->label(__('panel-admin::agenda.form.host_role')) + ->maxLength(255), + ]), + + Section::make(__('panel-admin::agenda.form.section_publish')) + ->schema([ + Toggle::make('is_active') + ->label(__('panel-admin::agenda.form.is_active')) + ->default(state: true), + + Toggle::make('skip_next_occurrence') + ->label(__('panel-admin::agenda.form.skip_next_occurrence')) + ->hint(__('panel-admin::agenda.form.skip_hint')), + ]), + ]), + ]), ]); } } 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 4e6fc9e2f..b0bda2029 100644 --- a/app-modules/portal/resources/views/sections/upcoming-events.blade.php +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -3,104 +3,248 @@ @endphp
- @if ($events->isNotEmpty()) -
+
+ @if ($events->isNotEmpty()) + @endif
Próximos eventos da comunidade He4rt - Reuniões semanais, aulas e encontros gratuitos e abertos para quem está começando ou já - atua na área. Participe ao vivo, aprenda programação, tire suas dúvidas e faça networking - com outros desenvolvedores — presencial ou online. + 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. -
- @foreach ($events as $item) - @php - $event = $item['event']; - $occurrence = $item['occurrence']; - $cover = $event->getFirstMedia('cover'); - @endphp + @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
- @endif
diff --git a/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php index 73e2f2b39..62043f44e 100644 --- a/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php +++ b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php @@ -14,6 +14,7 @@ beforeEach(function (): void { $this->withoutVite(); + app()->setLocale('pt_BR'); }); it('exibe apenas eventos ativos com próxima ocorrência futura, ordenados por data', function (): void { @@ -71,13 +72,78 @@ ->and($events->pluck('event.title')->values()->all())->toBe(['Aula de Inglês', 'Encontro de Pub']); }); -it('não renderiza a seção quando não há eventos futuros', function (): void { +it('renderiza a mensagem de fallback quando não há eventos futuros', function (): void { UpcomingEvent::factory()->oneOff()->create([ 'event_at' => CarbonImmutable::now()->subDay(), ]); livewire(UpcomingEventsSection::class) - ->assertDontSee('Próximos eventos da comunidade'); + ->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 { + UpcomingEvent::factory()->create([ + 'title' => 'Aula de Inglês', + 'week_day' => 6, + 'time' => '15:00', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Aula de Inglês') + ->assertSee('landingLogo.svg', escape: false) + ->assertSee('Online') + ->assertSee('id="agenda"', escape: false); +}); + +it('marca eventos recorrentes com badge e padrão de recorrência', function (): void { + UpcomingEvent::factory()->create([ + 'title' => 'Reunião Semanal', + 'week_day' => 1, + 'time' => '21:00', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Recorrente') + ->assertSee('Toda Seg') + ->assertSee('21:00'); +}); + +it('exibe badge Presencial quando o evento tem local', function (): void { + UpcomingEvent::factory()->oneOff()->create([ + 'title' => 'Encontro de Pub', + 'category' => UpcomingEventCategory::Networking, + 'event_at' => CarbonImmutable::now()->addDays(10), + 'location' => 'Pub', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Presencial') + ->assertDontSee('Online'); +}); + +it('renderiza a linha do anfitrião com nome e cargo no card', function (): void { + UpcomingEvent::factory()->create([ + 'title' => 'Reunião Semanal', + 'week_day' => 1, + 'time' => '21:00', + 'host_name' => 'Fernando', + 'host_role' => 'Mentor', + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Fernando') + ->assertSee('Mentor'); +}); + +it('renderiza o fallback quando não há eventos futuros', function (): void { + UpcomingEvent::factory()->oneOff()->create([ + 'event_at' => CarbonImmutable::now()->subDay(), + ]); + + livewire(UpcomingEventsSection::class) + ->assertSee('Nenhum evento agendado no momento') + ->assertDontSee('events-carousel'); }); it('inclui dados estruturados JSON-LD na home quando existem eventos', function (): void { From 8951c330f206c12397e7143cec43d6abce60f3c9 Mon Sep 17 00:00:00 2001 From: fernanduandrade Date: Thu, 13 Aug 2026 19:48:39 -0300 Subject: [PATCH 3/5] fix: upload de cover image das agendas --- app-modules/panel-admin/lang/en/agenda.php | 1 + app-modules/panel-admin/lang/pt_BR/agenda.php | 1 + .../Schemas/UpcomingEventForm.php | 215 ++++++++---------- .../views/sections/upcoming-events.blade.php | 25 +- 4 files changed, 111 insertions(+), 131 deletions(-) diff --git a/app-modules/panel-admin/lang/en/agenda.php b/app-modules/panel-admin/lang/en/agenda.php index b2e511634..78a4c0dd2 100644 --- a/app-modules/panel-admin/lang/en/agenda.php +++ b/app-modules/panel-admin/lang/en/agenda.php @@ -20,6 +20,7 @@ 'category' => 'Category', 'cover' => 'Cover image', 'cover_hint' => 'Image shown on top of the event card on the landing page. Landscape format recommended.', + 'cover_dimension_hint' => 'The image will be cropped and resized to 1200×400 px (3:1 ratio) automatically.', 'week_day' => 'Week day', 'time' => 'Time', 'event_at' => 'Event date', diff --git a/app-modules/panel-admin/lang/pt_BR/agenda.php b/app-modules/panel-admin/lang/pt_BR/agenda.php index 32bf2a8e8..ac028af11 100644 --- a/app-modules/panel-admin/lang/pt_BR/agenda.php +++ b/app-modules/panel-admin/lang/pt_BR/agenda.php @@ -20,6 +20,7 @@ 'category' => 'Categoria', 'cover' => 'Imagem de capa', 'cover_hint' => 'Imagem exibida no topo do card do evento na landing. Formato paisagem recomendado.', + 'cover_dimension_hint' => 'A imagem será cortada e redimensionada automaticamente para 1200×400 px (proporção 3:1).', 'week_day' => 'Dia da semana', 'time' => 'Horário', 'event_at' => 'Data do evento', diff --git a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php index cd1dd2a11..e07312bbf 100644 --- a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php @@ -12,7 +12,6 @@ use Filament\Forms\Components\TimePicker; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Fieldset; -use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use He4rt\Community\UpcomingEvent\Enums\UpcomingEventCategory; @@ -22,124 +21,110 @@ class UpcomingEventForm public static function configure(Schema $schema): Schema { return $schema + ->columns(1) ->components([ - Grid::make(3) + Section::make(__('panel-admin::agenda.form.section_info')) ->schema([ - Grid::make() - ->columnSpan(2) + TextInput::make('title') + ->label(__('panel-admin::agenda.form.title')) + ->required() + ->maxLength(200), + + Select::make('category') + ->label(__('panel-admin::agenda.form.category')) + ->options(UpcomingEventCategory::class) + ->required(), + + Textarea::make('description') + ->label(__('panel-admin::agenda.form.description')) + ->rows(3), + ]), + + Section::make(__('panel-admin::agenda.form.section_date_location')) + ->description(__('panel-admin::agenda.form.section_date_location_hint')) + ->schema([ + Fieldset::make(__('panel-admin::agenda.form.section_recurring')) ->schema([ - Section::make(__('panel-admin::agenda.form.section_info')) - ->schema([ - TextInput::make('title') - ->label(__('panel-admin::agenda.form.title')) - ->required() - ->maxLength(200) - ->columnSpanFull(), - - Select::make('category') - ->label(__('panel-admin::agenda.form.category')) - ->options(UpcomingEventCategory::class) - ->required(), - - Textarea::make('description') - ->label(__('panel-admin::agenda.form.description')) - ->rows(3) - ->columnSpanFull(), - ]) - ->columns(2), - - Section::make(__('panel-admin::agenda.form.section_date_location')) - ->description(__('panel-admin::agenda.form.section_date_location_hint')) - ->schema([ - Fieldset::make(__('panel-admin::agenda.form.section_recurring')) - ->schema([ - Select::make('week_day') - ->label(__('panel-admin::agenda.form.week_day')) - ->options(collect(range(0, 6))->mapWithKeys( - fn (int $day) => [$day => __('panel-admin::agenda.weekdays.'.$day)] - )) - ->requiredWithout('event_at'), - - TimePicker::make('time') - ->label(__('panel-admin::agenda.form.time')) - ->hint(__('panel-admin::agenda.form.time_hint')) - ->native(condition: false) - ->seconds(condition: false) - ->format('H:i') - ->requiredWithout('event_at'), - ]) - ->columnSpanFull() - ->columns(2), - - Fieldset::make(__('panel-admin::agenda.form.section_event')) - ->schema([ - DateTimePicker::make('event_at') - ->label(__('panel-admin::agenda.form.event_at')) - ->hint(__('panel-admin::agenda.form.event_at_hint')) - ->native(condition: false) - ->seconds(condition: false) - ->requiredWithout('week_day'), - - TextInput::make('location') - ->label(__('panel-admin::agenda.form.location')) - ->hint(__('panel-admin::agenda.form.location_hint')) - ->maxLength(255), - - TextInput::make('external_url') - ->label(__('panel-admin::agenda.form.external_url')) - ->url() - ->maxLength(255) - ->columnSpanFull(), - ]) - ->columnSpanFull() - ->columns(2), - ]) - ->columns(2), - ]), - - Grid::make() - ->columnSpan(1) + Select::make('week_day') + ->label(__('panel-admin::agenda.form.week_day')) + ->options(collect(range(0, 6))->mapWithKeys( + fn (int $day) => [$day => __('panel-admin::agenda.weekdays.'.$day)] + )) + ->requiredWithout('event_at'), + + TimePicker::make('time') + ->label(__('panel-admin::agenda.form.time')) + ->hint(__('panel-admin::agenda.form.time_hint')) + ->native(condition: false) + ->seconds(condition: false) + ->format('H:i') + ->requiredWithout('event_at'), + ]) + ->columns(1), + + Fieldset::make(__('panel-admin::agenda.form.section_event')) ->schema([ - Section::make(__('panel-admin::agenda.form.cover')) - ->schema([ - SpatieMediaLibraryFileUpload::make('cover') - ->collection('cover') - ->label(__('panel-admin::agenda.form.cover')) - ->hint(__('panel-admin::agenda.form.cover_hint')) - ->image() - ->imageEditor() - ->panelAspectRatio('3:1') - ->imageAspectRatio('3:1') - ->imageEditorAspectRatioOptions(['3:1', '16:9']) - ->imageResizeMode('cover') - ->imageResizeTargetWidth('1200') - ->imageResizeTargetHeight('400') - ->maxSize(4_096), - ]), - - Section::make(__('panel-admin::agenda.form.section_host')) - ->schema([ - TextInput::make('host_name') - ->label(__('panel-admin::agenda.form.host_name')) - ->hint(__('panel-admin::agenda.form.host_name_hint')) - ->maxLength(255), - - TextInput::make('host_role') - ->label(__('panel-admin::agenda.form.host_role')) - ->maxLength(255), - ]), - - Section::make(__('panel-admin::agenda.form.section_publish')) - ->schema([ - Toggle::make('is_active') - ->label(__('panel-admin::agenda.form.is_active')) - ->default(state: true), - - Toggle::make('skip_next_occurrence') - ->label(__('panel-admin::agenda.form.skip_next_occurrence')) - ->hint(__('panel-admin::agenda.form.skip_hint')), - ]), - ]), + DateTimePicker::make('event_at') + ->label(__('panel-admin::agenda.form.event_at')) + ->hint(__('panel-admin::agenda.form.event_at_hint')) + ->native(condition: false) + ->seconds(condition: false) + ->requiredWithout('week_day'), + + TextInput::make('location') + ->label(__('panel-admin::agenda.form.location')) + ->hint(__('panel-admin::agenda.form.location_hint')) + ->maxLength(255), + + TextInput::make('external_url') + ->label(__('panel-admin::agenda.form.external_url')) + ->url() + ->maxLength(255) + ->columnSpanFull(), + ]) + ->columns(1), + ]), + + Section::make(__('panel-admin::agenda.form.cover')) + ->schema([ + SpatieMediaLibraryFileUpload::make('cover') + ->collection('cover') + ->label(__('panel-admin::agenda.form.cover')) + ->hint(__('panel-admin::agenda.form.cover_hint')) + ->helperText(__('panel-admin::agenda.form.cover_dimension_hint')) + ->image() + ->imageEditor() + ->panelAspectRatio('3:1') + ->imageAspectRatio('3:1') + ->automaticallyCropImagesToAspectRatio() + ->imageEditorAspectRatioOptions(['3:1']) + ->automaticallyResizeImagesMode('cover') + ->automaticallyResizeImagesToWidth('1200') + ->automaticallyResizeImagesToHeight('400') + ->maxSize(4_096), + ]), + + Section::make(__('panel-admin::agenda.form.section_host')) + ->schema([ + TextInput::make('host_name') + ->label(__('panel-admin::agenda.form.host_name')) + ->hint(__('panel-admin::agenda.form.host_name_hint')) + ->maxLength(255), + + TextInput::make('host_role') + ->label(__('panel-admin::agenda.form.host_role')) + ->maxLength(255), + ]), + + Section::make(__('panel-admin::agenda.form.section_publish')) + ->schema([ + Toggle::make('is_active') + ->label(__('panel-admin::agenda.form.is_active')) + ->default(state: true), + + Toggle::make('skip_next_occurrence') + ->label(__('panel-admin::agenda.form.skip_next_occurrence')) + ->hint(__('panel-admin::agenda.form.skip_hint')), ]), ]); } 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 b0bda2029..341ed171c 100644 --- a/app-modules/portal/resources/views/sections/upcoming-events.blade.php +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -132,22 +132,15 @@ class="group relative flex h-full flex-col overflow-hidden rounded-2xl border bo
@if ($cover) -
- Capa do evento {{ $event->title }}width) width="{{ $cover->width }}" @endif - @if ($cover->height) height="{{ $cover->height }}" @endif - loading="lazy" - fetchpriority="low" - class="h-full w-full object-cover opacity-100 transition-opacity duration-1000 ease-in" - :class="shown ? 'opacity-100' : 'opacity-0'" - /> -
+ Capa do evento {{ $event->title }}width) width="{{ $cover->width }}" @endif + @if ($cover->height) height="{{ $cover->height }}" @endif + loading="lazy" + fetchpriority="low" + class="h-full w-full object-cover" + /> @else
Date: Fri, 14 Aug 2026 07:55:25 -0300 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20corre=C3=A7=C3=B5es=20do=20code=20re?= =?UTF-8?q?view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...dd_skip_until_to_upcoming_events_table.php | 24 +++++++ .../UpcomingEvent/Models/UpcomingEvent.php | 7 +- .../Observers/UpcomingEventObserver.php | 31 +++++++++ .../tests/Unit/UpcomingEventTest.php | 54 +++++++++++++++ .../views/sections/upcoming-events.blade.php | 2 +- .../src/Livewire/UpcomingEventsSection.php | 25 +++++-- database/seeders/DatabaseSeeder.php | 1 - database/seeders/UpcomingEventSeeder.php | 66 ------------------- tests/Feature/LoginFlowProbeTest.php | 54 --------------- 9 files changed, 135 insertions(+), 129 deletions(-) create mode 100644 app-modules/community/database/migrations/2026_08_14_000001_add_skip_until_to_upcoming_events_table.php create mode 100644 app-modules/community/src/UpcomingEvent/Observers/UpcomingEventObserver.php delete mode 100644 database/seeders/UpcomingEventSeeder.php delete mode 100644 tests/Feature/LoginFlowProbeTest.php diff --git a/app-modules/community/database/migrations/2026_08_14_000001_add_skip_until_to_upcoming_events_table.php b/app-modules/community/database/migrations/2026_08_14_000001_add_skip_until_to_upcoming_events_table.php new file mode 100644 index 000000000..97f784d6c --- /dev/null +++ b/app-modules/community/database/migrations/2026_08_14_000001_add_skip_until_to_upcoming_events_table.php @@ -0,0 +1,24 @@ +timestampTz('skip_until')->nullable()->after('skip_next_occurrence'); + }); + } + + public function down(): void + { + Schema::table('upcoming_events', static function (Blueprint $table): void { + $table->dropColumn('skip_until'); + }); + } +}; diff --git a/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php index 66439d514..4f4be0ee7 100644 --- a/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php +++ b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php @@ -7,6 +7,8 @@ use Carbon\CarbonInterface; use He4rt\Community\Database\Factories\UpcomingEventFactory; use He4rt\Community\UpcomingEvent\Enums\UpcomingEventCategory; +use He4rt\Community\UpcomingEvent\Observers\UpcomingEventObserver; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -28,10 +30,12 @@ * @property string|null $host_role * @property bool $is_active * @property bool $skip_next_occurrence + * @property CarbonInterface|null $skip_until * @property int $sort_order * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at */ +#[ObservedBy(classes: UpcomingEventObserver::class)] #[Table(name: 'upcoming_events')] final class UpcomingEvent extends Model implements HasMedia { @@ -59,7 +63,7 @@ public function nextOccurrence(): ?CarbonInterface $occurrence = $this->resolveRecurringOccurrence(); - if ($this->skip_next_occurrence) { + if ($this->skip_until instanceof CarbonInterface && $occurrence->lessThanOrEqualTo($this->skip_until)) { return $occurrence->addWeek(); } @@ -78,6 +82,7 @@ protected function casts(): array 'week_day' => 'integer', 'is_active' => 'boolean', 'skip_next_occurrence' => 'boolean', + 'skip_until' => 'datetime', 'event_at' => 'datetime', 'sort_order' => 'integer', ]; diff --git a/app-modules/community/src/UpcomingEvent/Observers/UpcomingEventObserver.php b/app-modules/community/src/UpcomingEvent/Observers/UpcomingEventObserver.php new file mode 100644 index 000000000..eaba28373 --- /dev/null +++ b/app-modules/community/src/UpcomingEvent/Observers/UpcomingEventObserver.php @@ -0,0 +1,31 @@ +skip_until instanceof CarbonInterface && $event->skip_until->isPast()) { + $event->skip_next_occurrence = false; + $event->skip_until = null; + + return; + } + + if (!$event->skip_next_occurrence) { + $event->skip_until = null; + + return; + } + + if ($event->skip_until === null) { + $event->skip_until = $event->nextOccurrence(); + } + } +} diff --git a/app-modules/community/tests/Unit/UpcomingEventTest.php b/app-modules/community/tests/Unit/UpcomingEventTest.php index 14b2cda07..50432e1df 100644 --- a/app-modules/community/tests/Unit/UpcomingEventTest.php +++ b/app-modules/community/tests/Unit/UpcomingEventTest.php @@ -54,6 +54,60 @@ expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-19 19:00:00'); }); +it('consome o skip após a ocorrência pulada ter passado', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 10:00:00')); + + $event = UpcomingEvent::factory()->create([ + 'week_day' => 3, + 'time' => '19:00', + 'skip_next_occurrence' => true, + ]); + + expect($event->skip_until->toDateTimeString())->toBe('2026-08-12 19:00:00'); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-13 10:00:00')); + + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-08-19 19:00:00'); +}); + +it('limpa o skip na próxima gravação após a ocorrência ter passado', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 10:00:00')); + + $event = UpcomingEvent::factory()->create([ + 'week_day' => 3, + 'time' => '19:00', + 'skip_next_occurrence' => true, + ]); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-13 10:00:00')); + + $event->save(); + + expect($event->skip_next_occurrence)->toBeFalse(); + expect($event->skip_until)->toBeNull(); +}); + +it('rearma o skip para uma nova ocorrência ao ativar novamente', function (): void { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 10:00:00')); + + $event = UpcomingEvent::factory()->create([ + 'week_day' => 3, + 'time' => '19:00', + 'skip_next_occurrence' => true, + ]); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-13 10:00:00')); + + $event->save(); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-20 10:00:00')); + + $event->update(['skip_next_occurrence' => true]); + + expect($event->skip_until->toDateTimeString())->toBe('2026-08-26 19:00:00'); + expect($event->nextOccurrence()->toDateTimeString())->toBe('2026-09-02 19:00:00'); +}); + it('usa event_at para eventos pontuais', function (): void { $event = UpcomingEvent::factory()->oneOff()->create([ 'category' => UpcomingEventCategory::Networking, 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 341ed171c..2318345ca 100644 --- a/app-modules/portal/resources/views/sections/upcoming-events.blade.php +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -6,7 +6,7 @@
@if ($events->isNotEmpty()) @endif diff --git a/app-modules/portal/src/Livewire/UpcomingEventsSection.php b/app-modules/portal/src/Livewire/UpcomingEventsSection.php index 49cc6f13a..916930828 100644 --- a/app-modules/portal/src/Livewire/UpcomingEventsSection.php +++ b/app-modules/portal/src/Livewire/UpcomingEventsSection.php @@ -20,11 +20,7 @@ final class UpcomingEventsSection extends Component #[Computed] public function upcomingEvents(): Collection { - if (!app()->isProduction()) { - return $this->fetchUpcomingEvents(); - } - - return Cache::remember('portal:upcoming-events', now()->addHour(), fn () => $this->fetchUpcomingEvents()); + return $this->fetchUpcomingEvents(); } /** @@ -74,6 +70,23 @@ public function render(): View return view('portal::sections.upcoming-events'); } + /** + * @return Collection + */ + private function activeEvents(): Collection + { + $query = fn (): Collection => UpcomingEvent::query() + ->where('is_active', operator: true) + ->orderBy('sort_order') + ->get(); + + if (!app()->isProduction()) { + return $query(); + } + + return Cache::remember('portal:upcoming-events', now()->addHour(), $query); + } + /** * @return Collection */ @@ -81,7 +94,7 @@ private function fetchUpcomingEvents(): Collection { $events = []; - foreach (UpcomingEvent::query()->where('is_active', operator: true)->orderBy('sort_order')->get() as $event) { + foreach ($this->activeEvents() as $event) { $occurrence = $event->nextOccurrence(); if ($occurrence === null) { continue; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c8fd9c10a..0f56a1814 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,7 +16,6 @@ public function run(): void { $this->call([ BaseSeeder::class, - UpcomingEventSeeder::class, ]); } } diff --git a/database/seeders/UpcomingEventSeeder.php b/database/seeders/UpcomingEventSeeder.php deleted file mode 100644 index ccc7ba928..000000000 --- a/database/seeders/UpcomingEventSeeder.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Reunião Semanal', - 'description' => 'Encontro semanal da comunidade para tirar dúvidas, apresentar projetos e trocar ideias.', - 'category' => UpcomingEventCategory::ReuniaoSemanal, - 'week_day' => 1, - 'time' => '21:00', - 'sort_order' => 1, - ], - [ - 'title' => 'Aula Livre', - 'description' => 'Aula aberta toda quarta-feira sobre temas escolhidos pela comunidade.', - 'category' => UpcomingEventCategory::Aula, - 'week_day' => 3, - 'time' => '19:00', - 'sort_order' => 2, - ], - [ - 'title' => 'Aula de Inglês', - 'description' => 'Aula de inglês para devs, todo sábado às 15h.', - 'category' => UpcomingEventCategory::AulaIngles, - 'week_day' => 6, - 'time' => '15:00', - 'sort_order' => 3, - ], - [ - 'title' => 'Aula de Onboarding', - 'description' => 'Boas-vindas e apresentação da comunidade para novos membros, todo início de mês.', - 'category' => UpcomingEventCategory::Onboarding, - 'event_at' => now()->addMonth()->startOfMonth()->setTime(19, 0), - 'sort_order' => 4, - ], - [ - 'title' => 'Encontro Presencial de Pub', - 'description' => 'Encontro presencial da galera para networking e resenha num pub.', - 'category' => UpcomingEventCategory::Networking, - 'event_at' => CarbonImmutable::parse('2026-08-28 20:00:00'), - 'location' => 'Pub', - 'external_url' => 'https://discord.gg/he4rt', - 'sort_order' => 5, - ], - ]; - - foreach ($events as $event) { - UpcomingEvent::query()->updateOrCreate( - ['title' => $event['title']], - $event, - ); - } - } -} diff --git a/tests/Feature/LoginFlowProbeTest.php b/tests/Feature/LoginFlowProbeTest.php deleted file mode 100644 index 7045f444d..000000000 --- a/tests/Feature/LoginFlowProbeTest.php +++ /dev/null @@ -1,54 +0,0 @@ -create([ - 'email' => 'admin@admin.com', - 'password' => 'admin', - 'username' => 'danielhe4rt', - ]); - - $this->get('/admin/login')->assertOk(); - - Filament::setCurrentPanel(Filament::getPanel('admin')); - - livewire(Login::class) - ->fillForm([ - 'email' => 'admin@admin.com', - 'password' => 'admin', - ]) - ->call('authenticate') - ->assertHasNoErrors(); - - expect(auth()->check())->toBeTrue() - ->and(auth()->id())->not->toBeNull(); -}); - -test('wrong password stays on login with error', function (): void { - User::factory()->create([ - 'email' => 'admin@admin.com', - 'password' => 'admin', - 'username' => 'danielhe4rt', - ]); - - $this->get('/admin/login')->assertOk(); - - Filament::setCurrentPanel(Filament::getPanel('admin')); - - livewire(Login::class) - ->fillForm([ - 'email' => 'admin@admin.com', - 'password' => 'senha-errada', - ]) - ->call('authenticate') - ->assertHasErrors(); - - expect(auth()->check())->toBeFalse(); -}); From 61d240d84226a5cef103332d198927e71ae77754 Mon Sep 17 00:00:00 2001 From: fernanduandrade Date: Fri, 14 Aug 2026 13:25:47 -0300 Subject: [PATCH 5/5] fix: corrigir test cases --- .../activity/database/factories/MessageFactory.php | 2 +- app-modules/community/tests/Unit/UpcomingEventTest.php | 4 ++++ .../UpcomingEventResource/Schemas/UpcomingEventForm.php | 9 ++++++--- .../UpcomingEventResource/Tables/UpcomingEventsTable.php | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app-modules/activity/database/factories/MessageFactory.php b/app-modules/activity/database/factories/MessageFactory.php index dd3a9f722..9b40d450d 100644 --- a/app-modules/activity/database/factories/MessageFactory.php +++ b/app-modules/activity/database/factories/MessageFactory.php @@ -20,7 +20,7 @@ public function definition(): array return [ 'id' => fake()->uuid(), 'external_identity_id' => ExternalIdentity::factory(), - 'provider_message_id' => fake()->randomNumber(4), + 'provider_message_id' => fake()->unique()->randomNumber(4), 'channel_id' => fake()->randomNumber(4), 'content' => fake()->sentence(), 'sent_at' => now(), diff --git a/app-modules/community/tests/Unit/UpcomingEventTest.php b/app-modules/community/tests/Unit/UpcomingEventTest.php index 50432e1df..0f4e0dd8d 100644 --- a/app-modules/community/tests/Unit/UpcomingEventTest.php +++ b/app-modules/community/tests/Unit/UpcomingEventTest.php @@ -9,6 +9,10 @@ uses(RefreshDatabase::class); +afterEach(function (): void { + CarbonImmutable::setTestNow(); +}); + it('calcula a próxima ocorrência de um evento recorrente semanal', function (): void { CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-08-12 10:00:00')); diff --git a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php index e07312bbf..c1535e2ec 100644 --- a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Schemas/UpcomingEventForm.php @@ -50,7 +50,8 @@ public static function configure(Schema $schema): Schema ->options(collect(range(0, 6))->mapWithKeys( fn (int $day) => [$day => __('panel-admin::agenda.weekdays.'.$day)] )) - ->requiredWithout('event_at'), + ->requiredWithout('event_at') + ->prohibits('event_at'), TimePicker::make('time') ->label(__('panel-admin::agenda.form.time')) @@ -58,7 +59,8 @@ public static function configure(Schema $schema): Schema ->native(condition: false) ->seconds(condition: false) ->format('H:i') - ->requiredWithout('event_at'), + ->requiredWithout('event_at') + ->prohibits('event_at'), ]) ->columns(1), @@ -69,7 +71,8 @@ public static function configure(Schema $schema): Schema ->hint(__('panel-admin::agenda.form.event_at_hint')) ->native(condition: false) ->seconds(condition: false) - ->requiredWithout('week_day'), + ->requiredWithout('week_day') + ->prohibits(['week_day', 'time']), TextInput::make('location') ->label(__('panel-admin::agenda.form.location')) diff --git a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php index ec2493d13..33fbc5b3f 100644 --- a/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php @@ -19,6 +19,7 @@ public static function table(Table $table): Table { return $table ->defaultSort('sort_order') + ->reorderable('sort_order') ->columns([ TextColumn::make('title') ->label(__('panel-admin::agenda.form.title'))