diff --git a/app-modules/activity/database/factories/MessageFactory.php b/app-modules/activity/database/factories/MessageFactory.php index dd3a9f72..9b40d450 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/database/factories/UpcomingEventFactory.php b/app-modules/community/database/factories/UpcomingEventFactory.php new file mode 100644 index 00000000..11bcb02c --- /dev/null +++ b/app-modules/community/database/factories/UpcomingEventFactory.php @@ -0,0 +1,44 @@ + + */ +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'), + 'location' => null, + 'external_url' => null, + 'host_name' => fake()->name(), + 'host_role' => fake()->jobTitle(), + '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 00000000..f122efd0 --- /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/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 00000000..d36c870a --- /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/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 00000000..97f784d6 --- /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/CommunityServiceProvider.php b/app-modules/community/src/CommunityServiceProvider.php index c01dfc98..9cd48af0 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 00000000..8f299c2e --- /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 00000000..4f4be0ee --- /dev/null +++ b/app-modules/community/src/UpcomingEvent/Models/UpcomingEvent.php @@ -0,0 +1,104 @@ + */ + 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_until instanceof CarbonInterface && $occurrence->lessThanOrEqualTo($this->skip_until)) { + 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', + 'skip_until' => 'datetime', + '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/src/UpcomingEvent/Observers/UpcomingEventObserver.php b/app-modules/community/src/UpcomingEvent/Observers/UpcomingEventObserver.php new file mode 100644 index 00000000..eaba2837 --- /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 new file mode 100644 index 00000000..0f4e0dd8 --- /dev/null +++ b/app-modules/community/tests/Unit/UpcomingEventTest.php @@ -0,0 +1,122 @@ +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('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, + '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/views/components/headline.blade.php b/app-modules/he4rt/resources/views/components/headline.blade.php index a089a5ab..30856f9d 100644 --- a/app-modules/he4rt/resources/views/components/headline.blade.php +++ b/app-modules/he4rt/resources/views/components/headline.blade.php @@ -4,6 +4,7 @@ "size" => "lg", "animation" => "fade-up", "keywords" => [], + "titleTag" => "h1", ]) @php @@ -64,7 +65,7 @@ class="hp-headline-badge"
@isset($title) -

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

+ @endisset @isset($subtitle) diff --git a/app-modules/panel-admin/lang/en/agenda.php b/app-modules/panel-admin/lang/en/agenda.php new file mode 100644 index 00000000..78a4c0dd --- /dev/null +++ b/app-modules/panel-admin/lang/en/agenda.php @@ -0,0 +1,59 @@ + [ + '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.', + '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', + '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).', + '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 00000000..ac028af1 --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/agenda.php @@ -0,0 +1,59 @@ + [ + '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.', + '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', + '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).', + '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 00000000..5266cc15 --- /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 00000000..b53b8774 --- /dev/null +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Pages/CreateUpcomingEvent.php @@ -0,0 +1,13 @@ +columns(1) + ->components([ + Section::make(__('panel-admin::agenda.form.section_info')) + ->schema([ + 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([ + 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') + ->prohibits('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') + ->prohibits('event_at'), + ]) + ->columns(1), + + 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') + ->prohibits(['week_day', 'time']), + + 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/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 00000000..33fbc5b3 --- /dev/null +++ b/app-modules/panel-admin/src/Agenda/Resources/UpcomingEventResource/Tables/UpcomingEventsTable.php @@ -0,0 +1,71 @@ +defaultSort('sort_order') + ->reorderable('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 c379aa64..5d84d641 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 0b1d7397..c9d849c8 100644 --- a/app-modules/portal/resources/views/homepage.blade.php +++ b/app-modules/portal/resources/views/homepage.blade.php @@ -1,5 +1,6 @@
+ {{-- --}} {{-- --}} {{-- --}} diff --git a/app-modules/portal/resources/views/sections/upcoming-events.blade.php b/app-modules/portal/resources/views/sections/upcoming-events.blade.php new file mode 100644 index 00000000..2318345c --- /dev/null +++ b/app-modules/portal/resources/views/sections/upcoming-events.blade.php @@ -0,0 +1,243 @@ +@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 +
+
+
diff --git a/app-modules/portal/src/Livewire/UpcomingEventsSection.php b/app-modules/portal/src/Livewire/UpcomingEventsSection.php new file mode 100644 index 00000000..91693082 --- /dev/null +++ b/app-modules/portal/src/Livewire/UpcomingEventsSection.php @@ -0,0 +1,114 @@ + + */ + #[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(), + '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 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 + */ + private function fetchUpcomingEvents(): Collection + { + $events = []; + + foreach ($this->activeEvents() 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 2bcdc9d3..a372c9c0 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -8,6 +8,7 @@ use He4rt\Portal\Livewire\HeroSection; use He4rt\Portal\Livewire\Homepage; use He4rt\Portal\Livewire\SocialLinksPage; +use He4rt\Portal\Livewire\UpcomingEventsSection; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; @@ -23,5 +24,6 @@ public function boot(): void Route::get('/comunidade/retrospectiva', CommunityRetrospectivePage::class)->name('community.retrospective'); Livewire::component('hero-section', HeroSection::class); + Livewire::component('upcoming-events-section', UpcomingEventsSection::class); } } diff --git a/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php new file mode 100644 index 00000000..62043f44 --- /dev/null +++ b/app-modules/portal/tests/Feature/UpcomingEventsSectionTest.php @@ -0,0 +1,183 @@ +withoutVite(); + app()->setLocale('pt_BR'); +}); + +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('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) + ->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 { + 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/package-lock.json b/package-lock.json index b0a3b35f..29ed7abc 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",