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:
@@ -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,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ use App\Http\Controllers\Api\V1\ShiftAssignmentController;
|
|||||||
use App\Http\Controllers\Api\V1\ShiftController;
|
use App\Http\Controllers\Api\V1\ShiftController;
|
||||||
use App\Http\Controllers\Api\V1\TimeSlotController;
|
use App\Http\Controllers\Api\V1\TimeSlotController;
|
||||||
use App\Http\Controllers\Api\V1\VolunteerAvailabilityController;
|
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\Http\Controllers\Api\V1\UserOrganisationTagController;
|
||||||
use App\Models\FestivalSection;
|
use App\Models\FestivalSection;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
@@ -50,12 +54,20 @@ Route::post('auth/login', LoginController::class);
|
|||||||
Route::get('invitations/{token}', [InvitationController::class, 'show']);
|
Route::get('invitations/{token}', [InvitationController::class, 'show']);
|
||||||
Route::post('invitations/{token}/accept', [InvitationController::class, 'accept']);
|
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
|
// Protected routes
|
||||||
Route::middleware('auth:sanctum')->group(function () {
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
// Auth
|
// Auth
|
||||||
Route::get('auth/me', MeController::class);
|
Route::get('auth/me', MeController::class);
|
||||||
Route::post('auth/logout', LogoutController::class);
|
Route::post('auth/logout', LogoutController::class);
|
||||||
|
|
||||||
|
// Portal (authenticated)
|
||||||
|
Route::get('portal/me', [PortalMeController::class, 'index']);
|
||||||
|
|
||||||
// Organisations
|
// Organisations
|
||||||
Route::apiResource('organisations', OrganisationController::class)
|
Route::apiResource('organisations', OrganisationController::class)
|
||||||
->only(['index', 'show', 'store', 'update']);
|
->only(['index', 'show', 'store', 'update']);
|
||||||
|
|||||||
85
api/tests/Feature/Api/V1/PublicRegistrationDataTest.php
Normal file
85
api/tests/Feature/Api/V1/PublicRegistrationDataTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@tiptap/pm": "^2.27.1",
|
"@tiptap/pm": "^2.27.1",
|
||||||
"@tiptap/starter-kit": "^2.27.1",
|
"@tiptap/starter-kit": "^2.27.1",
|
||||||
"@tiptap/vue-3": "^2.27.1",
|
"@tiptap/vue-3": "^2.27.1",
|
||||||
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/core": "10.11.1",
|
"@vueuse/core": "10.11.1",
|
||||||
"@vueuse/math": "10.11.1",
|
"@vueuse/math": "10.11.1",
|
||||||
"apexcharts": "3.54.1",
|
"apexcharts": "3.54.1",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"swiper": "11.2.10",
|
"swiper": "11.2.10",
|
||||||
"ufo": "1.6.1",
|
"ufo": "1.6.1",
|
||||||
"unplugin-vue-define-options": "1.5.5",
|
"unplugin-vue-define-options": "1.5.5",
|
||||||
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "3.5.22",
|
"vue": "3.5.22",
|
||||||
"vue-chartjs": "5.3.2",
|
"vue-chartjs": "5.3.2",
|
||||||
"vue-flatpickr-component": "11.0.5",
|
"vue-flatpickr-component": "11.0.5",
|
||||||
|
|||||||
30
apps/portal/pnpm-lock.yaml
generated
30
apps/portal/pnpm-lock.yaml
generated
@@ -51,6 +51,9 @@ importers:
|
|||||||
'@tiptap/vue-3':
|
'@tiptap/vue-3':
|
||||||
specifier: ^2.27.1
|
specifier: ^2.27.1
|
||||||
version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(vue@3.5.22(typescript@5.9.3))
|
version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))(@tiptap/pm@2.27.1)(vue@3.5.22(typescript@5.9.3))
|
||||||
|
'@vee-validate/zod':
|
||||||
|
specifier: ^4.15.1
|
||||||
|
version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: 10.11.1
|
specifier: 10.11.1
|
||||||
version: 10.11.1(vue@3.5.22(typescript@5.9.3))
|
version: 10.11.1(vue@3.5.22(typescript@5.9.3))
|
||||||
@@ -105,6 +108,9 @@ importers:
|
|||||||
unplugin-vue-define-options:
|
unplugin-vue-define-options:
|
||||||
specifier: 1.5.5
|
specifier: 1.5.5
|
||||||
version: 1.5.5(vue@3.5.22(typescript@5.9.3))
|
version: 1.5.5(vue@3.5.22(typescript@5.9.3))
|
||||||
|
vee-validate:
|
||||||
|
specifier: ^4.15.1
|
||||||
|
version: 4.15.1(vue@3.5.22(typescript@5.9.3))
|
||||||
vue:
|
vue:
|
||||||
specifier: 3.5.22
|
specifier: 3.5.22
|
||||||
version: 3.5.22(typescript@5.9.3)
|
version: 3.5.22(typescript@5.9.3)
|
||||||
@@ -1747,6 +1753,11 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@vee-validate/zod@4.15.1':
|
||||||
|
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.24.0
|
||||||
|
|
||||||
'@vitejs/plugin-vue-jsx@5.1.1':
|
'@vitejs/plugin-vue-jsx@5.1.1':
|
||||||
resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==}
|
resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -4543,6 +4554,11 @@ packages:
|
|||||||
validate-npm-package-license@3.0.4:
|
validate-npm-package-license@3.0.4:
|
||||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||||
|
|
||||||
|
vee-validate@4.15.1:
|
||||||
|
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.4.26
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||||
|
|
||||||
@@ -6349,6 +6365,14 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vee-validate/zod@4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)':
|
||||||
|
dependencies:
|
||||||
|
type-fest: 4.41.0
|
||||||
|
vee-validate: 4.15.1(vue@3.5.22(typescript@5.9.3))
|
||||||
|
zod: 3.25.76
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- vue
|
||||||
|
|
||||||
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
@@ -9714,6 +9738,12 @@ snapshots:
|
|||||||
spdx-correct: 3.2.0
|
spdx-correct: 3.2.0
|
||||||
spdx-expression-parse: 3.0.1
|
spdx-expression-parse: 3.0.1
|
||||||
|
|
||||||
|
vee-validate@4.15.1(vue@3.5.22(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 7.7.7
|
||||||
|
type-fest: 4.41.0
|
||||||
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
|
|
||||||
vfile-message@4.0.3:
|
vfile-message@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|||||||
36
apps/portal/src/composables/api/useVolunteerRegistration.ts
Normal file
36
apps/portal/src/composables/api/useVolunteerRegistration.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery, useMutation } from '@tanstack/vue-query'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { apiClient } from '@/lib/axios'
|
||||||
|
import type { EventRegistrationData, VolunteerRegistrationForm } from '@/types/registration'
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegistrationData(eventSlug: Ref<string>) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['registration-data', eventSlug],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<EventRegistrationData>>(
|
||||||
|
`/public/events/${eventSlug.value}/registration-data`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
enabled: () => !!eventSlug.value,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitRegistration() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ eventId, form }: { eventId: string; form: VolunteerRegistrationForm }) => {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Record<string, unknown>>>(
|
||||||
|
`/events/${eventId}/volunteer-register`,
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
|
||||||
|
import { fullRegistrationSchema } from '@/schemas/registrationSchema'
|
||||||
|
import type {
|
||||||
|
SectionPreference,
|
||||||
|
TimeSlotOption,
|
||||||
|
VolunteerAvailability,
|
||||||
|
VolunteerRegistrationForm,
|
||||||
|
} from '@/types/registration'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
name: 'volunteer-register',
|
name: 'volunteer-register',
|
||||||
meta: {
|
meta: {
|
||||||
@@ -8,27 +21,662 @@ definePage({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute('volunteer-register')
|
const route = useRoute('volunteer-register')
|
||||||
const eventSlug = computed(() => route.params.eventSlug)
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { smAndDown } = useDisplay()
|
||||||
|
|
||||||
|
const eventSlug = computed(() => route.params.eventSlug as string)
|
||||||
|
const { data: registrationData, isLoading, isError } = useRegistrationData(eventSlug)
|
||||||
|
const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRegistration()
|
||||||
|
|
||||||
|
const currentStep = ref(1)
|
||||||
|
const submitError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// VeeValidate form
|
||||||
|
const { errors, defineField, validateField, setFieldValue } = useForm({
|
||||||
|
validationSchema: toTypedSchema(fullRegistrationSchema),
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
tshirt_size: '',
|
||||||
|
first_aid: false,
|
||||||
|
allergies: '',
|
||||||
|
access_requirements: '',
|
||||||
|
driving_licence: false,
|
||||||
|
motivation: '',
|
||||||
|
motivation_other: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [name] = defineField('name')
|
||||||
|
const [email] = defineField('email')
|
||||||
|
const [phone] = defineField('phone')
|
||||||
|
const [tshirtSize] = defineField('tshirt_size')
|
||||||
|
const [firstAid] = defineField('first_aid')
|
||||||
|
const [allergies] = defineField('allergies')
|
||||||
|
const [accessRequirements] = defineField('access_requirements')
|
||||||
|
const [drivingLicence] = defineField('driving_licence')
|
||||||
|
const [motivation] = defineField('motivation')
|
||||||
|
const [motivationOther] = defineField('motivation_other')
|
||||||
|
|
||||||
|
// Pre-fill authenticated user data
|
||||||
|
watch(() => authStore.user, (user) => {
|
||||||
|
if (user) {
|
||||||
|
setFieldValue('name', user.name)
|
||||||
|
setFieldValue('email', user.email)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Step 4: Section preferences
|
||||||
|
const selectedSectionIds = ref<string[]>([])
|
||||||
|
|
||||||
|
// Step 5: Availability
|
||||||
|
const selectedTimeSlotIds = ref<string[]>([])
|
||||||
|
const timeSlotPreferences = ref<Record<string, number>>({})
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const tshirtSizeItems = [
|
||||||
|
{ title: 'Geen voorkeur', value: '' },
|
||||||
|
...['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'].map(s => ({ title: s, value: s })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const motivationItems = [
|
||||||
|
{ title: 'Gratis festivalpas', value: 'Gratis festivalpas' },
|
||||||
|
{ title: 'Ervaring opdoen', value: 'Ervaring opdoen' },
|
||||||
|
{ title: 'Vrienden helpen', value: 'Vrienden helpen' },
|
||||||
|
{ title: 'CV opbouwen', value: 'CV opbouwen' },
|
||||||
|
{ title: 'Anders', value: 'Anders' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid']
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const timeSlotsByDate = computed(() => {
|
||||||
|
if (!registrationData.value?.time_slots) return []
|
||||||
|
const groups = new Map<string, TimeSlotOption[]>()
|
||||||
|
for (const slot of registrationData.value.time_slots) {
|
||||||
|
if (!groups.has(slot.date)) groups.set(slot.date, [])
|
||||||
|
groups.get(slot.date)!.push(slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalSelectedHours = computed(() => {
|
||||||
|
if (!registrationData.value?.time_slots) return 0
|
||||||
|
|
||||||
|
return registrationData.value.time_slots
|
||||||
|
.filter(s => selectedTimeSlotIds.value.includes(s.id))
|
||||||
|
.reduce((sum, s) => sum + s.duration_hours, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step field mapping for validation
|
||||||
|
type FormField = 'name' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'access_requirements' | 'driving_licence' | 'motivation' | 'motivation_other'
|
||||||
|
|
||||||
|
const stepFields: Record<number, FormField[]> = {
|
||||||
|
1: ['name', 'email', 'phone'],
|
||||||
|
2: ['tshirt_size', 'first_aid', 'allergies', 'access_requirements', 'driving_licence'],
|
||||||
|
3: ['motivation', 'motivation_other'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
async function validateCurrentStep(): Promise<boolean> {
|
||||||
|
const fields = stepFields[currentStep.value]
|
||||||
|
if (!fields) return true
|
||||||
|
const results = await Promise.all(fields.map(f => validateField(f)))
|
||||||
|
|
||||||
|
return results.every(r => r.valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextStep() {
|
||||||
|
if (await validateCurrentStep()) {
|
||||||
|
if (currentStep.value < 5) currentStep.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
if (currentStep.value > 1) currentStep.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section toggle
|
||||||
|
function toggleSection(sectionId: string) {
|
||||||
|
const idx = selectedSectionIds.value.indexOf(sectionId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedSectionIds.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
else if (selectedSectionIds.value.length < 5) {
|
||||||
|
selectedSectionIds.value.push(sectionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time slot toggle
|
||||||
|
function toggleTimeSlot(slotId: string) {
|
||||||
|
const idx = selectedTimeSlotIds.value.indexOf(slotId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedTimeSlotIds.value.splice(idx, 1)
|
||||||
|
delete timeSlotPreferences.value[slotId]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
selectedTimeSlotIds.value.push(slotId)
|
||||||
|
timeSlotPreferences.value[slotId] = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(`${dateStr}T00:00:00`).toLocaleDateString('nl-NL', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeRange(start: string, end: string): string {
|
||||||
|
return `${start.slice(0, 5)} – ${end.slice(0, 5)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
async function onSubmit() {
|
||||||
|
submitError.value = null
|
||||||
|
|
||||||
|
// Validate steps 1-3
|
||||||
|
for (let step = 1; step <= 3; step++) {
|
||||||
|
const fields = stepFields[step]
|
||||||
|
if (!fields) continue
|
||||||
|
const results = await Promise.all(fields.map(f => validateField(f)))
|
||||||
|
if (!results.every(r => r.valid)) {
|
||||||
|
currentStep.value = step
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registrationData.value) return
|
||||||
|
|
||||||
|
const sectionPreferences: SectionPreference[] = selectedSectionIds.value.map((id, index) => ({
|
||||||
|
section_id: id,
|
||||||
|
priority: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const availabilities: VolunteerAvailability[] = selectedTimeSlotIds.value.map(id => ({
|
||||||
|
time_slot_id: id,
|
||||||
|
preference_level: timeSlotPreferences.value[id] ?? 3,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const payload: VolunteerRegistrationForm = {
|
||||||
|
name: name.value ?? '',
|
||||||
|
email: email.value ?? '',
|
||||||
|
phone: phone.value ?? '',
|
||||||
|
tshirt_size: tshirtSize.value ?? '',
|
||||||
|
first_aid: firstAid.value ?? false,
|
||||||
|
allergies: allergies.value ?? '',
|
||||||
|
access_requirements: accessRequirements.value ?? '',
|
||||||
|
driving_licence: drivingLicence.value ?? false,
|
||||||
|
motivation: motivation.value ?? '',
|
||||||
|
motivation_other: motivationOther.value ?? '',
|
||||||
|
section_preferences: sectionPreferences,
|
||||||
|
availabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitRegistration({
|
||||||
|
eventId: registrationData.value.event.id,
|
||||||
|
form: payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
path: '/register/success',
|
||||||
|
query: {
|
||||||
|
event: registrationData.value.event.name,
|
||||||
|
authenticated: authStore.isAuthenticated ? '1' : '0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
|
const axiosError = error as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } }
|
||||||
|
if (axiosError.response?.status === 422) {
|
||||||
|
const serverErrors = axiosError.response.data?.errors
|
||||||
|
if (serverErrors) {
|
||||||
|
for (const field of Object.keys(serverErrors)) {
|
||||||
|
for (const [step, fields] of Object.entries(stepFields)) {
|
||||||
|
if (fields.includes(field as FormField)) {
|
||||||
|
currentStep.value = Number(step)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submitError.value = 'Er zijn validatiefouten gevonden. Controleer je invoer.'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
submitError.value = 'Er is een fout opgetreden. Probeer het opnieuw.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VRow justify="center">
|
<!-- Loading -->
|
||||||
|
<VRow
|
||||||
|
v-if="isLoading"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="10"
|
||||||
|
lg="8"
|
||||||
|
>
|
||||||
|
<VCard class="pa-6">
|
||||||
|
<VSkeletonLoader type="heading" />
|
||||||
|
<VSkeletonLoader
|
||||||
|
type="text@3"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
<VSkeletonLoader
|
||||||
|
type="button"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Error / Not available -->
|
||||||
|
<VRow
|
||||||
|
v-else-if="isError || !registrationData"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
lg="6"
|
lg="6"
|
||||||
>
|
>
|
||||||
<VCard class="text-center pa-6">
|
<VCard class="text-center pa-8">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-calendar-off"
|
||||||
|
size="64"
|
||||||
|
color="warning"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
<VCardTitle class="text-h5">
|
<VCardTitle class="text-h5">
|
||||||
|
Niet beschikbaar
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText class="text-body-1">
|
||||||
|
Dit evenement accepteert momenteel geen aanmeldingen.
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions class="justify-center">
|
||||||
|
<VBtn
|
||||||
|
to="/"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
Terug naar startpagina
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Registration Form -->
|
||||||
|
<VRow
|
||||||
|
v-else
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="10"
|
||||||
|
lg="8"
|
||||||
|
>
|
||||||
|
<VCard>
|
||||||
|
<VCardTitle class="text-h5 pa-4 pa-sm-6 pb-2">
|
||||||
Aanmelden als vrijwilliger
|
Aanmelden als vrijwilliger
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<VCardSubtitle>
|
<VCardSubtitle class="px-4 px-sm-6 pb-4">
|
||||||
Vul het formulier in om je aan te melden
|
{{ registrationData.event.name }}
|
||||||
</VCardSubtitle>
|
</VCardSubtitle>
|
||||||
<VCardText class="text-body-1 mt-4">
|
|
||||||
Evenement: <strong>{{ eventSlug }}</strong>
|
<VAlert
|
||||||
</VCardText>
|
v-if="authStore.isAuthenticated"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mx-4 mx-sm-6"
|
||||||
|
>
|
||||||
|
Je bent ingelogd als {{ authStore.user?.name }}. Je gegevens zijn automatisch ingevuld.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="submitError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mx-4 mx-sm-6 mt-4"
|
||||||
|
closable
|
||||||
|
@click:close="submitError = null"
|
||||||
|
>
|
||||||
|
{{ submitError }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Step indicator -->
|
||||||
|
<div class="d-flex flex-wrap align-center justify-center ga-1 px-4 px-sm-6 pt-6">
|
||||||
|
<template
|
||||||
|
v-for="(title, i) in stepTitles"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<VChip
|
||||||
|
:color="currentStep === i + 1 ? 'primary' : currentStep > i + 1 ? 'success' : undefined"
|
||||||
|
:variant="currentStep === i + 1 ? 'elevated' : currentStep > i + 1 ? 'tonal' : 'outlined'"
|
||||||
|
size="small"
|
||||||
|
class="step-chip"
|
||||||
|
@click="i + 1 < currentStep ? currentStep = i + 1 : undefined"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
v-if="currentStep > i + 1"
|
||||||
|
icon="tabler-check"
|
||||||
|
size="14"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-caption font-weight-bold me-1"
|
||||||
|
>{{ i + 1 }}</span>
|
||||||
|
<span v-if="!smAndDown || currentStep === i + 1">{{ title }}</span>
|
||||||
|
</VChip>
|
||||||
|
<VIcon
|
||||||
|
v-if="i < 4"
|
||||||
|
icon="tabler-chevron-right"
|
||||||
|
size="14"
|
||||||
|
class="text-disabled d-none d-sm-inline-flex"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step content -->
|
||||||
|
<VWindow v-model="currentStep">
|
||||||
|
<!-- Step 1: Over jou -->
|
||||||
|
<VWindowItem :value="1">
|
||||||
|
<div class="pa-4 pa-sm-6">
|
||||||
|
<VTextField
|
||||||
|
v-model="name"
|
||||||
|
label="Naam *"
|
||||||
|
:error-messages="errors.name"
|
||||||
|
:disabled="authStore.isAuthenticated"
|
||||||
|
autofocus
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VTextField
|
||||||
|
v-model="email"
|
||||||
|
label="E-mailadres *"
|
||||||
|
type="email"
|
||||||
|
:error-messages="errors.email"
|
||||||
|
:disabled="authStore.isAuthenticated"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VTextField
|
||||||
|
v-model="phone"
|
||||||
|
label="Telefoonnummer"
|
||||||
|
:error-messages="errors.phone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- Step 2: Meer over jou -->
|
||||||
|
<VWindowItem :value="2">
|
||||||
|
<div class="pa-4 pa-sm-6">
|
||||||
|
<VSelect
|
||||||
|
v-model="tshirtSize"
|
||||||
|
:items="tshirtSizeItems"
|
||||||
|
label="Shirtmaat"
|
||||||
|
:error-messages="errors.tshirt_size"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VSwitch
|
||||||
|
v-model="firstAid"
|
||||||
|
label="Ik heb een EHBO-diploma"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VTextarea
|
||||||
|
v-model="allergies"
|
||||||
|
label="Allergieën"
|
||||||
|
:error-messages="errors.allergies"
|
||||||
|
:counter="500"
|
||||||
|
rows="2"
|
||||||
|
auto-grow
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VTextarea
|
||||||
|
v-model="accessRequirements"
|
||||||
|
label="Toegangsbehoeften"
|
||||||
|
:error-messages="errors.access_requirements"
|
||||||
|
hint="Bijv. rolstoeltoegankelijk, rustige werkplek, etc."
|
||||||
|
persistent-hint
|
||||||
|
:counter="500"
|
||||||
|
rows="2"
|
||||||
|
auto-grow
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VSwitch
|
||||||
|
v-model="drivingLicence"
|
||||||
|
label="Ik heb een rijbewijs B"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- Step 3: Motivatie -->
|
||||||
|
<VWindowItem :value="3">
|
||||||
|
<div class="pa-4 pa-sm-6">
|
||||||
|
<VSelect
|
||||||
|
v-model="motivation"
|
||||||
|
:items="motivationItems"
|
||||||
|
label="Wat is je motivatie?"
|
||||||
|
:error-messages="errors.motivation"
|
||||||
|
clearable
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VTextarea
|
||||||
|
v-if="motivation"
|
||||||
|
v-model="motivationOther"
|
||||||
|
label="Toelichting"
|
||||||
|
:error-messages="errors.motivation_other"
|
||||||
|
:counter="500"
|
||||||
|
rows="3"
|
||||||
|
auto-grow
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- Step 4: Voorkeurssecties -->
|
||||||
|
<VWindowItem :value="4">
|
||||||
|
<div class="pa-4 pa-sm-6">
|
||||||
|
<p
|
||||||
|
v-if="registrationData.sections.length === 0"
|
||||||
|
class="text-body-1 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
Er zijn geen secties beschikbaar voor dit evenement.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Selecteer maximaal 5 secties waar je graag wilt werken.
|
||||||
|
De volgorde van selectie bepaalt je voorkeur.
|
||||||
|
</p>
|
||||||
|
<VList lines="two">
|
||||||
|
<VListItem
|
||||||
|
v-for="section in registrationData.sections"
|
||||||
|
:key="section.id"
|
||||||
|
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
|
||||||
|
class="section-item"
|
||||||
|
@click="toggleSection(section.id)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VCheckboxBtn
|
||||||
|
:model-value="selectedSectionIds.includes(section.id)"
|
||||||
|
:disabled="!selectedSectionIds.includes(section.id) && selectedSectionIds.length >= 5"
|
||||||
|
@click.stop="toggleSection(section.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="d-flex align-center ga-2">
|
||||||
|
<VIcon
|
||||||
|
v-if="section.icon"
|
||||||
|
:icon="section.icon"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
{{ section.name }}
|
||||||
|
</VListItemTitle>
|
||||||
|
<VListItemSubtitle v-if="section.category">
|
||||||
|
<VChip
|
||||||
|
size="x-small"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{{ section.category }}
|
||||||
|
</VChip>
|
||||||
|
</VListItemSubtitle>
|
||||||
|
<template
|
||||||
|
v-if="selectedSectionIds.includes(section.id)"
|
||||||
|
#append
|
||||||
|
>
|
||||||
|
<VChip
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Voorkeur {{ selectedSectionIds.indexOf(section.id) + 1 }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="selectedSectionIds.length > 0"
|
||||||
|
class="text-body-2 text-medium-emphasis mt-4"
|
||||||
|
>
|
||||||
|
{{ selectedSectionIds.length }} van 5 secties geselecteerd
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</VWindowItem>
|
||||||
|
|
||||||
|
<!-- Step 5: Beschikbaarheid -->
|
||||||
|
<VWindowItem :value="5">
|
||||||
|
<div class="pa-4 pa-sm-6">
|
||||||
|
<p
|
||||||
|
v-if="registrationData.time_slots.length === 0"
|
||||||
|
class="text-body-1 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
Er zijn geen tijdsloten beschikbaar voor dit evenement.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Selecteer de tijdsloten waarop je beschikbaar bent en geef je voorkeur aan met sterren.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="[date, slots] in timeSlotsByDate"
|
||||||
|
:key="date"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<h4 class="text-subtitle-1 font-weight-bold mb-2 text-capitalize">
|
||||||
|
{{ formatDate(date) }}
|
||||||
|
</h4>
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
v-for="slot in slots"
|
||||||
|
:key="slot.id"
|
||||||
|
class="timeslot-item"
|
||||||
|
@click="toggleTimeSlot(slot.id)"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<VCheckboxBtn
|
||||||
|
:model-value="selectedTimeSlotIds.includes(slot.id)"
|
||||||
|
@click.stop="toggleTimeSlot(slot.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>{{ slot.name }}</VListItemTitle>
|
||||||
|
<VListItemSubtitle>
|
||||||
|
{{ formatTimeRange(slot.start_time, slot.end_time) }} · {{ slot.duration_hours }}u
|
||||||
|
</VListItemSubtitle>
|
||||||
|
<template
|
||||||
|
v-if="selectedTimeSlotIds.includes(slot.id)"
|
||||||
|
#append
|
||||||
|
>
|
||||||
|
<VRating
|
||||||
|
v-model="timeSlotPreferences[slot.id]"
|
||||||
|
density="compact"
|
||||||
|
size="small"
|
||||||
|
length="5"
|
||||||
|
color="warning"
|
||||||
|
active-color="warning"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider class="mb-4" />
|
||||||
|
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<span class="text-body-1">
|
||||||
|
Totaal geselecteerd: <strong>{{ totalSelectedHours }} uur</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VAlert
|
||||||
|
v-if="selectedTimeSlotIds.length > 0 && totalSelectedHours < 8"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
Minimaal 8 uur nodig voor een festivalpas.
|
||||||
|
</VAlert>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</VWindowItem>
|
||||||
|
</VWindow>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="d-flex align-center pa-4 pa-sm-6">
|
||||||
|
<VBtn
|
||||||
|
v-if="currentStep > 1"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="tabler-arrow-left"
|
||||||
|
@click="prevStep"
|
||||||
|
>
|
||||||
|
Vorige
|
||||||
|
</VBtn>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
v-if="currentStep < 5"
|
||||||
|
color="primary"
|
||||||
|
append-icon="tabler-arrow-right"
|
||||||
|
@click="nextStep"
|
||||||
|
>
|
||||||
|
Volgende
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
prepend-icon="tabler-send"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
Aanmelding versturen
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
min-block-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-item,
|
||||||
|
.timeslot-item {
|
||||||
|
min-block-size: 48px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
68
apps/portal/src/pages/register/success.vue
Normal file
68
apps/portal/src/pages/register/success.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore'
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
name: 'register-success',
|
||||||
|
meta: {
|
||||||
|
layout: 'portal',
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute('register-success')
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const eventName = computed(() => (route.query.event as string) || 'het evenement')
|
||||||
|
const isAuthenticated = computed(() => route.query.authenticated === '1' || authStore.isAuthenticated)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VRow justify="center">
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
md="8"
|
||||||
|
lg="6"
|
||||||
|
>
|
||||||
|
<VCard class="text-center pa-8">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-circle-check"
|
||||||
|
size="80"
|
||||||
|
color="success"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<VCardTitle class="text-h5 mb-2">
|
||||||
|
Bedankt voor je aanmelding!
|
||||||
|
</VCardTitle>
|
||||||
|
<VCardText class="text-body-1">
|
||||||
|
<p class="mb-4">
|
||||||
|
Bedankt voor je aanmelding bij <strong>{{ eventName }}</strong>!
|
||||||
|
</p>
|
||||||
|
<p class="mb-4">
|
||||||
|
Je aanmelding wordt beoordeeld door het organisatieteam.
|
||||||
|
</p>
|
||||||
|
<p class="text-medium-emphasis">
|
||||||
|
Je ontvangt een e-mail zodra je aanmelding is goedgekeurd.
|
||||||
|
</p>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions class="justify-center pt-4">
|
||||||
|
<VBtn
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
to="/dashboard"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="tabler-dashboard"
|
||||||
|
>
|
||||||
|
Ga naar je dashboard
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-else
|
||||||
|
to="/login"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-icon="tabler-login"
|
||||||
|
>
|
||||||
|
Heb je al een account? Log in
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</template>
|
||||||
24
apps/portal/src/schemas/registrationSchema.ts
Normal file
24
apps/portal/src/schemas/registrationSchema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const step1Schema = z.object({
|
||||||
|
name: z.string().min(1, 'Naam is verplicht').max(255),
|
||||||
|
email: z.string().min(1, 'E-mailadres is verplicht').email('Ongeldig e-mailadres').max(255),
|
||||||
|
phone: z.string().max(50).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const step2Schema = z.object({
|
||||||
|
tshirt_size: z.enum(['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']).optional().or(z.literal('')),
|
||||||
|
first_aid: z.boolean().default(false),
|
||||||
|
allergies: z.string().max(500).optional().or(z.literal('')),
|
||||||
|
access_requirements: z.string().max(500).optional().or(z.literal('')),
|
||||||
|
driving_licence: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const step3Schema = z.object({
|
||||||
|
motivation: z.string().max(1000).optional().or(z.literal('')),
|
||||||
|
motivation_other: z.string().max(500).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fullRegistrationSchema = step1Schema
|
||||||
|
.merge(step2Schema)
|
||||||
|
.merge(step3Schema)
|
||||||
57
apps/portal/src/types/registration.ts
Normal file
57
apps/portal/src/types/registration.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface EventRegistrationData {
|
||||||
|
event: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
organisation_id: string
|
||||||
|
}
|
||||||
|
sections: SectionOption[]
|
||||||
|
time_slots: TimeSlotOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string | null
|
||||||
|
icon: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSlotOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
date: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
duration_hours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionPreference {
|
||||||
|
section_id: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolunteerAvailability {
|
||||||
|
time_slot_id: string
|
||||||
|
preference_level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolunteerRegistrationForm {
|
||||||
|
// Step 1
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
// Step 2
|
||||||
|
tshirt_size: string
|
||||||
|
first_aid: boolean
|
||||||
|
allergies: string
|
||||||
|
access_requirements: string
|
||||||
|
driving_licence: boolean
|
||||||
|
// Step 3
|
||||||
|
motivation: string
|
||||||
|
motivation_other: string
|
||||||
|
// Step 4
|
||||||
|
section_preferences: SectionPreference[]
|
||||||
|
// Step 5
|
||||||
|
availabilities: VolunteerAvailability[]
|
||||||
|
}
|
||||||
1
apps/portal/typed-router.d.ts
vendored
1
apps/portal/typed-router.d.ts
vendored
@@ -25,6 +25,7 @@ declare module 'vue-router/auto-routes' {
|
|||||||
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
||||||
'portal-profile': RouteRecordInfo<'portal-profile', '/profile', Record<never, never>, Record<never, never>>,
|
'portal-profile': RouteRecordInfo<'portal-profile', '/profile', Record<never, never>, Record<never, never>>,
|
||||||
'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue<true> }, { eventSlug: ParamValue<false> }>,
|
'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue<true> }, { eventSlug: ParamValue<false> }>,
|
||||||
|
'register-success': RouteRecordInfo<'register-success', '/register/success', Record<never, never>, Record<never, never>>,
|
||||||
'portal-shifts': RouteRecordInfo<'portal-shifts', '/shifts', Record<never, never>, Record<never, never>>,
|
'portal-shifts': RouteRecordInfo<'portal-shifts', '/shifts', Record<never, never>, Record<never, never>>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,4 +389,33 @@ Response: `{ "confirmed": 2, "errors": [{ "match_id": "ulid3", "error": "User al
|
|||||||
- `GET /events/{event}/persons?tag={person_tag_id}` — filter persons by single tag
|
- `GET /events/{event}/persons?tag={person_tag_id}` — filter persons by single tag
|
||||||
- `GET /events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all)
|
- `GET /events/{event}/persons?tags=ulid1,ulid2` — filter persons by multiple tags (AND logic: must have all)
|
||||||
|
|
||||||
|
## Public Registration Data
|
||||||
|
|
||||||
|
- `GET /public/events/{slug}/registration-data` — public, no auth. Returns event info, available sections, and volunteer time slots for the registration form. Only returns events with status `registration_open`. Excludes `cross_event` sections. Only includes time slots with `person_type = VOLUNTEER`. Resolves sub-events to parent festival.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"event": { "id": "01JXYZ...", "name": "Echt Feesten 2026", "start_date": "2026-07-10", "end_date": "2026-07-12", "organisation_id": "01JXYZ..." },
|
||||||
|
"sections": [{ "id": "01JXYZ...", "name": "Hoofdpodium Bar", "category": "Bar", "icon": "tabler-glass" }],
|
||||||
|
"time_slots": [{ "id": "01JXYZ...", "name": "Vrijdag Avond", "date": "2026-07-10", "start_time": "18:00:00", "end_time": "02:00:00", "duration_hours": 8 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
- `404` — Event not found or not accepting registrations
|
||||||
|
|
||||||
|
## Volunteer Registration
|
||||||
|
|
||||||
|
- `POST /events/{event}/volunteer-register` — public, auth-aware (optional Sanctum). Registers a volunteer for an event. Resolves sub-events to the parent festival. Accepts name, email, phone, tshirt_size, motivation, section_preferences, availabilities. Authenticated users have their name/email taken from the auth token. Returns `PersonResource` (201 on new, 200 on re-registration of rejected person).
|
||||||
|
|
||||||
|
## Portal
|
||||||
|
|
||||||
|
- `POST /portal/token-auth` — public. Validates a portal token against artists/production_requests tables. Returns `{ context, data, event }` on success. Returns 501 if token tables don't exist yet, 401 if token is invalid.
|
||||||
|
- `GET /portal/me` — auth:sanctum. Returns the authenticated user's person record for a given event. Query param: `event_id` (required, ULID). Resolves sub-events to parent festival. Returns `PersonResource` with crowdType, shiftAssignments, and volunteerAvailabilities eager-loaded. Returns 404 if no registration found.
|
||||||
|
|
||||||
_(Extend this contract per module as endpoints are implemented.)_
|
_(Extend this contract per module as endpoints are implemented.)_
|
||||||
|
|||||||
Reference in New Issue
Block a user