feat(portal): multi-step volunteer registration form with public event endpoint

- Add GET /api/v1/public/events/{slug}/registration-data endpoint for fetching
  event sections and time slots without auth
- Create 5-step registration form: personal info, details, motivation, section
  preferences, availability
- VeeValidate + Zod validation per step with Dutch error messages
- Auth-aware: pre-fills name/email for authenticated users
- Mobile responsive with custom chip-based step indicator
- Success page with contextual actions (dashboard vs login)
- Types, composable (TanStack Query), and Zod schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 18:41:20 +02:00
parent 60ea1f0b40
commit 3400e4cc7e
12 changed files with 1077 additions and 8 deletions

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\TimeSlot;
use Illuminate\Http\JsonResponse;
final class PublicRegistrationDataController extends Controller
{
public function __invoke(string $slug): JsonResponse
{
$event = Event::where('slug', $slug)
->where('status', 'registration_open')
->first();
if ($event === null) {
abort(404, 'Event not found or not accepting registrations.');
}
$festivalEvent = $event->isSubEvent() ? $event->parent : $event;
$sectionQuery = FestivalSection::where('event_id', $festivalEvent->id)
->where(function ($query) {
$query->where('type', '!=', 'cross_event')
->orWhereNull('type');
})
->ordered();
if ($festivalEvent->isFestival()) {
$childIds = $festivalEvent->children()->pluck('id');
$sectionQuery->orWhere(function ($query) use ($childIds) {
$query->whereIn('event_id', $childIds)
->where(function ($q) {
$q->where('type', '!=', 'cross_event')
->orWhereNull('type');
});
});
}
$sections = $sectionQuery->get(['id', 'name', 'category', 'icon']);
$timeSlots = $festivalEvent->getAllRelevantTimeSlots()
->where('person_type', 'VOLUNTEER')
->values();
return response()->json([
'data' => [
'event' => [
'id' => $festivalEvent->id,
'name' => $festivalEvent->name,
'start_date' => $festivalEvent->start_date->toDateString(),
'end_date' => $festivalEvent->end_date->toDateString(),
'organisation_id' => $festivalEvent->organisation_id,
],
'sections' => $sections->map(fn (FestivalSection $section) => [
'id' => $section->id,
'name' => $section->name,
'category' => $section->category,
'icon' => $section->icon,
]),
'time_slots' => $timeSlots->map(fn (TimeSlot $slot) => [
'id' => $slot->id,
'name' => $slot->name,
'date' => $slot->date->toDateString(),
'start_time' => $slot->start_time,
'end_time' => $slot->end_time,
'duration_hours' => $slot->duration_hours,
]),
],
]);
}
}

View File

@@ -21,6 +21,10 @@ use App\Http\Controllers\Api\V1\ShiftAssignmentController;
use App\Http\Controllers\Api\V1\ShiftController;
use App\Http\Controllers\Api\V1\TimeSlotController;
use App\Http\Controllers\Api\V1\VolunteerAvailabilityController;
use App\Http\Controllers\Api\V1\VolunteerRegistrationController;
use App\Http\Controllers\Api\V1\PublicRegistrationDataController;
use App\Http\Controllers\Api\V1\PortalTokenController;
use App\Http\Controllers\Api\V1\PortalMeController;
use App\Http\Controllers\Api\V1\UserOrganisationTagController;
use App\Models\FestivalSection;
use App\Models\Organisation;
@@ -50,12 +54,20 @@ Route::post('auth/login', LoginController::class);
Route::get('invitations/{token}', [InvitationController::class, 'show']);
Route::post('invitations/{token}/accept', [InvitationController::class, 'accept']);
// Public portal routes
Route::get('public/events/{slug}/registration-data', PublicRegistrationDataController::class);
Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class);
Route::post('portal/token-auth', [PortalTokenController::class, 'auth']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
// Auth
Route::get('auth/me', MeController::class);
Route::post('auth/logout', LogoutController::class);
// Portal (authenticated)
Route::get('portal/me', [PortalMeController::class, 'index']);
// Organisations
Route::apiResource('organisations', OrganisationController::class)
->only(['index', 'show', 'store', 'update']);

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FestivalSection;
use App\Models\Organisation;
use App\Models\TimeSlot;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PublicRegistrationDataTest extends TestCase
{
use RefreshDatabase;
private Organisation $organisation;
protected function setUp(): void
{
parent::setUp();
$this->organisation = Organisation::factory()->create();
}
public function test_returns_registration_data_for_open_event(): void
{
$event = Event::factory()->create([
'organisation_id' => $this->organisation->id,
'status' => 'registration_open',
'slug' => 'test-event-2026',
]);
$section = FestivalSection::factory()->create([
'event_id' => $event->id,
'type' => 'standard',
]);
FestivalSection::factory()->create([
'event_id' => $event->id,
'type' => 'cross_event',
]);
$timeSlot = TimeSlot::factory()->create([
'event_id' => $event->id,
'person_type' => 'VOLUNTEER',
]);
TimeSlot::factory()->create([
'event_id' => $event->id,
'person_type' => 'CREW',
]);
$response = $this->getJson('/api/v1/public/events/test-event-2026/registration-data');
$response->assertOk()
->assertJsonPath('data.event.id', $event->id)
->assertJsonPath('data.event.name', $event->name)
->assertJsonCount(1, 'data.sections')
->assertJsonPath('data.sections.0.id', $section->id)
->assertJsonCount(1, 'data.time_slots')
->assertJsonPath('data.time_slots.0.id', $timeSlot->id);
}
public function test_returns_404_for_non_registration_open_event(): void
{
Event::factory()->create([
'organisation_id' => $this->organisation->id,
'status' => 'draft',
'slug' => 'draft-event',
]);
$response = $this->getJson('/api/v1/public/events/draft-event/registration-data');
$response->assertNotFound();
}
public function test_returns_404_for_nonexistent_slug(): void
{
$response = $this->getJson('/api/v1/public/events/does-not-exist/registration-data');
$response->assertNotFound();
}
}