feat(portal): redesign registration wizard, header and login with Vuexy patterns

- Registration page: switch to blank layout with split-screen design (event branding
  illustration on left, AppStepper wizard on right), replacing generic chip stepper
- Portal header: refined to flat surface bar with proper branding and icon-based nav
- Login page: upgrade to Vuexy V2 auth pattern with split-screen illustration
- Success page: blank layout with centered card, avatar icon, and footer mask

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 20:47:24 +02:00
parent d1ad0e1f89
commit 212db0d3cb
4 changed files with 691 additions and 320 deletions

View File

@@ -36,55 +36,90 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
<AppLoadingIndicator ref="refLoadingIndicator" /> <AppLoadingIndicator ref="refLoadingIndicator" />
<VAppBar <VAppBar
color="primary" flat
color="surface"
border="b"
density="comfortable" density="comfortable"
> >
<VContainer
fluid
class="d-flex align-center py-0"
style="max-inline-size: 1440px;"
>
<!-- Logo & Brand -->
<RouterLink
to="/"
class="d-flex align-center gap-x-2 text-decoration-none"
>
<VIcon
icon="tabler-users-group"
size="26"
color="primary"
/>
<span class="text-h6 font-weight-bold text-high-emphasis">
Crewli
</span>
</RouterLink>
<!-- Mobile nav toggle -->
<VAppBarNavIcon <VAppBarNavIcon
class="d-sm-none" class="d-sm-none ms-auto"
@click="isMobileMenuOpen = !isMobileMenuOpen" @click="isMobileMenuOpen = !isMobileMenuOpen"
/> />
<VAppBarTitle class="text-h6"> <!-- Desktop navigation -->
Crewli Portal <div class="d-none d-sm-flex align-center gap-1 ms-6">
</VAppBarTitle>
<template #append>
<div class="d-none d-sm-flex align-center gap-2">
<VBtn <VBtn
v-for="item in navItems" v-for="item in navItems"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
variant="text" variant="text"
color="white" color="default"
size="small"
class="text-medium-emphasis"
> >
<VIcon <VIcon
start start
:icon="item.icon" :icon="item.icon"
size="18"
/> />
{{ item.title }} {{ item.title }}
</VBtn> </VBtn>
</div> </div>
<VSpacer />
<!-- Auth actions -->
<VBtn <VBtn
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
variant="outlined" variant="tonal"
color="white" color="primary"
class="ms-2" size="small"
@click="authStore.logout(); $router.push('/login')" @click="authStore.logout(); $router.push('/login')"
> >
<VIcon
start
icon="tabler-logout"
size="18"
/>
Uitloggen Uitloggen
</VBtn> </VBtn>
<VBtn <VBtn
v-else v-else
variant="outlined" variant="tonal"
color="white" color="primary"
class="ms-2" size="small"
to="/login" to="/login"
> >
<VIcon
start
icon="tabler-login"
size="18"
/>
Inloggen Inloggen
</VBtn> </VBtn>
</template> </VContainer>
</VAppBar> </VAppBar>
<!-- Mobile navigation drawer --> <!-- Mobile navigation drawer -->
@@ -126,6 +161,7 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
<VContainer <VContainer
fluid fluid
class="pa-4 pa-sm-6" class="pa-4 pa-sm-6"
style="max-inline-size: 1440px;"
> >
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<Suspense <Suspense

View File

@@ -1,4 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useGenerateImageVariant } from '@core/composable/useGenerateImageVariant'
import authV2LoginIllustrationLight from '@images/pages/auth-v2-login-illustration-light.png'
import authV2LoginIllustrationDark from '@images/pages/auth-v2-login-illustration-dark.png'
import authV2LoginIllustrationBorderedLight from '@images/pages/auth-v2-login-illustration-bordered-light.png'
import authV2LoginIllustrationBorderedDark from '@images/pages/auth-v2-login-illustration-bordered-dark.png'
import miscMaskLight from '@images/pages/misc-mask-light.png'
import miscMaskDark from '@images/pages/misc-mask-dark.png'
definePage({ definePage({
name: 'login', name: 'login',
meta: { meta: {
@@ -13,43 +21,116 @@ const form = ref({
}) })
const isPasswordVisible = ref(false) const isPasswordVisible = ref(false)
const authThemeImg = useGenerateImageVariant(
authV2LoginIllustrationLight,
authV2LoginIllustrationDark,
authV2LoginIllustrationBorderedLight,
authV2LoginIllustrationBorderedDark,
true,
)
const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
</script> </script>
<template> <template>
<VApp> <!-- Logo -->
<VMain class="d-flex align-center justify-center" style="min-height: 100vh;"> <RouterLink to="/">
<VCard <div class="auth-logo d-flex align-center gap-x-3">
max-width="450" <VIcon
width="100%" icon="tabler-users-group"
class="pa-6 ma-4" size="28"
color="primary"
/>
<h1 class="auth-title">
Crewli
</h1>
</div>
</RouterLink>
<VRow
no-gutters
class="auth-wrapper bg-surface"
> >
<VCardTitle class="text-h5 text-center mb-2"> <!-- Left: Illustration -->
Inloggen <VCol
</VCardTitle> md="8"
<VCardSubtitle class="text-center mb-6"> class="d-none d-md-flex"
>
<div class="position-relative bg-background w-100 me-0">
<div
class="d-flex align-center justify-center w-100 h-100"
style="padding-inline: 6.25rem;"
>
<VImg
max-width="613"
:src="authThemeImg"
class="auth-illustration mt-16 mb-2"
/>
</div>
<img
class="auth-footer-mask flip-in-rtl"
:src="authThemeMask"
alt="auth-footer-mask"
height="280"
width="100"
>
</div>
</VCol>
<!-- Right: Login form -->
<VCol
cols="12"
md="4"
class="auth-card-v2 d-flex align-center justify-center"
>
<VCard
flat
:max-width="500"
class="mt-12 mt-sm-0 pa-6"
>
<VCardText>
<h4 class="text-h4 mb-1">
Welkom terug!
</h4>
<p class="mb-0">
Log in om je rooster en shifts te bekijken Log in om je rooster en shifts te bekijken
</VCardSubtitle> </p>
</VCardText>
<VCardText> <VCardText>
<VForm @submit.prevent> <VForm @submit.prevent>
<VRow>
<VCol cols="12">
<VTextField <VTextField
v-model="form.email" v-model="form.email"
autofocus
label="E-mailadres" label="E-mailadres"
type="email" type="email"
placeholder="je@email.nl" placeholder="je@email.nl"
class="mb-4"
/> />
</VCol>
<VCol cols="12">
<VTextField <VTextField
v-model="form.password" v-model="form.password"
label="Wachtwoord" label="Wachtwoord"
placeholder="Je wachtwoord" placeholder="Je wachtwoord"
:type="isPasswordVisible ? 'text' : 'password'" :type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'" :append-inner-icon="isPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
class="mb-6"
@click:append-inner="isPasswordVisible = !isPasswordVisible" @click:append-inner="isPasswordVisible = !isPasswordVisible"
/> />
<div class="d-flex align-center flex-wrap justify-end my-6">
<a
class="text-primary text-body-2"
href="javascript:void(0)"
>
Wachtwoord vergeten?
</a>
</div>
<VBtn <VBtn
block block
type="submit" type="submit"
@@ -57,9 +138,15 @@ const isPasswordVisible = ref(false)
> >
Inloggen Inloggen
</VBtn> </VBtn>
</VCol>
</VRow>
</VForm> </VForm>
</VCardText> </VCardText>
</VCard> </VCard>
</VMain> </VCol>
</VApp> </VRow>
</template> </template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -2,6 +2,11 @@
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { useDisplay } from 'vuetify' 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 { useAuthStore } from '@/stores/useAuthStore'
import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration' import { useRegistrationData, useSubmitRegistration } from '@/composables/api/useVolunteerRegistration'
import { fullRegistrationSchema } from '@/schemas/registrationSchema' import { fullRegistrationSchema } from '@/schemas/registrationSchema'
@@ -16,7 +21,7 @@ import type {
definePage({ definePage({
name: 'volunteer-register', name: 'volunteer-register',
meta: { meta: {
layout: 'portal', layout: 'blank',
requiresAuth: false, requiresAuth: false,
}, },
}) })
@@ -30,9 +35,12 @@ const eventSlug = computed(() => route.params.eventSlug as string)
const { data: registrationData, isLoading, isError } = useRegistrationData(eventSlug) const { data: registrationData, isLoading, isError } = useRegistrationData(eventSlug)
const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRegistration() const { mutateAsync: submitRegistration, isPending: isSubmitting } = useSubmitRegistration()
const currentStep = ref(1) const currentStep = ref(0)
const submitError = ref<string | null>(null) const submitError = ref<string | null>(null)
const registerMultiStepIllustration = useGenerateImageVariant(registerMultiStepIllustrationLight, registerMultiStepIllustrationDark)
const registerMultiStepBg = useGenerateImageVariant(registerMultiStepBgLight, registerMultiStepBgDark)
// VeeValidate form // VeeValidate form
const { errors, defineField, validateField, setFieldValue } = useForm({ const { errors, defineField, validateField, setFieldValue } = useForm({
validationSchema: toTypedSchema(fullRegistrationSchema), validationSchema: toTypedSchema(fullRegistrationSchema),
@@ -62,20 +70,29 @@ const [motivation] = defineField('motivation')
const [motivationOther] = defineField('motivation_other') const [motivationOther] = defineField('motivation_other')
// Pre-fill authenticated user data // Pre-fill authenticated user data
watch(() => authStore.user, (user) => { watch(() => authStore.user, user => {
if (user) { if (user) {
setFieldValue('name', user.name) setFieldValue('name', user.name)
setFieldValue('email', user.email) setFieldValue('email', user.email)
} }
}, { immediate: true }) }, { immediate: true })
// Step 4: Section preferences (by name, not ID) // Step 3: Section preferences (by name, not ID)
const selectedSections = ref<string[]>([]) const selectedSections = ref<string[]>([])
// Step 5: Availability // Step 4: Availability
const selectedTimeSlotIds = ref<string[]>([]) const selectedTimeSlotIds = ref<string[]>([])
const timeSlotPreferences = ref<Record<string, number>>({}) const timeSlotPreferences = ref<Record<string, number>>({})
// 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' },
]
// Constants // Constants
const tshirtSizeItems = [ const tshirtSizeItems = [
{ title: 'Geen voorkeur', value: '' }, { title: 'Geen voorkeur', value: '' },
@@ -90,25 +107,25 @@ const motivationItems = [
{ title: 'Anders', value: 'Anders' }, { title: 'Anders', value: 'Anders' },
] ]
const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid']
// Section helpers // Section helpers
const sectionsByCategory = computed(() => { const sectionsByCategory = computed(() => {
if (!registrationData.value?.sections) return {} if (!registrationData.value?.sections) return {}
return registrationData.value.sections.reduce((groups, section) => { return registrationData.value.sections.reduce((groups, section) => {
const cat = section.category || 'Overig' const cat = section.category || 'Overig'
if (!groups[cat]) groups[cat] = [] if (!groups[cat]) groups[cat] = []
groups[cat].push(section) groups[cat].push(section)
return groups return groups
}, {} as Record<string, SectionOption[]>) }, {} as Record<string, SectionOption[]>)
}) })
function isSelected(name: string) { function isSelected(sectionName: string) {
return selectedSections.value.includes(name) return selectedSections.value.includes(sectionName)
} }
function getSelectionPriority(name: string) { function getSelectionPriority(sectionName: string) {
return selectedSections.value.indexOf(name) + 1 return selectedSections.value.indexOf(sectionName) + 1
} }
const selectedCount = computed(() => selectedSections.value.length) const selectedCount = computed(() => selectedSections.value.length)
@@ -117,6 +134,7 @@ const selectedCount = computed(() => selectedSections.value.length)
const timeSlotsByDate = computed(() => { const timeSlotsByDate = computed(() => {
if (!registrationData.value?.time_slots) return [] if (!registrationData.value?.time_slots) return []
const groups = new Map<string, TimeSlotOption[]>() const groups = new Map<string, TimeSlotOption[]>()
for (const slot of registrationData.value.time_slots) { for (const slot of registrationData.value.time_slots) {
if (!groups.has(slot.date)) groups.set(slot.date, []) if (!groups.has(slot.date)) groups.set(slot.date, [])
groups.get(slot.date)!.push(slot) groups.get(slot.date)!.push(slot)
@@ -133,13 +151,13 @@ const totalSelectedHours = computed(() => {
.reduce((sum, s) => sum + s.duration_hours, 0) .reduce((sum, s) => sum + s.duration_hours, 0)
}) })
// Step field mapping for validation // Step field mapping for validation (0-based)
type FormField = 'name' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'access_requirements' | 'driving_licence' | 'motivation' | 'motivation_other' type FormField = 'name' | 'email' | 'phone' | 'tshirt_size' | 'first_aid' | 'allergies' | 'access_requirements' | 'driving_licence' | 'motivation' | 'motivation_other'
const stepFields: Record<number, FormField[]> = { const stepFields: Record<number, FormField[]> = {
1: ['name', 'email', 'phone'], 0: ['name', 'email', 'phone'],
2: ['tshirt_size', 'first_aid', 'allergies', 'access_requirements', 'driving_licence'], 1: ['tshirt_size', 'first_aid', 'allergies', 'access_requirements', 'driving_licence'],
3: ['motivation', 'motivation_other'], 2: ['motivation', 'motivation_other'],
} }
// Navigation // Navigation
@@ -153,22 +171,22 @@ async function validateCurrentStep(): Promise<boolean> {
async function nextStep() { async function nextStep() {
if (await validateCurrentStep()) { if (await validateCurrentStep()) {
if (currentStep.value < 5) currentStep.value++ if (currentStep.value < 4) currentStep.value++
} }
} }
function prevStep() { function prevStep() {
if (currentStep.value > 1) currentStep.value-- if (currentStep.value > 0) currentStep.value--
} }
// Section toggle // Section toggle
function toggleSection(name: string) { function toggleSection(sectionName: string) {
const idx = selectedSections.value.indexOf(name) const idx = selectedSections.value.indexOf(sectionName)
if (idx !== -1) { if (idx !== -1) {
selectedSections.value.splice(idx, 1) selectedSections.value.splice(idx, 1)
} }
else if (selectedSections.value.length < 5) { else if (selectedSections.value.length < 5) {
selectedSections.value.push(name) selectedSections.value.push(sectionName)
} }
} }
@@ -194,6 +212,17 @@ function formatDate(dateStr: string): string {
}) })
} }
function formatDateRange(start: string, end: string): string {
const startDate = new Date(`${start}T00:00:00`)
const endDate = new Date(`${end}T00:00:00`)
if (startDate.getMonth() === endDate.getMonth() && startDate.getFullYear() === endDate.getFullYear()) {
return `${startDate.getDate()} ${endDate.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`
}
return `${startDate.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long' })} ${endDate.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`
}
function formatTimeRange(start: string, end: string): string { function formatTimeRange(start: string, end: string): string {
return `${start.slice(0, 5)} ${end.slice(0, 5)}` return `${start.slice(0, 5)} ${end.slice(0, 5)}`
} }
@@ -202,11 +231,12 @@ function formatTimeRange(start: string, end: string): string {
async function onSubmit() { async function onSubmit() {
submitError.value = null submitError.value = null
// Validate steps 1-3 // Validate steps 0-2
for (let step = 1; step <= 3; step++) { for (let step = 0; step <= 2; step++) {
const fields = stepFields[step] const fields = stepFields[step]
if (!fields) continue if (!fields) continue
const results = await Promise.all(fields.map(f => validateField(f))) const results = await Promise.all(fields.map(f => validateField(f)))
if (!results.every(r => r.valid)) { if (!results.every(r => r.valid)) {
currentStep.value = step currentStep.value = step
@@ -216,8 +246,8 @@ async function onSubmit() {
if (!registrationData.value) return if (!registrationData.value) return
const sectionPreferences: SectionPreference[] = selectedSections.value.map((name, index) => ({ const sectionPreferences: SectionPreference[] = selectedSections.value.map((sectionName, index) => ({
section_name: name, section_name: sectionName,
priority: index + 1, priority: index + 1,
})) }))
@@ -257,8 +287,10 @@ async function onSubmit() {
} }
catch (error: unknown) { catch (error: unknown) {
const axiosError = error as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } } const axiosError = error as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } }
if (axiosError.response?.status === 422) { if (axiosError.response?.status === 422) {
const serverErrors = axiosError.response.data?.errors const serverErrors = axiosError.response.data?.errors
if (serverErrors) { if (serverErrors) {
for (const field of Object.keys(serverErrors)) { for (const field of Object.keys(serverErrors)) {
for (const [step, fields] of Object.entries(stepFields)) { for (const [step, fields] of Object.entries(stepFields)) {
@@ -280,26 +312,44 @@ async function onSubmit() {
</script> </script>
<template> <template>
<!-- Loading --> <!-- 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>
<!-- Loading state -->
<VRow <VRow
v-if="isLoading" v-if="isLoading"
justify="center" no-gutters
class="auth-wrapper bg-surface"
> >
<VCol <VCol
cols="12" cols="12"
md="10" class="d-flex align-center justify-center"
lg="8"
> >
<VCard class="pa-6"> <VCard
<VSkeletonLoader type="heading" /> flat
<VSkeletonLoader :max-width="500"
type="text@3" class="pa-12 text-center"
class="mt-4" >
/> <VProgressCircular
<VSkeletonLoader indeterminate
type="button" color="primary"
class="mt-4" size="48"
class="mb-4"
/> />
<p class="text-body-1 text-medium-emphasis mb-0">
Registratieformulier laden...
</p>
</VCard> </VCard>
</VCol> </VCol>
</VRow> </VRow>
@@ -307,34 +357,36 @@ async function onSubmit() {
<!-- Error / Not available --> <!-- Error / Not available -->
<VRow <VRow
v-else-if="isError || !registrationData" v-else-if="isError || !registrationData"
justify="center" no-gutters
class="auth-wrapper bg-surface"
> >
<VCol <VCol
cols="12" cols="12"
md="8" class="d-flex align-center justify-center"
lg="6" >
<VCard
flat
:max-width="500"
class="text-center pa-8"
> >
<VCard class="text-center pa-8">
<VIcon <VIcon
icon="tabler-calendar-off" icon="tabler-calendar-off"
size="64" size="64"
color="warning" color="warning"
class="mb-4" class="mb-4"
/> />
<VCardTitle class="text-h5"> <h4 class="text-h5 mb-2">
Niet beschikbaar Niet beschikbaar
</VCardTitle> </h4>
<VCardText class="text-body-1"> <p class="text-body-1 text-medium-emphasis mb-6">
Dit evenement accepteert momenteel geen aanmeldingen. Dit evenement accepteert momenteel geen aanmeldingen.
</VCardText> </p>
<VCardActions class="justify-center">
<VBtn <VBtn
to="/" to="/"
variant="outlined" variant="outlined"
> >
Terug naar startpagina Terug naar startpagina
</VBtn> </VBtn>
</VCardActions>
</VCard> </VCard>
</VCol> </VCol>
</VRow> </VRow>
@@ -342,121 +394,233 @@ async function onSubmit() {
<!-- Registration Form --> <!-- Registration Form -->
<VRow <VRow
v-else v-else
justify="center" no-gutters
class="auth-wrapper"
> >
<!-- Left: Event branding & illustration -->
<VCol
md="4"
class="d-none d-md-flex"
>
<div class="d-flex flex-column align-center justify-center w-100 position-relative">
<!-- Event info -->
<div
class="text-center px-8 mb-6"
style="z-index: 1;"
>
<VChip
color="primary"
variant="flat"
size="small"
class="mb-4"
>
Aanmelding vrijwilliger
</VChip>
<h2 class="text-h4 font-weight-bold mb-2">
{{ registrationData.event.name }}
</h2>
<div class="d-flex align-center justify-center gap-2 text-body-1 text-medium-emphasis">
<VIcon
icon="tabler-calendar"
size="18"
/>
{{ formatDateRange(registrationData.event.start_date, registrationData.event.end_date) }}
</div>
</div>
<!-- Illustration -->
<VImg
:src="registerMultiStepIllustration"
class="illustration-image flip-in-rtl"
style="z-index: 1;"
/>
<img
class="bg-image position-absolute w-100 flip-in-rtl"
:src="registerMultiStepBg"
alt="register-bg"
height="340"
>
</div>
</VCol>
<!-- Right: Registration form -->
<VCol <VCol
cols="12" cols="12"
md="10" md="8"
lg="8" class="auth-card-v2 d-flex align-center justify-center pa-4 pa-sm-10"
style="background-color: rgb(var(--v-theme-surface));"
> >
<VCard> <VCard
<VCardTitle class="text-h5 pa-4 pa-sm-6 pb-2"> flat
Aanmelden als vrijwilliger class="mt-12 mt-sm-0 w-100"
</VCardTitle> :max-width="750"
<VCardSubtitle class="px-4 px-sm-6 pb-4"> >
{{ registrationData.event.name }} <!-- Mobile: Event info (hidden on desktop where it shows in left panel) -->
</VCardSubtitle> <div class="d-md-none text-center mb-6">
<VChip
color="primary"
variant="flat"
size="small"
class="mb-2"
>
Aanmelding vrijwilliger
</VChip>
<h3 class="text-h5 font-weight-bold mb-1">
{{ registrationData.event.name }}
</h3>
<div class="d-flex align-center justify-center gap-2 text-body-2 text-medium-emphasis">
<VIcon
icon="tabler-calendar"
size="16"
/>
{{ formatDateRange(registrationData.event.start_date, registrationData.event.end_date) }}
</div>
</div>
<!-- Auth info / Login hint -->
<VAlert <VAlert
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
type="info" type="info"
variant="tonal" variant="tonal"
class="mx-4 mx-sm-6" class="mb-6"
> >
Je bent ingelogd als {{ authStore.user?.name }}. Je gegevens zijn automatisch ingevuld. Je bent ingelogd als {{ authStore.user?.name }}. Je gegevens zijn automatisch ingevuld.
</VAlert> </VAlert>
<div
v-else
class="text-body-2 text-medium-emphasis mb-6"
>
Al een account?
<RouterLink
to="/login"
class="text-primary font-weight-medium"
>
Log in
</RouterLink>
om je gegevens automatisch in te vullen.
</div>
<!-- Submit error -->
<VAlert <VAlert
v-if="submitError" v-if="submitError"
type="error" type="error"
variant="tonal" variant="tonal"
class="mx-4 mx-sm-6 mt-4" class="mb-6"
closable closable
@click:close="submitError = null" @click:close="submitError = null"
> >
{{ submitError }} {{ submitError }}
</VAlert> </VAlert>
<!-- Step indicator --> <!-- Stepper -->
<div class="d-flex flex-wrap align-center justify-center ga-1 px-4 px-sm-6 pt-6"> <AppStepper
<template v-model:current-step="currentStep"
v-for="(title, i) in stepTitles" :items="stepperItems"
:key="i" :direction="smAndDown ? 'vertical' : 'horizontal'"
> icon-size="22"
<VChip class="stepper-icon-step-bg mb-6"
: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 <VDivider class="mb-2" />
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 --> <!-- Step content -->
<VWindow v-model="currentStep"> <VWindow
<!-- Step 1: Over jou --> v-model="currentStep"
<VWindowItem :value="1"> class="disable-tab-transition"
<div class="pa-4 pa-sm-6"> >
<!-- Step 0: Over jou -->
<VWindowItem>
<div class="pt-6">
<h4 class="text-h4 mb-1">
Over jou
</h4>
<p class="text-body-1 text-medium-emphasis mb-6">
Vul je persoonlijke gegevens in
</p>
<VRow>
<VCol cols="12">
<VTextField <VTextField
v-model="name" v-model="name"
label="Naam *" label="Naam *"
:error-messages="errors.name" :error-messages="errors.name"
:disabled="authStore.isAuthenticated" :disabled="authStore.isAuthenticated"
autofocus autofocus
class="mb-4"
/> />
</VCol>
<VCol cols="12">
<VTextField <VTextField
v-model="email" v-model="email"
label="E-mailadres *" label="E-mailadres *"
type="email" type="email"
:error-messages="errors.email" :error-messages="errors.email"
:disabled="authStore.isAuthenticated" :disabled="authStore.isAuthenticated"
class="mb-4"
/> />
</VCol>
<VCol cols="12">
<VTextField <VTextField
v-model="phone" v-model="phone"
label="Telefoonnummer" label="Telefoonnummer"
:error-messages="errors.phone" :error-messages="errors.phone"
/> />
</VCol>
</VRow>
</div> </div>
</VWindowItem> </VWindowItem>
<!-- Step 2: Meer over jou --> <!-- Step 1: Extra info -->
<VWindowItem :value="2"> <VWindowItem>
<div class="pa-4 pa-sm-6"> <div class="pt-6">
<h4 class="text-h4 mb-1">
Extra informatie
</h4>
<p class="text-body-1 text-medium-emphasis mb-6">
Vertel ons meer over jezelf
</p>
<VRow>
<VCol
cols="12"
sm="6"
>
<VSelect <VSelect
v-model="tshirtSize" v-model="tshirtSize"
:items="tshirtSizeItems" :items="tshirtSizeItems"
label="Shirtmaat" label="Shirtmaat"
:error-messages="errors.tshirt_size" :error-messages="errors.tshirt_size"
class="mb-4"
/> />
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="d-flex flex-column gap-4 pt-2">
<VSwitch <VSwitch
v-model="firstAid" v-model="firstAid"
label="Ik heb een EHBO-diploma" label="Ik heb een EHBO-diploma"
color="primary" color="primary"
hide-details hide-details
class="mb-4"
/> />
<VSwitch
v-model="drivingLicence"
label="Ik heb een rijbewijs B"
color="primary"
hide-details
/>
</div>
</VCol>
<VCol cols="12">
<VTextarea <VTextarea
v-model="allergies" v-model="allergies"
label="Allergieën" label="Allergieën"
@@ -464,8 +628,10 @@ async function onSubmit() {
:counter="500" :counter="500"
rows="2" rows="2"
auto-grow auto-grow
class="mb-4"
/> />
</VCol>
<VCol cols="12">
<VTextarea <VTextarea
v-model="accessRequirements" v-model="accessRequirements"
label="Toegangsbehoeften" label="Toegangsbehoeften"
@@ -475,30 +641,38 @@ async function onSubmit() {
:counter="500" :counter="500"
rows="2" rows="2"
auto-grow auto-grow
class="mb-4"
/>
<VSwitch
v-model="drivingLicence"
label="Ik heb een rijbewijs B"
color="primary"
hide-details
/> />
</VCol>
</VRow>
</div> </div>
</VWindowItem> </VWindowItem>
<!-- Step 3: Motivatie --> <!-- Step 2: Motivatie -->
<VWindowItem :value="3"> <VWindowItem>
<div class="pa-4 pa-sm-6"> <div class="pt-6">
<h4 class="text-h4 mb-1">
Motivatie
</h4>
<p class="text-body-1 text-medium-emphasis mb-6">
Waarom wil je vrijwilliger worden?
</p>
<VRow>
<VCol cols="12">
<VSelect <VSelect
v-model="motivation" v-model="motivation"
:items="motivationItems" :items="motivationItems"
label="Wat is je motivatie?" label="Wat is je motivatie?"
:error-messages="errors.motivation" :error-messages="errors.motivation"
clearable clearable
class="mb-4"
/> />
<VTextarea </VCol>
<VCol
v-if="motivation" v-if="motivation"
cols="12"
>
<VTextarea
v-model="motivationOther" v-model="motivationOther"
label="Toelichting" label="Toelichting"
:error-messages="errors.motivation_other" :error-messages="errors.motivation_other"
@@ -506,16 +680,18 @@ async function onSubmit() {
rows="3" rows="3"
auto-grow auto-grow
/> />
</VCol>
</VRow>
</div> </div>
</VWindowItem> </VWindowItem>
<!-- Step 4: Voorkeurssecties --> <!-- Step 3: Voorkeurssecties -->
<VWindowItem :value="4"> <VWindowItem>
<div class="pa-4 pa-sm-6"> <div class="pt-6">
<h3 class="text-h6 mb-1"> <h4 class="text-h4 mb-1">
Bij welke onderdelen wil je het liefst helpen? Bij welke onderdelen wil je helpen?
</h3> </h4>
<p class="text-body-2 text-medium-emphasis mb-4"> <p class="text-body-1 text-medium-emphasis mb-6">
Selecteer maximaal 5 onderdelen. Je eerste keuze heeft de hoogste prioriteit. Selecteer maximaal 5 onderdelen. Je eerste keuze heeft de hoogste prioriteit.
</p> </p>
@@ -526,6 +702,7 @@ async function onSubmit() {
<div class="text-subtitle-2 text-medium-emphasis mt-4 mb-2"> <div class="text-subtitle-2 text-medium-emphasis mt-4 mb-2">
{{ category }} {{ category }}
</div> </div>
<VRow dense> <VRow dense>
<VCol <VCol
v-for="section in sections" v-for="section in sections"
@@ -547,12 +724,14 @@ async function onSubmit() {
density="compact" density="compact"
hide-details hide-details
/> />
<VIcon <VIcon
v-if="section.icon" v-if="section.icon"
size="20" size="20"
> >
{{ section.icon }} {{ section.icon }}
</VIcon> </VIcon>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="text-body-1 font-weight-medium"> <div class="text-body-1 font-weight-medium">
{{ section.name }} {{ section.name }}
@@ -564,6 +743,7 @@ async function onSubmit() {
{{ section.registration_description }} {{ section.registration_description }}
</div> </div>
</div> </div>
<VChip <VChip
v-if="isSelected(section.name)" v-if="isSelected(section.name)"
size="x-small" size="x-small"
@@ -596,19 +776,24 @@ async function onSubmit() {
</div> </div>
</VWindowItem> </VWindowItem>
<!-- Step 5: Beschikbaarheid --> <!-- Step 4: Beschikbaarheid -->
<VWindowItem :value="5"> <VWindowItem>
<div class="pa-4 pa-sm-6"> <div class="pt-6">
<h4 class="text-h4 mb-1">
Beschikbaarheid
</h4>
<p class="text-body-1 text-medium-emphasis mb-6">
Selecteer de tijdsloten waarop je beschikbaar bent en geef je voorkeur aan met sterren.
</p>
<p <p
v-if="registrationData.time_slots.length === 0" v-if="registrationData.time_slots.length === 0"
class="text-body-1 text-medium-emphasis" class="text-body-1 text-medium-emphasis"
> >
Er zijn geen tijdsloten beschikbaar voor dit evenement. Er zijn geen tijdsloten beschikbaar voor dit evenement.
</p> </p>
<template v-else> <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 <div
v-for="[date, slots] in timeSlotsByDate" v-for="[date, slots] in timeSlotsByDate"
:key="date" :key="date"
@@ -621,7 +806,6 @@ async function onSubmit() {
<VListItem <VListItem
v-for="slot in slots" v-for="slot in slots"
:key="slot.id" :key="slot.id"
class="timeslot-item"
@click="toggleTimeSlot(slot.id)" @click="toggleTimeSlot(slot.id)"
> >
<template #prepend> <template #prepend>
@@ -630,10 +814,12 @@ async function onSubmit() {
@click.stop="toggleTimeSlot(slot.id)" @click.stop="toggleTimeSlot(slot.id)"
/> />
</template> </template>
<VListItemTitle>{{ slot.name }}</VListItemTitle> <VListItemTitle>{{ slot.name }}</VListItemTitle>
<VListItemSubtitle> <VListItemSubtitle>
{{ formatTimeRange(slot.start_time, slot.end_time) }} &middot; {{ slot.duration_hours }}u {{ formatTimeRange(slot.start_time, slot.end_time) }} &middot; {{ slot.duration_hours }}u
</VListItemSubtitle> </VListItemSubtitle>
<template <template
v-if="selectedTimeSlotIds.includes(slot.id)" v-if="selectedTimeSlotIds.includes(slot.id)"
#append #append
@@ -673,35 +859,46 @@ async function onSubmit() {
</VWindowItem> </VWindowItem>
</VWindow> </VWindow>
<VDivider /> <!-- Navigation buttons -->
<div class="d-flex flex-wrap justify-space-between gap-x-4 mt-6">
<!-- Navigation -->
<div class="d-flex align-center pa-4 pa-sm-6">
<VBtn <VBtn
v-if="currentStep > 1" color="secondary"
variant="text" :disabled="currentStep === 0"
prepend-icon="tabler-arrow-left" variant="tonal"
@click="prevStep" @click="prevStep"
> >
<VIcon
icon="tabler-arrow-left"
start
class="flip-in-rtl"
/>
Vorige Vorige
</VBtn> </VBtn>
<VSpacer />
<VBtn <VBtn
v-if="currentStep < 5" v-if="currentStep === 4"
color="primary" color="success"
append-icon="tabler-arrow-right"
@click="nextStep"
>
Volgende
</VBtn>
<VBtn
v-else
color="primary"
:loading="isSubmitting" :loading="isSubmitting"
prepend-icon="tabler-send"
@click="onSubmit" @click="onSubmit"
> >
Aanmelding versturen Aanmelding versturen
<VIcon
icon="tabler-send"
end
/>
</VBtn>
<VBtn
v-else
color="primary"
@click="nextStep"
>
Volgende
<VIcon
icon="tabler-arrow-right"
end
class="flip-in-rtl"
/>
</VBtn> </VBtn>
</div> </div>
</VCard> </VCard>
@@ -709,14 +906,15 @@ async function onSubmit() {
</VRow> </VRow>
</template> </template>
<style scoped> <style lang="scss">
.step-chip { @use "@core/scss/template/pages/page-auth.scss";
cursor: pointer;
min-block-size: 32px; .illustration-image {
block-size: 550px;
inline-size: 248px;
} }
.section-item, .bg-image {
.timeslot-item { inset-block-end: 0;
min-block-size: 48px;
} }
</style> </style>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts"> <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' import { useAuthStore } from '@/stores/useAuthStore'
definePage({ definePage({
name: 'register-success', name: 'register-success',
meta: { meta: {
layout: 'portal', layout: 'blank',
requiresAuth: false, requiresAuth: false,
}, },
}) })
@@ -14,37 +17,69 @@ const authStore = useAuthStore()
const eventName = computed(() => (route.query.event as string) || 'het evenement') const eventName = computed(() => (route.query.event as string) || 'het evenement')
const isAuthenticated = computed(() => route.query.authenticated === '1' || authStore.isAuthenticated) const isAuthenticated = computed(() => route.query.authenticated === '1' || authStore.isAuthenticated)
const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark)
</script> </script>
<template> <template>
<VRow justify="center"> <!-- 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 <VCol
cols="12" cols="12"
md="8" class="d-flex align-center justify-center"
lg="6" >
<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"
> >
<VCard class="text-center pa-8">
<VIcon <VIcon
icon="tabler-circle-check" icon="tabler-circle-check"
size="80" size="60"
color="success"
class="mb-4"
/> />
<VCardTitle class="text-h5 mb-2"> </VAvatar>
<h4 class="text-h4 mb-2">
Bedankt voor je aanmelding! Bedankt voor je aanmelding!
</VCardTitle> </h4>
<VCardText class="text-body-1">
<p class="mb-4"> <p class="text-body-1 text-medium-emphasis mb-2">
Bedankt voor je aanmelding bij <strong>{{ eventName }}</strong>! Je aanmelding bij <strong>{{ eventName }}</strong> is succesvol ontvangen.
</p> </p>
<p class="mb-4">
Je aanmelding wordt beoordeeld door het organisatieteam. <p class="text-body-1 text-medium-emphasis mb-2">
Het organisatieteam beoordeelt je aanmelding zo snel mogelijk.
</p> </p>
<p class="text-medium-emphasis">
<p class="text-body-2 text-disabled mb-8">
Je ontvangt een e-mail zodra je aanmelding is goedgekeurd. Je ontvangt een e-mail zodra je aanmelding is goedgekeurd.
</p> </p>
</VCardText>
<VCardActions class="justify-center pt-4"> <div class="d-flex flex-wrap justify-center gap-4">
<VBtn <VBtn
v-if="isAuthenticated" v-if="isAuthenticated"
to="/dashboard" to="/dashboard"
@@ -53,16 +88,31 @@ const isAuthenticated = computed(() => route.query.authenticated === '1' || auth
> >
Ga naar je dashboard Ga naar je dashboard
</VBtn> </VBtn>
<VBtn <VBtn
v-else v-else
to="/login" to="/login"
variant="outlined" color="primary"
variant="tonal"
prepend-icon="tabler-login" prepend-icon="tabler-login"
> >
Heb je al een account? Log in Heb je al een account? Log in
</VBtn> </VBtn>
</VCardActions> </div>
</VCard> </VCard>
<img
class="auth-footer-mask flip-in-rtl"
:src="authThemeMask"
alt="footer-mask"
height="280"
width="100"
>
</div>
</VCol> </VCol>
</VRow> </VRow>
</template> </template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth.scss";
</style>