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
-
+ {{ $titleTag }}>
@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",