feat: hunt resource and personalised mails

This commit is contained in:
Leon Morival 2026-04-29 15:03:44 +02:00
parent 33f87bfa10
commit 932d9a38a4
5 changed files with 249 additions and 10 deletions

View File

@ -2,11 +2,14 @@
namespace App\Filament\Resources\Hunts\RelationManagers; namespace App\Filament\Resources\Hunts\RelationManagers;
use Dotswan\MapPicker\Fields\Map;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
@ -20,6 +23,14 @@ class StepsRelationManager extends RelationManager
public function form(Schema $schema): Schema public function form(Schema $schema): Schema
{ {
$ownerRecord = $this->getOwnerRecord();
$defaultLocation = ($ownerRecord->latitude !== null && $ownerRecord->longitude !== null)
? [
'lat' => (float) $ownerRecord->latitude,
'lng' => (float) $ownerRecord->longitude,
]
: null;
return $schema return $schema
->components([ ->components([
TextInput::make('step_number') TextInput::make('step_number')
@ -31,14 +42,16 @@ class StepsRelationManager extends RelationManager
Textarea::make('description'), Textarea::make('description'),
Textarea::make('hint_text') Textarea::make('hint_text')
->label('Hint'), ->label('Hint'),
TextInput::make('hint_media_url') FileUpload::make('hint_media_url')
->url() ->label('Hint image')
->label('Hint Media URL'), ->image()
TextInput::make('latitude') ->disk('public')
->numeric() ->directory('hunt-steps'),
Hidden::make('latitude')
->default($defaultLocation['lat'] ?? null)
->required(), ->required(),
TextInput::make('longitude') Hidden::make('longitude')
->numeric() ->default($defaultLocation['lng'] ?? null)
->required(), ->required(),
TextInput::make('radius_m') TextInput::make('radius_m')
->numeric() ->numeric()
@ -47,6 +60,58 @@ class StepsRelationManager extends RelationManager
TextInput::make('xp') TextInput::make('xp')
->numeric() ->numeric()
->default(10), ->default(10),
Map::make('location')
->label('Location')
->columnSpanFull()
->default($defaultLocation)
->defaultLocation(
latitude: $defaultLocation['lat'] ?? 40.4168,
longitude: $defaultLocation['lng'] ?? -3.7038,
)
->draggable(true)
->clickable(true)
->zoom(15)
->minZoom(0)
->maxZoom(28)
->tilesUrl('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
->detectRetina(true)
->showFullscreenControl(false)
->showZoomControl(true)
->showMyLocationButton(true)
->boundaries(true, 49.5, -11, 61, 2)
->rangeSelectField('radius_m')
->extraStyles([
'border-radius: 10px',
])
->afterStateUpdated(function ($set, ?array $state): void {
if ($state === null) {
return;
}
$set('latitude', $state['lat']);
$set('longitude', $state['lng']);
})
->afterStateHydrated(function (?array $state, $record, $set) use ($defaultLocation): void {
if (($state['lat'] ?? null) !== null && ($state['lng'] ?? null) !== null) {
return;
}
$latitude = $record?->latitude ?? ($defaultLocation['lat'] ?? null);
$longitude = $record?->longitude ?? ($defaultLocation['lng'] ?? null);
if ($latitude === null || $longitude === null) {
return;
}
$location = [
'lat' => (float) $latitude,
'lng' => (float) $longitude,
];
$set('location', $location);
$set('latitude', $location['lat']);
$set('longitude', $location['lng']);
}),
]); ]);
} }
@ -56,6 +121,9 @@ class StepsRelationManager extends RelationManager
->recordTitleAttribute('title') ->recordTitleAttribute('title')
->columns([ ->columns([
Tables\Columns\TextColumn::make('step_number')->sortable(), Tables\Columns\TextColumn::make('step_number')->sortable(),
Tables\Columns\ImageColumn::make('hint_media_url')
->label('Hint image')
->disk('public'),
Tables\Columns\TextColumn::make('title'), Tables\Columns\TextColumn::make('title'),
Tables\Columns\TextColumn::make('latitude'), Tables\Columns\TextColumn::make('latitude'),
Tables\Columns\TextColumn::make('longitude'), Tables\Columns\TextColumn::make('longitude'),

View File

@ -30,8 +30,11 @@ class HuntParticipantJoinedNotification extends Notification implements ShouldQu
{ {
return (new MailMessage) return (new MailMessage)
->subject('Nouveau participant sur '.$this->hunt->title) ->subject('Nouveau participant sur '.$this->hunt->title)
->greeting('Bonjour '.$notifiable->username) ->view('mail.hunts.joined-hunt', [
->line($this->participant->username.' participe maintenant à votre chasse "'.$this->hunt->title.'".') 'notifiable' => $notifiable,
->line('Vous pouvez suivre les participants depuis votre espace organisateur.'); 'hunt' => $this->hunt,
'participant' => $this->participant,
'participantsCount' => $this->hunt->participants()->count(),
]);
} }
} }

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nouveau participant sur {{ $hunt->title }}</title>
</head>
<body style="margin: 0; padding: 0; background: #f4f7fb; color: #172033; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: #f4f7fb; padding: 32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 560px; overflow: hidden; background: #ffffff; border: 1px solid #e6ebf2; border-radius: 12px;">
<tr>
<td style="padding: 32px 32px 16px;">
<p style="margin: 0 0 8px; color: #657086; font-size: 14px;">{{ config('app.name') }}</p>
<h1 style="margin: 0; color: #101828; font-size: 28px; line-height: 1.2;">Quelqu'un vient de rejoindre ta hunt</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 24px;">
<p style="margin: 0 0 16px; color: #344054; font-size: 16px; line-height: 1.6;">
Bonjour {{ $notifiable->username }},
</p>
<p style="margin: 0 0 24px; color: #344054; font-size: 16px; line-height: 1.6;">
<strong>{{ $participant->username }}</strong> participe maintenant à ta hunt <strong>{{ $hunt->title }}</strong>.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 0 0 24px; background: #f8fafc; border: 1px solid #e6ebf2; border-radius: 10px;">
<tr>
<td style="padding: 18px 20px;">
<p style="margin: 0 0 6px; color: #667085; font-size: 13px; font-weight: 700; text-transform: uppercase;">Hunt</p>
<p style="margin: 0 0 16px; color: #101828; font-size: 18px; font-weight: 700; line-height: 1.35;">{{ $hunt->title }}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="width: 50%; padding: 0 12px 0 0;">
<p style="margin: 0 0 4px; color: #667085; font-size: 13px;">Ville</p>
<p style="margin: 0; color: #344054; font-size: 15px; font-weight: 700;">{{ $hunt->city }}</p>
</td>
<td style="width: 50%; padding: 0 0 0 12px;">
<p style="margin: 0 0 4px; color: #667085; font-size: 13px;">Participants</p>
<p style="margin: 0; color: #344054; font-size: 15px; font-weight: 700;">{{ $participantsCount }}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin: 0 0 16px; color: #344054; font-size: 16px; line-height: 1.6;">
Garde un oeil sur l'activité de cette hunt pour suivre sa progression et préparer la suite de l'expérience.
</p>
<p style="margin: 0;">
<a href="{{ config('app.url') }}" style="display: inline-block; padding: 14px 22px; background: #16a34a; border-radius: 8px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
Ouvrir {{ config('app.name') }}
</a>
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 32px 32px; border-top: 1px solid #edf1f7;">
<p style="margin: 0; color: #98a2b3; font-size: 13px; line-height: 1.5;">
Cet email est envoyé parce que tu es le créateur de la hunt {{ $hunt->title }}.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,58 @@
<?php
use App\Enums\UserRole;
use App\Filament\Resources\Hunts\Pages\EditHunts;
use App\Filament\Resources\Hunts\RelationManagers\StepsRelationManager;
use App\Models\Hunts;
use App\Models\User;
use Dotswan\MapPicker\Fields\Map;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('uses an image upload and map picker when creating hunt steps', function () {
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
'creator_id' => $admin->id,
'latitude' => 44.837789,
'longitude' => -0.57918,
]));
$this->actingAs($admin);
Livewire::test(StepsRelationManager::class, [
'ownerRecord' => $hunt,
'pageClass' => EditHunts::class,
])
->mountTableAction('create')
->assertTableActionDataSet([
'latitude' => 44.837789,
'longitude' => -0.57918,
'location' => [
'lat' => 44.837789,
'lng' => -0.57918,
],
])
->assertFormFieldExists(
'hint_media_url',
fn ($field): bool => $field instanceof FileUpload,
)
->assertFormFieldExists(
'latitude',
fn ($field): bool => $field instanceof Hidden,
)
->assertFormFieldExists(
'longitude',
fn ($field): bool => $field instanceof Hidden,
)
->assertFormFieldExists(
'location',
fn ($field): bool => $field instanceof Map,
);
});

View File

@ -61,6 +61,46 @@ it('does not notify the hunt creator when the user already participates', functi
Notification::assertNotSentTo($creator, HuntParticipantJoinedNotification::class); Notification::assertNotSentTo($creator, HuntParticipantJoinedNotification::class);
}); });
it('uses the personalized joined hunt email view', function () {
$creator = User::factory()->create([
'username' => 'creator-test',
]);
$participant = User::factory()->create([
'username' => 'participant-test',
]);
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
'creator_id' => $creator->id,
'title' => 'La quête des lanternes',
'city' => 'Lyon',
]));
$hunt->participants()->attach($participant->id, [
'current_step_number' => 0,
'status' => 'in_progress',
]);
$notification = new HuntParticipantJoinedNotification($hunt, $participant);
$mailMessage = $notification->toMail($creator);
expect($mailMessage->subject)->toBe('Nouveau participant sur La quête des lanternes')
->and($mailMessage->view)->toBe('mail.hunts.joined-hunt')
->and($mailMessage->viewData)
->toMatchArray([
'notifiable' => $creator,
'hunt' => $hunt,
'participant' => $participant,
'participantsCount' => 1,
]);
$renderedView = view($mailMessage->view, $mailMessage->viewData)->render();
expect($renderedView)
->toContain('Bonjour creator-test')
->toContain('participant-test')
->toContain('La quête des lanternes')
->toContain('Lyon');
});
it('queues email verification notifications', function () { it('queues email verification notifications', function () {
expect(new VerifyEmailNotification)->toBeInstanceOf(ShouldQueue::class); expect(new VerifyEmailNotification)->toBeInstanceOf(ShouldQueue::class);
}); });