From 0d741550a8412ea2bada847aeb524aecbf616924 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 21:09:49 +0200 Subject: [PATCH] feat: event registration branding with vertical wizard layout - Add registration_banner_url, registration_welcome_text, registration_logo_url columns to events table with migration - Add uploadImage endpoint (POST .../upload-image) with form request validation for banner and logo images (jpg/png/webp, max 5MB) - Include branding fields in EventResource and PublicRegistrationDataController - Build registration settings UI in organizer event settings page with banner/logo upload and welcome text editor - Redesign portal registration page: hero banner with gradient overlay, welcome text card, vertical step navigation (desktop) / horizontal chips (mobile), two-column form fields with density="comfortable" - Update success page with event banner and consistent branding - Seed welcome text for Echt Feesten 2026 - Add 9 PHPUnit tests covering image upload, branding fields in API responses Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controllers/Api/V1/EventController.php | 20 + .../V1/PublicRegistrationDataController.php | 3 + .../Requests/Api/V1/UpdateEventRequest.php | 1 + .../Api/V1/UploadEventImageRequest.php | 24 + .../Http/Resources/Api/V1/EventResource.php | 3 + api/app/Models/Event.php | 3 + ..._registration_branding_to_events_table.php | 30 + api/database/seeders/DevSeeder.php | 1 + api/routes/api.php | 1 + .../Feature/Api/V1/EventImageUploadTest.php | 191 +++ apps/app/src/composables/api/useEvents.ts | 23 + .../src/pages/events/[id]/settings/index.vue | 227 +++- apps/app/src/types/event.ts | 4 + .../portal/src/pages/register/[eventSlug].vue | 1179 +++++++++-------- apps/portal/src/pages/register/success.vue | 184 ++- apps/portal/src/types/registration.ts | 3 + 16 files changed, 1225 insertions(+), 672 deletions(-) create mode 100644 api/app/Http/Requests/Api/V1/UploadEventImageRequest.php create mode 100644 api/database/migrations/2026_04_10_100000_add_registration_branding_to_events_table.php create mode 100644 api/tests/Feature/Api/V1/EventImageUploadTest.php diff --git a/api/app/Http/Controllers/Api/V1/EventController.php b/api/app/Http/Controllers/Api/V1/EventController.php index 977c233b..6ff9e349 100644 --- a/api/app/Http/Controllers/Api/V1/EventController.php +++ b/api/app/Http/Controllers/Api/V1/EventController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\StoreEventRequest; use App\Http\Requests\Api\V1\UpdateEventRequest; +use App\Http\Requests\Api\V1\UploadEventImageRequest; use App\Http\Resources\Api\V1\EventResource; use App\Models\Event; use App\Models\Organisation; @@ -15,6 +16,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Storage; final class EventController extends Controller { @@ -126,6 +128,24 @@ final class EventController extends Controller return EventResource::collection($children); } + public function uploadImage(UploadEventImageRequest $request, Organisation $organisation, Event $event): JsonResponse + { + Gate::authorize('update', [$event, $organisation]); + + $path = $request->file('image')->store( + "events/{$event->id}", + 'public' + ); + + $field = $request->type === 'banner' + ? 'registration_banner_url' + : 'registration_logo_url'; + + $event->update([$field => Storage::disk('public')->url($path)]); + + return response()->json(['url' => $event->fresh()->{$field}]); + } + public function stats(Event $event): JsonResponse { Gate::authorize('view', $event); diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php index 912896c9..2b4c6483 100644 --- a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php +++ b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php @@ -60,6 +60,9 @@ final class PublicRegistrationDataController extends Controller 'start_date' => $festivalEvent->start_date->toDateString(), 'end_date' => $festivalEvent->end_date->toDateString(), 'organisation_id' => $festivalEvent->organisation_id, + 'registration_banner_url' => $festivalEvent->registration_banner_url, + 'registration_welcome_text' => $festivalEvent->registration_welcome_text, + 'registration_logo_url' => $festivalEvent->registration_logo_url, ], 'sections' => $sections->map(fn (FestivalSection $section) => [ 'id' => $section->id, diff --git a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php index 7742f389..2f63ac5c 100644 --- a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php @@ -27,6 +27,7 @@ final class UpdateEventRequest extends FormRequest 'event_type' => ['sometimes', 'in:event,festival,series'], 'event_type_label' => ['nullable', 'string', 'max:50'], 'sub_event_label' => ['nullable', 'string', 'max:50'], + 'registration_welcome_text' => ['nullable', 'string', 'max:1000'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UploadEventImageRequest.php b/api/app/Http/Requests/Api/V1/UploadEventImageRequest.php new file mode 100644 index 00000000..fdb20172 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/UploadEventImageRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], + 'type' => ['required', 'in:banner,logo'], + ]; + } +} diff --git a/api/app/Http/Resources/Api/V1/EventResource.php b/api/app/Http/Resources/Api/V1/EventResource.php index d5c27cf0..8b7a3de2 100644 --- a/api/app/Http/Resources/Api/V1/EventResource.php +++ b/api/app/Http/Resources/Api/V1/EventResource.php @@ -27,6 +27,9 @@ final class EventResource extends JsonResource 'event_type_label' => $this->event_type_label, 'sub_event_label' => $this->sub_event_label, 'is_recurring' => $this->is_recurring, + 'registration_banner_url' => $this->registration_banner_url, + 'registration_welcome_text' => $this->registration_welcome_text, + 'registration_logo_url' => $this->registration_logo_url, 'is_festival' => $this->resource->isFestival(), 'is_sub_event' => $this->resource->isSubEvent(), 'is_flat_event' => $this->resource->isFlatEvent(), diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 445e24cc..9963dc12 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -63,6 +63,9 @@ final class Event extends Model 'is_recurring', 'recurrence_rule', 'recurrence_exceptions', + 'registration_banner_url', + 'registration_welcome_text', + 'registration_logo_url', ]; protected function casts(): array diff --git a/api/database/migrations/2026_04_10_100000_add_registration_branding_to_events_table.php b/api/database/migrations/2026_04_10_100000_add_registration_branding_to_events_table.php new file mode 100644 index 00000000..793665b3 --- /dev/null +++ b/api/database/migrations/2026_04_10_100000_add_registration_branding_to_events_table.php @@ -0,0 +1,30 @@ +string('registration_banner_url')->nullable()->after('recurrence_exceptions'); + $table->text('registration_welcome_text')->nullable()->after('registration_banner_url'); + $table->string('registration_logo_url')->nullable()->after('registration_welcome_text'); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn([ + 'registration_banner_url', + 'registration_welcome_text', + 'registration_logo_url', + ]); + }); + } +}; diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index 52e5f16e..9f000d47 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -175,6 +175,7 @@ class DevSeeder extends Seeder 'event_type' => 'festival', 'event_type_label' => 'Festival', 'sub_event_label' => 'Programmaonderdeel', + 'registration_welcome_text' => 'Wij zoeken enthousiaste vrijwilligers voor Echt Feesten 2026! Word onderdeel van ons team en beleef het festival van de andere kant. Gratis toegang, maaltijden, en een onvergetelijke ervaring.', ]); $vrijdag = Event::create([ diff --git a/api/routes/api.php b/api/routes/api.php index 7ebedfc7..19f7c862 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -77,6 +77,7 @@ Route::middleware('auth:sanctum')->group(function () { ->only(['index', 'show', 'store', 'update', 'destroy']); Route::get('organisations/{organisation}/events/{event}/children', [EventController::class, 'children']); Route::post('organisations/{organisation}/events/{event}/transition', [EventController::class, 'transition']); + Route::post('organisations/{organisation}/events/{event}/upload-image', [EventController::class, 'uploadImage']); // Organisation-scoped resources Route::prefix('organisations/{organisation}')->group(function () { diff --git a/api/tests/Feature/Api/V1/EventImageUploadTest.php b/api/tests/Feature/Api/V1/EventImageUploadTest.php new file mode 100644 index 00000000..4ac910c7 --- /dev/null +++ b/api/tests/Feature/Api/V1/EventImageUploadTest.php @@ -0,0 +1,191 @@ +seed(RoleSeeder::class); + + $this->organisation = Organisation::factory()->create(); + $this->orgAdmin = User::factory()->create(); + $this->orgAdmin->assignRole('org_admin'); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + + $this->event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + Storage::fake('public'); + } + + public function test_upload_banner_jpg(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image", + [ + 'image' => UploadedFile::fake()->image('banner.jpg', 1200, 400), + 'type' => 'banner', + ] + ); + + $response->assertOk() + ->assertJsonStructure(['url']); + + $this->event->refresh(); + $this->assertNotNull($this->event->registration_banner_url); + } + + public function test_upload_logo_png(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image", + [ + 'image' => UploadedFile::fake()->image('logo.png', 200, 200), + 'type' => 'logo', + ] + ); + + $response->assertOk() + ->assertJsonStructure(['url']); + + $this->event->refresh(); + $this->assertNotNull($this->event->registration_logo_url); + } + + public function test_upload_rejects_invalid_file_type(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image", + [ + 'image' => UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'), + 'type' => 'banner', + ] + ); + + $response->assertUnprocessable(); + } + + public function test_upload_rejects_file_too_large(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image", + [ + 'image' => UploadedFile::fake()->image('large.jpg')->size(6000), + 'type' => 'banner', + ] + ); + + $response->assertUnprocessable(); + } + + public function test_upload_requires_authentication(): void + { + $response = $this->postJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/upload-image", + [ + 'image' => UploadedFile::fake()->image('banner.jpg'), + 'type' => 'banner', + ] + ); + + $response->assertUnauthorized(); + } + + public function test_upload_rejects_wrong_organisation(): void + { + $otherOrg = Organisation::factory()->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson( + "/api/v1/organisations/{$otherOrg->id}/events/{$this->event->id}/upload-image", + [ + 'image' => UploadedFile::fake()->image('banner.jpg'), + 'type' => 'banner', + ] + ); + + $response->assertForbidden(); + } + + public function test_registration_data_includes_branding_fields(): void + { + $event = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'branding-test-event', + 'registration_welcome_text' => 'Welcome to our event!', + 'registration_banner_url' => 'https://example.com/banner.jpg', + 'registration_logo_url' => 'https://example.com/logo.png', + ]); + + $response = $this->getJson('/api/v1/public/events/branding-test-event/registration-data'); + + $response->assertOk() + ->assertJsonPath('data.event.registration_welcome_text', 'Welcome to our event!') + ->assertJsonPath('data.event.registration_banner_url', 'https://example.com/banner.jpg') + ->assertJsonPath('data.event.registration_logo_url', 'https://example.com/logo.png'); + } + + public function test_registration_data_includes_null_branding_fields(): void + { + Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + 'status' => 'registration_open', + 'slug' => 'no-branding-event', + ]); + + $response = $this->getJson('/api/v1/public/events/no-branding-event/registration-data'); + + $response->assertOk() + ->assertJsonPath('data.event.registration_welcome_text', null) + ->assertJsonPath('data.event.registration_banner_url', null) + ->assertJsonPath('data.event.registration_logo_url', null); + } + + public function test_event_update_saves_welcome_text(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson( + "/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}", + [ + 'registration_welcome_text' => 'We zoeken vrijwilligers!', + ] + ); + + $response->assertOk(); + + $this->event->refresh(); + $this->assertEquals('We zoeken vrijwilligers!', $this->event->registration_welcome_text); + } +} diff --git a/apps/app/src/composables/api/useEvents.ts b/apps/app/src/composables/api/useEvents.ts index 1ee5f35a..79bcb043 100644 --- a/apps/app/src/composables/api/useEvents.ts +++ b/apps/app/src/composables/api/useEvents.ts @@ -132,6 +132,29 @@ export function useUpdateEvent(orgId: Ref, id: Ref) { }) } +export function useUploadEventImage(orgId: Ref, eventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ file, type }: { file: File; type: 'banner' | 'logo' }) => { + const formData = new FormData() + formData.append('image', file) + formData.append('type', type) + + const { data } = await apiClient.post<{ url: string }>( + `/organisations/${orgId.value}/events/${eventId.value}/upload-image`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }, + ) + + return data.url + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events', orgId.value, eventId.value] }) + }, + }) +} + export function useEventStats(eventId: Ref) { return useQuery({ queryKey: ['events', eventId, 'stats'], diff --git a/apps/app/src/pages/events/[id]/settings/index.vue b/apps/app/src/pages/events/[id]/settings/index.vue index 2d552558..dd0d091b 100644 --- a/apps/app/src/pages/events/[id]/settings/index.vue +++ b/apps/app/src/pages/events/[id]/settings/index.vue @@ -1,19 +1,234 @@ diff --git a/apps/app/src/types/event.ts b/apps/app/src/types/event.ts index 11294188..17f3b517 100644 --- a/apps/app/src/types/event.ts +++ b/apps/app/src/types/event.ts @@ -20,6 +20,9 @@ export interface EventItem { event_type_label: string | null sub_event_label: string | null is_recurring: boolean + registration_banner_url: string | null + registration_welcome_text: string | null + registration_logo_url: string | null is_festival: boolean is_sub_event: boolean is_flat_event: boolean @@ -47,6 +50,7 @@ export interface CreateEventPayload { export interface UpdateEventPayload extends Partial { status?: EventStatus + registration_welcome_text?: string | null } export interface EventStats { diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index c4c62800..2cb3cb96 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -2,11 +2,6 @@ import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { useDisplay } from 'vuetify' -import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant' -import registerMultiStepIllustrationLight from '@images/illustrations/register-multi-step-illustration-light.png' -import registerMultiStepIllustrationDark from '@images/illustrations/register-multi-step-illustration-dark.png' -import registerMultiStepBgLight from '@images/pages/register-multi-step-bg-light.png' -import registerMultiStepBgDark from '@images/pages/register-multi-step-bg-dark.png' import { useAuthStore } from '@/stores/useAuthStore' import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration' import { fullRegistrationSchema } from '@/schemas/registrationSchema' @@ -29,7 +24,7 @@ definePage({ const route = useRoute('volunteer-register') const router = useRouter() const authStore = useAuthStore() -const { smAndDown } = useDisplay() +const { mdAndUp } = useDisplay() const eventSlug = computed(() => route.params.eventSlug as string) const { data: registrationData, isLoading, isError } = useRegistrationData(eventSlug) @@ -38,9 +33,6 @@ const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRe const currentStep = ref(0) const submitError = ref(null) -const registerMultiStepIllustration = useGenerateImageVariant(registerMultiStepIllustrationLight, registerMultiStepIllustrationDark) -const registerMultiStepBg = useGenerateImageVariant(registerMultiStepBgLight, registerMultiStepBgDark) - // VeeValidate form const { errors, defineField, validateField, setFieldValue } = useForm({ validationSchema: toTypedSchema(fullRegistrationSchema), @@ -84,13 +76,13 @@ const selectedSections = ref([]) const selectedTimeSlotIds = ref([]) const timeSlotPreferences = ref>({}) -// Stepper items for AppStepper -const stepperItems = [ - { title: 'Over jou', subtitle: 'Persoonlijke gegevens', icon: 'tabler-user' }, - { title: 'Extra info', subtitle: 'Aanvullende details', icon: 'tabler-list-details' }, - { title: 'Motivatie', subtitle: 'Waarom wil je helpen?', icon: 'tabler-heart' }, - { title: 'Secties', subtitle: 'Voorkeur werkgebieden', icon: 'tabler-layout-grid' }, - { title: 'Beschikbaarheid', subtitle: 'Wanneer ben je er?', icon: 'tabler-calendar-event' }, +// Steps definition +const steps = [ + { title: 'Over jou', subtitle: 'Vul je persoonlijke gegevens in' }, + { title: 'Meer over jou', subtitle: 'Praktische informatie' }, + { title: 'Motivatie', subtitle: 'Waarom wil je meehelpen?' }, + { title: 'Secties', subtitle: 'Waar wil je het liefst werken?' }, + { title: 'Beschikbaarheid', subtitle: 'Wanneer kun je helpen?' }, ] // Constants @@ -104,6 +96,7 @@ const motivationItems = [ { title: 'Ervaring opdoen', value: 'Ervaring opdoen' }, { title: 'Vrienden helpen', value: 'Vrienden helpen' }, { title: 'CV opbouwen', value: 'CV opbouwen' }, + { title: 'Ik ben gevraagd', value: 'Ik ben gevraagd' }, { title: 'Anders', value: 'Anders' }, ] @@ -151,6 +144,15 @@ const totalSelectedHours = computed(() => { .reduce((sum, s) => sum + s.duration_hours, 0) }) +const formattedDates = computed(() => { + if (!registrationData.value) return '' + + return formatDateRange( + registrationData.value.event.start_date, + registrationData.value.event.end_date, + ) +}) + // Step field mapping for validation (0-based) type FormField = 'name' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'access_requirements' | 'driving_licence' | 'motivation' | 'motivation_other' @@ -179,6 +181,12 @@ function prevStep() { if (currentStep.value > 0) currentStep.value-- } +function goToStep(index: number) { + if (index < currentStep.value) { + currentStep.value = index + } +} + // Section toggle function toggleSection(sectionName: string) { const idx = selectedSections.value.indexOf(sectionName) @@ -281,6 +289,7 @@ async function onSubmit() { path: '/register/success', query: { event: registrationData.value.event.name, + banner: registrationData.value.event.registration_banner_url ?? '', authenticated: authStore.isAuthenticated ? '1' : '0', }, }) @@ -312,609 +321,639 @@ async function onSubmit() { - diff --git a/apps/portal/src/pages/register/success.vue b/apps/portal/src/pages/register/success.vue index 2b92e3f6..10e5f512 100644 --- a/apps/portal/src/pages/register/success.vue +++ b/apps/portal/src/pages/register/success.vue @@ -1,7 +1,4 @@ + - + +
+

+ {{ eventName }} +

+
+ + + + + + + +

+ Bedankt voor je aanmelding! +

+ +

+ Je aanmelding bij {{ eventName }} is succesvol ontvangen. +

+ +

+ Je aanmelding wordt beoordeeld door het organisatieteam. +

+ +

+ Je ontvangt een e-mail zodra je aanmelding is goedgekeurd. +

+ +
+ + Ga naar je dashboard + + + + Heb je al een account? Log in + +
+
+ + +
+ Powered by Crewli +
+
+ + diff --git a/apps/portal/src/types/registration.ts b/apps/portal/src/types/registration.ts index 40163f3e..9cb267f6 100644 --- a/apps/portal/src/types/registration.ts +++ b/apps/portal/src/types/registration.ts @@ -5,6 +5,9 @@ export interface EventRegistrationData { start_date: string end_date: string organisation_id: string + registration_banner_url: string | null + registration_welcome_text: string | null + registration_logo_url: string | null } sections: SectionOption[] time_slots: TimeSlotOption[]