- {{ str($user->name)->substr(0, 2)->upper() }}
+ {{ str($displayName)->substr(0, 2)->upper() }}
-
{{ $user->name }}
- @if ($user->username)
+
{{ $displayName }}
+ @if ($user?->username)
{{ '@' . $user->username }}
+
+
+
+
{{ $this->event->title }}
+
+
+ {{ $this->event->starts_at->format('d/m/Y H:i') }}
+ —
+ {{ $this->event->ends_at->format('d/m/Y H:i') }}
+
+ @if ($this->event->location)
+ {{ $this->event->location }}
+ @endif
+
+
+
+
+ {{ $this->event->event_type->getLabel() }}
+
+
+
+ @if ($this->event->description)
+
{{ $this->event->description }}
+ @endif
+
+
+ @if ($this->enrollment)
+
+
+
+ {{ __('events::pages.enrollment_status_label') }}
+
+
+
+ {{ $this->enrollment->status->getLabel() }}
+
+
+
+ @if ($this->enrollment->status->isWaitlisted() && $this->enrollment->waitlist_position)
+
+ {{
+ __('events::pages.waitlist_status', [
+ 'position' => $this->enrollment->waitlist_position,
+ ])
+ }}
+
+ @endif
+
+ @if ($this->enrollment->status === \He4rt\Events\Enrollment\Enums\EnrollmentStatus::Pending)
+
+ {{ __('events::pages.application_pending_hint') }}
+
+ @if ($this->enrollment->application_data && $this->event->enrollmentPolicy?->application_schema)
+
+
+ {{ __('events::pages.application_your_answers') }}
+
+ @foreach ($this->event->enrollmentPolicy->application_schema as $field)
+
+
{{ $field['label'] ?? '' }}
+
+ @php $answer = $this->enrollment->application_data[$field['key'] ?? null] ?? null; @endphp
+ @if (is_array($answer))
+ {{ $answer !== [] ? implode(', ', $answer) : '—' }}
+ @else
+ {{ $answer ?? '—' }}
+ @endif
+
+
+ @endforeach
+
+ @endif
+ @endif
+
+ @if ($this->enrollment->status === \He4rt\Events\Enrollment\Enums\EnrollmentStatus::Rejected)
+
+ {{ __('events::pages.application_rejected_hint') }}
+
+ @if ($this->enrollment->rejection_reason)
+
+ {{
+ __('events::pages.application_rejection_reason', [
+ 'reason' => $this->enrollment->rejection_reason,
+ ])
+ }}
+
+ @endif
+ @endif
+
+ @if ($this->qrToken)
+
+
+
+ {{ __('events::pages.my_qr_code') }}
+
+
+ @if ($this->hasCheckedInToday)
+
+ {{ __('events::pages.checked_in_today') }}
+
+ @endif
+
+
+
{!! $this->qrCodeSvg !!}
+
+
+ {{ __('events::pages.qr_code_hint') }}
+
+
+
+
+ {{ __('events::pages.copy_token') }}
+ {{ __('events::pages.token_copied') }}
+
+
+
+ {{ __('events::pages.download_qr') }}
+
+
+
+ @endif
+ @if ($this->checkIns->isNotEmpty())
+
+
+ {{ __('events::pages.check_in_history') }}
+
+
+
+ @foreach ($this->checkIns as $checkIn)
+ -
+
+ {{ $checkIn->event_date->format('d/m/Y') }}
+
+ @endforeach
+
+
+ @endif
+ @elseif ($this->canApply)
+
+
{{ __('events::pages.apply_hint') }}
+
+
+
+ @elseif ($this->canConfirmPresence)
+
+
{{ __('events::pages.confirm_presence_hint') }}
+
+
+
+ {{ __('events::pages.confirm_presence') }}
+
+
+
+
+
+ {{ __('events::pages.confirm_presence_prompt') }}
+
+
+
+
+ {{ __('events::pages.confirm_presence_yes') }}
+
+
+
+ {{ __('events::pages.confirm_presence_cancel') }}
+
+
+
+
+ @elseif ($this->isEventFull)
+
+
{{ __('events::pages.event_full') }}
+
+ @endif
+
+
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php
new file mode 100644
index 000000000..70858da7c
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php
@@ -0,0 +1,43 @@
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php
new file mode 100644
index 000000000..f86fa00b7
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php
@@ -0,0 +1,26 @@
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php b/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php
new file mode 100644
index 000000000..bab7e26cf
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php
@@ -0,0 +1,35 @@
+
+ @if ($this->canCheckIn)
+
+
+ {{ __('events::pages.enter_check_in_code_hint') }}
+
+
+
+
+
+
+
+
+ @error ('code')
+
{{ $message }}
+ @enderror
+
+ @if ($error)
+
{{ $error }}
+ @endif
+
+
+
+ {{ __('events::pages.check_in') }}
+
+
+
+ @endif
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php b/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php
new file mode 100644
index 000000000..216b46b70
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php
@@ -0,0 +1,27 @@
+
+
+
{{ $enrollment->event->title }}
+
+
+ {{ $enrollment->event->starts_at->format('d/m/Y H:i') }}
+
+ @if ($enrollment->enrolled_at)
+ {{
+ __('events::pages.enrolled_at', [
+ 'date' => $enrollment->enrolled_at->format('d/m/Y H:i'),
+ ])
+ }}
+ @endif
+
+
+
+
+
+ {{ $enrollment->status->getLabel() }}
+
+
+
+ {{ $enrollment->event->status->getLabel() }}
+
+
+
diff --git a/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php b/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php
index 23f57f0ac..eee4e5d3c 100644
--- a/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php
+++ b/app-modules/panel-app/resources/views/livewire/timeline/post-show.blade.php
@@ -14,7 +14,7 @@
@if ($showReplies && $timeline->children_count > 0)
@foreach ($timeline->children->take(3) as $reply)
- @continue (!$reply->postable)
+ @continue (!$reply->postable || !$reply->user)
+
+
diff --git a/app-modules/panel-app/resources/views/pages/events.blade.php b/app-modules/panel-app/resources/views/pages/events.blade.php
new file mode 100644
index 000000000..b277f96c0
--- /dev/null
+++ b/app-modules/panel-app/resources/views/pages/events.blade.php
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-modules/panel-app/resources/views/pages/my-events.blade.php b/app-modules/panel-app/resources/views/pages/my-events.blade.php
new file mode 100644
index 000000000..d60ad02ca
--- /dev/null
+++ b/app-modules/panel-app/resources/views/pages/my-events.blade.php
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
new file mode 100644
index 000000000..1b71eb6f2
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -0,0 +1,264 @@
+ $checkIns
+ * @property-read bool $hasCheckedInToday
+ * @property-read bool $canConfirmPresence
+ * @property-read bool $canApply
+ * @property-read bool $isEventFull
+ */
+final class EventDetail extends Component
+{
+ public string $eventId;
+
+ /** @var array
*/
+ public array $applicationFormData = [];
+
+ public function mount(string $eventId): void
+ {
+ $this->eventId = $eventId;
+
+ foreach ($this->event->enrollmentPolicy->application_schema ?? [] as $field) {
+ if (($field['type'] ?? null) === 'checkbox' && isset($field['key'])) {
+ $this->applicationFormData[(string) $field['key']] = [];
+ }
+ }
+ }
+
+ #[Computed]
+ public function event(): Event
+ {
+ return Event::query()
+ ->with('enrollmentPolicy')
+ ->where('id', $this->eventId)
+ ->where('tenant_id', filament()->getTenant()->getKey())
+ ->viewableByParticipant()
+ ->firstOrFail();
+ }
+
+ #[Computed]
+ public function enrollment(): ?Enrollment
+ {
+ return Enrollment::query()
+ ->with('qrToken')
+ ->where('event_id', $this->eventId)
+ ->where('user_id', auth()->id())
+ ->first();
+ }
+
+ #[Computed]
+ public function qrToken(): ?QrToken
+ {
+ if ($this->enrollment === null) {
+ return null;
+ }
+
+ if (!in_array($this->enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true)) {
+ return null;
+ }
+
+ if ($this->event->enrollmentPolicy?->check_in_method !== CheckInMethod::QrCode) {
+ return null;
+ }
+
+ return $this->enrollment->qrToken;
+ }
+
+ #[Computed]
+ public function qrCodeSvg(): ?string
+ {
+ if ($this->qrToken === null) {
+ return null;
+ }
+
+ $writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd()));
+
+ return $writer->writeString($this->qrToken->token);
+ }
+
+ /** @return Collection */
+ #[Computed]
+ public function checkIns(): Collection
+ {
+ if ($this->enrollment === null) {
+ return new Collection();
+ }
+
+ return CheckIn::query()
+ ->where('enrollment_id', $this->enrollment->id)
+ ->oldest('event_date')
+ ->get();
+ }
+
+ #[Computed]
+ public function hasCheckedInToday(): bool
+ {
+ return $this->checkIns->contains(
+ fn (CheckIn $c): bool => $c->event_date->isToday(),
+ );
+ }
+
+ #[Computed]
+ public function canConfirmPresence(): bool
+ {
+ if ($this->event->status !== EventStatus::Published) {
+ return false;
+ }
+
+ if ($this->enrollment !== null) {
+ return false;
+ }
+
+ $policy = $this->event->enrollmentPolicy;
+
+ if (!in_array($policy?->enrollment_method, [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], strict: true)) {
+ return false;
+ }
+
+ if (!$this->event->starts_at->isFuture()) {
+ return false;
+ }
+
+ if ($policy->capacity === null) {
+ return true;
+ }
+
+ $occupiedCount = Enrollment::query()
+ ->where('event_id', $this->eventId)
+ ->active()
+ ->count();
+
+ if ($occupiedCount < $policy->capacity) {
+ return true;
+ }
+
+ return $policy->has_waitlist;
+ }
+
+ #[Computed]
+ public function canApply(): bool
+ {
+ if ($this->event->status !== EventStatus::Published) {
+ return false;
+ }
+
+ if ($this->enrollment !== null) {
+ return false;
+ }
+
+ if ($this->event->enrollmentPolicy?->enrollment_method !== EnrollmentMethod::Application) {
+ return false;
+ }
+
+ return $this->event->starts_at->isFuture();
+ }
+
+ #[Computed]
+ public function isEventFull(): bool
+ {
+ if ($this->enrollment !== null) {
+ return false;
+ }
+
+ $policy = $this->event->enrollmentPolicy;
+
+ if ($policy?->capacity === null || $policy->has_waitlist) {
+ return false;
+ }
+
+ $occupiedCount = Enrollment::query()
+ ->where('event_id', $this->eventId)
+ ->active()
+ ->count();
+
+ return $occupiedCount >= $policy->capacity;
+ }
+
+ public function confirmPresence(): void
+ {
+ /** @var User $user */
+ $user = auth()->user();
+
+ try {
+ $enrollment = resolve(EnrollUserAction::class)->handle(
+ EnrollUserDTO::fromModels($this->event, $user),
+ );
+
+ unset($this->enrollment, $this->canConfirmPresence, $this->canApply, $this->isEventFull);
+
+ Notification::make()
+ ->success()
+ ->title($enrollment->status->getResponseMessage($enrollment->waitlist_position))
+ ->send();
+ } catch (EnrollmentException $enrollmentException) {
+ Notification::make()
+ ->danger()
+ ->title($enrollmentException->getMessage())
+ ->send();
+ }
+ }
+
+ public function apply(): void
+ {
+ /** @var User $user */
+ $user = auth()->user();
+
+ try {
+ resolve(EnrollUserAction::class)->handle(
+ new EnrollUserDTO(
+ eventId: $this->event->id,
+ userId: $user->id,
+ applicationData: $this->applicationFormData,
+ ),
+ );
+
+ unset($this->enrollment, $this->canApply);
+ $this->applicationFormData = [];
+
+ Notification::make()
+ ->success()
+ ->title(__('events::pages.application_submitted'))
+ ->send();
+ } catch (EnrollmentException $enrollmentException) {
+ Notification::make()
+ ->danger()
+ ->title($enrollmentException->getMessage())
+ ->send();
+ }
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.event-detail');
+ }
+}
diff --git a/app-modules/panel-app/src/Livewire/Events/EventsList.php b/app-modules/panel-app/src/Livewire/Events/EventsList.php
new file mode 100644
index 000000000..4d3ba3f05
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/EventsList.php
@@ -0,0 +1,35 @@
+ */
+ public function getEventsProperty(): Collection
+ {
+ return Event::query()
+ ->with('enrollmentPolicy')
+ ->where('tenant_id', filament()->getTenant()->getKey())
+ ->active()
+ ->orderBy('starts_at')
+ ->get();
+ }
+
+ public function eventUrl(Event $event): string
+ {
+ return EventPage::getUrl(['record' => $event->getKey()]);
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.events-list');
+ }
+}
diff --git a/app-modules/panel-app/src/Livewire/Events/MyEventsList.php b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php
new file mode 100644
index 000000000..45af6f442
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php
@@ -0,0 +1,45 @@
+ */
+ public function getEnrollmentsProperty(): Collection
+ {
+ return Enrollment::query()
+ ->with(['event'])
+ ->where('user_id', auth()->id())
+ ->whereHas('event', fn (Builder $query) => $query->where('tenant_id', filament()->getTenant()->getKey()))
+ ->latest('enrolled_at')
+ ->get();
+ }
+
+ public function canOpenEvent(Enrollment $enrollment): bool
+ {
+ /** @var Event $event */
+ $event = $enrollment->event;
+
+ return $event->status->isViewableByParticipant();
+ }
+
+ public function eventUrl(Enrollment $enrollment): string
+ {
+ return EventPage::getUrl(['record' => $enrollment->event_id]);
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.my-events-list');
+ }
+}
diff --git a/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
new file mode 100644
index 000000000..f63d212d3
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
@@ -0,0 +1,133 @@
+eventId = $eventId;
+ }
+
+ #[Computed]
+ public function enrollment(): ?Enrollment
+ {
+ return Enrollment::query()
+ ->with('event.enrollmentPolicy')
+ ->where('event_id', $this->eventId)
+ ->where('user_id', auth()->id())
+ ->first();
+ }
+
+ #[Computed]
+ public function canCheckIn(): bool
+ {
+ if ($this->enrollment === null) {
+ return false;
+ }
+
+ if ($this->enrollment->event->enrollmentPolicy?->check_in_method !== CheckInMethod::NumericCode) {
+ return false;
+ }
+
+ return in_array($this->enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true);
+ }
+
+ public function checkIn(): void
+ {
+ $this->error = null;
+ $this->code = mb_trim($this->code);
+
+ if ($this->enrollment === null || !$this->canCheckIn) {
+ return;
+ }
+
+ $this->validate([
+ 'code' => ['required', 'string', new CheckInCodeRule()],
+ ], [
+ 'code.required' => CheckInException::invalidCheckInCodeFormat()->getMessage(),
+ ]);
+
+ if (!$this->ensureNotRateLimited()) {
+ return;
+ }
+
+ try {
+ resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $this->enrollment,
+ code: $this->code,
+ eventDate: Date::today(),
+ ),
+ );
+
+ RateLimiter::clear($this->getRateLimitKey());
+ $this->code = '';
+
+ Notification::make()
+ ->success()
+ ->title('Check-in confirmed!')
+ ->send();
+ } catch (CheckInException $checkInException) {
+ $this->error = $checkInException->getMessage();
+ }
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.numeric-code-check-in');
+ }
+
+ private function ensureNotRateLimited(): bool
+ {
+ $rateLimitKey = $this->getRateLimitKey();
+
+ if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) {
+ $this->error = CheckInException::checkInCodeRateLimited(
+ RateLimiter::availableIn($rateLimitKey)
+ )->getMessage();
+
+ return false;
+ }
+
+ RateLimiter::hit($rateLimitKey, 60);
+
+ return true;
+ }
+
+ private function getRateLimitKey(): string
+ {
+ return sprintf(
+ 'numeric-code-check-in:%s:%s:%s',
+ auth()->id() ?? 'guest',
+ $this->eventId,
+ md5((string) (request()->ip() ?? 'unknown')),
+ );
+ }
+}
diff --git a/app-modules/panel-app/src/Livewire/Timeline/Feed.php b/app-modules/panel-app/src/Livewire/Timeline/Feed.php
index e4f13c0b7..cd64d8a36 100644
--- a/app-modules/panel-app/src/Livewire/Timeline/Feed.php
+++ b/app-modules/panel-app/src/Livewire/Timeline/Feed.php
@@ -18,9 +18,9 @@ final class Feed extends Component
#[Locked]
public string $tenantId;
- #[On('timeline.post-created')]
- #[On('timeline.reply-created')]
- #[On('timeline.reply-deleted')]
+ #[On(event: 'timeline.post-created')]
+ #[On(event: 'timeline.reply-created')]
+ #[On(event: 'timeline.reply-deleted')]
public function refresh(): void {}
public function render(): View
diff --git a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php
index 1157445b2..bd1abfa33 100644
--- a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php
+++ b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php
@@ -21,7 +21,7 @@ final class PostShow extends Component
public bool $showReplies = true;
- #[On('timeline.post-updated')]
+ #[On(event: 'timeline.post-updated')]
public function refresh(): void {}
public function togglePin(#[CurrentUser] ?User $user): void
diff --git a/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php b/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php
index e5ad05983..353c8719e 100644
--- a/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php
+++ b/app-modules/panel-app/src/Livewire/Timeline/ThreadReplies.php
@@ -21,8 +21,8 @@ final class ThreadReplies extends Component
#[Locked]
public string $timelineId;
- #[On('timeline.reply-created')]
- #[On('timeline.reply-deleted')]
+ #[On(event: 'timeline.reply-created')]
+ #[On(event: 'timeline.reply-deleted')]
public function refresh(): void {}
public function deleteReply(string $replyId, #[CurrentUser] ?User $user): void
diff --git a/app-modules/panel-app/src/Pages/EventPage.php b/app-modules/panel-app/src/Pages/EventPage.php
new file mode 100644
index 000000000..c5b2f0ad9
--- /dev/null
+++ b/app-modules/panel-app/src/Pages/EventPage.php
@@ -0,0 +1,37 @@
+where('id', $record)
+ ->where('tenant_id', filament()->getTenant()->getKey())
+ ->viewableByParticipant()
+ ->exists();
+
+ abort_unless($exists, 404);
+
+ $this->record = $record;
+ }
+}
diff --git a/app-modules/panel-app/src/Pages/EventsPage.php b/app-modules/panel-app/src/Pages/EventsPage.php
new file mode 100644
index 000000000..b309db0e6
--- /dev/null
+++ b/app-modules/panel-app/src/Pages/EventsPage.php
@@ -0,0 +1,21 @@
+schema([
Select::make('country')
->label(__('panel-app::profile.fields.country'))
- ->options([
- 'BRA' => '🇧🇷 Brasil',
- 'USA' => '🇺🇸 United States',
- 'PRT' => '🇵🇹 Portugal',
- 'ARG' => '🇦🇷 Argentina',
- 'DEU' => '🇩🇪 Deutschland',
- 'CAN' => '🇨🇦 Canada',
- 'GBR' => '🇬🇧 United Kingdom',
- 'FRA' => '🇫🇷 France',
- 'ESP' => '🇪🇸 España',
- 'ITA' => '🇮🇹 Italia',
- 'JPN' => '🇯🇵 Japan',
- 'AUS' => '🇦🇺 Australia',
- 'MEX' => '🇲🇽 México',
- 'COL' => '🇨🇴 Colombia',
- 'CHL' => '🇨🇱 Chile',
- 'URY' => '🇺🇾 Uruguay',
- 'IRL' => '🇮🇪 Ireland',
- 'NLD' => '🇳🇱 Nederland',
- ])
+ ->options(static fn (): array => GeoLocation::countries())
->default('BRA')
->searchable()
+ ->preload()
->live()
+ ->afterStateUpdated(static function (Set $set): void {
+ $set('state', null);
+ $set('city', null);
+ })
->columnSpan(1),
Select::make('state')
->label(__('panel-app::profile.fields.state'))
- ->options(fn (Get $get): array => ($get('country') ?? 'BRA') === 'BRA' ? [
- 'AC' => 'Acre', 'AL' => 'Alagoas', 'AP' => 'Amapá', 'AM' => 'Amazonas',
- 'BA' => 'Bahia', 'CE' => 'Ceará', 'DF' => 'Distrito Federal',
- 'ES' => 'Espírito Santo', 'GO' => 'Goiás', 'MA' => 'Maranhão',
- 'MT' => 'Mato Grosso', 'MS' => 'Mato Grosso do Sul', 'MG' => 'Minas Gerais',
- 'PA' => 'Pará', 'PB' => 'Paraíba', 'PR' => 'Paraná', 'PE' => 'Pernambuco',
- 'PI' => 'Piauí', 'RJ' => 'Rio de Janeiro', 'RN' => 'Rio Grande do Norte',
- 'RS' => 'Rio Grande do Sul', 'RO' => 'Rondônia', 'RR' => 'Roraima',
- 'SC' => 'Santa Catarina', 'SP' => 'São Paulo', 'SE' => 'Sergipe',
- 'TO' => 'Tocantins',
- ] : [])
+ ->options(static fn (Get $get): array => GeoLocation::statesFor($get('country')))
->searchable()
- ->allowHtml(condition: false)
->live()
+ ->visible(static fn (Get $get): bool => filled($get('country')))
+ ->afterStateUpdated(static function (Set $set): void {
+ $set('city', null);
+ })
->columnSpan(1),
- TextInput::make('city')
+ Select::make('city')
->label(__('panel-app::profile.fields.city'))
- ->placeholder('São Paulo')
- ->maxLength(100)
- ->live(onBlur: true)
+ ->options(static fn (Get $get): array => GeoLocation::citiesFor($get('country'), $get('state')))
+ ->getSearchResultsUsing(static fn (string $search, Get $get): array => GeoLocation::citiesFor($get('country'), $get('state'), $search))
+ ->getOptionLabelUsing(static fn (?string $value): ?string => $value)
+ ->searchable()
+ ->preload()
+ ->searchPrompt(__('panel-app::profile.placeholders.city_search'))
+ ->hintIcon('heroicon-o-information-circle', __('panel-app::profile.hints.city'))
+ ->visible(static fn (Get $get): bool => filled($get('state')))
->columnSpan(1),
]),
]),
diff --git a/app-modules/panel-app/src/PanelAppServiceProvider.php b/app-modules/panel-app/src/PanelAppServiceProvider.php
index c64ef513a..8a0da2def 100644
--- a/app-modules/panel-app/src/PanelAppServiceProvider.php
+++ b/app-modules/panel-app/src/PanelAppServiceProvider.php
@@ -4,6 +4,10 @@
namespace He4rt\PanelApp;
+use He4rt\PanelApp\Livewire\Events\EventDetail;
+use He4rt\PanelApp\Livewire\Events\EventsList;
+use He4rt\PanelApp\Livewire\Events\MyEventsList;
+use He4rt\PanelApp\Livewire\Events\NumericCodeCheckIn;
use He4rt\PanelApp\Livewire\Timeline\Composer;
use He4rt\PanelApp\Livewire\Timeline\Feed;
use He4rt\PanelApp\Livewire\Timeline\PostShow;
@@ -21,6 +25,11 @@ public function boot(): void
$this->loadViewsFrom(__DIR__.'/../resources/views', 'panel-app');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'panel-app');
+ Livewire::component('events-list', EventsList::class);
+ Livewire::component('my-events-list', MyEventsList::class);
+ Livewire::component('event-detail', EventDetail::class);
+ Livewire::component('numeric-code-check-in', NumericCodeCheckIn::class);
+
Livewire::component('timeline-composer', Composer::class);
Livewire::component('timeline-feed', Feed::class);
Livewire::component('timeline-post-show', PostShow::class);
diff --git a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
new file mode 100644
index 000000000..75e5bd133
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
@@ -0,0 +1,139 @@
+user = User::factory()->create();
+ $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']);
+ $this->tenant->members()->attach($this->user);
+
+ $this->actingAs($this->user);
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+ Filament::setTenant($this->tenant);
+
+ $this->schema = [
+ ['key' => 'why_join', 'type' => 'text', 'label' => 'Why do you want to join?', 'required' => true],
+ ['key' => 'experience_level', 'type' => 'select', 'label' => 'Experience level', 'required' => false, 'options' => ['Beginner', 'Intermediate', 'Advanced']],
+ ];
+
+ $this->event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->application($this->schema), 'enrollmentPolicy')
+ ->create(['title' => 'He4rt Conf Application']);
+});
+
+test('event page renders apply button for application event', function (): void {
+ $this->get(EventPage::getUrl(['record' => $this->event->id]))
+ ->assertSuccessful()
+ ->assertSee('He4rt Conf Application')
+ ->assertSee(__('events::pages.apply_submit'));
+});
+
+test('event detail shows canApply true and canConfirmPresence false for application event', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', value: true)
+ ->assertSet('canConfirmPresence', value: false);
+});
+
+test('when user submits application, then enrollment is created as pending', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', ['why_join' => 'I love Laravel!', 'experience_level' => 'Intermediate'])
+ ->call('apply')
+ ->assertHasNoErrors()
+ ->assertNotified();
+
+ $enrollment = Enrollment::query()
+ ->where('event_id', $this->event->id)
+ ->where('user_id', $this->user->id)
+ ->first();
+
+ expect($enrollment)->not->toBeNull()
+ ->and($enrollment->status)->toBe(EnrollmentStatus::Pending)
+ ->and($enrollment->confirmed_at)->toBeNull()
+ ->and($enrollment->application_data['why_join'])->toBe('I love Laravel!');
+});
+
+test('after submitting application, event detail shows pending status with answers', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', ['why_join' => 'I love Laravel!', 'experience_level' => 'Intermediate'])
+ ->call('apply');
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', value: false)
+ ->assertSee(EnrollmentStatus::Pending->getLabel())
+ ->assertSee(__('events::pages.application_pending_hint'))
+ ->assertSee('I love Laravel!');
+});
+
+test('when application is rejected, then rejection reason is shown', function (): void {
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Rejected,
+ 'enrolled_at' => now(),
+ 'rejection_reason' => 'Not enough experience.',
+ 'application_data' => ['why_join' => 'I am new'],
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', value: false)
+ ->assertSee(EnrollmentStatus::Rejected->getLabel())
+ ->assertSee(__('events::pages.application_rejected_hint'))
+ ->assertSee('Not enough experience.');
+});
+
+test('when user has already applied, then apply button is not shown', function (): void {
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Pending,
+ 'enrolled_at' => now(),
+ 'application_data' => ['why_join' => 'My answer'],
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', value: false)
+ ->assertDontSee(__('events::pages.apply_submit'));
+});
+
+test('when questions are reordered after submission, then answers still match the correct question', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', ['why_join' => 'I love Laravel!', 'experience_level' => 'Intermediate'])
+ ->call('apply');
+
+ $this->event->enrollmentPolicy->update([
+ 'application_schema' => array_reverse($this->schema),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSeeInOrder([
+ 'Experience level',
+ 'Intermediate',
+ 'Why do you want to join?',
+ 'I love Laravel!',
+ ]);
+});
+
+test('when application data is missing required field, then error notification is shown', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', ['why_join' => '', 'experience_level' => 'Beginner'])
+ ->call('apply')
+ ->assertNotified();
+
+ expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(0);
+});
diff --git a/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php
new file mode 100644
index 000000000..fcd5529f6
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php
@@ -0,0 +1,67 @@
+user = User::factory()->create();
+ $this->tenant = Tenant::factory()->create(['slug' => 'numeric-code-test-tenant']);
+ $this->tenant->members()->attach($this->user);
+
+ $this->actingAs($this->user);
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+ Filament::setTenant($this->tenant);
+
+ $this->event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp()->state([
+ 'check_in_method' => CheckInMethod::NumericCode,
+ ]), 'enrollmentPolicy')
+ ->create();
+
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ RateLimiter::clear(sprintf('numeric-code-check-in:%s:%s:%s', $this->user->id, $this->event->id, md5('127.0.0.1')));
+});
+
+test('when check-in code format is invalid, then component rejects it before action', function (): void {
+ livewire(NumericCodeCheckIn::class, ['eventId' => $this->event->id])
+ ->set('code', 'abc123')
+ ->call('checkIn')
+ ->assertHasErrors(['code']);
+});
+
+test('when participant repeats invalid numeric codes, then component rate limits attempts', function (): void {
+ $component = livewire(NumericCodeCheckIn::class, ['eventId' => $this->event->id])
+ ->set('code', '999999');
+
+ foreach (range(1, 5) as $_) {
+ $component
+ ->call('checkIn')
+ ->assertSet('error', __('events::check_in.invalid_check_in_code'));
+ }
+
+ $component
+ ->call('checkIn')
+ ->assertSet('error', fn (string $error): bool => str_contains($error, 'Too many attempts.'));
+});
diff --git a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
new file mode 100644
index 000000000..1bcc5f39f
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
@@ -0,0 +1,205 @@
+user = User::factory()->create();
+ $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']);
+ $this->tenant->members()->attach($this->user);
+
+ $this->actingAs($this->user);
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+ Filament::setTenant($this->tenant);
+
+ $this->event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create(['title' => 'He4rt Meetup RSVP']);
+});
+
+test('events page renders successfully', function (): void {
+ $this->get(EventsPage::getUrl())
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP');
+});
+
+test('event page renders confirm presence button for rsvp event', function (): void {
+ $this->get(EventPage::getUrl(['record' => $this->event->id]))
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP')
+ ->assertSee(__('events::pages.confirm_presence'));
+});
+
+test('when user confirms presence, then enrollment is created and shown in my events', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence')
+ ->assertHasNoErrors();
+
+ $enrollment = Enrollment::query()
+ ->where('event_id', $this->event->id)
+ ->where('user_id', $this->user->id)
+ ->first();
+
+ expect($enrollment)->not->toBeNull()
+ ->and($enrollment->status)->toBe(EnrollmentStatus::Confirmed);
+
+ $this->get(MyEventsPage::getUrl())
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP')
+ ->assertSee(EnrollmentStatus::Confirmed->getLabel());
+});
+
+test('when user tries to confirm presence twice, then duplicate enrollment is rejected', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence');
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence')
+ ->assertNotified();
+
+ expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(1);
+});
+
+test('event page returns 404 for event from another tenant', function (): void {
+ $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']);
+ $otherEvent = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($otherTenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create();
+
+ $this->get(EventPage::getUrl(['record' => $otherEvent->id]))
+ ->assertNotFound();
+});
+
+test('when event is at capacity without waitlist, then event full message is shown', function (): void {
+ $this->event->enrollmentPolicy->update([
+ 'capacity' => 1,
+ 'has_waitlist' => false,
+ ]);
+
+ $otherUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $otherUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canConfirmPresence', value: false)
+ ->assertSet('isEventFull', value: true)
+ ->assertSee(__('events::pages.event_full'))
+ ->assertDontSee(__('events::pages.confirm_presence'));
+});
+
+test('when user is waitlisted, then waitlist position is shown on event page', function (): void {
+ $this->event->enrollmentPolicy->update([
+ 'capacity' => 1,
+ 'has_waitlist' => true,
+ ]);
+
+ $otherUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $otherUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence')
+ ->assertHasNoErrors()
+ ->assertSee(__('events::pages.waitlist_status', ['position' => 1]));
+});
+
+test('when event is at capacity with waitlist, then confirm presence button is still shown', function (): void {
+ $this->event->enrollmentPolicy->update([
+ 'capacity' => 1,
+ 'has_waitlist' => true,
+ ]);
+
+ $otherUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $otherUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canConfirmPresence', value: true)
+ ->assertSee(__('events::pages.confirm_presence'));
+});
+
+test('when enrolled event is completed, then event detail page is accessible', function (): void {
+ $this->event->update(['status' => EventStatus::Completed]);
+
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ $this->get(EventPage::getUrl(['record' => $this->event->id]))
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP');
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canConfirmPresence', value: false);
+});
+
+test('when enrolled event is draft, then my events list does not link to detail', function (): void {
+ $this->event->update(['status' => EventStatus::Draft]);
+
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(MyEventsList::class)
+ ->assertSet('enrollments', fn ($enrollments): bool => $enrollments->count() === 1)
+ ->assertSee('He4rt Meetup RSVP')
+ ->assertDontSee(EventPage::getUrl(['record' => $this->event->id]), escape: false);
+});
+
+test('past event does not show confirm presence button', function (): void {
+ $pastEvent = Event::factory()
+ ->published()
+ ->past()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create(['title' => 'Past Meetup']);
+
+ livewire(EventDetail::class, ['eventId' => $pastEvent->id])
+ ->assertSet('canConfirmPresence', value: false)
+ ->assertDontSee(__('events::pages.confirm_presence'));
+});
diff --git a/app-modules/panel-app/tests/Feature/ProfilePageTest.php b/app-modules/panel-app/tests/Feature/ProfilePageTest.php
index 428e7a260..60ff35996 100644
--- a/app-modules/panel-app/tests/Feature/ProfilePageTest.php
+++ b/app-modules/panel-app/tests/Feature/ProfilePageTest.php
@@ -12,10 +12,33 @@
use He4rt\Profile\Enums\StartAvailability;
use He4rt\Profile\Models\Profile;
use He4rt\Profile\Models\Skill;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Http;
use function Pest\Livewire\livewire;
beforeEach(function (): void {
+ Cache::flush();
+
+ Http::fake([
+ 'world.bmbc.cloud/api/countries*' => Http::response([
+ 'success' => true,
+ 'data' => [
+ ['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil'],
+ ['id' => 231, 'iso2' => 'US', 'iso3' => 'USA', 'name' => 'United States'],
+ ],
+ ]),
+ 'world.bmbc.cloud/api/states*' => Http::response([
+ 'success' => true,
+ 'data' => [
+ ['id' => 486, 'name' => 'São Paulo', 'cities' => [
+ ['id' => 2, 'name' => 'São Paulo'],
+ ['id' => 3, 'name' => 'Campinas'],
+ ]],
+ ],
+ ]),
+ ]);
+
$this->user = User::factory()->create();
$this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']);
$this->tenant->members()->attach($this->user);
diff --git a/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php b/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php
index 8311289d1..d4bc4f4c2 100644
--- a/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php
+++ b/app-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.php
@@ -7,6 +7,7 @@
use He4rt\Activity\Timeline\Timeline;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
+use He4rt\PanelApp\Livewire\Timeline\PostShow;
use He4rt\PanelApp\Livewire\Timeline\ReplyComposer;
use He4rt\PanelApp\Livewire\Timeline\ThreadReplies;
use He4rt\PanelApp\Pages\ThreadPage;
@@ -84,6 +85,23 @@
->assertSeeInOrder(['First reply', 'Second reply']);
});
+test('post renders without crashing when its author was deleted', function (): void {
+ $ghost = User::factory()->create();
+ $entry = PostEntry::factory()->create(['content' => 'Orphaned post content']);
+ $orphan = Timeline::factory()->for($ghost)->create([
+ 'tenant_id' => $this->tenant->id,
+ 'postable_type' => (new PostEntry)->getMorphClass(),
+ 'postable_id' => $entry->id,
+ ]);
+
+ $ghost->delete();
+
+ livewire(PostShow::class, ['timelineId' => $orphan->id])
+ ->assertOk()
+ ->assertSee('Usuário removido')
+ ->assertSee('Orphaned post content');
+});
+
// --- Critical: Tenant isolation ---
test('thread page returns 404 for post from another tenant', function (): void {
diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php
index 63824d6d5..770b5e59e 100644
--- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php
+++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php
@@ -17,8 +17,8 @@
use Livewire\Attributes\Url;
use Livewire\Component;
-#[Layout('portal::components.layouts.deck')]
-#[Title('Quem fez a He4rt bater')]
+#[Layout(name: 'portal::components.layouts.deck')]
+#[Title(content: 'Quem fez a He4rt bater')]
final class CommunityRetrospectivePage extends Component
{
public string $tenantId;
diff --git a/app-modules/portal/src/Livewire/Homepage.php b/app-modules/portal/src/Livewire/Homepage.php
index fbd79fa31..d1de23f08 100644
--- a/app-modules/portal/src/Livewire/Homepage.php
+++ b/app-modules/portal/src/Livewire/Homepage.php
@@ -9,8 +9,8 @@
use Livewire\Attributes\Title;
use Livewire\Component;
-#[Layout('portal::components.layouts.app')]
-#[Title('Home')]
+#[Layout(name: 'portal::components.layouts.app')]
+#[Title(content: 'Home')]
final class Homepage extends Component
{
public function render(): View
diff --git a/app-modules/portal/src/Livewire/SocialLinksPage.php b/app-modules/portal/src/Livewire/SocialLinksPage.php
index 6885e9656..ce3f1064c 100644
--- a/app-modules/portal/src/Livewire/SocialLinksPage.php
+++ b/app-modules/portal/src/Livewire/SocialLinksPage.php
@@ -10,8 +10,8 @@
use Livewire\Attributes\Title;
use Livewire\Component;
-#[Layout('portal::components.layouts.app')]
-#[Title('Nossas redes')]
+#[Layout(name: 'portal::components.layouts.app')]
+#[Title(content: 'Nossas redes')]
final class SocialLinksPage extends Component
{
/**
diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/portal/src/Retrospective/CommunityRetrospective.php
index 60da2533a..af26f16c9 100644
--- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php
+++ b/app-modules/portal/src/Retrospective/CommunityRetrospective.php
@@ -211,7 +211,7 @@ private function filteredOutByOutcome(GithubContribution $contribution): bool
return match ($this->filters->outcome) {
'merged' => !$merged,
'open' => $state !== 'open',
- 'closed' => !($state === 'closed' && !$merged),
+ 'closed' => $state !== 'closed' || $merged,
};
}
diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php
index 2c44b529a..3e536350d 100644
--- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php
+++ b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php
@@ -219,6 +219,28 @@ function contribution(Tenant $tenant, array $attributes): GithubContribution
->and($data['people'][0]['prs'])->toBe(1);
});
+it('keeps only truly-closed PRs under outcome=closed: !(A ∧ ¬B) ≡ ¬A ∨ B', function (string $state, bool $merged, bool $shouldKeep): void {
+ contribution($this->tenant, [
+ 'actor_login' => 'a',
+ 'type' => ContributionType::Pr,
+ 'external_ref' => 'pr:1',
+ 'occurred_at' => '2026-06-02',
+ 'metadata' => ['state' => $state, 'merged' => $merged],
+ ]);
+
+ $filters = RetrospectiveFilters::make($this->since, $this->until, outcome: 'closed');
+ $data = ($this->build)($filters);
+
+ // meta.prs counts PRs surviving reject(filteredOutByOutcome).
+ expect($data['meta']['prs'])->toBe($shouldKeep ? 1 : 0);
+})->with([
+ // state, merged, keep?
+ 'closed + unmerged → kept (the only true "closed")' => ['closed', false, true],
+ 'closed + merged → dropped (it is a merge, not a pure close)' => ['closed', true, false],
+ 'open + unmerged → dropped (state !== closed)' => ['open', false, false],
+ 'open + merged → dropped (state !== closed)' => ['open', true, false],
+]);
+
it('ordena o ranking por linhas quando sort=lines', function (): void {
contribution($this->tenant, ['actor_login' => 'poucas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 10, 'deletions' => 0]]);
contribution($this->tenant, ['actor_login' => 'muitas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 900, 'deletions' => 50]]);
diff --git a/app-modules/profile/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php
index 2f2f3c247..433fc64e5 100644
--- a/app-modules/profile/src/Models/Profile.php
+++ b/app-modules/profile/src/Models/Profile.php
@@ -43,7 +43,7 @@
* @property CarbonInterface|null $created_at
* @property CarbonInterface|null $updated_at
*/
-#[UseFactory(ProfileFactory::class)]
+#[UseFactory(factoryClass: ProfileFactory::class)]
#[Table(name: 'user_profiles')]
final class Profile extends Model
{
diff --git a/app-modules/profile/src/Models/WorkExperience.php b/app-modules/profile/src/Models/WorkExperience.php
index a6db349e8..a1e41b722 100644
--- a/app-modules/profile/src/Models/WorkExperience.php
+++ b/app-modules/profile/src/Models/WorkExperience.php
@@ -26,7 +26,7 @@
* @property CarbonInterface|null $updated_at
* @property-read Profile $profile
*/
-#[UseFactory(WorkExperienceFactory::class)]
+#[UseFactory(factoryClass: WorkExperienceFactory::class)]
#[Table(name: 'work_experiences')]
final class WorkExperience extends Model
{
diff --git a/app-modules/squads/composer.json b/app-modules/squads/composer.json
index c7b79a4c9..784beeb38 100644
--- a/app-modules/squads/composer.json
+++ b/app-modules/squads/composer.json
@@ -20,7 +20,7 @@
"extra": {
"laravel": {
"providers": [
- "He4rt\\Squads\\Providers\\SquadsServiceProvider"
+ "He4rt\\Squads\\SquadsServiceProvider"
]
}
}
diff --git a/app-modules/squads/src/Providers/SquadsServiceProvider.php b/app-modules/squads/src/SquadsServiceProvider.php
similarity index 85%
rename from app-modules/squads/src/Providers/SquadsServiceProvider.php
rename to app-modules/squads/src/SquadsServiceProvider.php
index c9437e8e2..35c7e62f6 100644
--- a/app-modules/squads/src/Providers/SquadsServiceProvider.php
+++ b/app-modules/squads/src/SquadsServiceProvider.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace He4rt\Squads\Providers;
+namespace He4rt\Squads;
use Illuminate\Support\ServiceProvider;
diff --git a/app/Console/Commands/AnalyzeDiscordProfiles.php b/app/Console/Commands/AnalyzeDiscordProfiles.php
index a8c4a885a..56288fa92 100644
--- a/app/Console/Commands/AnalyzeDiscordProfiles.php
+++ b/app/Console/Commands/AnalyzeDiscordProfiles.php
@@ -15,8 +15,8 @@
use function Laravel\Prompts\table;
use function Laravel\Prompts\warning;
-#[Description('Analyze scraped Discord profiles and count connected social accounts')]
-#[Signature('discord:analyze-profiles')]
+#[Description(description: 'Analyze scraped Discord profiles and count connected social accounts')]
+#[Signature(signature: 'discord:analyze-profiles')]
class AnalyzeDiscordProfiles extends Command
{
public function handle(): void
diff --git a/app/Console/Commands/CommunityReport.php b/app/Console/Commands/CommunityReport.php
index b71e5b72e..164a2bc8d 100644
--- a/app/Console/Commands/CommunityReport.php
+++ b/app/Console/Commands/CommunityReport.php
@@ -21,8 +21,8 @@
use function Laravel\Prompts\table;
use function Laravel\Prompts\warning;
-#[Description('Generate a comprehensive community analytics report')]
-#[Signature('discord:community-report')]
+#[Description(description: 'Generate a comprehensive community analytics report')]
+#[Signature(signature: 'discord:community-report')]
class CommunityReport extends Command
{
/** @var array */
diff --git a/app/Console/Commands/FetchDiscordMembers.php b/app/Console/Commands/FetchDiscordMembers.php
index ce765efcd..81aab108d 100644
--- a/app/Console/Commands/FetchDiscordMembers.php
+++ b/app/Console/Commands/FetchDiscordMembers.php
@@ -11,8 +11,8 @@
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Sleep;
-#[Description('Fetch all guild members from Discord API and save to JSON')]
-#[Signature('discord:fetch-members {--limit=0 : Max members to fetch (0 = all)}')]
+#[Description(description: 'Fetch all guild members from Discord API and save to JSON')]
+#[Signature(signature: 'discord:fetch-members {--limit=0 : Max members to fetch (0 = all)}')]
class FetchDiscordMembers extends Command
{
public function handle(): void
diff --git a/app/Console/Commands/FetchDiscordProfile.php b/app/Console/Commands/FetchDiscordProfile.php
index 5c28f73a1..6d7be0475 100644
--- a/app/Console/Commands/FetchDiscordProfile.php
+++ b/app/Console/Commands/FetchDiscordProfile.php
@@ -9,8 +9,8 @@
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
-#[Description('Fetch a Discord user profile including connected accounts using a user token')]
-#[Signature('discord:fetch-profile
+#[Description(description: 'Fetch a Discord user profile including connected accounts using a user token')]
+#[Signature(signature: 'discord:fetch-profile
{user_id : Discord user ID to fetch}
{--token= : Discord user token (or set DISCORD_USER_TOKEN in .env)}')]
class FetchDiscordProfile extends Command
diff --git a/app/Console/Commands/FetchDiscordProfiles.php b/app/Console/Commands/FetchDiscordProfiles.php
index 3a64c4276..5fce87e00 100644
--- a/app/Console/Commands/FetchDiscordProfiles.php
+++ b/app/Console/Commands/FetchDiscordProfiles.php
@@ -20,8 +20,8 @@
use function Laravel\Prompts\task;
use function Laravel\Prompts\warning;
-#[Description('Fetch full Discord profiles for all members, tracking progress across runs')]
-#[Signature('discord:fetch-profiles
+#[Description(description: 'Fetch full Discord profiles for all members, tracking progress across runs')]
+#[Signature(signature: 'discord:fetch-profiles
{--token= : Discord user token (or set DISCORD_USER_TOKEN in .env)}
{--members-file=discord/members.json : Path to members JSON}
{--limit=0 : Max profiles to fetch this run (0 = all remaining)}
diff --git a/app/Console/Commands/FixPostSwitchTimestampsCommand.php b/app/Console/Commands/FixPostSwitchTimestampsCommand.php
index e130e9155..8d54f5258 100644
--- a/app/Console/Commands/FixPostSwitchTimestampsCommand.php
+++ b/app/Console/Commands/FixPostSwitchTimestampsCommand.php
@@ -16,8 +16,8 @@
use function Laravel\Prompts\task;
use function Laravel\Prompts\warning;
-#[Description('Fix post-switch timestamp data after ALTER migrations (Option D Step 2)')]
-#[Signature('maintenance:fix-post-switch-timestamps
+#[Description(description: 'Fix post-switch timestamp data after ALTER migrations (Option D Step 2)')]
+#[Signature(signature: 'maintenance:fix-post-switch-timestamps
{--dry-run : Show what would be done without altering data}
{--module= : Process only a specific module}
{--table= : Process only a specific table}')]
diff --git a/app/Console/Commands/GenerateDiscordTenant.php b/app/Console/Commands/GenerateDiscordTenant.php
index a671680bc..3e1e821a0 100644
--- a/app/Console/Commands/GenerateDiscordTenant.php
+++ b/app/Console/Commands/GenerateDiscordTenant.php
@@ -13,8 +13,8 @@
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
-#[Description('Command description')]
-#[Signature('misc:generate-discord-tenant {guildId}')]
+#[Description(description: 'Command description')]
+#[Signature(signature: 'misc:generate-discord-tenant {guildId}')]
class GenerateDiscordTenant extends Command
{
/**
diff --git a/app/Console/Commands/ImportDiscordMembers.php b/app/Console/Commands/ImportDiscordMembers.php
index 374acbbbb..171eeb614 100644
--- a/app/Console/Commands/ImportDiscordMembers.php
+++ b/app/Console/Commands/ImportDiscordMembers.php
@@ -14,8 +14,8 @@
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Support\Facades\Storage;
-#[Description('Import Discord members and GitHub connections from JSON files into the database')]
-#[Signature('discord:import-members
+#[Description(description: 'Import Discord members and GitHub connections from JSON files into the database')]
+#[Signature(signature: 'discord:import-members
{--members-file=discord/members.json : Path to members JSON (relative to storage/app/private)}
{--github-file=discord/github_connections.json : Path to GitHub connections JSON}
{--dry-run : Show what would be imported without writing}
diff --git a/app/Geo/Models/GeoCountry.php b/app/Geo/Models/GeoCountry.php
new file mode 100644
index 000000000..97600ca5b
--- /dev/null
+++ b/app/Geo/Models/GeoCountry.php
@@ -0,0 +1,47 @@
+
+ */
+ protected $schema = [
+ 'id' => 'integer',
+ 'iso2' => 'string',
+ 'iso3' => 'string',
+ 'name' => 'string',
+ ];
+
+ /**
+ * @return list>
+ */
+ public function getRows(): array
+ {
+ return Cache::remember(
+ 'geo.countries',
+ 60 * 60 * 24 * 30,
+ static fn (): array => resolve(WorldApiClient::class)->countries(),
+ );
+ }
+}
diff --git a/app/Geo/Support/GeoLocation.php b/app/Geo/Support/GeoLocation.php
new file mode 100644
index 000000000..949044665
--- /dev/null
+++ b/app/Geo/Support/GeoLocation.php
@@ -0,0 +1,135 @@
+ ISO3 => country name
+ */
+ public static function countries(): array
+ {
+ return GeoCountry::query()
+ ->orderBy('name')
+ ->get()
+ ->mapWithKeys(static fn (GeoCountry $country): array => [
+ $country->iso3 => $country->name,
+ ])
+ ->all();
+ }
+
+ public static function countryLabel(?string $iso3): ?string
+ {
+ if (blank($iso3)) {
+ return null;
+ }
+
+ /** @var string|null $name */
+ $name = GeoCountry::query()
+ ->where('iso3', mb_strtoupper($iso3))
+ ->value('name');
+
+ return $name;
+ }
+
+ /**
+ * @return array state name => state name
+ */
+ public static function statesFor(?string $iso3): array
+ {
+ return collect(self::statesPayload($iso3))
+ ->sortBy('name')
+ ->mapWithKeys(static fn (array $state): array => [
+ (string) $state['name'] => (string) $state['name'],
+ ])
+ ->all();
+ }
+
+ /**
+ * @return array city name => city name
+ */
+ public static function citiesFor(?string $iso3, ?string $stateName, ?string $search = null): array
+ {
+ if (blank($stateName)) {
+ return [];
+ }
+
+ /** @var array|null $state */
+ $state = collect(self::statesPayload($iso3))
+ ->firstWhere('name', $stateName);
+
+ if ($state === null) {
+ return [];
+ }
+
+ /** @var list> $cities */
+ $cities = $state['cities'] ?? [];
+ $term = mb_trim((string) $search);
+
+ return collect($cities)
+ ->when($term !== '', static fn (Collection $collection): Collection => $collection->filter(
+ static fn (array $city): bool => self::matches((string) $city['name'], $term),
+ ))
+ ->sortBy('name')
+ ->take(50)
+ ->mapWithKeys(static fn (array $city): array => [
+ (string) $city['name'] => (string) $city['name'],
+ ])
+ ->all();
+ }
+
+ public static function formatLocation(?string $city, ?string $state, ?string $iso3): ?string
+ {
+ $country = filled($iso3) ? (self::countryLabel($iso3) ?? $iso3) : null;
+
+ $location = collect([$city, $state, $country])
+ ->filter()
+ ->implode(', ');
+
+ return $location !== '' ? $location : null;
+ }
+
+ /**
+ * @return list>
+ */
+ private static function statesPayload(?string $iso3): array
+ {
+ if (blank($iso3)) {
+ return [];
+ }
+
+ $iso3 = mb_strtoupper($iso3);
+
+ /** @var list> $payload */
+ $payload = Cache::remember('geo.states.'.$iso3, self::CACHE_TTL, static function () use ($iso3): array {
+ /** @var string|null $iso2 */
+ $iso2 = GeoCountry::query()->where('iso3', $iso3)->value('iso2');
+
+ if (blank($iso2)) {
+ return [];
+ }
+
+ return resolve(WorldApiClient::class)->states((string) $iso2);
+ });
+
+ return $payload;
+ }
+
+ private static function matches(string $value, string $term): bool
+ {
+ return str_contains(
+ Str::lower(Str::ascii($value)),
+ Str::lower(Str::ascii($term)),
+ );
+ }
+}
diff --git a/app/Geo/WorldApiClient.php b/app/Geo/WorldApiClient.php
new file mode 100644
index 000000000..c64eaf5a8
--- /dev/null
+++ b/app/Geo/WorldApiClient.php
@@ -0,0 +1,53 @@
+>
+ */
+ public function countries(): array
+ {
+ return $this->get('/countries', [
+ 'fields' => 'iso2,iso3',
+ ]);
+ }
+
+ /**
+ * @return list>
+ */
+ public function states(string $iso2): array
+ {
+ return $this->get('/states', [
+ 'filters' => ['country_code' => $iso2],
+ 'fields' => 'cities',
+ ]);
+ }
+
+ /**
+ * @param array $query
+ * @return list>
+ */
+ private function get(string $path, array $query = []): array
+ {
+ $response = Http::baseUrl(mb_rtrim($this->baseUrl, '/'))
+ ->acceptJson()
+ ->get($path, $query);
+
+ $response->throw();
+
+ /** @var list> $data */
+ $data = $response->json('data', []);
+
+ return $data;
+ }
+}
diff --git a/app/Http/Controllers/SwitchLocaleController.php b/app/Http/Controllers/SwitchLocaleController.php
new file mode 100644
index 000000000..283ae17d4
--- /dev/null
+++ b/app/Http/Controllers/SwitchLocaleController.php
@@ -0,0 +1,20 @@
+ $locale]);
+
+ return back();
+ }
+}
diff --git a/app/Http/Middleware/SetApplicationLocale.php b/app/Http/Middleware/SetApplicationLocale.php
new file mode 100644
index 000000000..6f202800b
--- /dev/null
+++ b/app/Http/Middleware/SetApplicationLocale.php
@@ -0,0 +1,23 @@
+app->bind(PaginatorInterface::class, Paginator::class);
+ $this->app->singleton(WorldApiClient::class, fn (): WorldApiClient => new WorldApiClient(
+ config()->string('geo.world_api_url'),
+ ));
+
$this->registerDebugbar();
$this->registerTelescope();
}
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 12abd930f..4685346fb 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -6,6 +6,7 @@
use App\Enums\FilamentPanel;
use App\Filament\Pages\Login;
+use App\Http\Middleware\SetApplicationLocale;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -53,6 +54,7 @@ public function panel(Panel $panel): Panel
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
+ SetApplicationLocale::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php
index 3d225ca57..52cb04081 100644
--- a/app/Providers/Filament/AppPanelProvider.php
+++ b/app/Providers/Filament/AppPanelProvider.php
@@ -5,6 +5,7 @@
namespace App\Providers\Filament;
use App\Enums\FilamentPanel;
+use App\Http\Middleware\SetApplicationLocale;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -13,7 +14,10 @@
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use He4rt\Identity\Tenant\Models\Tenant;
+use He4rt\PanelApp\Pages\EventPage;
+use He4rt\PanelApp\Pages\EventsPage;
use He4rt\PanelApp\Pages\LoginPage;
+use He4rt\PanelApp\Pages\MyEventsPage;
use He4rt\PanelApp\Pages\ProfilePage;
use He4rt\PanelApp\Pages\ThreadPage;
use He4rt\PanelApp\Pages\TimelinePage;
@@ -46,6 +50,9 @@ public function panel(Panel $panel): Panel
->discoverWidgets(in: app_path('Filament/App/Widgets'), for: 'App\Filament\App\Widgets')
->pages([
TimelinePage::class,
+ EventsPage::class,
+ MyEventsPage::class,
+ EventPage::class,
ThreadPage::class,
ProfilePage::class,
])
@@ -53,6 +60,7 @@ public function panel(Panel $panel): Panel
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
+ SetApplicationLocale::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
diff --git a/app/Providers/FilamentServiceProvider.php b/app/Providers/FilamentServiceProvider.php
index 4077959aa..134113479 100644
--- a/app/Providers/FilamentServiceProvider.php
+++ b/app/Providers/FilamentServiceProvider.php
@@ -5,6 +5,7 @@
namespace App\Providers;
use App\Enums\FilamentPanel;
+use App\Support\ApplicationLocale;
use Filament\Actions\Action;
use Filament\Forms\Components\Builder;
use Filament\Forms\Components\DateTimePicker;
@@ -12,6 +13,7 @@
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Panel;
+use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\VerticalAlignment;
use Filament\Support\Enums\Width;
@@ -44,6 +46,7 @@ public function boot(): void
$this->configureBuilder();
$this->configureSelectFilter();
$this->configureTable();
+ $this->configureSchema();
}
public function register(): void
@@ -123,7 +126,18 @@ private function configureTable(): void
->defaultPaginationPageOption(10)
->filtersFormWidth(Width::Medium)
->paginated([10, 25, 50])
- ->emptyStateIcon(Heroicon::OutlinedExclamationTriangle));
+ ->emptyStateIcon(Heroicon::OutlinedExclamationTriangle)
+ ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat())
+ ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat())
+ ->defaultTimeDisplayFormat('H:i:s'));
+ }
+
+ private function configureSchema(): void
+ {
+ Schema::configureUsing(fn (Schema $schema): Schema => $schema
+ ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat())
+ ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat())
+ ->defaultTimeDisplayFormat('H:i:s'));
}
private function configureSelect(): void
@@ -143,6 +157,8 @@ private function configureDateTimePicker(): void
->seconds(condition: false)
->minDate(now()->subYears(25))
->maxDate(now()->addYears(25))
+ ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat())
+ ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat())
->translateLabel());
}
diff --git a/app/Support/ApplicationLocale.php b/app/Support/ApplicationLocale.php
new file mode 100644
index 000000000..e39be1a7e
--- /dev/null
+++ b/app/Support/ApplicationLocale.php
@@ -0,0 +1,70 @@
+
+ */
+ public const array SUPPORTED = [
+ self::EN,
+ self::PT_BR,
+ ];
+
+ public static function isSupported(string $locale): bool
+ {
+ return in_array($locale, self::SUPPORTED, strict: true);
+ }
+
+ public static function resolve(): string
+ {
+ $locale = session(self::SESSION_KEY, config('app.locale', self::EN));
+
+ if (!is_string($locale) || !self::isSupported($locale)) {
+ return self::EN;
+ }
+
+ return $locale;
+ }
+
+ public static function apply(string $locale): void
+ {
+ app()->setLocale($locale);
+ Date::setLocale(self::carbonLocale($locale));
+ }
+
+ public static function carbonLocale(string $locale): string
+ {
+ return match ($locale) {
+ self::PT_BR => 'pt_BR',
+ default => 'en',
+ };
+ }
+
+ public static function dateFormat(): string
+ {
+ return match (app()->getLocale()) {
+ self::PT_BR => 'd/m/Y',
+ default => 'M j, Y',
+ };
+ }
+
+ public static function dateTimeFormat(): string
+ {
+ return match (app()->getLocale()) {
+ self::PT_BR => 'd/m/Y H:i:s',
+ default => 'M j, Y H:i:s',
+ };
+ }
+}
diff --git a/app/Support/PestShardPlugin.php b/app/Support/PestShardPlugin.php
new file mode 100644
index 000000000..6992dc70d
--- /dev/null
+++ b/app/Support/PestShardPlugin.php
@@ -0,0 +1,558 @@
+\tests\...` (lowercase
+ * `tests`), so they match nothing, get filtered OUT of every shard, and run in
+ * NO shard — 87/945 tests were silently skipped in sharded CI while it stayed
+ * green. This plugin generalizes that one regex to `([^:]+)` so every test
+ * class is discovered regardless of namespace.
+ *
+ * This class is a non-official plugin, so Pest's Loader sorts it BEFORE the
+ * official `Pest\Plugins\Shard`; it consumes `--shard` first (via popArgument),
+ * leaving the built-in one a no-op. Everything else mirrors the upstream Shard
+ * plugin verbatim so `--update-shards` / time-balancing keep working.
+ *
+ * Remove once Pest fixes the upstream regex.
+ *
+ * @see https://mozex.dev/blog/2-fixing-pest-sharding-for-modular-laravel-applications
+ * @see https://github.com/pestphp/pest/issues/1445
+ * @see https://github.com/pestphp/pest/issues/1711
+ * @see https://github.com/pestphp/pest/issues/1688
+ */
+final class PestShardPlugin implements AddsOutput, HandlesArguments, Terminable
+{
+ use HandleArguments;
+
+ private const string SHARD_OPTION = 'shard';
+
+ /**
+ * The shard index and total number of shards.
+ *
+ * @var array{
+ * index: int,
+ * total: int,
+ * testsRan: int,
+ * testsCount: int
+ * }|null
+ */
+ private static ?array $shard = null;
+
+ /**
+ * Whether to update the shards.json file.
+ */
+ private static bool $updateShards = false;
+
+ /**
+ * Whether time-balanced sharding was used.
+ */
+ private static bool $timeBalanced = false;
+
+ /**
+ * Whether the shards.json file is outdated.
+ */
+ private static bool $shardsOutdated = false;
+
+ /**
+ * Whether the test suite passed.
+ */
+ private static bool $passed = false;
+
+ /**
+ * Collected timings from workers or subscribers.
+ *
+ * @var array|null
+ */
+ private static ?array $collectedTimings = null;
+
+ /**
+ * The canonical list of test classes from --list-tests.
+ *
+ * @var list|null
+ */
+ private static ?array $knownTests = null;
+
+ /**
+ * Creates a new Plugin instance.
+ */
+ public function __construct(
+ private readonly OutputInterface $output,
+ ) {}
+
+ /**
+ * Returns the shard information.
+ *
+ * @return array{index: int, total: int}
+ */
+ public static function getShard(InputInterface $input): array
+ {
+ if ($input->hasParameterOption('--'.self::SHARD_OPTION)) {
+ $shard = $input->getParameterOption('--'.self::SHARD_OPTION);
+ } else {
+ $shard = null;
+ }
+
+ if (!is_string($shard) || !preg_match('/^\d+\/\d+$/', $shard)) {
+ throw new InvalidOption('The [--shard] option must be in the format "index/total".');
+ }
+
+ [$index, $total] = explode('/', $shard);
+
+ if (!is_numeric($index) || !is_numeric($total)) {
+ throw new InvalidOption('The [--shard] option must be in the format "index/total".');
+ }
+
+ if ($index <= 0 || $total <= 0 || $index > $total) {
+ throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.');
+ }
+
+ $index = (int) $index;
+ $total = (int) $total;
+
+ return [
+ 'index' => $index,
+ 'total' => $total,
+ ];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function handleArguments(array $arguments): array
+ {
+ if ($this->hasArgument('--update-shards', $arguments)) {
+ return $this->handleUpdateShards($arguments);
+ }
+
+ if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
+ self::$updateShards = true;
+
+ Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
+ Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
+
+ return $arguments;
+ }
+
+ if (!$this->hasArgument('--shard', $arguments)) {
+ return $arguments;
+ }
+
+ // @phpstan-ignore-next-line
+ $input = new ArgvInput($arguments);
+
+ ['index' => $index, 'total' => $total] = self::getShard($input);
+
+ $arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument(
+ "$index/$total",
+ $arguments,
+ )));
+
+ /** @phpstan-ignore-next-line */
+ $tests = $this->allTests($arguments);
+
+ $timings = $this->loadShardsFile();
+ if ($timings !== null) {
+ $knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
+ $newTests = array_values(array_diff($tests, $knownTests));
+
+ $partitions = $this->partitionByTime($knownTests, $timings, $total);
+
+ foreach ($newTests as $i => $test) {
+ $partitions[$i % $total][] = $test;
+ }
+
+ $testsToRun = $partitions[$index - 1] ?? [];
+ self::$timeBalanced = true;
+ self::$shardsOutdated = $newTests !== [];
+ } else {
+ $testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
+ }
+
+ self::$shard = [
+ 'index' => $index,
+ 'total' => $total,
+ 'testsRan' => count($testsToRun),
+ 'testsCount' => count($tests),
+ ];
+
+ if ($testsToRun === []) {
+ return $arguments;
+ }
+
+ return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
+ }
+
+ /**
+ * Adds output after the Test Suite execution.
+ */
+ public function addOutput(int $exitCode): int
+ {
+ self::$passed = $exitCode === 0;
+
+ if (self::$updateShards && self::$passed && !Parallel::isWorker()) {
+ self::$collectedTimings = $this->collectTimings();
+
+ $count = self::$knownTests !== null
+ ? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
+ : count(self::$collectedTimings);
+
+ $this->output->writeln(sprintf(
+ ' Shards:> shards.json updated with timings for %d test class%s.>',
+ $count,
+ $count === 1 ? '' : 'es',
+ ));
+ }
+
+ if (self::$shard === null) {
+ return $exitCode;
+ }
+
+ [
+ 'index' => $index,
+ 'total' => $total,
+ 'testsRan' => $testsRan,
+ 'testsCount' => $testsCount,
+ ] = self::$shard;
+
+ $this->output->writeln(sprintf(
+ ' Shard:> %d of %d> — %d file%s ran, out of %d%s.',
+ $index,
+ $total,
+ $testsRan,
+ $testsRan === 1 ? '' : 's',
+ $testsCount,
+ self::$timeBalanced ? ' (time-balanced)>' : '',
+ ));
+
+ if (self::$shardsOutdated) {
+ $this->output->writeln(' WARN> The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.>');
+ }
+
+ return $exitCode;
+ }
+
+ /**
+ * Terminates the plugin.
+ */
+ public function terminate(): void
+ {
+ if (!self::$updateShards) {
+ return;
+ }
+
+ if (Parallel::isWorker()) {
+ $this->writeWorkerTimings();
+
+ return;
+ }
+
+ if (!self::$passed) {
+ return;
+ }
+
+ $timings = self::$collectedTimings ?? $this->collectTimings();
+
+ if ($timings === []) {
+ return;
+ }
+
+ $this->writeTimings($timings);
+ }
+
+ /**
+ * Handles the --update-shards argument.
+ *
+ * @param array $arguments
+ * @return array
+ */
+ private function handleUpdateShards(array $arguments): array
+ {
+ if ($this->hasArgument('--shard', $arguments)) {
+ throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
+ }
+
+ $arguments = $this->popArgument('--update-shards', $arguments);
+
+ self::$updateShards = true;
+
+ /** @phpstan-ignore-next-line */
+ self::$knownTests = $this->allTests($arguments);
+
+ if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
+ Parallel::setGlobal('UPDATE_SHARDS', true);
+ Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true));
+ } else {
+ Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
+ Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
+ }
+
+ return $arguments;
+ }
+
+ /**
+ * Returns all tests that the test suite would run.
+ *
+ * @param list $arguments
+ * @return list
+ */
+ private function allTests(array $arguments): array
+ {
+ $output = (new Process([
+ 'php',
+ ...$this->removeParallelArguments($arguments),
+ '--list-tests',
+ ]))->setTimeout(120)->mustRun()->getOutput();
+
+ // Upstream hard-codes `Tests\\` here, which excludes modular tests
+ // namespaced `Appmodules\\tests\...`. Match ANY class instead.
+ preg_match_all('/ - (?:P\\\\)?([^:]+)::/', $output, $matches);
+
+ return array_values(array_unique($matches[1]));
+ }
+
+ /**
+ * @param array $arguments
+ * @return array
+ */
+ private function removeParallelArguments(array $arguments): array
+ {
+ return array_filter($arguments, fn (string $argument): bool => !in_array($argument, ['--parallel', '-p'], strict: true));
+ }
+
+ /**
+ * Builds the filter argument for the given tests to run.
+ */
+ private function buildFilterArgument(mixed $testsToRun): string
+ {
+ return addslashes(implode('|', $testsToRun));
+ }
+
+ /**
+ * Collects timings from subscribers or worker temp files.
+ *
+ * @return array
+ */
+ private function collectTimings(): array
+ {
+ $runId = Parallel::getGlobal('SHARD_RUN_ID');
+
+ if (is_string($runId)) {
+ return $this->readWorkerTimings($runId);
+ }
+
+ return EnsureShardTimingsAreCollected::timings();
+ }
+
+ /**
+ * Writes the current worker's timing data to a temp file.
+ */
+ private function writeWorkerTimings(): void
+ {
+ $timings = EnsureShardTimingsAreCollected::timings();
+
+ if ($timings === []) {
+ return;
+ }
+
+ $runId = Parallel::getGlobal('SHARD_RUN_ID');
+
+ if (!is_string($runId)) {
+ return;
+ }
+
+ $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';
+
+ file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * Reads and merges timing data from all worker temp files.
+ *
+ * @return array
+ */
+ private function readWorkerTimings(string $runId): array
+ {
+ $pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
+ $files = glob($pattern);
+
+ if ($files === false || $files === []) {
+ return [];
+ }
+
+ $merged = [];
+
+ foreach ($files as $file) {
+ $contents = file_get_contents($file);
+
+ if ($contents === false) {
+ continue;
+ }
+
+ $timings = json_decode($contents, true);
+
+ if (is_array($timings)) {
+ $merged = array_merge($merged, $timings);
+ }
+
+ unlink($file);
+ }
+
+ return $merged;
+ }
+
+ /**
+ * Returns the path to shards.json.
+ */
+ private function shardsPath(): string
+ {
+ $testSuite = TestSuite::getInstance();
+
+ return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
+ }
+
+ /**
+ * Loads the timings from shards.json.
+ *
+ * @return array|null
+ */
+ private function loadShardsFile(): ?array
+ {
+ $path = $this->shardsPath();
+
+ if (!file_exists($path)) {
+ return null;
+ }
+
+ $contents = file_get_contents($path);
+
+ if ($contents === false) {
+ throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
+ }
+
+ $data = json_decode($contents, true);
+
+ if (!is_array($data) || !isset($data['timings']) || !is_array($data['timings'])) {
+ throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
+ }
+
+ return $data['timings'];
+ }
+
+ /**
+ * Partitions tests across shards using the LPT (Longest Processing Time) algorithm.
+ *
+ * @param list $tests
+ * @param array $timings
+ * @return list>
+ */
+ private function partitionByTime(array $tests, array $timings, int $total): array
+ {
+ $knownTimings = array_filter(
+ array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
+ fn (?float $t): bool => $t !== null,
+ );
+
+ $median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;
+
+ $testsWithTimings = array_map(
+ fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
+ $tests,
+ );
+
+ usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);
+
+ /** @var list> */
+ $bins = array_fill(0, $total, []);
+ /** @var non-empty-list */
+ $binTimes = array_fill(0, $total, 0.0);
+
+ foreach ($testsWithTimings as $item) {
+ $minIndex = array_search(min($binTimes), $binTimes, strict: true);
+ assert(is_int($minIndex));
+
+ $bins[$minIndex][] = $item['test'];
+ $binTimes[$minIndex] += $item['time'];
+ }
+
+ return $bins;
+ }
+
+ /**
+ * Calculates the median of an array of floats.
+ *
+ * @param list $values
+ */
+ private function median(array $values): float
+ {
+ sort($values);
+
+ $count = count($values);
+ $middle = (int) floor($count / 2);
+
+ if ($count % 2 === 0) {
+ return ($values[$middle - 1] + $values[$middle]) / 2;
+ }
+
+ return $values[$middle];
+ }
+
+ /**
+ * Writes the timings to shards.json.
+ *
+ * @param array $timings
+ */
+ private function writeTimings(array $timings): void
+ {
+ $path = $this->shardsPath();
+
+ $directory = dirname($path);
+ if (!is_dir($directory)) {
+ mkdir($directory, 0755, true);
+ }
+
+ if (self::$knownTests !== null) {
+ $knownSet = array_flip(self::$knownTests);
+ $timings = array_intersect_key($timings, $knownSet);
+ }
+
+ ksort($timings);
+
+ $canonical = self::$knownTests ?? array_keys($timings);
+ sort($canonical);
+
+ file_put_contents($path, json_encode([
+ 'timings' => $timings,
+ 'checksum' => md5(implode("\n", $canonical)),
+ 'updated_at' => date('c'),
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index bb3bcbe0e..86c27f279 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Http\Middleware\SetApplicationLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -19,6 +20,10 @@
TrustProxies::class,
Monicahq\Cloudflare\Http\Middleware\TrustProxies::class
);
+
+ $middleware->web(append: [
+ SetApplicationLocale::class,
+ ]);
})
->withExceptions(static function (Exceptions $exceptions): void {})
->create();
diff --git a/composer.json b/composer.json
index da144666b..e84e22e26 100644
--- a/composer.json
+++ b/composer.json
@@ -9,36 +9,37 @@
"license": "MIT",
"require": {
"php": "^8.4",
+ "bacon/bacon-qr-code": "^2.0.8",
"calebporzio/sushi": "^2.5.4",
- "dedoc/scramble": "^0.13.30",
- "filament/filament": "^5.6.7",
- "filament/spatie-laravel-media-library-plugin": "^5.6.7",
- "guzzlehttp/guzzle": "^7.13.1",
- "he4rt/activity": ">=1",
- "he4rt/bot-discord": ">=1.0",
- "he4rt/community": ">=1",
- "he4rt/docs": ">=1",
- "he4rt/economy": ">=1",
- "he4rt/events": ">=1",
- "he4rt/gamification": ">=1",
- "he4rt/he4rt-core": ">=1",
- "he4rt/identity": ">=1",
- "he4rt/integration-devto": ">=1",
- "he4rt/integration-discord": ">=1",
- "he4rt/integration-github": ">=1",
- "he4rt/integration-twitch": ">=1",
- "he4rt/integration-whatsapp": ">=1",
- "he4rt/moderation": "^1.0",
- "he4rt/onboarding": ">=1",
- "he4rt/panel-admin": ">=1",
- "he4rt/panel-app": ">=1",
- "he4rt/portal": ">=1",
- "he4rt/profile": ">=1",
- "he4rt/squads": ">=1",
+ "dedoc/scramble": "^0.13.32",
+ "filament/filament": "^5.6.8",
+ "filament/spatie-laravel-media-library-plugin": "^5.6.8",
+ "guzzlehttp/guzzle": "^7.14.0",
+ "he4rt/activity": "^1.0.0",
+ "he4rt/bot-discord": "^1.0.0",
+ "he4rt/community": "^1.0.0",
+ "he4rt/docs": "^1.0.0",
+ "he4rt/economy": "^1.0.0",
+ "he4rt/events": "^1.0.0",
+ "he4rt/gamification": "^1.0.0",
+ "he4rt/he4rt-core": "^1.0.0",
+ "he4rt/identity": "^1.0.0",
+ "he4rt/integration-devto": "^1.0.0",
+ "he4rt/integration-discord": "^1.0.0",
+ "he4rt/integration-github": "^1.0.0",
+ "he4rt/integration-twitch": "^1.0.0",
+ "he4rt/integration-whatsapp": "^1.0.0",
+ "he4rt/moderation": "^1.0.0",
+ "he4rt/onboarding": "^1.0.0",
+ "he4rt/panel-admin": "^1.0.0",
+ "he4rt/panel-app": "^1.0.0",
+ "he4rt/portal": "^1.0.0",
+ "he4rt/profile": "^1.0.0",
+ "he4rt/squads": "^1.0.0",
"internachi/modular": "^3.0.2",
"laracord/framework": "dev-next#e7b64d6",
- "laravel/framework": "^13.17.0",
- "laravel/nightwatch": "^1.28.3",
+ "laravel/framework": "^13.19.0",
+ "laravel/nightwatch": "^1.28.4",
"laravel/sanctum": "^4.3.2",
"laravel/telescope": "^5.20.0",
"laravel/tinker": "^3.0.2",
@@ -58,22 +59,22 @@
"require-dev": {
"driftingly/rector-laravel": "^2.5.0",
"fakerphp/faker": "^1.24.1",
- "fruitcake/laravel-debugbar": "^4.3.0",
+ "fruitcake/laravel-debugbar": "^4.4.0",
"larastan/larastan": "^3.10.0",
- "laravel/boost": "^2.4.10",
+ "laravel/boost": "^2.4.12",
"laravel/pail": "^1.2.7",
"laravel/pao": "^1.1.2",
"laravel/pint": "^1.29.3",
"mockery/mockery": "^1.6.12",
- "mrpunyapal/rector-pest": "^0.2.15",
+ "mrpunyapal/rector-pest": "^0.2.17",
"nunomaduro/collision": "^8.9.4",
- "pestphp/pest": "^4.7.4",
+ "pestphp/pest": "^4.7.5",
"pestphp/pest-plugin-drift": "^4.1.0",
"pestphp/pest-plugin-faker": "^4.0.0",
"pestphp/pest-plugin-laravel": "^4.1.0",
"pestphp/pest-plugin-livewire": "^4.1.0",
"phpstan/extension-installer": "^1.4.3",
- "rector/rector": "^2.5.2"
+ "rector/rector": "^2.5.5"
},
"autoload": {
"psr-4": {
@@ -93,16 +94,25 @@
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
- "@php artisan package:discover --ansi"
+ "@php artisan package:discover --ansi",
+ "@php artisan filament:upgrade",
+ "@php artisan modules:sync --no-phpunit --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
+ "pre-package-uninstall": [
+ "Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
+ ],
"post-root-package-install": [
- "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
+ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
+ "@php -r \"file_exists('.env.testing') || copy('.env.testing.example', '.env.testing');\""
],
- "post-create-project-cmd": [
+ "key:generate": [
"@php artisan key:generate --ansi",
+ "@php artisan key:generate --ansi --env=testing"
+ ],
+ "post-create-project-cmd": [
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
@@ -110,8 +120,8 @@
"composer install",
"npm install",
"composer run-script post-root-package-install",
+ "composer run-script key:generate",
"composer run-script post-create-project-cmd",
- "@php artisan key:generate --ansi",
"@php artisan storage:link --ansi"
],
"bot": "@php artisan bot:boot",
@@ -144,6 +154,11 @@
"barryvdh/laravel-debugbar",
"laravel/telescope"
]
+ },
+ "pest": {
+ "plugins": [
+ "App\\Support\\PestShardPlugin"
+ ]
}
},
"config": {
diff --git a/composer.lock b/composer.lock
index 0d5ccabcf..938bb3d83 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,127 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "d99073592af48a849b8d049d16bddaee",
+ "content-hash": "8486c7b6b0ca0dda54288a27381865a1",
"packages": [
+ {
+ "name": "anourvalar/eloquent-serialize",
+ "version": "1.3.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/AnourValar/eloquent-serialize.git",
+ "reference": "2be26f176b764a2d6f20118bfa4b125f71fa88f8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/AnourValar/eloquent-serialize/zipball/2be26f176b764a2d6f20118bfa4b125f71fa88f8",
+ "reference": "2be26f176b764a2d6f20118bfa4b125f71fa88f8",
+ "shasum": ""
+ },
+ "require": {
+ "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.26",
+ "laravel/legacy-factories": "^1.1",
+ "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^9.5|^10.5|^11.0",
+ "squizlabs/php_codesniffer": "^3.7"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "EloquentSerialize": "AnourValar\\EloquentSerialize\\Facades\\EloquentSerializeFacade"
+ }
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "AnourValar\\EloquentSerialize\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Laravel Query Builder (Eloquent) serialization",
+ "homepage": "https://github.com/AnourValar/eloquent-serialize",
+ "keywords": [
+ "anourvalar",
+ "builder",
+ "copy",
+ "eloquent",
+ "job",
+ "laravel",
+ "query",
+ "querybuilder",
+ "queue",
+ "serializable",
+ "serialization",
+ "serialize"
+ ],
+ "support": {
+ "issues": "https://github.com/AnourValar/eloquent-serialize/issues",
+ "source": "https://github.com/AnourValar/eloquent-serialize/tree/1.3.10"
+ },
+ "time": "2026-06-20T14:30:25+00:00"
+ },
+ {
+ "name": "bacon/bacon-qr-code",
+ "version": "2.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Bacon/BaconQrCode.git",
+ "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
+ "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
+ "shasum": ""
+ },
+ "require": {
+ "dasprid/enum": "^1.0.3",
+ "ext-iconv": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "phly/keep-a-changelog": "^2.1",
+ "phpunit/phpunit": "^7 | ^8 | ^9",
+ "spatie/phpunit-snapshot-assertions": "^4.2.9",
+ "squizlabs/php_codesniffer": "^3.4"
+ },
+ "suggest": {
+ "ext-imagick": "to generate QR code images"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "BaconQrCode\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "BaconQrCode is a QR code generator for PHP.",
+ "homepage": "https://github.com/Bacon/BaconQrCode",
+ "support": {
+ "issues": "https://github.com/Bacon/BaconQrCode/issues",
+ "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
+ },
+ "time": "2022-12-07T17:46:57+00:00"
+ },
{
"name": "blade-ui-kit/blade-heroicons",
"version": "2.7.0",
@@ -77,16 +196,16 @@
},
{
"name": "blade-ui-kit/blade-icons",
- "version": "1.10.0",
+ "version": "1.10.1",
"source": {
"type": "git",
"url": "https://github.com/driesvints/blade-icons.git",
- "reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a"
+ "reference": "6e072d021ea6249986c330b93293c33d0c4f0e34"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a",
- "reference": "74189a80bbaa4966aebaee54fec3a3c2ef0a5f3a",
+ "url": "https://api.github.com/repos/driesvints/blade-icons/zipball/6e072d021ea6249986c330b93293c33d0c4f0e34",
+ "reference": "6e072d021ea6249986c330b93293c33d0c4f0e34",
"shasum": ""
},
"require": {
@@ -154,7 +273,7 @@
"type": "paypal"
}
],
- "time": "2026-04-23T19:03:45+00:00"
+ "time": "2026-06-30T09:44:12+00:00"
},
{
"name": "brick/math",
@@ -643,16 +762,16 @@
},
{
"name": "composer/composer",
- "version": "2.10.1",
+ "version": "2.10.2",
"source": {
"type": "git",
"url": "https://github.com/composer/composer.git",
- "reference": "4120703b9bda8795075047b40361d7ec4d2abe49"
+ "reference": "8d4439f572a97670a9edc039eb3b093cc976b4bc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/composer/zipball/4120703b9bda8795075047b40361d7ec4d2abe49",
- "reference": "4120703b9bda8795075047b40361d7ec4d2abe49",
+ "url": "https://api.github.com/repos/composer/composer/zipball/8d4439f572a97670a9edc039eb3b093cc976b4bc",
+ "reference": "8d4439f572a97670a9edc039eb3b093cc976b4bc",
"shasum": ""
},
"require": {
@@ -740,7 +859,7 @@
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/composer/issues",
"security": "https://github.com/composer/composer/security/policy",
- "source": "https://github.com/composer/composer/tree/2.10.1"
+ "source": "https://github.com/composer/composer/tree/2.10.2"
},
"funding": [
{
@@ -752,20 +871,20 @@
"type": "github"
}
],
- "time": "2026-06-04T08:25:59+00:00"
+ "time": "2026-07-01T09:24:45+00:00"
},
{
"name": "composer/metadata-minifier",
- "version": "1.0.0",
+ "version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/composer/metadata-minifier.git",
- "reference": "c549d23829536f0d0e984aaabbf02af91f443207"
+ "reference": "8e86142e3ade750b837d55e55f0cdc786d8da006"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207",
- "reference": "c549d23829536f0d0e984aaabbf02af91f443207",
+ "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/8e86142e3ade750b837d55e55f0cdc786d8da006",
+ "reference": "8e86142e3ade750b837d55e55f0cdc786d8da006",
"shasum": ""
},
"require": {
@@ -773,8 +892,8 @@
},
"require-dev": {
"composer/composer": "^2",
- "phpstan/phpstan": "^0.12.55",
- "symfony/phpunit-bridge": "^4.2 || ^5"
+ "phpstan/phpstan": "^1",
+ "symfony/phpunit-bridge": "^4.2 || ^5 || ^6 || ^7"
},
"type": "library",
"extra": {
@@ -805,7 +924,7 @@
],
"support": {
"issues": "https://github.com/composer/metadata-minifier/issues",
- "source": "https://github.com/composer/metadata-minifier/tree/1.0.0"
+ "source": "https://github.com/composer/metadata-minifier/tree/1.0.1"
},
"funding": [
{
@@ -815,13 +934,9 @@
{
"url": "https://github.com/composer",
"type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
}
],
- "time": "2021-04-07T13:37:33+00:00"
+ "time": "2026-06-30T11:34:59+00:00"
},
{
"name": "composer/pcre",
@@ -1223,18 +1338,68 @@
],
"time": "2026-03-16T11:29:23+00:00"
},
+ {
+ "name": "dasprid/enum",
+ "version": "1.0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DASPRiD/Enum.git",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1 <9.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "*"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "DASPRiD\\Enum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "PHP 7.1 enum implementation",
+ "keywords": [
+ "enum",
+ "map"
+ ],
+ "support": {
+ "issues": "https://github.com/DASPRiD/Enum/issues",
+ "source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
+ },
+ "time": "2025-09-16T12:23:56+00:00"
+ },
{
"name": "dedoc/scramble",
- "version": "v0.13.30",
+ "version": "v0.13.32",
"source": {
"type": "git",
"url": "https://github.com/dedoc/scramble.git",
- "reference": "e727d2ac2a7561528f90296226788727937f9a28"
+ "reference": "546a727720d4c38f2248e7c63b43178fbea7a96e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dedoc/scramble/zipball/e727d2ac2a7561528f90296226788727937f9a28",
- "reference": "e727d2ac2a7561528f90296226788727937f9a28",
+ "url": "https://api.github.com/repos/dedoc/scramble/zipball/546a727720d4c38f2248e7c63b43178fbea7a96e",
+ "reference": "546a727720d4c38f2248e7c63b43178fbea7a96e",
"shasum": ""
},
"require": {
@@ -1294,7 +1459,7 @@
],
"support": {
"issues": "https://github.com/dedoc/scramble/issues",
- "source": "https://github.com/dedoc/scramble/tree/v0.13.30"
+ "source": "https://github.com/dedoc/scramble/tree/v0.13.32"
},
"funding": [
{
@@ -1302,7 +1467,7 @@
"type": "github"
}
],
- "time": "2026-06-26T16:57:26+00:00"
+ "time": "2026-07-09T09:04:02+00:00"
},
{
"name": "dflydev/dot-access-data",
@@ -2006,19 +2171,20 @@
},
{
"name": "filament/actions",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/actions.git",
- "reference": "4697f9e6dab1f023b2ea21b0e449c3fd204fb3d9"
+ "reference": "1c479a47ee1612f7c05a7921b2491230aeed392c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/actions/zipball/4697f9e6dab1f023b2ea21b0e449c3fd204fb3d9",
- "reference": "4697f9e6dab1f023b2ea21b0e449c3fd204fb3d9",
+ "url": "https://api.github.com/repos/filamentphp/actions/zipball/1c479a47ee1612f7c05a7921b2491230aeed392c",
+ "reference": "1c479a47ee1612f7c05a7921b2491230aeed392c",
"shasum": ""
},
"require": {
+ "anourvalar/eloquent-serialize": "^1.3.6",
"filament/forms": "self.version",
"filament/infolists": "self.version",
"filament/notifications": "self.version",
@@ -2050,20 +2216,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:27:06+00:00"
+ "time": "2026-07-02T08:19:13+00:00"
},
{
"name": "filament/filament",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/panels.git",
- "reference": "2b6bfa9888e0bcba8c2a7dfbdfe913cab72cfe6a"
+ "reference": "c54ec7692245787057c24f82d92d9fee7bc24941"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/panels/zipball/2b6bfa9888e0bcba8c2a7dfbdfe913cab72cfe6a",
- "reference": "2b6bfa9888e0bcba8c2a7dfbdfe913cab72cfe6a",
+ "url": "https://api.github.com/repos/filamentphp/panels/zipball/c54ec7692245787057c24f82d92d9fee7bc24941",
+ "reference": "c54ec7692245787057c24f82d92d9fee7bc24941",
"shasum": ""
},
"require": {
@@ -2107,20 +2273,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:24:15+00:00"
+ "time": "2026-07-02T08:17:14+00:00"
},
{
"name": "filament/forms",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/forms.git",
- "reference": "f7b5e8701982408f84e1b12fcca75eadad47905b"
+ "reference": "7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/forms/zipball/f7b5e8701982408f84e1b12fcca75eadad47905b",
- "reference": "f7b5e8701982408f84e1b12fcca75eadad47905b",
+ "url": "https://api.github.com/repos/filamentphp/forms/zipball/7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb",
+ "reference": "7e6e4e52a63f4fc11edb253201bdd8cbc7d956eb",
"shasum": ""
},
"require": {
@@ -2157,20 +2323,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:27:44+00:00"
+ "time": "2026-07-02T08:16:27+00:00"
},
{
"name": "filament/infolists",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/infolists.git",
- "reference": "0b87686a37160bf7f8bccae1eedc733bbf928dc9"
+ "reference": "01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/infolists/zipball/0b87686a37160bf7f8bccae1eedc733bbf928dc9",
- "reference": "0b87686a37160bf7f8bccae1eedc733bbf928dc9",
+ "url": "https://api.github.com/repos/filamentphp/infolists/zipball/01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7",
+ "reference": "01152bfc7d5d2b65a83eb1045a8f3625d4eaeae7",
"shasum": ""
},
"require": {
@@ -2202,20 +2368,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:28:23+00:00"
+ "time": "2026-07-02T08:19:40+00:00"
},
{
"name": "filament/notifications",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/notifications.git",
- "reference": "28ce63bf4e378a4e38efac7b1f0519a5fed4b352"
+ "reference": "a2b4564d9c7fcb5b8e904da40b4f6c2782934071"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/notifications/zipball/28ce63bf4e378a4e38efac7b1f0519a5fed4b352",
- "reference": "28ce63bf4e378a4e38efac7b1f0519a5fed4b352",
+ "url": "https://api.github.com/repos/filamentphp/notifications/zipball/a2b4564d9c7fcb5b8e904da40b4f6c2782934071",
+ "reference": "a2b4564d9c7fcb5b8e904da40b4f6c2782934071",
"shasum": ""
},
"require": {
@@ -2249,20 +2415,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:27:42+00:00"
+ "time": "2026-07-02T08:08:59+00:00"
},
{
"name": "filament/query-builder",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/query-builder.git",
- "reference": "fb4b6cdebe6ab2c233cc78d3688a64f022263c56"
+ "reference": "401d0d61f5ead5d440f184857e1921675138fd5a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/fb4b6cdebe6ab2c233cc78d3688a64f022263c56",
- "reference": "fb4b6cdebe6ab2c233cc78d3688a64f022263c56",
+ "url": "https://api.github.com/repos/filamentphp/query-builder/zipball/401d0d61f5ead5d440f184857e1921675138fd5a",
+ "reference": "401d0d61f5ead5d440f184857e1921675138fd5a",
"shasum": ""
},
"require": {
@@ -2295,20 +2461,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-05-27T16:17:11+00:00"
+ "time": "2026-07-02T08:20:10+00:00"
},
{
"name": "filament/schemas",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/schemas.git",
- "reference": "c3ecdfe73a215927caaf7b28bc5ae2b3891e805b"
+ "reference": "036549f1b6cbf3d908e1b9fa42f5e221d1db3240"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/schemas/zipball/c3ecdfe73a215927caaf7b28bc5ae2b3891e805b",
- "reference": "c3ecdfe73a215927caaf7b28bc5ae2b3891e805b",
+ "url": "https://api.github.com/repos/filamentphp/schemas/zipball/036549f1b6cbf3d908e1b9fa42f5e221d1db3240",
+ "reference": "036549f1b6cbf3d908e1b9fa42f5e221d1db3240",
"shasum": ""
},
"require": {
@@ -2340,11 +2506,11 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:28:03+00:00"
+ "time": "2026-07-02T08:20:18+00:00"
},
{
"name": "filament/spatie-laravel-media-library-plugin",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
@@ -2381,16 +2547,16 @@
},
{
"name": "filament/support",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/support.git",
- "reference": "caa2bf186de5b32a789d3c7167bc63db153386a1"
+ "reference": "024caa553010c2617bb90114eb0e78883e33138b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/support/zipball/caa2bf186de5b32a789d3c7167bc63db153386a1",
- "reference": "caa2bf186de5b32a789d3c7167bc63db153386a1",
+ "url": "https://api.github.com/repos/filamentphp/support/zipball/024caa553010c2617bb90114eb0e78883e33138b",
+ "reference": "024caa553010c2617bb90114eb0e78883e33138b",
"shasum": ""
},
"require": {
@@ -2435,20 +2601,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:28:36+00:00"
+ "time": "2026-07-02T08:20:15+00:00"
},
{
"name": "filament/tables",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/tables.git",
- "reference": "bb98022d73347eeb090976ae0730147289135ffb"
+ "reference": "bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/tables/zipball/bb98022d73347eeb090976ae0730147289135ffb",
- "reference": "bb98022d73347eeb090976ae0730147289135ffb",
+ "url": "https://api.github.com/repos/filamentphp/tables/zipball/bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a",
+ "reference": "bfe49781b2879e0c1acdfd4065d1c8499ee7ba1a",
"shasum": ""
},
"require": {
@@ -2481,20 +2647,20 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:28:26+00:00"
+ "time": "2026-07-02T08:20:10+00:00"
},
{
"name": "filament/widgets",
- "version": "v5.6.7",
+ "version": "v5.6.8",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/widgets.git",
- "reference": "8c6411e0331aab124ffdf6c49d1161b1ecfbc9bc"
+ "reference": "329c92e8560536fd6dd738b45eaf6ba063cd53ea"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filamentphp/widgets/zipball/8c6411e0331aab124ffdf6c49d1161b1ecfbc9bc",
- "reference": "8c6411e0331aab124ffdf6c49d1161b1ecfbc9bc",
+ "url": "https://api.github.com/repos/filamentphp/widgets/zipball/329c92e8560536fd6dd738b45eaf6ba063cd53ea",
+ "reference": "329c92e8560536fd6dd738b45eaf6ba063cd53ea",
"shasum": ""
},
"require": {
@@ -2525,7 +2691,7 @@
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
- "time": "2026-06-08T09:28:17+00:00"
+ "time": "2026-07-02T08:19:55+00:00"
},
{
"name": "fruitcake/php-cors",
@@ -2662,22 +2828,22 @@
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.13.1",
+ "version": "7.14.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d"
+ "reference": "aef242412e13128b5049864867bb49fc37dd39de"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d",
- "reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de",
+ "reference": "aef242412e13128b5049864867bb49fc37dd39de",
"shasum": ""
},
"require": {
"ext-json": "*",
- "guzzlehttp/promises": "^2.5",
- "guzzlehttp/psr7": "^2.12.3",
+ "guzzlehttp/promises": "^2.5.1",
+ "guzzlehttp/psr7": "^2.12.4",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
@@ -2689,7 +2855,7 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
- "guzzle/client-integration-tests": "3.0.2",
+ "guzzle/client-integration-tests": "3.0.3",
"guzzlehttp/test-server": "^0.6",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
@@ -2770,7 +2936,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.13.1"
+ "source": "https://github.com/guzzle/guzzle/tree/7.14.0"
},
"funding": [
{
@@ -2786,20 +2952,20 @@
"type": "tidelift"
}
],
- "time": "2026-06-29T20:14:18+00:00"
+ "time": "2026-07-08T22:54:09+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.5.0",
+ "version": "2.5.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "4360e982f87f5f258bf872d094647791db2f4c8e"
+ "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e",
- "reference": "4360e982f87f5f258bf872d094647791db2f4c8e",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29",
+ "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29",
"shasum": ""
},
"require": {
@@ -2854,7 +3020,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.5.0"
+ "source": "https://github.com/guzzle/promises/tree/2.5.1"
},
"funding": [
{
@@ -2870,20 +3036,20 @@
"type": "tidelift"
}
],
- "time": "2026-06-02T12:23:43+00:00"
+ "time": "2026-07-08T15:48:39+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.12.3",
+ "version": "2.12.4",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
+ "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
- "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
+ "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
"shasum": ""
},
"require": {
@@ -2973,7 +3139,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.12.3"
+ "source": "https://github.com/guzzle/psr7/tree/2.12.4"
},
"funding": [
{
@@ -2989,20 +3155,20 @@
"type": "tidelift"
}
],
- "time": "2026-06-23T15:21:08+00:00"
+ "time": "2026-07-08T15:56:20+00:00"
},
{
"name": "guzzlehttp/uri-template",
- "version": "v1.0.8",
+ "version": "v1.0.9",
"source": {
"type": "git",
"url": "https://github.com/guzzle/uri-template.git",
- "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd"
+ "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd",
- "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd",
+ "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d7580af6d3f8384325d9cd3e99b21c3ed1848176",
+ "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176",
"shasum": ""
},
"require": {
@@ -3059,7 +3225,7 @@
],
"support": {
"issues": "https://github.com/guzzle/uri-template/issues",
- "source": "https://github.com/guzzle/uri-template/tree/v1.0.8"
+ "source": "https://github.com/guzzle/uri-template/tree/v1.0.9"
},
"funding": [
{
@@ -3075,7 +3241,7 @@
"type": "tidelift"
}
],
- "time": "2026-06-23T13:02:23+00:00"
+ "time": "2026-07-08T16:19:22+00:00"
},
{
"name": "he4rt/activity",
@@ -3613,13 +3779,13 @@
"dist": {
"type": "path",
"url": "app-modules/onboarding",
- "reference": "3a220d43dbb84baa4f55ad195653e0cf99c92c84"
+ "reference": "323beee88a6e398ee6655e9b2051ad55525c9920"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
- "He4rt\\Onboarding\\Providers\\OnboardingServiceProvider"
+ "He4rt\\Onboarding\\OnboardingServiceProvider"
]
}
},
@@ -3799,13 +3965,13 @@
"dist": {
"type": "path",
"url": "app-modules/squads",
- "reference": "6d710964545ed1c8635e42b11780e181211075e7"
+ "reference": "9f403fc7ce21863d32cead1e6e8718adea29bdf8"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
- "He4rt\\Squads\\Providers\\SquadsServiceProvider"
+ "He4rt\\Squads\\SquadsServiceProvider"
]
}
},
@@ -4240,16 +4406,16 @@
},
{
"name": "laravel/framework",
- "version": "v13.17.0",
+ "version": "v13.19.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "0802b7a81f3252d78200b8037ac183a686a529f0"
+ "reference": "514502b38e11bd676ecf83b271c9452cc7500f16"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/0802b7a81f3252d78200b8037ac183a686a529f0",
- "reference": "0802b7a81f3252d78200b8037ac183a686a529f0",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/514502b38e11bd676ecf83b271c9452cc7500f16",
+ "reference": "514502b38e11bd676ecf83b271c9452cc7500f16",
"shasum": ""
},
"require": {
@@ -4460,20 +4626,20 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2026-06-23T19:42:45+00:00"
+ "time": "2026-07-07T14:13:33+00:00"
},
{
"name": "laravel/nightwatch",
- "version": "v1.28.3",
+ "version": "v1.28.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/nightwatch.git",
- "reference": "375f05b589e53d90560dd4aaac9f53d26d1d0433"
+ "reference": "c38c1a3eb7dfbaf27023dbf011bf1078fdf28bdb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/nightwatch/zipball/375f05b589e53d90560dd4aaac9f53d26d1d0433",
- "reference": "375f05b589e53d90560dd4aaac9f53d26d1d0433",
+ "url": "https://api.github.com/repos/laravel/nightwatch/zipball/c38c1a3eb7dfbaf27023dbf011bf1078fdf28bdb",
+ "reference": "c38c1a3eb7dfbaf27023dbf011bf1078fdf28bdb",
"shasum": ""
},
"require": {
@@ -4554,7 +4720,7 @@
"issues": "https://github.com/laravel/nightwatch/issues",
"source": "https://github.com/laravel/nightwatch"
},
- "time": "2026-06-24T05:04:12+00:00"
+ "time": "2026-06-30T06:54:16+00:00"
},
{
"name": "laravel/prompts",
@@ -5215,16 +5381,16 @@
},
{
"name": "league/flysystem",
- "version": "3.35.1",
+ "version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
- "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c"
+ "reference": "b277b5dc3d56650b68904117124e79c851e12376"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c",
- "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c",
+ "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
+ "reference": "b277b5dc3d56650b68904117124e79c851e12376",
"shasum": ""
},
"require": {
@@ -5292,9 +5458,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
- "source": "https://github.com/thephpleague/flysystem/tree/3.35.1"
+ "source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
},
- "time": "2026-06-25T06:52:23+00:00"
+ "time": "2026-07-06T14:42:07+00:00"
},
{
"name": "league/flysystem-local",
@@ -6668,20 +6834,19 @@
},
{
"name": "nikic/php-parser",
- "version": "v5.7.0",
+ "version": "v5.8.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
- "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
+ "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"shasum": ""
},
"require": {
- "ext-ctype": "*",
"ext-json": "*",
"ext-tokenizer": "*",
"php": ">=7.4"
@@ -6720,9 +6885,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
},
- "time": "2025-12-06T11:56:16+00:00"
+ "time": "2026-07-04T14:30:18+00:00"
},
{
"name": "nunomaduro/termwind",
@@ -7344,16 +7509,16 @@
},
{
"name": "phpstan/phpdoc-parser",
- "version": "2.3.2",
+ "version": "2.3.3",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
+ "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
- "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
+ "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
"shasum": ""
},
"require": {
@@ -7385,9 +7550,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
- "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
},
- "time": "2026-01-25T14:56:51+00:00"
+ "time": "2026-07-08T07:01:06+00:00"
},
{
"name": "pragmarx/google2fa",
@@ -13606,16 +13771,16 @@
},
{
"name": "team-reflex/discord-php",
- "version": "v10.50.0",
+ "version": "v10.51.0",
"source": {
"type": "git",
"url": "https://github.com/discord-php/DiscordPHP.git",
- "reference": "11fa8eddc78cde79b7f9eb185a02c91683178942"
+ "reference": "02f7c48e5e1af09564f77b70efed429acc52d96f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/11fa8eddc78cde79b7f9eb185a02c91683178942",
- "reference": "11fa8eddc78cde79b7f9eb185a02c91683178942",
+ "url": "https://api.github.com/repos/discord-php/DiscordPHP/zipball/02f7c48e5e1af09564f77b70efed429acc52d96f",
+ "reference": "02f7c48e5e1af09564f77b70efed429acc52d96f",
"shasum": ""
},
"require": {
@@ -13687,7 +13852,7 @@
"chat": "https://discord.gg/dphp",
"docs": "https://discord-php.github.io/DiscordPHP/",
"issues": "https://github.com/discord-php/DiscordPHP/issues",
- "source": "https://github.com/discord-php/DiscordPHP/tree/v10.50.0",
+ "source": "https://github.com/discord-php/DiscordPHP/tree/v10.51.0",
"wiki": "https://github.com/discord-php/DiscordPHP/wiki"
},
"funding": [
@@ -13708,7 +13873,7 @@
"type": "patreon"
}
],
- "time": "2026-06-26T17:59:46+00:00"
+ "time": "2026-07-01T05:08:27+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
@@ -13884,16 +14049,16 @@
},
{
"name": "vlucas/phpdotenv",
- "version": "v5.6.3",
+ "version": "v5.6.4",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
- "reference": "955e7815d677a3eaa7075231212f2110983adecc"
+ "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
- "reference": "955e7815d677a3eaa7075231212f2110983adecc",
+ "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b",
+ "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b",
"shasum": ""
},
"require": {
@@ -13952,7 +14117,7 @@
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
- "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
+ "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4"
},
"funding": [
{
@@ -13964,7 +14129,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-27T19:49:13+00:00"
+ "time": "2026-07-06T19:11:50+00:00"
},
{
"name": "voku/portable-ascii",
@@ -14416,16 +14581,16 @@
},
{
"name": "fruitcake/laravel-debugbar",
- "version": "v4.3.0",
+ "version": "v4.4.0",
"source": {
"type": "git",
"url": "https://github.com/fruitcake/laravel-debugbar.git",
- "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c"
+ "reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/3d76ea8d78b82225b92789de65fc630c1cd8e80c",
- "reference": "3d76ea8d78b82225b92789de65fc630c1cd8e80c",
+ "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/80ef956bda9e1a5824037d6f2cd06e73092e5634",
+ "reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634",
"shasum": ""
},
"require": {
@@ -14433,7 +14598,7 @@
"illuminate/session": "^11|^12|^13.0",
"illuminate/support": "^11|^12|^13.0",
"php": "^8.2",
- "php-debugbar/php-debugbar": "^3.7.2",
+ "php-debugbar/php-debugbar": "^3.8.0",
"php-debugbar/symfony-bridge": "^1.1"
},
"replace": {
@@ -14441,6 +14606,7 @@
},
"require-dev": {
"larastan/larastan": "^3",
+ "laravel/ai": "^0.8",
"laravel/octane": "^2",
"laravel/pennant": "^1",
"laravel/pint": "^1",
@@ -14502,7 +14668,7 @@
],
"support": {
"issues": "https://github.com/fruitcake/laravel-debugbar/issues",
- "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.3.0"
+ "source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.0"
},
"funding": [
{
@@ -14514,7 +14680,7 @@
"type": "github"
}
],
- "time": "2026-06-04T07:54:01+00:00"
+ "time": "2026-07-04T08:30:57+00:00"
},
{
"name": "hamcrest/hamcrest-php",
@@ -14822,16 +14988,16 @@
},
{
"name": "laravel/boost",
- "version": "v2.4.10",
+ "version": "v2.4.12",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
- "reference": "080189f51c8d27c0792a03483a70adc7770f6eeb"
+ "reference": "d43bdf901fee8d216145cb062c9847c892844908"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/080189f51c8d27c0792a03483a70adc7770f6eeb",
- "reference": "080189f51c8d27c0792a03483a70adc7770f6eeb",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/d43bdf901fee8d216145cb062c9847c892844908",
+ "reference": "d43bdf901fee8d216145cb062c9847c892844908",
"shasum": ""
},
"require": {
@@ -14884,7 +15050,7 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
- "time": "2026-06-09T10:21:08+00:00"
+ "time": "2026-07-08T09:53:34+00:00"
},
{
"name": "laravel/mcp",
@@ -15339,16 +15505,16 @@
},
{
"name": "mrpunyapal/rector-pest",
- "version": "0.2.15",
+ "version": "0.2.17",
"source": {
"type": "git",
"url": "https://github.com/MrPunyapal/rector-pest.git",
- "reference": "624dba596ad1605e34a2ec7ac5eb5fffce960596"
+ "reference": "967b5fe54c524f24a616e6e65327cb4f30bf9486"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/MrPunyapal/rector-pest/zipball/624dba596ad1605e34a2ec7ac5eb5fffce960596",
- "reference": "624dba596ad1605e34a2ec7ac5eb5fffce960596",
+ "url": "https://api.github.com/repos/MrPunyapal/rector-pest/zipball/967b5fe54c524f24a616e6e65327cb4f30bf9486",
+ "reference": "967b5fe54c524f24a616e6e65327cb4f30bf9486",
"shasum": ""
},
"require": {
@@ -15395,7 +15561,7 @@
],
"support": {
"issues": "https://github.com/MrPunyapal/rector-pest/issues",
- "source": "https://github.com/MrPunyapal/rector-pest/tree/0.2.15"
+ "source": "https://github.com/MrPunyapal/rector-pest/tree/0.2.17"
},
"funding": [
{
@@ -15403,7 +15569,7 @@
"type": "github"
}
],
- "time": "2026-05-15T18:51:02+00:00"
+ "time": "2026-07-03T10:14:25+00:00"
},
{
"name": "nunomaduro/collision",
@@ -15503,16 +15669,16 @@
},
{
"name": "pestphp/pest",
- "version": "v4.7.4",
+ "version": "v4.7.5",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest.git",
- "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb"
+ "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pestphp/pest/zipball/ee2e97e932d158faceeaa63a4dc17324b15152cb",
- "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb",
+ "url": "https://api.github.com/repos/pestphp/pest/zipball/5dc49a71d63602a9b98fed0f2017c4679ef9f8e0",
+ "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0",
"shasum": ""
},
"require": {
@@ -15535,11 +15701,11 @@
"webmozart/assert": "<1.11.0"
},
"require-dev": {
- "mrpunyapal/peststan": "^0.2.10",
+ "mrpunyapal/peststan": "^0.2.11",
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
- "psy/psysh": "^0.12.23"
+ "psy/psysh": "^0.12.24"
},
"bin": [
"bin/pest"
@@ -15606,7 +15772,7 @@
],
"support": {
"issues": "https://github.com/pestphp/pest/issues",
- "source": "https://github.com/pestphp/pest/tree/v4.7.4"
+ "source": "https://github.com/pestphp/pest/tree/v4.7.5"
},
"funding": [
{
@@ -15618,7 +15784,7 @@
"type": "github"
}
],
- "time": "2026-06-25T19:09:05+00:00"
+ "time": "2026-07-06T17:06:29+00:00"
},
{
"name": "pestphp/pest-plugin",
@@ -16286,16 +16452,16 @@
},
{
"name": "php-debugbar/php-debugbar",
- "version": "v3.7.6",
+ "version": "v3.8.0",
"source": {
"type": "git",
"url": "https://github.com/php-debugbar/php-debugbar.git",
- "reference": "1690ee1728827f9deb4b60457fa387cf44672c56"
+ "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/1690ee1728827f9deb4b60457fa387cf44672c56",
- "reference": "1690ee1728827f9deb4b60457fa387cf44672c56",
+ "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/18ced90d4b882ed449b2278fea8692f8f7d1c13c",
+ "reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c",
"shasum": ""
},
"require": {
@@ -16337,7 +16503,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-master": "3.8-dev"
}
},
"autoload": {
@@ -16372,7 +16538,7 @@
],
"support": {
"issues": "https://github.com/php-debugbar/php-debugbar/issues",
- "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.7.6"
+ "source": "https://github.com/php-debugbar/php-debugbar/tree/v3.8.0"
},
"funding": [
{
@@ -16384,7 +16550,7 @@
"type": "github"
}
],
- "time": "2026-04-30T07:31:44+00:00"
+ "time": "2026-07-02T12:38:20+00:00"
},
{
"name": "php-debugbar/symfony-bridge",
@@ -16678,11 +16844,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.2.2",
+ "version": "2.2.5",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
- "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
+ "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"shasum": ""
},
"require": {
@@ -16738,7 +16904,7 @@
"type": "github"
}
],
- "time": "2026-06-05T09:00:01+00:00"
+ "time": "2026-07-05T06:31:06+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -17177,16 +17343,16 @@
},
{
"name": "rector/rector",
- "version": "2.5.2",
+ "version": "2.5.5",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
- "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
+ "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
- "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e",
+ "reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e",
"shasum": ""
},
"require": {
@@ -17225,7 +17391,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
- "source": "https://github.com/rectorphp/rector/tree/2.5.2"
+ "source": "https://github.com/rectorphp/rector/tree/2.5.5"
},
"funding": [
{
@@ -17233,7 +17399,7 @@
"type": "github"
}
],
- "time": "2026-06-22T11:39:33+00:00"
+ "time": "2026-07-09T09:48:44+00:00"
},
{
"name": "sebastian/cli-parser",
diff --git a/config/app-modules.php b/config/app-modules.php
index 159bd93c6..1420eb140 100644
--- a/config/app-modules.php
+++ b/config/app-modules.php
@@ -85,7 +85,7 @@
'composer.json' => base_path('stubs/app-modules/composer-stub.json'),
'phpstan.neon' => base_path('stubs/app-modules/phpstan.neon'),
'phpstan.ignore.neon' => base_path('stubs/app-modules/phpstan.ignore.neon'),
- 'src/Providers/StubClassNamePrefixServiceProvider.php' => base_path('stubs/app-modules/ServiceProvider.php'),
+ 'src/StubClassNamePrefixServiceProvider.php' => base_path('stubs/app-modules/ServiceProvider.php'),
'tests/Unit/.gitkeep' => base_path('stubs/app-modules/.gitkeep'),
'tests/Feature/.gitkeep' => base_path('stubs/app-modules/.gitkeep'),
'database/factories/.gitkeep' => base_path('stubs/app-modules/.gitkeep'),
diff --git a/config/geo.php b/config/geo.php
new file mode 100644
index 000000000..d42109a50
--- /dev/null
+++ b/config/geo.php
@@ -0,0 +1,18 @@
+ env('GEO_WORLD_API_URL', 'https://world.bmbc.cloud/api'),
+
+];
diff --git a/database/factories/.gitkeep b/database/factories/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/database/migrations/2026_07_08_010345_widen_addresses_state_length.php b/database/migrations/2026_07_08_010345_widen_addresses_state_length.php
new file mode 100644
index 000000000..9c060b1a7
--- /dev/null
+++ b/database/migrations/2026_07_08_010345_widen_addresses_state_length.php
@@ -0,0 +1,27 @@
+string('state', 120)->nullable()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('addresses', static function (Blueprint $table): void {
+ $table->string('state', 4)->nullable()->change();
+ });
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 0f56a1814..361e9e762 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -5,6 +5,7 @@
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use He4rt\Events\Database\Seeders\EventsSeeder;
use Illuminate\Database\Seeder;
final class DatabaseSeeder extends Seeder
@@ -16,6 +17,7 @@ public function run(): void
{
$this->call([
BaseSeeder::class,
+ EventsSeeder::class,
]);
}
}
diff --git a/docs/agents/domain.md b/docs/agents/domain.md
deleted file mode 100644
index 206319759..000000000
--- a/docs/agents/domain.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Domain Docs
-
-How the engineering skills should consume this repo's domain documentation when exploring the codebase.
-
-## Before exploring, read these
-
-- **`CONTEXT-MAP.md`** at the repo root — it points at one `CONTEXT.md` per module. Read each one relevant to the topic.
-- **`docs/adr/`** — read ADRs that touch the area you're about to work in. Also check `app-modules//docs/adr/` for module-scoped decisions.
-
-If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
-
-## File structure
-
-This is a multi-context repo (modular monorepo via `internachi/modular`):
-
-```
-/
-├── CONTEXT-MAP.md <- system-wide context map
-├── docs/adr/ <- system-wide decisions
-└── app-modules/
- ├── moderation/
- │ ├── CONTEXT.md
- │ └── docs/adr/ <- module-specific decisions
- ├── bot-discord/
- │ ├── CONTEXT.md
- │ └── docs/adr/
- ├── identity/
- │ ├── CONTEXT.md
- │ └── docs/adr/
- └── ...
-```
-
-## Use the glossary's vocabulary
-
-When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
-
-If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
-
-## Flag ADR conflicts
-
-If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
-
-> _Contradicts ADR-0007 — but worth reopening because..._
diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md
deleted file mode 100644
index e8569160e..000000000
--- a/docs/agents/issue-tracker.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Issue tracker: GitHub
-
-Issues and PRDs for this repo live as GitHub issues on `he4rt/he4rt-bot-api`. Use the `gh` CLI for all operations.
-
-## Conventions
-
-- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
-- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels.
-- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
-- **Comment on an issue**: `gh issue comment --body "..."`
-- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."`
-- **Close**: `gh issue close --comment "..."`
-
-Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
-
-## When a skill says "publish to the issue tracker"
-
-Create a GitHub issue.
-
-## When a skill says "fetch the relevant ticket"
-
-Run `gh issue view --comments`.
diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md
deleted file mode 100644
index a1c862949..000000000
--- a/docs/agents/triage-labels.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# Triage Labels
-
-The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
-
-| Label in skills | Label in our tracker | Meaning |
-| ----------------- | -------------------- | ---------------------------------------- |
-| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
-| `needs-info` | `needs-info` | Waiting on reporter for more information |
-| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
-| `ready-for-human` | `ready-for-human` | Requires human implementation |
-| `wontfix` | `wontfix` | Will not be actioned |
-
-When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
-
-Edit the right-hand column to match whatever vocabulary you actually use.
diff --git a/lang/en/app.php b/lang/en/app.php
new file mode 100644
index 000000000..8f8917c0e
--- /dev/null
+++ b/lang/en/app.php
@@ -0,0 +1,12 @@
+ [
+ 'english' => 'English',
+ 'english_short' => 'EN',
+ 'portuguese' => 'Português (Brasil)',
+ 'portuguese_short' => 'PT-BR',
+ ],
+];
diff --git a/lang/pt_BR/app.php b/lang/pt_BR/app.php
new file mode 100644
index 000000000..8f8917c0e
--- /dev/null
+++ b/lang/pt_BR/app.php
@@ -0,0 +1,12 @@
+ [
+ 'english' => 'English',
+ 'english_short' => 'EN',
+ 'portuguese' => 'Português (Brasil)',
+ 'portuguese_short' => 'PT-BR',
+ ],
+];
diff --git a/package-lock.json b/package-lock.json
index b0efcf12b..72e833fba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,8 @@
"version": "3.0.0",
"license": "MIT",
"devDependencies": {
- "@emnapi/core": "1.11.1",
- "@emnapi/runtime": "1.11.1",
+ "@emnapi/core": "1.11.2",
+ "@emnapi/runtime": "1.11.2",
"@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "4.3.2",
"concurrently": "10.0.3",
@@ -18,19 +18,19 @@
"laravel-vite-plugin": "3.1.0",
"lint-staged": "17.0.8",
"npm-check-updates": "22.2.9",
- "prettier": "3.9.3",
+ "prettier": "3.9.4",
"tailwindcss": "4.3.2",
"tw-animate-css": "1.4.0",
- "vite": "8.1.0"
+ "vite": "8.1.4"
},
"engines": {
"node": "^24"
}
},
"node_modules/@emnapi/core": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
- "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
+ "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -40,9 +40,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -130,9 +130,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.137.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
- "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -140,9 +140,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
- "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
- "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
- "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
- "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
- "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
- "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
@@ -242,9 +242,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
- "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
@@ -259,9 +259,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
- "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
@@ -276,9 +276,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
- "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
@@ -293,9 +293,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
- "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
@@ -310,9 +310,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
- "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
@@ -327,9 +327,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
- "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
@@ -344,9 +344,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
- "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
@@ -362,10 +362,33 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
- "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
@@ -380,9 +403,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
- "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
@@ -1526,9 +1549,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1582,9 +1605,9 @@
}
},
"node_modules/prettier": {
- "version": "3.9.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.3.tgz",
- "integrity": "sha512-HWmu+K+zvHNpaMfSnYeqdqrDbR16cuIXaPx8WoHaviQkDJh1/0BNtOZmHVQI5jc3wXv0H1yXc9wjvFdXh+n3hQ==",
+ "version": "3.9.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz",
+ "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -1622,13 +1645,13 @@
"license": "MIT"
},
"node_modules/rolldown": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
- "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.137.0",
+ "@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -1638,21 +1661,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.1.3",
- "@rolldown/binding-darwin-arm64": "1.1.3",
- "@rolldown/binding-darwin-x64": "1.1.3",
- "@rolldown/binding-freebsd-x64": "1.1.3",
- "@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
- "@rolldown/binding-linux-arm64-gnu": "1.1.3",
- "@rolldown/binding-linux-arm64-musl": "1.1.3",
- "@rolldown/binding-linux-ppc64-gnu": "1.1.3",
- "@rolldown/binding-linux-s390x-gnu": "1.1.3",
- "@rolldown/binding-linux-x64-gnu": "1.1.3",
- "@rolldown/binding-linux-x64-musl": "1.1.3",
- "@rolldown/binding-openharmony-arm64": "1.1.3",
- "@rolldown/binding-wasm32-wasi": "1.1.3",
- "@rolldown/binding-win32-arm64-msvc": "1.1.3",
- "@rolldown/binding-win32-x64-msvc": "1.1.3"
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/rxjs": {
@@ -1729,9 +1752,9 @@
}
},
"node_modules/string-width": {
- "version": "8.2.1",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
- "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1858,17 +1881,17 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
- "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
+ "version": "8.1.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+ "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
- "picomatch": "^4.0.4",
- "postcss": "^8.5.15",
- "rolldown": "~1.1.2",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.16",
+ "rolldown": "~1.1.4",
"tinyglobby": "^0.2.17"
},
"bin": {
diff --git a/package.json b/package.json
index 443a26ee4..4b678305b 100644
--- a/package.json
+++ b/package.json
@@ -16,8 +16,8 @@
"outdated": "npm-check-updates"
},
"devDependencies": {
- "@emnapi/core": "1.11.1",
- "@emnapi/runtime": "1.11.1",
+ "@emnapi/core": "1.11.2",
+ "@emnapi/runtime": "1.11.2",
"@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "4.3.2",
"concurrently": "10.0.3",
@@ -25,10 +25,10 @@
"laravel-vite-plugin": "3.1.0",
"lint-staged": "17.0.8",
"npm-check-updates": "22.2.9",
- "prettier": "3.9.3",
+ "prettier": "3.9.4",
"tailwindcss": "4.3.2",
"tw-animate-css": "1.4.0",
- "vite": "8.1.0"
+ "vite": "8.1.4"
},
"lint-staged": {
"*.{js,ts,json,yml,md,css}": [
diff --git a/phpstan.neon b/phpstan.neon
index ac57cebb1..6f76e4c21 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -6,6 +6,10 @@ parameters:
level: 7
checkImplicitMixed: true
-
paths:
- app/
+
+ # Verbatim copy of Pest's internal Shard plugin (only the discovery regex is
+ # patched). Treated as third-party code — kept re-syncable with upstream.
+ excludePaths:
+ - app/Support/PestShardPlugin.php
diff --git a/phpunit.xml b/phpunit.xml
index 74f048c08..c6b90c572 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -21,7 +21,6 @@
-
diff --git a/rector.php b/rector.php
index dd7180d32..e95c5f606 100644
--- a/rector.php
+++ b/rector.php
@@ -3,10 +3,8 @@
declare(strict_types=1);
use Rector\Caching\ValueObject\Storage\FileCacheStorage;
-use Rector\CodingStyle\Rector\Closure\StaticClosureRector;
use Rector\CodingStyle\Rector\PostInc\PostIncDecToPreIncDecRector;
use Rector\Config\RectorConfig;
-use Rector\Php70\Rector\StaticCall\StaticCallOnNonStaticToInstanceCallRector;
use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector;
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
use RectorLaravel\Rector\Class_\AddHasFactoryToModelsRector;
@@ -78,8 +76,10 @@
AddHasFactoryToModelsRector::class,
AddOverrideAttributeToOverriddenMethodsRector::class,
PostIncDecToPreIncDecRector::class,
- StaticCallOnNonStaticToInstanceCallRector::class,
__DIR__.'/bootstrap/cache',
+ // Verbatim copy of Pest's internal Shard plugin (only the discovery
+ // regex is patched) — keep it re-syncable with upstream.
+ __DIR__.'/app/Support/PestShardPlugin.php',
])
->withCache(cacheDirectory: __DIR__.'/.rector.result.cache', cacheClass: FileCacheStorage::class)
->withImportNames(removeUnusedImports: true)
@@ -111,7 +111,6 @@
ReplaceFakerInstanceWithHelperRector::class,
ConfigToTypedConfigMethodCallRector::class,
EnsureTypeChecksFirstRector::class,
- StaticClosureRector::class,
...$laravel13Attributes,
])
->withSets([
diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css
index 6b2c0eb56..40308c886 100644
--- a/resources/css/filament/admin/theme.css
+++ b/resources/css/filament/admin/theme.css
@@ -1,5 +1,6 @@
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@import '../../../../vendor/livewire/flux/dist/flux.css';
+@import '../locale-switcher.css' layer(components);
@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/**/*';
diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css
index 8bc6c0311..d6f968e49 100644
--- a/resources/css/filament/app/theme.css
+++ b/resources/css/filament/app/theme.css
@@ -1,4 +1,5 @@
@import '../../../../vendor/filament/filament/resources/css/theme.css';
+@import '../locale-switcher.css' layer(components);
@source '../../../../app/Filament/**/*';
@source '../../../../app/Filament/**/*';
@@ -47,3 +48,15 @@
opacity: 0.35;
}
}
+
+/* Keep user avatar circle visible against dark sidebar
+ (default ui-avatars uses gray-950, which blends into dark mode) */
+.fi-sidebar .fi-user-avatar {
+ @apply ring-1 ring-gray-300 dark:ring-white/20;
+}
+
+/* Soft edge between sidebar navigation and main content
+ (same weight as Filament form input borders) */
+.fi-sidebar {
+ @apply border-e border-gray-200 dark:border-white/10;
+}
diff --git a/resources/css/filament/locale-switcher.css b/resources/css/filament/locale-switcher.css
new file mode 100644
index 000000000..9b89b86c8
--- /dev/null
+++ b/resources/css/filament/locale-switcher.css
@@ -0,0 +1,15 @@
+.fi-locale-switcher {
+ @apply grid grid-flow-col gap-x-1;
+}
+
+.fi-locale-switcher-btn {
+ @apply flex justify-center rounded-md px-3 py-2 text-xs font-semibold tracking-wide outline-hidden transition duration-75 hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5;
+
+ &.fi-active {
+ @apply text-primary-500 dark:text-primary-400 bg-gray-50 dark:bg-white/5;
+ }
+
+ &:not(.fi-active) {
+ @apply text-gray-400 hover:text-gray-500 focus-visible:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 dark:focus-visible:text-gray-400;
+ }
+}
diff --git a/resources/views/components/filament/locale-switcher/button.blade.php b/resources/views/components/filament/locale-switcher/button.blade.php
new file mode 100644
index 000000000..84c581405
--- /dev/null
+++ b/resources/views/components/filament/locale-switcher/button.blade.php
@@ -0,0 +1,26 @@
+@props (['label', 'locale', 'active' => false])
+
+@php
+ $ariaLabel = match ($locale) {
+ \App\Support\ApplicationLocale::EN => __('app.locale.english'),
+ \App\Support\ApplicationLocale::PT_BR => __('app.locale.portuguese'),
+ default => $label,
+ };
+@endphp
+
+ $active,
+ 'text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus-visible:bg-gray-50 focus-visible:text-gray-500 dark:text-gray-500 dark:hover:bg-white/5 dark:hover:text-gray-400 dark:focus-visible:bg-white/5' => !$active
+ ])
+ x-on:click="close()"
+>
+ {{ $label }}
+
diff --git a/resources/views/components/filament/locale-switcher/dropdown.blade.php b/resources/views/components/filament/locale-switcher/dropdown.blade.php
new file mode 100644
index 000000000..e029ec636
--- /dev/null
+++ b/resources/views/components/filament/locale-switcher/dropdown.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/components/filament/locale-switcher/index.blade.php b/resources/views/components/filament/locale-switcher/index.blade.php
new file mode 100644
index 000000000..35c89c614
--- /dev/null
+++ b/resources/views/components/filament/locale-switcher/index.blade.php
@@ -0,0 +1,19 @@
+@php
+ use App\Support\ApplicationLocale;
+
+ $currentLocale = app()->getLocale();
+@endphp
+
+
+
+
+
+
diff --git a/resources/views/vendor/filament-panels/components/user-menu.blade.php b/resources/views/vendor/filament-panels/components/user-menu.blade.php
new file mode 100644
index 000000000..3d958e341
--- /dev/null
+++ b/resources/views/vendor/filament-panels/components/user-menu.blade.php
@@ -0,0 +1,137 @@
+@props([
+ 'position' => null,
+])
+
+@php
+ use Filament\Actions\Action;
+ use Filament\Enums\UserMenuPosition;
+ use Illuminate\Support\Arr;
+
+ $user = filament()->auth()->user();
+
+ $items = $this->getUserMenuItems();
+
+ $itemsBeforeAndAfterThemeSwitcher = collect($items)
+ ->groupBy(fn (Action $item): bool => $item->getSort() < 0, preserveKeys: true)
+ ->all();
+ $itemsBeforeThemeSwitcher = $itemsBeforeAndAfterThemeSwitcher[true] ?? collect();
+ $itemsAfterThemeSwitcher = $itemsBeforeAndAfterThemeSwitcher[false] ?? collect();
+
+ $hasProfileHeader = $itemsBeforeThemeSwitcher->has('profile') &&
+ blank(($item = Arr::first($itemsBeforeThemeSwitcher))->getUrl()) &&
+ (! $item->hasAction());
+
+ if ($itemsBeforeThemeSwitcher->has('profile')) {
+ $itemsBeforeThemeSwitcher = $itemsBeforeThemeSwitcher->prepend($itemsBeforeThemeSwitcher->pull('profile'), 'profile');
+ }
+
+ $position ??= filament()->getUserMenuPosition();
+
+ $isSidebarCollapsibleOnDesktop = filament()->isSidebarCollapsibleOnDesktop();
+@endphp
+
+{{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_BEFORE) }}
+
+
+
+ @if ($position === UserMenuPosition::Topbar)
+
+ @else
+
+ @endif
+
+
+ @if ($hasProfileHeader)
+ @php
+ $item = $itemsBeforeThemeSwitcher['profile'];
+ $itemColor = $item->getColor();
+ $itemIcon = $item->getIcon();
+
+ unset($itemsBeforeThemeSwitcher['profile']);
+ @endphp
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }}
+
+
+ {{ $item->getLabel() }}
+
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }}
+ @endif
+
+ @if ($itemsBeforeThemeSwitcher->isNotEmpty())
+
+ @foreach ($itemsBeforeThemeSwitcher as $key => $item)
+ @if ($key === 'profile')
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }}
+
+ {{ $item }}
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }}
+ @else
+ {{ $item }}
+ @endif
+ @endforeach
+
+ @endif
+
+ @if (filament()->hasDarkMode() && (! filament()->hasDarkModeForced()))
+
+
+
+ @endif
+
+
+
+ @if ($itemsAfterThemeSwitcher->isNotEmpty())
+
+ @foreach ($itemsAfterThemeSwitcher as $key => $item)
+ @if ($key === 'profile')
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }}
+
+ {{ $item }}
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }}
+ @else
+ {{ $item }}
+ @endif
+ @endforeach
+
+ @endif
+
+
+{{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_AFTER) }}
diff --git a/routes/web.php b/routes/web.php
index 51d13a585..68bccfd9e 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,15 +2,10 @@
declare(strict_types=1);
-/*
-|--------------------------------------------------------------------------
-| Web Routes
-|--------------------------------------------------------------------------
-|
-| Here is where you can register web routes for your application. These
-| routes are loaded by the RouteServiceProvider within a group which
-| contains the "web" middleware group. Now create something great!
-|
-*/
+use App\Http\Controllers\SwitchLocaleController;
+use App\Support\ApplicationLocale;
+use Illuminate\Support\Facades\Route;
-// Route::get('/', fn () => view('welcome'));
+Route::get('/locale/{locale}', SwitchLocaleController::class)
+ ->whereIn('locale', ApplicationLocale::SUPPORTED)
+ ->name('locale.switch');
diff --git a/stubs/app-modules/ServiceProvider.php b/stubs/app-modules/ServiceProvider.php
index 616bcb0d7..94c269a96 100644
--- a/stubs/app-modules/ServiceProvider.php
+++ b/stubs/app-modules/ServiceProvider.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace StubModuleNamespace\StubClassNamePrefix\Providers;
+namespace StubModuleNamespace\StubClassNamePrefix;
use Illuminate\Support\ServiceProvider;
diff --git a/stubs/app-modules/composer-stub.json b/stubs/app-modules/composer-stub.json
index ae03850e1..fa7c1b45a 100644
--- a/stubs/app-modules/composer-stub.json
+++ b/stubs/app-modules/composer-stub.json
@@ -19,7 +19,7 @@
"minimum-stability": "stable",
"extra": {
"laravel": {
- "providers": ["StubModuleNamespace\\StubClassNamePrefix\\Providers\\StubClassNamePrefixServiceProvider"]
+ "providers": ["StubModuleNamespace\\StubClassNamePrefix\\StubClassNamePrefixServiceProvider"]
}
}
}
diff --git a/tests/.pest/shards.json b/tests/.pest/shards.json
new file mode 100644
index 000000000..fa7917d70
--- /dev/null
+++ b/tests/.pest/shards.json
@@ -0,0 +1,161 @@
+{
+ "timings": {
+ "Appmodules\\activity\\tests\\Unit\\Actions\\NewMessageTest": 2.095,
+ "Appmodules\\activity\\tests\\Unit\\Actions\\NewVoiceMessageTest": 0.8115,
+ "Appmodules\\activity\\tests\\Unit\\Actions\\RecordVoicePresenceTest": 1.1597,
+ "Appmodules\\activity\\tests\\Unit\\Queries\\GetCurrentVoiceChannelTest": 1.1538,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\CreatePostTest": 0.5821,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\CreateReplyTest": 0.5406,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\DeleteReplyTest": 0.5695,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\PublishModerationEntryTest": 0.7663,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineFeedQueryTest": 0.8555,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\TimelineModelTest": 0.4472,
+ "Appmodules\\activity\\tests\\Unit\\Timeline\\TogglePinPostTest": 0.6389,
+ "Appmodules\\activity\\tests\\Unit\\Tracking\\ApproveInteractionTest": 0.1645,
+ "Appmodules\\activity\\tests\\Unit\\Tracking\\CalculateRewardTest": 0.5703,
+ "Appmodules\\activity\\tests\\Unit\\Tracking\\ClassifyActivityTest": 0.1508,
+ "Appmodules\\activity\\tests\\Unit\\Tracking\\RejectInteractionTest": 0.1945,
+ "Appmodules\\activity\\tests\\Unit\\Tracking\\TrackActivityTest": 0.4563,
+ "Appmodules\\botdiscord\\tests\\Feature\\Actions\\VoiceChannel\\HandleStateChannelActionTest": 0.2335,
+ "Appmodules\\botdiscord\\tests\\Feature\\Events\\RawGatewayEventTest": 0.2337,
+ "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\AutoExecuteActionTest": 0.5523,
+ "Appmodules\\botdiscord\\tests\\Feature\\Listeners\\NotifyModerationChannelTest": 0.5776,
+ "Appmodules\\botdiscord\\tests\\Feature\\Moderation\\DiscordModerationAdapterTest": 5.7378,
+ "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\EditProfileCommandTest": 0.5881,
+ "Appmodules\\botdiscord\\tests\\Feature\\SlashCommands\\IntroductionCommandTest": 0.3027,
+ "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\JoiningChannelActionTest": 0.0536,
+ "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceChannel\\LeftChannelActionTest": 0.1009,
+ "Appmodules\\botdiscord\\tests\\Unit\\Actions\\VoiceTransitionResolverTest": 0.301,
+ "Appmodules\\botdiscord\\tests\\Unit\\Moderation\\ModerationEmbedBuilderTest": 0.6902,
+ "Appmodules\\botdiscord\\tests\\Unit\\SlashCommands\\EditProfileModalValidationTest": 0.4961,
+ "Appmodules\\docs\\tests\\Feature\\Discovery\\CacheDocsCommandTest": 1.8584,
+ "Appmodules\\docs\\tests\\Feature\\Discovery\\DocsRoutingTest": 0.9489,
+ "Appmodules\\docs\\tests\\Unit\\Discovery\\BuildTreeTest": 0.4992,
+ "Appmodules\\docs\\tests\\Unit\\Discovery\\DiscoveryIntegrationTest": 0.2006,
+ "Appmodules\\docs\\tests\\Unit\\Discovery\\DocumentTierTest": 0.4947,
+ "Appmodules\\docs\\tests\\Unit\\Discovery\\FoundationsTest": 0.9454,
+ "Appmodules\\docs\\tests\\Unit\\Discovery\\ParsingTest": 0.3025,
+ "Appmodules\\docs\\tests\\Unit\\Discovery\\StrategiesTest": 0.4243,
+ "Appmodules\\economy\\tests\\Feature\\EconomyFlowTest": 0.3033,
+ "Appmodules\\economy\\tests\\Unit\\CreditTest": 0.1546,
+ "Appmodules\\economy\\tests\\Unit\\DebitTest": 0.3025,
+ "Appmodules\\economy\\tests\\Unit\\TransferTest": 0.4794,
+ "Appmodules\\events\\tests\\Feature\\CheckInActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Closure\\CloseEventActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Closure\\ClosePendingEventsCommandTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Closure\\OverrideEnrollmentStatusActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Closure\\ProcessEventClosureJobTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\EnrollUserActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Enrollment\\ApproveApplicationActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Enrollment\\EnrollmentPolicyTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\Enrollment\\RejectApplicationActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\EventFactoriesTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\EventResourceTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\HandleBotCheckInTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\NumericCodeCheckInActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Feature\\QrCheckInActionTest": 0.5448,
+ "Appmodules\\events\\tests\\Unit\\EnrollmentScopeTest": 0.5448,
+ "Appmodules\\events\\tests\\Unit\\EnrollmentStatusTest": 0.5448,
+ "Appmodules\\events\\tests\\Unit\\EventStatusTest": 0.5448,
+ "Appmodules\\gamification\\tests\\Unit\\Character\\GenerateTextExperienceTest": 0.704,
+ "Appmodules\\gamification\\tests\\Unit\\Character\\LevelCalculationTest": 2.0406,
+ "Appmodules\\gamification\\tests\\Unit\\Character\\VoiceExperienceTest": 0.7491,
+ "Appmodules\\identity\\tests\\Feature\\Auth\\DetectMergeConflictTest": 0.98,
+ "Appmodules\\identity\\tests\\Feature\\Auth\\EnrichUserOnFirstLoginTest": 0.5563,
+ "Appmodules\\identity\\tests\\Feature\\Auth\\FindOrCreateUserByProviderTest": 1.5236,
+ "Appmodules\\identity\\tests\\Feature\\Auth\\HandleOAuthCallbackActionTest": 0.3237,
+ "Appmodules\\identity\\tests\\Feature\\Auth\\MergeAccountsActionTest": 1.773,
+ "Appmodules\\identity\\tests\\Unit\\ExternalIdentity\\FindExternalIdentityTest": 0.8692,
+ "Appmodules\\integrationdevto\\tests\\Feature\\DevToOAuthTest": 0.1746,
+ "Appmodules\\integrationdevto\\tests\\Feature\\SyncDevToArticlesTest": 0.404,
+ "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordMessageTest": 6.8537,
+ "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\ImportDiscordProfileTest": 2.2039,
+ "Appmodules\\integrationdiscord\\tests\\Feature\\ETL\\MergeDuplicateDiscordProfilesTest": 2.92,
+ "Appmodules\\integrationdiscord\\tests\\Feature\\Models\\DiscordEventLogTest": 0.1222,
+ "Appmodules\\integrationdiscord\\tests\\Feature\\OAuth\\DiscordOAuthClientTest": 0.177,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\DiscordMessageAdapterTest": 2.2978,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Sync\\PurgeUnusedInvitesActionTest": 11.8086,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordConnectorTest": 0.1482,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\DiscordRoleResolverTest": 0.2467,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateBanTest": 0.1543,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateDmChannelTest": 0.1472,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\CreateMessageTest": 0.1466,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteInviteTest": 0.1051,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\DeleteMessageTest": 0.0979,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ExchangeCodeForTokenTest": 0.1463,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetCurrentUserTest": 0.1469,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\GetMemberTest": 0.1053,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ListGuildInvitesTest": 0.0985,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\ModifyMemberTest": 0.1462,
+ "Appmodules\\integrationdiscord\\tests\\Unit\\Transport\\Requests\\RemoveMemberTest": 0.0975,
+ "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubCommandTest": 0.549,
+ "Appmodules\\integrationgithub\\tests\\Feature\\BackfillGithubRepositoryJobTest": 0.2128,
+ "Appmodules\\integrationgithub\\tests\\Feature\\BackfillRepositoryTest": 1.8143,
+ "Appmodules\\integrationgithub\\tests\\Feature\\GitHubOAuthClientTest": 0.2886,
+ "Appmodules\\integrationgithub\\tests\\Feature\\GithubContributionTest": 0.4922,
+ "Appmodules\\integrationgithub\\tests\\Feature\\GithubRepositoryTest": 0.6587,
+ "Appmodules\\integrationgithub\\tests\\Feature\\GithubWebhookTest": 1.3299,
+ "Appmodules\\integrationtwitch\\tests\\Feature\\LinkTwitchChannelCommandTest": 0.413,
+ "Appmodules\\integrationtwitch\\tests\\Feature\\SubscribeTwitchEventsCommandTest": 0.5222,
+ "Appmodules\\integrationtwitch\\tests\\Feature\\TwitchWebhookTest": 0.9796,
+ "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\StoreWhatsAppEventActionTest": 0.4336,
+ "Appmodules\\integrationwhatsapp\\tests\\Feature\\Ingest\\WebhookIngestTest": 0.6951,
+ "Appmodules\\moderation\\tests\\Feature\\AppealWorkflowTest": 1.077,
+ "Appmodules\\moderation\\tests\\Feature\\Appeals\\FileAppealTest": 1.3777,
+ "Appmodules\\moderation\\tests\\Feature\\Appeals\\ReviewAppealTest": 1.2935,
+ "Appmodules\\moderation\\tests\\Feature\\Audit\\AuditLogTest": 0.8392,
+ "Appmodules\\moderation\\tests\\Feature\\CaseWorkflowTest": 0.5974,
+ "Appmodules\\moderation\\tests\\Feature\\Cases\\SubmitReportTest": 1.5618,
+ "Appmodules\\moderation\\tests\\Feature\\Classification\\RouteDecisionTest": 3.968,
+ "Appmodules\\moderation\\tests\\Feature\\Classification\\RuleBasedClassifierTest": 0.7962,
+ "Appmodules\\moderation\\tests\\Feature\\Enforcement\\ExecuteActionTest": 1.2011,
+ "Appmodules\\moderation\\tests\\Feature\\ModelRelationshipTest": 1.8584,
+ "Appmodules\\moderation\\tests\\Feature\\PipelineTest": 1.0644,
+ "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ClassifyAndRouteTest": 0.4463,
+ "Appmodules\\moderation\\tests\\Feature\\Pipeline\\ScreenContentTest": 0.3201,
+ "Appmodules\\moderation\\tests\\Feature\\Pipeline\\SubmitForModerationTest": 0.3239,
+ "Appmodules\\moderation\\tests\\Feature\\Platform\\WebAdapterTest": 2.0921,
+ "Appmodules\\moderation\\tests\\Unit\\ClassifierTest": 0.4152,
+ "Appmodules\\moderation\\tests\\Unit\\DTOTest": 0.3993,
+ "Appmodules\\moderation\\tests\\Unit\\EnumTest": 0.3526,
+ "Appmodules\\moderation\\tests\\Unit\\ModerationRuleMatchTest": 0.2867,
+ "Appmodules\\moderation\\tests\\Unit\\PenaltyAdvisorTest": 2.1791,
+ "Appmodules\\moderation\\tests\\Unit\\PlatformRegistryTest": 0.2098,
+ "Appmodules\\moderation\\tests\\Unit\\RouteCaseActionTest": 0.8256,
+ "Appmodules\\paneladmin\\tests\\Feature\\AdminPanelAccessTest": 0.5649,
+ "Appmodules\\paneladmin\\tests\\Feature\\ApplyTenantScopesTest": 0.266,
+ "Appmodules\\paneladmin\\tests\\Feature\\Github\\GithubRepositoryResourceTest": 1.301,
+ "Appmodules\\paneladmin\\tests\\Feature\\Marketing\\PeriodStatsTest": 0.2831,
+ "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\AppealQueueTest": 3.4181,
+ "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationDashboardTest": 2.8877,
+ "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\ModerationQueueTest": 2.5896,
+ "Appmodules\\paneladmin\\tests\\Feature\\Moderation\\TestRuleActionTest": 1.1423,
+ "Appmodules\\panelapp\\tests\\Feature\\Events\\ApplicationEnrollmentTest": 0.5448,
+ "Appmodules\\panelapp\\tests\\Feature\\Events\\NumericCodeCheckInTest": 0.5448,
+ "Appmodules\\panelapp\\tests\\Feature\\Events\\RsvpEnrollmentTest": 0.5448,
+ "Appmodules\\panelapp\\tests\\Feature\\ProfilePageTest": 6.1316,
+ "Appmodules\\panelapp\\tests\\Feature\\Timeline\\ThreadPageTest": 1.6969,
+ "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectivePageTest": 1.3169,
+ "Appmodules\\portal\\tests\\Feature\\CommunityRetrospectiveTest": 1.5669,
+ "Appmodules\\portal\\tests\\Feature\\HomepageTest": 0.2566,
+ "Appmodules\\portal\\tests\\Feature\\RetrospectiveFiltersTest": 0.122,
+ "Appmodules\\portal\\tests\\Feature\\SocialLinksPageTest": 0.636,
+ "Appmodules\\profile\\tests\\Feature\\ProfileCreationTest": 0.6384,
+ "Appmodules\\profile\\tests\\Feature\\ProfilePreferencesTest": 0.4501,
+ "Appmodules\\profile\\tests\\Feature\\ProfileWorkExperiencesTest": 0.3042,
+ "Appmodules\\profile\\tests\\Feature\\SyncProfileSkillsTest": 1.2219,
+ "Appmodules\\profile\\tests\\Feature\\ToggleAvailabilityTest": 0.7305,
+ "Appmodules\\profile\\tests\\Feature\\UpsertProfileTest": 1.8365,
+ "Appmodules\\profile\\tests\\Feature\\WorkExperienceFactoryTest": 5.2888,
+ "Appmodules\\profile\\tests\\Unit\\ProfileEnumTest": 0.258,
+ "Appmodules\\profile\\tests\\Unit\\ProfileSocialLinksTest": 0.0502,
+ "Appmodules\\profile\\tests\\Unit\\SkillEnumTest": 0.1017,
+ "Appmodules\\profile\\tests\\Unit\\WorkExperienceTest": 0.1599,
+ "Appmodules\\profile\\tests\\Unit\\WorkPreferencesTest": 0.3199,
+ "Tests\\Feature\\AddressTest": 0.5364,
+ "Tests\\Feature\\Geo\\GeoLocationTest": 0.4048,
+ "Tests\\Feature\\LocaleSwitcherTest": 0.5448
+ },
+ "checksum": "8a07148d596627eadecf191913c62e76",
+ "updated_at": "2026-07-09T17:13:52+00:00"
+}
diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/Feature/AddressTest.php b/tests/Feature/AddressTest.php
index 98590352a..80d3a9fca 100644
--- a/tests/Feature/AddressTest.php
+++ b/tests/Feature/AddressTest.php
@@ -46,23 +46,23 @@
$user = User::factory()->create();
$user->address()->updateOrCreate([], [
- 'country' => 'BRA',
- 'state' => 'SP',
+ 'country' => 'BR',
+ 'state' => 'São Paulo',
'city' => 'São Paulo',
]);
expect($user->fresh()->address)
- ->country->toBe('BRA')
- ->state->toBe('SP')
+ ->country->toBe('BR')
+ ->state->toBe('São Paulo')
->city->toBe('São Paulo');
$user->address()->updateOrCreate([], [
- 'state' => 'RJ',
+ 'state' => 'Rio de Janeiro',
'city' => 'Rio de Janeiro',
]);
expect($user->fresh()->address)
- ->state->toBe('RJ')
+ ->state->toBe('Rio de Janeiro')
->city->toBe('Rio de Janeiro')
->and(Address::query()->where('addressable_id', $user->id)->count())->toBe(1);
});
diff --git a/tests/Feature/Geo/GeoLocationTest.php b/tests/Feature/Geo/GeoLocationTest.php
new file mode 100644
index 000000000..a962f9f5a
--- /dev/null
+++ b/tests/Feature/Geo/GeoLocationTest.php
@@ -0,0 +1,89 @@
+ Http::response([
+ 'success' => true,
+ 'data' => [
+ ['id' => 31, 'iso2' => 'BR', 'iso3' => 'BRA', 'name' => 'Brazil'],
+ ['id' => 231, 'iso2' => 'US', 'iso3' => 'USA', 'name' => 'United States'],
+ ],
+ ]),
+ 'world.bmbc.cloud/api/states*' => Http::response([
+ 'success' => true,
+ 'data' => [
+ ['id' => 485, 'name' => 'Acre', 'cities' => [
+ ['id' => 1, 'name' => 'Rio Branco'],
+ ]],
+ ['id' => 486, 'name' => 'São Paulo', 'cities' => [
+ ['id' => 2, 'name' => 'São Paulo'],
+ ['id' => 3, 'name' => 'Campinas'],
+ ]],
+ ],
+ ]),
+ ]);
+});
+
+it('returns an ISO3 => name map of countries', function (): void {
+ expect(GeoLocation::countries())->toBe([
+ 'BRA' => 'Brazil',
+ 'USA' => 'United States',
+ ]);
+});
+
+it('resolves a country label from an ISO3 code', function (): void {
+ expect(GeoLocation::countryLabel('BRA'))->toBe('Brazil')
+ ->and(GeoLocation::countryLabel('USA'))->toBe('United States')
+ ->and(GeoLocation::countryLabel(iso3: null))->toBeNull();
+});
+
+it('returns state names for a country', function (): void {
+ expect(GeoLocation::statesFor('BRA'))->toBe([
+ 'Acre' => 'Acre',
+ 'São Paulo' => 'São Paulo',
+ ]);
+});
+
+it('returns cities embedded in the states payload without a separate request', function (): void {
+ expect(GeoLocation::citiesFor('BRA', 'São Paulo'))->toBe([
+ 'Campinas' => 'Campinas',
+ 'São Paulo' => 'São Paulo',
+ ]);
+
+ Http::assertNotSent(static fn ($request): bool => str_contains((string) $request->url(), '/cities'));
+});
+
+it('filters cities accent-insensitively', function (): void {
+ expect(GeoLocation::citiesFor('BRA', 'São Paulo', 'sao'))
+ ->toBe(['São Paulo' => 'São Paulo']);
+});
+
+it('caches the states payload for a country across calls', function (): void {
+ GeoLocation::statesFor('BRA');
+ GeoLocation::statesFor('BRA');
+
+ Http::assertSentCount(2);
+
+ $statesRequests = 0;
+
+ Http::recorded(static function ($request) use (&$statesRequests): void {
+ if (str_contains((string) $request->url(), '/states')) {
+ $statesRequests++;
+ }
+ });
+
+ expect($statesRequests)->toBe(1);
+});
+
+it('formats a full location string', function (): void {
+ expect(GeoLocation::formatLocation('São Paulo', 'São Paulo', 'BRA'))
+ ->toBe('São Paulo, São Paulo, Brazil');
+});
diff --git a/tests/Feature/LocaleSwitcherTest.php b/tests/Feature/LocaleSwitcherTest.php
new file mode 100644
index 000000000..b635ba8e7
--- /dev/null
+++ b/tests/Feature/LocaleSwitcherTest.php
@@ -0,0 +1,63 @@
+from('/admin')
+ ->get(route('locale.switch', ['locale' => ApplicationLocale::PT_BR]))
+ ->assertRedirect('/admin');
+
+ expect(session(ApplicationLocale::SESSION_KEY))->toBe(ApplicationLocale::PT_BR);
+});
+
+it('rejects unsupported locale', function (): void {
+ $this->get('/locale/fr')->assertNotFound();
+});
+
+it('applies locale from session via middleware', function (): void {
+ session([ApplicationLocale::SESSION_KEY => ApplicationLocale::PT_BR]);
+
+ $middleware = new SetApplicationLocale;
+ $request = Request::create('/');
+
+ $middleware->handle($request, static function (): ResponseFactory|Response {
+ expect(app()->getLocale())->toBe(ApplicationLocale::PT_BR)
+ ->and(Date::getLocale())->toBe('pt_BR');
+
+ return response('ok');
+ });
+});
+
+it('formats datetimes according to the active locale', function (): void {
+ ApplicationLocale::apply(ApplicationLocale::PT_BR);
+
+ expect(ApplicationLocale::dateTimeFormat())->toBe('d/m/Y H:i:s');
+
+ ApplicationLocale::apply(ApplicationLocale::EN);
+
+ expect(ApplicationLocale::dateTimeFormat())->toBe('M j, Y H:i:s');
+});
+
+it('falls back to english for invalid session locale', function (): void {
+ session([ApplicationLocale::SESSION_KEY => 'invalid']);
+
+ expect(ApplicationLocale::resolve())->toBe(ApplicationLocale::EN);
+});
+
+it('renders locale switcher with supported locales', function (): void {
+ $html = Blade::render('');
+
+ expect($html)
+ ->toContain('EN')
+ ->toContain('PT-BR')
+ ->toContain(route('locale.switch', ['locale' => ApplicationLocale::EN]))
+ ->toContain(route('locale.switch', ['locale' => ApplicationLocale::PT_BR]));
+});