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

@@ -132,6 +132,29 @@ export function useUpdateEvent(orgId: Ref<string>, id: Ref<string>) {
})
}
export function useUploadEventImage(orgId: Ref<string>, eventId: Ref<string>) {
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<string>) {
return useQuery({
queryKey: ['events', eventId, 'stats'],

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>

View File

@@ -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<CreateEventPayload> {
status?: EventStatus
registration_welcome_text?: string | null
}
export interface EventStats {

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,4 @@
<script setup lang="ts">
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
@@ -16,103 +13,98 @@ const route = useRoute('register-success')
const authStore = useAuthStore()
const eventName = computed(() => (route.query.event as string) || 'het evenement')
const bannerUrl = computed(() => (route.query.banner as string) || null)
const isAuthenticated = computed(() => route.query.authenticated === '1' || authStore.isAuthenticated)
const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
</script>
<template>
<!-- Logo -->
<RouterLink to="/">
<div class="auth-logo d-flex align-center gap-x-3">
<VIcon
icon="tabler-users-group"
size="28"
color="primary"
/>
<h1 class="auth-title">
Crewli
</h1>
</div>
</RouterLink>
<VRow
no-gutters
class="auth-wrapper bg-surface"
>
<VCol
cols="12"
class="d-flex align-center justify-center"
<div>
<!-- Event banner (if available) -->
<VImg
v-if="bannerUrl"
:src="bannerUrl"
height="180"
cover
gradient="to bottom, rgba(0,0,0,0.1), rgba(0,0,0,0.4)"
>
<div class="position-relative w-100 d-flex align-center justify-center" style="min-block-size: 100dvh;">
<VCard
flat
:max-width="550"
class="text-center pa-8 pa-sm-12"
style="z-index: 1;"
>
<VAvatar
size="100"
color="success"
variant="tonal"
class="mb-6"
>
<VIcon
icon="tabler-circle-check"
size="60"
/>
</VAvatar>
<h4 class="text-h4 mb-2">
Bedankt voor je aanmelding!
</h4>
<p class="text-body-1 text-medium-emphasis mb-2">
Je aanmelding bij <strong>{{ eventName }}</strong> is succesvol ontvangen.
</p>
<p class="text-body-1 text-medium-emphasis mb-2">
Het organisatieteam beoordeelt je aanmelding zo snel mogelijk.
</p>
<p class="text-body-2 text-disabled mb-8">
Je ontvangt een e-mail zodra je aanmelding is goedgekeurd.
</p>
<div class="d-flex flex-wrap justify-center gap-4">
<VBtn
v-if="isAuthenticated"
to="/dashboard"
color="primary"
prepend-icon="tabler-dashboard"
>
Ga naar je dashboard
</VBtn>
<VBtn
v-else
to="/login"
color="primary"
variant="tonal"
prepend-icon="tabler-login"
>
Heb je al een account? Log in
</VBtn>
</div>
</VCard>
<img
class="auth-footer-mask flip-in-rtl"
:src="authThemeMask"
alt="footer-mask"
height="280"
width="100"
>
<div class="d-flex align-center justify-center fill-height">
<h3 class="text-h5 text-white font-weight-bold">
{{ eventName }}
</h3>
</div>
</VCol>
</VRow>
</template>
</VImg>
<style lang="scss">
@use "@core/scss/template/pages/page-auth.scss";
</style>
<!-- Fallback header -->
<div
v-else
class="d-flex align-center justify-center pa-6"
style="background: rgb(var(--v-theme-primary));"
>
<h3 class="text-h5 text-white font-weight-bold">
{{ eventName }}
</h3>
</div>
<VContainer style="max-inline-size: 600px;">
<VCard
class="text-center pa-8 pa-sm-12 mt-n6"
variant="flat"
style="position: relative; z-index: 1;"
>
<VAvatar
size="100"
color="success"
variant="tonal"
class="mb-6"
>
<VIcon
icon="tabler-circle-check"
size="60"
/>
</VAvatar>
<h4 class="text-h4 mb-4">
Bedankt voor je aanmelding!
</h4>
<p class="text-body-1 text-medium-emphasis mb-2">
Je aanmelding bij <strong>{{ eventName }}</strong> is succesvol ontvangen.
</p>
<p class="text-body-1 text-medium-emphasis mb-2">
Je aanmelding wordt beoordeeld door het organisatieteam.
</p>
<p class="text-body-2 text-disabled mb-8">
Je ontvangt een e-mail zodra je aanmelding is goedgekeurd.
</p>
<div class="d-flex flex-wrap justify-center gap-4">
<VBtn
v-if="isAuthenticated"
to="/dashboard"
color="primary"
prepend-icon="tabler-dashboard"
>
Ga naar je dashboard
</VBtn>
<VBtn
v-else
to="/login"
color="primary"
variant="tonal"
prepend-icon="tabler-login"
>
Heb je al een account? Log in
</VBtn>
</div>
</VCard>
<!-- Footer -->
<div class="text-center pa-4 text-caption text-medium-emphasis">
Powered by Crewli
</div>
</VContainer>
</div>
</template>

View File

@@ -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[]