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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 21:09:49 +02:00
parent 78cc19373e
commit 0d741550a8
16 changed files with 1225 additions and 672 deletions

View File

@@ -1,19 +1,234 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import EventTabsNav from '@/components/events/EventTabsNav.vue'
import { useUpdateEvent, useUploadEventImage } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import type { EventItem } from '@/types/event'
definePage({
meta: {
navActiveLink: 'events',
},
})
const route = useRoute()
const authStore = useAuthStore()
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
const { mutate: updateEvent, isPending: isUpdating } = useUpdateEvent(orgId, eventId)
const { mutate: uploadImage, isPending: isUploading } = useUploadEventImage(orgId, eventId)
const welcomeText = ref('')
const showSuccess = ref(false)
const refVForm = ref<VForm>()
function initForm(event: EventItem) {
welcomeText.value = event.registration_welcome_text ?? ''
}
function onSaveWelcomeText() {
updateEvent(
{ registration_welcome_text: welcomeText.value || null },
{
onSuccess: () => {
showSuccess.value = true
},
},
)
}
function onFileSelected(files: File[], type: 'banner' | 'logo') {
const file = files[0]
if (!file) return
uploadImage({ file, type })
}
function onClearImage(event: EventItem, type: 'banner' | 'logo') {
const field = type === 'banner' ? 'registration_banner_url' : 'registration_logo_url'
updateEvent({ [field]: null } as any)
}
</script>
<template>
<EventTabsNav>
<VCard class="ma-4">
<VCardText>
Deze module is binnenkort beschikbaar.
</VCardText>
</VCard>
<EventTabsNav v-slot="{ event }">
<div @vue:mounted="initForm(event)">
<VRow>
<VCol
cols="12"
md="8"
>
<!-- Registration Branding -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center gap-2">
<VIcon
icon="tabler-palette"
size="20"
/>
Registratie-uiterlijk
</VCardTitle>
<VCardSubtitle>
Pas het uiterlijk van het vrijwilligersregistratieformulier aan
</VCardSubtitle>
<VCardText>
<!-- Banner Image -->
<div class="mb-6">
<h6 class="text-subtitle-1 font-weight-medium mb-2">
Bannerafbeelding
</h6>
<p class="text-body-2 text-medium-emphasis mb-3">
Wordt bovenaan het registratieformulier getoond. Aanbevolen: 1200x400px.
</p>
<VImg
v-if="event.registration_banner_url"
:src="event.registration_banner_url"
height="160"
cover
rounded="lg"
class="mb-3"
/>
<div class="d-flex gap-2">
<VFileInput
accept="image/jpeg,image/png,image/webp"
label="Afbeelding uploaden"
prepend-icon=""
prepend-inner-icon="tabler-upload"
density="compact"
variant="outlined"
hide-details
:loading="isUploading"
class="flex-grow-1"
style="max-inline-size: 350px;"
@update:model-value="(files: File[]) => onFileSelected(files, 'banner')"
/>
<VBtn
v-if="event.registration_banner_url"
variant="outlined"
color="error"
density="compact"
@click="onClearImage(event, 'banner')"
>
Verwijder
</VBtn>
</div>
</div>
<VDivider class="mb-6" />
<!-- Logo Image -->
<div class="mb-6">
<h6 class="text-subtitle-1 font-weight-medium mb-2">
Logo
</h6>
<p class="text-body-2 text-medium-emphasis mb-3">
Wordt in de header van het registratieformulier getoond. Aanbevolen: vierkant, max 200x200px.
</p>
<VAvatar
v-if="event.registration_logo_url"
:image="event.registration_logo_url"
size="80"
rounded="lg"
class="mb-3"
/>
<div class="d-flex gap-2">
<VFileInput
accept="image/jpeg,image/png,image/webp"
label="Logo uploaden"
prepend-icon=""
prepend-inner-icon="tabler-upload"
density="compact"
variant="outlined"
hide-details
:loading="isUploading"
class="flex-grow-1"
style="max-inline-size: 350px;"
@update:model-value="(files: File[]) => onFileSelected(files, 'logo')"
/>
<VBtn
v-if="event.registration_logo_url"
variant="outlined"
color="error"
density="compact"
@click="onClearImage(event, 'logo')"
>
Verwijder
</VBtn>
</div>
</div>
<VDivider class="mb-6" />
<!-- Welcome Text -->
<div>
<h6 class="text-subtitle-1 font-weight-medium mb-2">
Welkomstbericht
</h6>
<VForm
ref="refVForm"
@submit.prevent="onSaveWelcomeText"
>
<VTextarea
v-model="welcomeText"
label="Welkomstbericht"
hint="Dit bericht wordt getoond boven het vrijwilligersregistratieformulier."
persistent-hint
:counter="1000"
rows="4"
auto-grow
class="mb-4"
/>
<VBtn
type="submit"
color="primary"
:loading="isUpdating"
>
Opslaan
</VBtn>
</VForm>
</div>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="4"
>
<!-- Preview hint -->
<VCard variant="tonal">
<VCardText>
<div class="d-flex align-center gap-2 mb-2">
<VIcon
icon="tabler-eye"
size="18"
/>
<span class="text-subtitle-2 font-weight-medium">Preview</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Open het registratieformulier om een preview te zien van de aanpassingen.
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
Registratie-instellingen opgeslagen
</VSnackbar>
</EventTabsNav>
</template>