From 212db0d3cbf6d63aac8f921fdfba558684b2c0c8 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 10 Apr 2026 20:47:24 +0200 Subject: [PATCH] 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) --- apps/portal/src/layouts/portal.vue | 72 +- apps/portal/src/pages/login.vue | 159 ++++- .../portal/src/pages/register/[eventSlug].vue | 650 ++++++++++++------ apps/portal/src/pages/register/success.vue | 130 ++-- 4 files changed, 691 insertions(+), 320 deletions(-) diff --git a/apps/portal/src/layouts/portal.vue b/apps/portal/src/layouts/portal.vue index c47f1f83..0c837173 100644 --- a/apps/portal/src/layouts/portal.vue +++ b/apps/portal/src/layouts/portal.vue @@ -36,55 +36,90 @@ watch([isFallbackStateActive, refLoadingIndicator], () => { - + + + + + + Crewli + + - - Crewli Portal - + + - + @@ -126,6 +161,7 @@ watch([isFallbackStateActive, refLoadingIndicator], () => { +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({ name: 'login', meta: { @@ -13,53 +21,132 @@ const form = ref({ }) const isPasswordVisible = ref(false) + +const authThemeImg = useGenerateImageVariant( + authV2LoginIllustrationLight, + authV2LoginIllustrationDark, + authV2LoginIllustrationBorderedLight, + authV2LoginIllustrationBorderedDark, + true, +) + +const authThemeMask = useGenerateImageVariant(miscMaskLight, miscMaskDark) + + diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index 2184fd5b..c4c62800 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -2,6 +2,11 @@ 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' @@ -16,7 +21,7 @@ import type { definePage({ name: 'volunteer-register', meta: { - layout: 'portal', + layout: 'blank', requiresAuth: false, }, }) @@ -30,9 +35,12 @@ 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 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), @@ -62,20 +70,29 @@ const [motivation] = defineField('motivation') const [motivationOther] = defineField('motivation_other') // Pre-fill authenticated user data -watch(() => authStore.user, (user) => { +watch(() => authStore.user, user => { if (user) { setFieldValue('name', user.name) setFieldValue('email', user.email) } }, { immediate: true }) -// Step 4: Section preferences (by name, not ID) +// Step 3: Section preferences (by name, not ID) const selectedSections = ref([]) -// Step 5: Availability +// Step 4: Availability 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' }, +] + // Constants const tshirtSizeItems = [ { title: 'Geen voorkeur', value: '' }, @@ -90,25 +107,25 @@ const motivationItems = [ { title: 'Anders', value: 'Anders' }, ] -const stepTitles = ['Over jou', 'Meer over jou', 'Motivatie', 'Secties', 'Beschikbaarheid'] - // Section helpers const sectionsByCategory = computed(() => { if (!registrationData.value?.sections) return {} + return registrationData.value.sections.reduce((groups, section) => { const cat = section.category || 'Overig' if (!groups[cat]) groups[cat] = [] groups[cat].push(section) + return groups }, {} as Record) }) -function isSelected(name: string) { - return selectedSections.value.includes(name) +function isSelected(sectionName: string) { + return selectedSections.value.includes(sectionName) } -function getSelectionPriority(name: string) { - return selectedSections.value.indexOf(name) + 1 +function getSelectionPriority(sectionName: string) { + return selectedSections.value.indexOf(sectionName) + 1 } const selectedCount = computed(() => selectedSections.value.length) @@ -117,6 +134,7 @@ const selectedCount = computed(() => selectedSections.value.length) const timeSlotsByDate = computed(() => { if (!registrationData.value?.time_slots) return [] const groups = new Map() + for (const slot of registrationData.value.time_slots) { if (!groups.has(slot.date)) groups.set(slot.date, []) groups.get(slot.date)!.push(slot) @@ -133,13 +151,13 @@ const totalSelectedHours = computed(() => { .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' const stepFields: Record = { - 1: ['name', 'email', 'phone'], - 2: ['tshirt_size', 'first_aid', 'allergies', 'access_requirements', 'driving_licence'], - 3: ['motivation', 'motivation_other'], + 0: ['name', 'email', 'phone'], + 1: ['tshirt_size', 'first_aid', 'allergies', 'access_requirements', 'driving_licence'], + 2: ['motivation', 'motivation_other'], } // Navigation @@ -153,22 +171,22 @@ async function validateCurrentStep(): Promise { async function nextStep() { if (await validateCurrentStep()) { - if (currentStep.value < 5) currentStep.value++ + if (currentStep.value < 4) currentStep.value++ } } function prevStep() { - if (currentStep.value > 1) currentStep.value-- + if (currentStep.value > 0) currentStep.value-- } // Section toggle -function toggleSection(name: string) { - const idx = selectedSections.value.indexOf(name) +function toggleSection(sectionName: string) { + const idx = selectedSections.value.indexOf(sectionName) if (idx !== -1) { selectedSections.value.splice(idx, 1) } 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 { return `${start.slice(0, 5)} – ${end.slice(0, 5)}` } @@ -202,11 +231,12 @@ function formatTimeRange(start: string, end: string): string { async function onSubmit() { submitError.value = null - // Validate steps 1-3 - for (let step = 1; step <= 3; step++) { + // Validate steps 0-2 + for (let step = 0; step <= 2; 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 @@ -216,8 +246,8 @@ async function onSubmit() { if (!registrationData.value) return - const sectionPreferences: SectionPreference[] = selectedSections.value.map((name, index) => ({ - section_name: name, + const sectionPreferences: SectionPreference[] = selectedSections.value.map((sectionName, index) => ({ + section_name: sectionName, priority: index + 1, })) @@ -257,8 +287,10 @@ async function onSubmit() { } catch (error: unknown) { const axiosError = error as { response?: { status?: number; data?: { errors?: Record } } } + 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)) { @@ -280,26 +312,44 @@ async function onSubmit() {