diff --git a/app-modules/panel-admin/lang/en/events.php b/app-modules/panel-admin/lang/en/events.php new file mode 100644 index 000000000..865227d09 --- /dev/null +++ b/app-modules/panel-admin/lang/en/events.php @@ -0,0 +1,113 @@ + 'Event', + 'plural' => 'Events', + + 'columns' => [ + 'title' => 'Title', + 'slug' => 'Slug', + 'type' => 'Type', + 'tenant' => 'Tenant', + 'location' => 'Location', + 'description' => 'Description', + 'starts_at' => 'Starts At', + 'ends_at' => 'Ends At', + 'status' => 'Status', + 'created_at' => 'Created At', + 'date' => 'Date', + 'code' => 'Code', + 'event_date' => 'Event Date', + 'valid_from' => 'Valid From', + 'expires_at' => 'Expires At', + 'uses' => 'Uses', + 'revoked_at' => 'Revoked At', + ], + + 'sections' => [ + 'enrollment_policy' => 'Enrollment Policy', + ], + + 'form' => [ + 'enrollment_method' => 'Enrollment Method', + 'check_in_method' => 'Check-in Method', + 'capacity' => 'Capacity', + 'waitlist_enabled' => 'Waitlist Enabled', + 'attendance_requirement' => 'Attendance Requirement', + 'minimum_days' => 'Minimum Days', + 'cancellation_deadline_hours' => 'Cancellation Deadline (hours before event)', + 'xp_on_confirmed' => 'XP on Confirmed', + 'xp_on_checked_in' => 'XP on Checked-in', + 'xp_on_attended' => 'XP on Attended', + 'application_form_schema' => 'Application Form Schema', + 'application_schema_key' => 'Field name', + 'application_schema_value' => 'Field type / label', + 'helpers' => [ + 'minimum_days' => 'Required when attendance requirement is "Minimum Days". Default 1, max = event days.', + ], + ], + + 'relations' => [ + 'enrollments' => 'Enrollments', + 'check_in_codes' => 'Check-in Codes', + ], + + 'enrollments' => [ + 'columns' => [ + 'participant' => 'Participant', + 'waitlist' => 'Waitlist', + 'enrolled_at' => 'Enrolled At', + 'confirmed_at' => 'Confirmed At', + 'check_in_history' => 'Check-in History', + 'cancelled_at' => 'Cancelled At', + ], + 'actions' => [ + 'check_in' => 'Check In', + 'check_in_selected' => 'Check In Selected', + 'override_status' => 'Override Status', + 'new_status' => 'New Status', + 'reason' => 'Reason', + ], + 'notifications' => [ + 'participant_checked_in' => 'Participant checked in.', + 'selected_participants_checked_in' => 'Selected participants checked in.', + 'status_overridden' => 'Enrollment status overridden.', + ], + ], + + 'check_in_codes' => [ + 'actions' => [ + 'generate_code' => 'Generate Code', + 'revoke' => 'Revoke', + ], + 'fields' => [ + 'code_length' => 'Code Length', + 'generated_code' => 'Generated Code', + 'max_uses' => 'Max Uses (optional)', + ], + 'digits' => [ + 'four' => '4 digits', + 'six' => '6 digits', + ], + 'unlimited' => 'Unlimited', + 'notifications' => [ + 'code_revoked' => 'Code revoked.', + ], + ], + + 'edit' => [ + 'scan_qr' => 'Scan QR', + 'qr_token' => 'QR Token', + 'qr_token_placeholder' => 'Scan or paste the participant token', + 'check_in_submit' => 'Check In', + 'participant_fallback' => 'Participant', + 'notifications' => [ + 'check_in_success_title' => 'Check-in successful', + 'check_in_success_body' => ':name has been checked in.', + 'check_in_failed_title' => 'Check-in failed', + 'check_in_unexpected_error' => 'An unexpected error occurred. Please try again.', + ], + ], +]; diff --git a/app-modules/panel-admin/lang/pt_BR/events.php b/app-modules/panel-admin/lang/pt_BR/events.php new file mode 100644 index 000000000..e4ee75ed0 --- /dev/null +++ b/app-modules/panel-admin/lang/pt_BR/events.php @@ -0,0 +1,113 @@ + 'Evento', + 'plural' => 'Eventos', + + 'columns' => [ + 'title' => 'Título', + 'slug' => 'Slug', + 'type' => 'Tipo', + 'tenant' => 'Tenant', + 'location' => 'Local', + 'description' => 'Descrição', + 'starts_at' => 'Início', + 'ends_at' => 'Término', + 'status' => 'Status', + 'created_at' => 'Criado em', + 'date' => 'Data', + 'code' => 'Código', + 'event_date' => 'Data do evento', + 'valid_from' => 'Válido de', + 'expires_at' => 'Expira em', + 'uses' => 'Usos', + 'revoked_at' => 'Revogado em', + ], + + 'sections' => [ + 'enrollment_policy' => 'Política de inscrição', + ], + + 'form' => [ + 'enrollment_method' => 'Método de inscrição', + 'check_in_method' => 'Método de check-in', + 'capacity' => 'Capacidade', + 'waitlist_enabled' => 'Lista de espera habilitada', + 'attendance_requirement' => 'Requisito de presença', + 'minimum_days' => 'Dias mínimos', + 'cancellation_deadline_hours' => 'Prazo de cancelamento (horas antes do evento)', + 'xp_on_confirmed' => 'XP ao confirmar', + 'xp_on_checked_in' => 'XP no check-in', + 'xp_on_attended' => 'XP ao comparecer', + 'application_form_schema' => 'Schema do formulário de inscrição', + 'application_schema_key' => 'Nome do campo', + 'application_schema_value' => 'Tipo / rótulo do campo', + 'helpers' => [ + 'minimum_days' => 'Obrigatório quando o requisito de presença é "Dias mínimos". Padrão 1, máximo = dias do evento.', + ], + ], + + 'relations' => [ + 'enrollments' => 'Inscrições', + 'check_in_codes' => 'Códigos de check-in', + ], + + 'enrollments' => [ + 'columns' => [ + 'participant' => 'Participante', + 'waitlist' => 'Lista de espera', + 'enrolled_at' => 'Inscrito em', + 'confirmed_at' => 'Confirmado em', + 'check_in_history' => 'Histórico de check-in', + 'cancelled_at' => 'Cancelado em', + ], + 'actions' => [ + 'check_in' => 'Fazer check-in', + 'check_in_selected' => 'Check-in selecionados', + 'override_status' => 'Alterar status', + 'new_status' => 'Novo status', + 'reason' => 'Motivo', + ], + 'notifications' => [ + 'participant_checked_in' => 'Participante com check-in realizado.', + 'selected_participants_checked_in' => 'Participantes selecionados com check-in realizado.', + 'status_overridden' => 'Status da inscrição alterado.', + ], + ], + + 'check_in_codes' => [ + 'actions' => [ + 'generate_code' => 'Gerar código', + 'revoke' => 'Revogar', + ], + 'fields' => [ + 'code_length' => 'Tamanho do código', + 'generated_code' => 'Código gerado', + 'max_uses' => 'Máximo de usos (opcional)', + ], + 'digits' => [ + 'four' => '4 dígitos', + 'six' => '6 dígitos', + ], + 'unlimited' => 'Ilimitado', + 'notifications' => [ + 'code_revoked' => 'Código revogado.', + ], + ], + + 'edit' => [ + 'scan_qr' => 'Escanear QR', + 'qr_token' => 'Token QR', + 'qr_token_placeholder' => 'Escaneie ou cole o token do participante', + 'check_in_submit' => 'Fazer check-in', + 'participant_fallback' => 'Participante', + 'notifications' => [ + 'check_in_success_title' => 'Check-in realizado', + 'check_in_success_body' => ':name fez check-in com sucesso.', + 'check_in_failed_title' => 'Falha no check-in', + 'check_in_unexpected_error' => 'Ocorreu um erro inesperado. Tente novamente.', + ], + ], +]; diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php index 36d0cef76..217aa62e1 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php @@ -29,6 +29,21 @@ final class EventResource extends Resource protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCalendarDays; + public static function getNavigationLabel(): string + { + return __('panel-admin::events.plural'); + } + + public static function getModelLabel(): string + { + return __('panel-admin::events.label'); + } + + public static function getPluralModelLabel(): string + { + return __('panel-admin::events.plural'); + } + public static function form(Schema $schema): Schema { return EventForm::configure($schema); diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php index bc809978d..3db3059f5 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php @@ -32,17 +32,17 @@ protected function getHeaderActions(): array { return [ Action::make('scanQr') - ->label('Scan QR') + ->label(__('panel-admin::events.edit.scan_qr')) ->icon(Heroicon::QrCode) ->color('success') ->schema([ TextInput::make('token') - ->label('QR Token') + ->label(__('panel-admin::events.edit.qr_token')) ->required() ->autofocus() - ->placeholder('Scan or paste the participant token'), + ->placeholder(__('panel-admin::events.edit.qr_token_placeholder')), ]) - ->modalSubmitActionLabel('Check In') + ->modalSubmitActionLabel(__('panel-admin::events.edit.check_in_submit')) ->action(function (array $data): void { /** @var Event $event */ $event = $this->getRecord(); @@ -58,24 +58,26 @@ protected function getHeaderActions(): array ); $checkIn->enrollment->loadMissing('user'); - $participantName = $checkIn->enrollment->user->name ?? 'Participant'; + $participantName = $checkIn->enrollment->user->name ?? __('panel-admin::events.edit.participant_fallback'); Notification::make() ->success() - ->title('Check-in successful') - ->body($participantName.' has been checked in.') + ->title(__('panel-admin::events.edit.notifications.check_in_success_title')) + ->body(__('panel-admin::events.edit.notifications.check_in_success_body', [ + 'name' => $participantName, + ])) ->send(); } catch (CheckInException $e) { Notification::make() ->danger() - ->title('Check-in failed') + ->title(__('panel-admin::events.edit.notifications.check_in_failed_title')) ->body($e->getMessage()) ->send(); } catch (Throwable $e) { Notification::make() ->danger() - ->title('Check-in failed') - ->body('An unexpected error occurred. Please try again.') + ->title(__('panel-admin::events.edit.notifications.check_in_failed_title')) + ->body(__('panel-admin::events.edit.notifications.check_in_unexpected_error')) ->send(); report($e); diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php index cd320331b..2d02bdd89 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php @@ -21,7 +21,7 @@ final class GenerateCheckInCodeAction extends Action protected function setUp(): void { parent::setUp(); - $this->label('Generate Code') + $this->label(__('panel-admin::events.check_in_codes.actions.generate_code')) ->icon(Heroicon::OutlinedPlusCircle) ->color('success') ->schema($this->generateFormSchema(...)) @@ -70,10 +70,10 @@ private function generateFormSchema(RelationManager $livewire): array ->columns(2) ->schema([ Select::make('digits') - ->label('Code Length') + ->label(__('panel-admin::events.check_in_codes.fields.code_length')) ->options([ - '4' => '4 digits', - '6' => '6 digits', + '4' => __('panel-admin::events.check_in_codes.digits.four'), + '6' => __('panel-admin::events.check_in_codes.digits.six'), ]) ->default('6') ->live() @@ -84,35 +84,35 @@ private function generateFormSchema(RelationManager $livewire): array ->required(), TextInput::make('code_preview') - ->label('Generated Code') + ->label(__('panel-admin::events.check_in_codes.fields.generated_code')) ->readOnly() ->default(fn (): string => $this->generateNumericCode(6)) ->dehydrated() ->required(), DatePicker::make('event_date') - ->label('Event Date') + ->label(__('panel-admin::events.columns.event_date')) ->default($event->starts_at->toDateString()) ->minDate($event->starts_at->toDateString()) ->maxDate($event->ends_at->toDateString()) ->required(), DateTimePicker::make('starts_at') - ->label('Valid From') + ->label(__('panel-admin::events.columns.valid_from')) ->default(now()) ->required(), DateTimePicker::make('expires_at') - ->label('Expires At') - ->default(now()->addHours(2)) + ->label(__('panel-admin::events.columns.expires_at')) ->afterOrEqual('starts_at') + ->default(now()->addHours(2)) ->required(), TextInput::make('max_uses') - ->label('Max Uses (optional)') + ->label(__('panel-admin::events.check_in_codes.fields.max_uses')) ->numeric() ->minValue(1) - ->placeholder('Unlimited'), + ->placeholder(__('panel-admin::events.check_in_codes.unlimited')), ]), ]; } diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php index 9d1f7938d..b7426845e 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php @@ -20,19 +20,19 @@ protected function setUp(): void { parent::setUp(); - $this->label('Override Status') + $this->label(__('panel-admin::events.enrollments.actions.override_status')) ->icon(Heroicon::OutlinedPencilSquare) ->color('warning') ->visible(fn (Enrollment $record): bool => OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status) !== []) ->schema([ Select::make('to_status') - ->label('New Status') + ->label(__('panel-admin::events.enrollments.actions.new_status')) ->options(fn (Enrollment $record): array => collect(OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status)) ->mapWithKeys(fn (EnrollmentStatus $s): array => [$s->value => $s->getLabel()]) ->all()) ->required(), Textarea::make('reason') - ->label('Reason') + ->label(__('panel-admin::events.enrollments.actions.reason')) ->required() ->minLength(3) ->rows(3), @@ -50,7 +50,7 @@ protected function setUp(): void Notification::make() ->success() - ->title('Enrollment status overridden.') + ->title(__('panel-admin::events.enrollments.notifications.status_overridden')) ->send(); }); } diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php index 9bd424d07..6bfd3cf2b 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php @@ -15,7 +15,7 @@ protected function setUp(): void { parent::setUp(); - $this->label('Revoke') + $this->label(__('panel-admin::events.check_in_codes.actions.revoke')) ->icon(Heroicon::OutlinedNoSymbol) ->color('danger') ->visible(fn (CheckInCode $record): bool => $record->revoked_at === null) @@ -25,7 +25,7 @@ protected function setUp(): void Notification::make() ->success() - ->title('Code revoked.') + ->title(__('panel-admin::events.check_in_codes.notifications.code_revoked')) ->send(); }); } diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php index 82f0bb7a5..d63daffc2 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php @@ -18,7 +18,10 @@ final class CheckInCodesRelationManager extends RelationManager { protected static string $relationship = 'checkInCodes'; - protected static ?string $title = 'Check-in Codes'; + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + return __('panel-admin::events.relations.check_in_codes'); + } public static function getBadge(Model $ownerRecord, string $pageClass): string { @@ -33,32 +36,32 @@ public function table(Table $table): Table ->modifyQueryUsing(fn (Builder $query): Builder => $query->latest()) ->columns([ TextColumn::make('code') - ->label('Code') + ->label(__('panel-admin::events.columns.code')) ->badge() ->color(fn (CheckInCode $record): string => $record->revoked_at !== null ? 'gray' : ($record->expires_at->isPast() ? 'warning' : 'success')) ->searchable(), TextColumn::make('event_date') - ->label('Event Date') + ->label(__('panel-admin::events.columns.event_date')) ->date() ->sortable(), TextColumn::make('starts_at') - ->label('Valid From') + ->label(__('panel-admin::events.columns.valid_from')) ->dateTime() ->sortable(), TextColumn::make('expires_at') - ->label('Expires At') + ->label(__('panel-admin::events.columns.expires_at')) ->dateTime() ->sortable(), TextColumn::make('uses_count') - ->label('Uses') + ->label(__('panel-admin::events.columns.uses')) ->state(fn (CheckInCode $record): string => $record->uses_count.($record->max_uses !== null ? '/'.$record->max_uses : '')), TextColumn::make('revoked_at') - ->label('Revoked At') + ->label(__('panel-admin::events.columns.revoked_at')) ->dateTime() ->placeholder('-'), ]) diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php index 9b322d5d8..c6a462328 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php @@ -25,12 +25,18 @@ use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\Actions\OverrideEnrollmentStatusAction; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Date; final class EnrollmentsRelationManager extends RelationManager { protected static string $relationship = 'enrollments'; + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + return __('panel-admin::events.relations.enrollments'); + } + public function table(Table $table): Table { return $table @@ -38,33 +44,33 @@ public function table(Table $table): Table ->modifyQueryUsing(fn (Builder $query): Builder => $query->with(['checkIns', 'user'])) ->columns([ TextColumn::make('user.name') - ->label('Participant') + ->label(__('panel-admin::events.enrollments.columns.participant')) ->searchable() ->sortable(), TextColumn::make('status') - ->label('Status') + ->label(__('panel-admin::events.columns.status')) ->badge() ->sortable(), TextColumn::make('waitlist_position') - ->label('Waitlist') + ->label(__('panel-admin::events.enrollments.columns.waitlist')) ->sortable() ->placeholder('-') ->toggleable(), TextColumn::make('enrolled_at') - ->label('Enrolled At') + ->label(__('panel-admin::events.enrollments.columns.enrolled_at')) ->dateTime() ->sortable(), TextColumn::make('confirmed_at') - ->label('Confirmed At') + ->label(__('panel-admin::events.enrollments.columns.confirmed_at')) ->dateTime() ->sortable(), TextColumn::make('check_in_history') - ->label('Check-in History') + ->label(__('panel-admin::events.enrollments.columns.check_in_history')) ->state(fn (Enrollment $record): string => $record->checkIns ->sortBy('event_date') ->map(fn (CheckIn $checkIn): string => $checkIn->event_date->toDateString()) @@ -73,14 +79,14 @@ public function table(Table $table): Table ->toggleable(), TextColumn::make('cancelled_at') - ->label('Cancelled At') + ->label(__('panel-admin::events.enrollments.columns.cancelled_at')) ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('status') - ->label('Status') + ->label(__('panel-admin::events.columns.status')) ->options(EnrollmentStatus::class), ]) ->recordActions([ @@ -99,7 +105,7 @@ public function table(Table $table): Table private function checkInAction(): Action { return Action::make('checkIn') - ->label('Check In') + ->label(__('panel-admin::events.enrollments.actions.check_in')) ->icon(Heroicon::OutlinedCheckCircle) ->color('success') ->visible(fn (Enrollment $record): bool => $record->status->is(EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn)) @@ -109,7 +115,7 @@ private function checkInAction(): Action Notification::make() ->success() - ->title('Participant checked in.') + ->title(__('panel-admin::events.enrollments.notifications.participant_checked_in')) ->send(); }); } @@ -117,7 +123,7 @@ private function checkInAction(): Action private function bulkCheckInAction(): BulkAction { return BulkAction::make('checkInSelected') - ->label('Check In Selected') + ->label(__('panel-admin::events.enrollments.actions.check_in_selected')) ->icon(Heroicon::OutlinedCheckCircle) ->color('success') ->schema($this->checkInSchema()) @@ -132,7 +138,7 @@ private function bulkCheckInAction(): BulkAction Notification::make() ->success() - ->title('Selected participants checked in.') + ->title(__('panel-admin::events.enrollments.notifications.selected_participants_checked_in')) ->send(); }) ->deselectRecordsAfterCompletion(); @@ -148,7 +154,7 @@ private function checkInSchema(): array return [ DatePicker::make('event_date') - ->label('Date') + ->label(__('panel-admin::events.columns.date')) ->default(now()) ->minDate($event->starts_at->toDateString()) ->maxDate($event->ends_at->toDateString()) diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php index c55277a3f..47ae0b0a2 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php @@ -30,13 +30,13 @@ public static function configure(Schema $schema): Schema ->columns(2) ->components([ TextInput::make('title') - ->label('Title') + ->label(__('panel-admin::events.columns.title')) ->required() ->maxLength(200) ->columnSpanFull(), TextInput::make('slug') - ->label('Slug') + ->label(__('panel-admin::events.columns.slug')) ->required() ->maxLength(120) ->unique( @@ -53,75 +53,75 @@ public static function configure(Schema $schema): Schema ), Select::make('event_type') - ->label('Type') + ->label(__('panel-admin::events.columns.type')) ->options(EventType::class) ->required(), Select::make('tenant_id') - ->label('Tenant') + ->label(__('panel-admin::events.columns.tenant')) ->relationship('tenant', 'name') ->searchable() ->nullable(), TextInput::make('location') - ->label('Location') + ->label(__('panel-admin::events.columns.location')) ->nullable(), Textarea::make('description') - ->label('Description') + ->label(__('panel-admin::events.columns.description')) ->nullable() ->columnSpanFull(), DateTimePicker::make('starts_at') - ->label('Starts At') + ->label(__('panel-admin::events.columns.starts_at')) ->required(), DateTimePicker::make('ends_at') - ->label('Ends At') + ->label(__('panel-admin::events.columns.ends_at')) ->required() ->after('starts_at'), Select::make('status') - ->label('Status') + ->label(__('panel-admin::events.columns.status')) ->options(EventStatus::class) ->default(EventStatus::Draft) ->required() ->columnSpanFull(), - Section::make('Enrollment Policy') + Section::make(__('panel-admin::events.sections.enrollment_policy')) ->relationship('enrollmentPolicy') ->columns(2) ->schema([ Select::make('enrollment_method') - ->label('Enrollment Method') + ->label(__('panel-admin::events.form.enrollment_method')) ->options(EnrollmentMethod::class) ->live() ->required(), Select::make('check_in_method') - ->label('Check-in Method') + ->label(__('panel-admin::events.form.check_in_method')) ->options(CheckInMethod::class) ->required(), TextInput::make('capacity') - ->label('Capacity') + ->label(__('panel-admin::events.form.capacity')) ->integer() ->minValue(1) ->nullable(), Toggle::make('has_waitlist') - ->label('Waitlist Enabled') + ->label(__('panel-admin::events.form.waitlist_enabled')) ->default(false), Select::make('attendance_requirement') - ->label('Attendance Requirement') + ->label(__('panel-admin::events.form.attendance_requirement')) ->options(fn (Get $get): array => self::attendanceRequirementOptions($get)) ->live() ->required(), TextInput::make('minimum_days') - ->label('Minimum Days') - ->helperText('Required when attendance requirement is "Minimum Days". Default 1, max = event days.') + ->label(__('panel-admin::events.form.minimum_days')) + ->helperText(__('panel-admin::events.form.helpers.minimum_days')) ->integer() ->minValue(1) ->maxValue(fn (Get $get): ?int => self::minimumDaysMaxValue($get)) @@ -130,33 +130,33 @@ public static function configure(Schema $schema): Schema ->visible(fn (Get $get): bool => $get('attendance_requirement') === AttendanceRequirement::MinimumDays->value), TextInput::make('cancellation_deadline_hours') - ->label('Cancellation Deadline (hours before event)') + ->label(__('panel-admin::events.form.cancellation_deadline_hours')) ->integer() ->minValue(0) ->nullable(), TextInput::make('xp_on_confirmed') - ->label('XP on Confirmed') + ->label(__('panel-admin::events.form.xp_on_confirmed')) ->integer() ->minValue(0) ->default(0), TextInput::make('xp_on_checked_in') - ->label('XP on Checked-in') + ->label(__('panel-admin::events.form.xp_on_checked_in')) ->integer() ->minValue(0) ->default(0), TextInput::make('xp_on_attended') - ->label('XP on Attended') + ->label(__('panel-admin::events.form.xp_on_attended')) ->integer() ->minValue(0) ->default(0), KeyValue::make('application_schema') - ->label('Application Form Schema') - ->keyLabel('Field name') - ->valueLabel('Field type / label') + ->label(__('panel-admin::events.form.application_form_schema')) + ->keyLabel(__('panel-admin::events.form.application_schema_key')) + ->valueLabel(__('panel-admin::events.form.application_schema_value')) ->nullable() ->columnSpanFull() ->visible(fn (Get $get): bool => $get('enrollment_method') === EnrollmentMethod::Application->value), diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php index e138d7fba..23e7e7d21 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php @@ -17,79 +17,79 @@ public static function configure(Schema $schema): Schema ->columns(2) ->components([ TextEntry::make('title') - ->label('Title') + ->label(__('panel-admin::events.columns.title')) ->columnSpanFull(), TextEntry::make('slug') - ->label('Slug'), + ->label(__('panel-admin::events.columns.slug')), TextEntry::make('event_type') - ->label('Type') + ->label(__('panel-admin::events.columns.type')) ->badge(), TextEntry::make('tenant.name') - ->label('Tenant'), + ->label(__('panel-admin::events.columns.tenant')), TextEntry::make('location') - ->label('Location'), + ->label(__('panel-admin::events.columns.location')), TextEntry::make('description') - ->label('Description') + ->label(__('panel-admin::events.columns.description')) ->columnSpanFull(), TextEntry::make('starts_at') - ->label('Starts At') + ->label(__('panel-admin::events.columns.starts_at')) ->dateTime(), TextEntry::make('ends_at') - ->label('Ends At') + ->label(__('panel-admin::events.columns.ends_at')) ->dateTime(), TextEntry::make('status') - ->label('Status') + ->label(__('panel-admin::events.columns.status')) ->badge(), TextEntry::make('created_at') - ->label('Created At') + ->label(__('panel-admin::events.columns.created_at')) ->dateTime(), - Section::make('Enrollment Policy') + Section::make(__('panel-admin::events.sections.enrollment_policy')) ->relationship('enrollmentPolicy') ->columns(2) ->schema([ TextEntry::make('enrollment_method') - ->label('Enrollment Method') + ->label(__('panel-admin::events.form.enrollment_method')) ->badge(), TextEntry::make('check_in_method') - ->label('Check-in Method') + ->label(__('panel-admin::events.form.check_in_method')) ->badge(), TextEntry::make('capacity') - ->label('Capacity'), + ->label(__('panel-admin::events.form.capacity')), IconEntry::make('has_waitlist') - ->label('Waitlist Enabled') + ->label(__('panel-admin::events.form.waitlist_enabled')) ->boolean(), TextEntry::make('attendance_requirement') - ->label('Attendance Requirement') + ->label(__('panel-admin::events.form.attendance_requirement')) ->badge(), TextEntry::make('minimum_days') - ->label('Minimum Days'), + ->label(__('panel-admin::events.form.minimum_days')), TextEntry::make('cancellation_deadline_hours') - ->label('Cancellation Deadline (hours before event)'), + ->label(__('panel-admin::events.form.cancellation_deadline_hours')), TextEntry::make('xp_on_confirmed') - ->label('XP on Confirmed'), + ->label(__('panel-admin::events.form.xp_on_confirmed')), TextEntry::make('xp_on_checked_in') - ->label('XP on Checked-in'), + ->label(__('panel-admin::events.form.xp_on_checked_in')), TextEntry::make('xp_on_attended') - ->label('XP on Attended'), + ->label(__('panel-admin::events.form.xp_on_attended')), ]), ]); } diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php index a9cf7ec98..827ba8d06 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php +++ b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php @@ -21,48 +21,48 @@ public static function table(Table $table): Table return $table ->columns([ TextColumn::make('title') - ->label('Title') + ->label(__('panel-admin::events.columns.title')) ->searchable() ->sortable(), TextColumn::make('event_type') - ->label('Type') + ->label(__('panel-admin::events.columns.type')) ->badge() ->sortable(), TextColumn::make('tenant.name') - ->label('Tenant') + ->label(__('panel-admin::events.columns.tenant')) ->searchable() ->sortable(), TextColumn::make('starts_at') - ->label('Starts At') + ->label(__('panel-admin::events.columns.starts_at')) ->dateTime() ->sortable(), TextColumn::make('ends_at') - ->label('Ends At') + ->label(__('panel-admin::events.columns.ends_at')) ->dateTime() ->sortable(), TextColumn::make('status') - ->label('Status') + ->label(__('panel-admin::events.columns.status')) ->badge() ->sortable(), TextColumn::make('created_at') - ->label('Created At') + ->label(__('panel-admin::events.columns.created_at')) ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('event_type') - ->label('Type') + ->label(__('panel-admin::events.columns.type')) ->options(EventType::class), SelectFilter::make('status') - ->label('Status') + ->label(__('panel-admin::events.columns.status')) ->options(EventStatus::class), ]) ->recordActions([ 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 @@ +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(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/bootstrap/app.php b/bootstrap/app.php index d31b71406..eb45bc175 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(function (Exceptions $exceptions): void {}) ->create(); 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/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..6f76cb5a7 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/**/*'; 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/tests/Feature/LocaleSwitcherTest.php b/tests/Feature/LocaleSwitcherTest.php new file mode 100644 index 000000000..4242c62b9 --- /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, 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])); +});