refactor(portal): move pages from apps/portal to apps/app

Per WS-3 PR-B1 charter §4.2: portal pages relocate into the
single-SPA layout under apps/app/src/pages/portal/** (authenticated
portal context) and apps/app/src/pages/register/** (public
token-based form-fill / confirmation).

Updated meta blocks:
  - Portal pages: layout: 'PortalLayout', context: 'portal'
    (preserving original requiresAuth + nav fields)
  - Register pages: layout: 'PublicLayout' (drop requiresAuth)

Skipped (apps/portal duplicates of pages already in apps/app):
  index.vue, login.vue, wachtwoord-{vergeten,resetten}.vue,
  verify-email-change.vue. Deleted: [...path].vue (apps/app already
  has [...error].vue catch-all).

NOTE: Component/store/composable imports inside these files still
point at apps/portal-relative paths and will be rewritten in the
next commits. Build will not be green again until commit 6
(composables/lib).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 18:58:06 +02:00
parent 79954aace6
commit 4cfcd5306a
12 changed files with 0 additions and 36 deletions

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
definePage({
name: 'artist-advance',
meta: {
layout: 'portal',
requiresAuth: false,
requiresToken: true,
},
})
const route = useRoute('artist-advance')
const token = computed(() => route.params.token)
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
>
<VCard class="text-center pa-6">
<VCardTitle class="text-h5">
Artist Advance Portal
</VCardTitle>
<VCardSubtitle>
Vul je technische en hospitality riders in
</VCardSubtitle>
<VCardText class="text-body-1 mt-4">
Token: <code>{{ token }}</code>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import OverzichtTab from '@/components/event/OverzichtTab.vue'
import RoosterTab from '@/components/event/RoosterTab.vue'
import ClaimenTab from '@/components/event/ClaimenTab.vue'
import InformatieTab from '@/components/event/InformatieTab.vue'
import { usePortalStore } from '@/stores/usePortalStore'
definePage({
name: 'portal-event-detail',
meta: {
layout: 'portal',
requiresAuth: true,
navMode: 'event',
},
})
const route = useRoute('portal-event-detail')
const router = useRouter()
const portal = usePortalStore()
const eventId = computed(() => route.params.eventId as string)
const isApproved = computed(() => portal.currentPerson?.status === 'approved')
// Sync the store's active event with the route param
watch(eventId, (id) => {
if (id && id !== portal.activeEventId) {
portal.setActiveEvent(id)
}
}, { immediate: true })
// Tab definitions
const allTabs = computed(() => {
const tabs = [
{ value: 'overzicht', label: 'Overzicht', icon: 'tabler-home' },
]
if (isApproved.value) {
tabs.push(
{ value: 'rooster', label: 'Mijn rooster', icon: 'tabler-calendar-check' },
{ value: 'claimen', label: 'Diensten claimen', icon: 'tabler-calendar-plus' },
)
}
tabs.push({ value: 'informatie', label: 'Informatie', icon: 'tabler-info-circle' })
return tabs
})
// Hash-based tab navigation
const activeTab = computed({
get() {
const hash = route.hash?.replace('#', '') || 'overzicht'
// Validate the hash is one of the valid tab values
if (allTabs.value.some(t => t.value === hash)) return hash
return 'overzicht'
},
set(tab: string) {
router.replace({ hash: `#${tab}` })
},
})
function switchTab(tab: string) {
activeTab.value = tab
}
// Validate the event exists in the user's events
const eventExists = computed(() =>
portal.userEvents.some(e => e.event_id === eventId.value),
)
const eventLoaded = computed(() => portal.isHydrated && !portal.isLoadingEvents)
</script>
<template>
<div>
<!-- Loading -->
<template v-if="!eventLoaded">
<VSkeletonLoader
type="heading"
class="mb-4"
/>
<VSkeletonLoader type="article" />
</template>
<!-- Event not found -->
<VCard
v-else-if="!eventExists"
variant="flat"
class="text-center pa-8"
>
<VAvatar
size="80"
color="warning"
variant="tonal"
class="mb-4"
>
<VIcon
icon="tabler-alert-triangle"
size="40"
/>
</VAvatar>
<h5 class="text-h5 mb-2">
Evenement niet gevonden
</h5>
<p class="text-body-1 text-medium-emphasis mb-4">
Dit evenement bestaat niet of je hebt er geen toegang toe.
</p>
<VBtn
color="primary"
to="/evenementen"
>
Terug naar mijn evenementen
</VBtn>
</VCard>
<!-- Event content with tabs -->
<template v-else>
<VTabs
v-model="activeTab"
class="mb-6"
>
<VTab
v-for="tab in allTabs"
:key="tab.value"
:value="tab.value"
>
<VIcon
start
:icon="tab.icon"
size="18"
/>
{{ tab.label }}
</VTab>
</VTabs>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
>
<VWindowItem value="overzicht">
<OverzichtTab
:event-id="eventId"
@switch-tab="switchTab"
/>
</VWindowItem>
<VWindowItem
v-if="isApproved"
value="rooster"
>
<RoosterTab
:event-id="eventId"
@switch-tab="switchTab"
/>
</VWindowItem>
<VWindowItem
v-if="isApproved"
value="claimen"
>
<ClaimenTab
:event-id="eventId"
@switch-tab="switchTab"
/>
</VWindowItem>
<VWindowItem value="informatie">
<InformatieTab :event-id="eventId" />
</VWindowItem>
</VWindow>
</template>
</div>
</template>
<style scoped>
.disable-tab-transition :deep(.v-window__container) {
transition: none !important;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import EventCard from '@/components/portal/EventCard.vue'
import { usePortalStore } from '@/stores/usePortalStore'
definePage({
name: 'portal-evenementen',
meta: {
layout: 'portal',
requiresAuth: true,
navMode: 'platform',
navTitle: 'Mijn evenementen',
},
})
const portal = usePortalStore()
const sortedEvents = computed(() => {
const now = new Date()
const events = [...portal.userEvents]
return events.sort((a, b) => {
const aEnd = new Date(`${a.end_date}T23:59:59`)
const bEnd = new Date(`${b.end_date}T23:59:59`)
const aIsPast = aEnd < now
const bIsPast = bEnd < now
// Upcoming first, past last
if (aIsPast !== bIsPast) return aIsPast ? 1 : -1
// Within same group, sort by start_date ascending (soonest first)
return a.start_date.localeCompare(b.start_date)
})
})
</script>
<template>
<div>
<!-- Loading -->
<template v-if="portal.isLoadingEvents">
<VRow>
<VCol
v-for="n in 3"
:key="n"
cols="12"
sm="6"
md="4"
>
<VSkeletonLoader type="card" />
</VCol>
</VRow>
</template>
<!-- Error -->
<VAlert
v-else-if="portal.loadError"
type="warning"
variant="tonal"
class="mb-4"
>
{{ portal.loadError }}
</VAlert>
<!-- Empty state -->
<VCard
v-else-if="!sortedEvents.length"
variant="flat"
class="text-center pa-8 pa-sm-12"
>
<VAvatar
size="80"
color="primary"
variant="tonal"
class="mb-4"
>
<VIcon
icon="tabler-calendar-off"
size="40"
/>
</VAvatar>
<h5 class="text-h5 mb-2">
Geen evenementen
</h5>
<p class="text-body-1 text-medium-emphasis mb-0">
Je bent nog niet aangemeld voor een evenement.
</p>
<p class="text-body-2 text-medium-emphasis mb-0">
Heb je een uitnodiging ontvangen? Neem contact op met de organisatie.
</p>
</VCard>
<!-- Event grid -->
<VRow v-else>
<VCol
v-for="ev in sortedEvents"
:key="ev.event_id"
cols="12"
sm="6"
md="4"
>
<EventCard :event="ev" />
</VCol>
</VRow>
</div>
</template>

View File

@@ -0,0 +1,859 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
import { usePortalStore } from '@/stores/usePortalStore'
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
import {
useMfaStatus,
useTrustedDevices,
useRevokeDevice,
useRevokeAllDevices,
useSetPreferredMethod,
} from '@/composables/api/useMfa'
import { apiClient } from '@/lib/axios'
import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue'
import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue'
import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue'
import axios from 'axios'
import type { ApiErrorResponse } from '@/types/api'
definePage({
name: 'portal-profiel',
meta: {
layout: 'portal',
requiresAuth: true,
navMode: 'platform',
navTitle: 'Mijn profiel',
},
})
const authStore = useAuthStore()
const portal = usePortalStore()
const router = useRouter()
const route = useRoute()
const snackbar = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref('success')
// ─── Tab management ───
const activeTab = computed({
get: () => {
const tab = route.query.tab as string | undefined
return tab === 'security' ? 'security' : 'profile'
},
set: (val: string) => {
router.replace({ query: { ...route.query, tab: val } })
},
})
// ─── Profile form ───
const profileForm = ref({
first_name: '',
last_name: '',
phone: '',
date_of_birth: '',
})
const profileError = ref<string | null>(null)
const updateProfileMutation = useUpdateProfile()
// Email change form
const showEmailChange = ref(false)
const emailForm = ref({
new_email: '',
password: '',
})
const emailError = ref<string | null>(null)
const emailFieldErrors = ref<Record<string, string>>({})
const emailSuccess = ref('')
const showEmailPw = ref(false)
const isChangingEmail = ref(false)
async function saveEmail() {
emailError.value = null
emailFieldErrors.value = {}
emailSuccess.value = ''
isChangingEmail.value = true
try {
const { data } = await apiClient.post<{ success: boolean; message: string }>(
'/me/change-email',
{ ...emailForm.value, app: 'portal' },
)
emailSuccess.value = data.message
emailForm.value = { new_email: '', password: '' }
showEmailChange.value = false
}
catch (err: unknown) {
const ax = err as { response?: { data?: { message?: string; errors?: Record<string, string[]> } } }
if (ax.response?.data?.errors) {
for (const [key, messages] of Object.entries(ax.response.data.errors)) {
emailFieldErrors.value[key] = (messages as string[])[0]
}
}
else {
emailError.value = ax.response?.data?.message ?? 'Er is een fout opgetreden.'
}
}
finally {
isChangingEmail.value = false
}
}
// Populate profile form from auth user / current person data
watch(
[() => authStore.user, () => portal.currentPerson],
([user, person]) => {
profileForm.value = {
first_name: person?.first_name ?? user?.first_name ?? '',
last_name: person?.last_name ?? user?.last_name ?? '',
phone: person?.phone ?? '',
date_of_birth: person?.date_of_birth ?? '',
}
},
{ immediate: true },
)
async function saveProfile() {
profileError.value = null
if (!portal.activeEventId) return
try {
const result = await updateProfileMutation.mutateAsync({
event_id: portal.activeEventId,
...profileForm.value,
phone: profileForm.value.phone || null,
date_of_birth: profileForm.value.date_of_birth || null,
})
// Refresh person data and auth user
await Promise.all([
portal.fetchCurrentPerson(),
authStore.fetchUser(),
])
snackbarMessage.value = result.message
snackbarColor.value = 'success'
snackbar.value = true
}
catch (err: unknown) {
if (axios.isAxiosError<ApiErrorResponse>(err)) {
const data = err.response?.data
if (data?.errors) {
const firstError = Object.values(data.errors).flat()[0]
profileError.value = firstError ?? 'Er is een fout opgetreden.'
}
else {
profileError.value = data?.message ?? 'Er is een fout opgetreden.'
}
}
else {
profileError.value = 'Er is een fout opgetreden.'
}
}
}
// ─── Password ───
const passwordForm = ref({
current_password: '',
password: '',
password_confirmation: '',
})
const passwordError = ref<string | null>(null)
const passwordFieldErrors = ref<Record<string, string>>({})
const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const updatePasswordMutation = useUpdatePassword()
async function savePassword() {
passwordError.value = null
passwordFieldErrors.value = {}
try {
const result = await updatePasswordMutation.mutateAsync(passwordForm.value)
passwordForm.value = {
current_password: '',
password: '',
password_confirmation: '',
}
snackbarMessage.value = result.message
snackbarColor.value = 'success'
snackbar.value = true
}
catch (err: unknown) {
if (axios.isAxiosError<ApiErrorResponse>(err)) {
const data = err.response?.data
if (data?.errors) {
passwordFieldErrors.value = {}
for (const [key, messages] of Object.entries(data.errors)) {
passwordFieldErrors.value[key] = messages[0]
}
}
else {
passwordError.value = data?.message ?? 'Er is een fout opgetreden.'
}
}
else {
passwordError.value = 'Er is een fout opgetreden.'
}
}
}
// ─── MFA ───
const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus()
const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices()
const revokeDeviceMutation = useRevokeDevice()
const revokeAllMutation = useRevokeAllDevices()
const setPreferredMethodMutation = useSetPreferredMethod()
const showTotpSetup = ref(false)
const showEmailSetup = ref(false)
const showDisableDialog = ref(false)
const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false)
const totpConfigured = computed(() => mfaStatus.value?.totp_configured ?? false)
const emailConfigured = computed(() => mfaStatus.value?.email_configured ?? false)
const preferredMethod = computed(() => mfaStatus.value?.method ?? null)
function handleSetPreferred(method: 'totp' | 'email') {
setPreferredMethodMutation.mutate(method)
}
function onMfaSetupCompleted() { refetchMfaStatus() }
function onMfaDisabled() { refetchMfaStatus(); refetchDevices() }
async function handleRevokeDevice(id: string) {
await revokeDeviceMutation.mutateAsync(id)
refetchDevices()
}
async function handleRevokeAllDevices() {
await revokeAllMutation.mutateAsync()
refetchDevices()
}
// ─── Events ───
function statusColor(status: string): string {
if (status === 'approved') return 'success'
if (status === 'pending' || status === 'applied' || status === 'invited') return 'warning'
if (status === 'rejected') return 'error'
return 'secondary'
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
pending: 'In behandeling',
applied: 'In behandeling',
invited: 'Uitgenodigd',
approved: 'Goedgekeurd',
rejected: 'Afgewezen',
no_show: 'Niet verschenen',
}
return map[status] ?? status
}
function formatEventDates(startDate: string, endDate: string): string {
try {
const start = new Date(`${startDate}T12:00:00`)
const end = new Date(`${endDate}T12:00:00`)
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' }
return `${start.toLocaleDateString('nl-NL', opts)} ${end.toLocaleDateString('nl-NL', opts)}`
}
catch {
return `${startDate} ${endDate}`
}
}
function viewEvent(eventId: string) {
router.push(`/evenementen/${eventId}`)
}
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
>
<h4 class="text-h4 mb-4">
Mijn Profiel
</h4>
<VTabs
v-model="activeTab"
class="v-tabs-pill mb-6"
>
<VTab value="profile">
<VIcon
icon="tabler-user"
size="20"
class="me-2"
/>
Mijn profiel
</VTab>
<VTab value="security">
<VIcon
icon="tabler-shield-lock"
size="20"
class="me-2"
/>
Beveiliging
</VTab>
</VTabs>
<VWindow
v-model="activeTab"
class="disable-tab-transition"
:touch="false"
>
<!-- Profile tab -->
<VWindowItem value="profile">
<!-- Profile form -->
<VCard class="mb-4">
<VCardTitle>Persoonlijke gegevens</VCardTitle>
<VCardText>
<VAlert
v-if="profileError"
type="error"
variant="tonal"
density="compact"
class="mb-4"
>
{{ profileError }}
</VAlert>
<VForm @submit.prevent="saveProfile">
<VRow>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.first_name"
label="Voornaam"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.last_name"
label="Achternaam"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
</VCol>
<VCol cols="12">
<VTextField
:model-value="authStore.user?.email"
label="E-mailadres"
variant="outlined"
density="comfortable"
hide-details="auto"
readonly
prepend-inner-icon="tabler-mail"
/>
<div class="d-flex align-center justify-space-between mt-1">
<p class="text-caption text-medium-emphasis mb-0">
{{ showEmailChange ? '' : 'Je kunt je e-mailadres hieronder wijzigen.' }}
</p>
<VBtn
v-if="!showEmailChange"
variant="text"
color="primary"
size="small"
@click="showEmailChange = true"
>
E-mail wijzigen
</VBtn>
</div>
</VCol>
<!-- Email change form -->
<template v-if="showEmailChange">
<VCol cols="12">
<VAlert
v-if="emailError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
>
{{ emailError }}
</VAlert>
<VTextField
v-model="emailForm.new_email"
label="Nieuw e-mailadres"
type="email"
variant="outlined"
density="comfortable"
hide-details="auto"
:error-messages="emailFieldErrors.new_email"
class="mb-3"
/>
<VTextField
v-model="emailForm.password"
label="Huidig wachtwoord"
:type="showEmailPw ? 'text' : 'password'"
:append-inner-icon="showEmailPw ? 'tabler-eye-off' : 'tabler-eye'"
variant="outlined"
density="comfortable"
hide-details="auto"
:error-messages="emailFieldErrors.password"
@click:append-inner="showEmailPw = !showEmailPw"
/>
<div class="d-flex gap-2 mt-3">
<VBtn
color="primary"
size="small"
:loading="isChangingEmail"
:disabled="!emailForm.new_email || !emailForm.password"
@click="saveEmail"
>
Verificatiemail versturen
</VBtn>
<VBtn
variant="tonal"
size="small"
@click="showEmailChange = false; emailForm = { new_email: '', password: '' }"
>
Annuleren
</VBtn>
</div>
</VCol>
</template>
<VCol
v-if="emailSuccess"
cols="12"
>
<VAlert
type="success"
variant="tonal"
density="compact"
>
{{ emailSuccess }}
</VAlert>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.phone"
label="Telefoonnummer"
variant="outlined"
density="comfortable"
hide-details="auto"
prepend-inner-icon="tabler-phone"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="profileForm.date_of_birth"
label="Geboortedatum"
variant="outlined"
density="comfortable"
hide-details="auto"
type="date"
>
<template #prepend-inner>
<VIcon
icon="tabler-calendar"
size="20"
/>
</template>
</VTextField>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="updateProfileMutation.isPending.value"
>
Opslaan
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<!-- My events -->
<VCard v-if="portal.userEvents.length > 0">
<VCardTitle>Mijn evenementen</VCardTitle>
<VCardText class="pa-0">
<VList>
<VListItem
v-for="ev in portal.userEvents"
:key="ev.event_id"
class="py-3"
>
<template #prepend>
<VIcon
icon="tabler-calendar-event"
size="24"
color="primary"
class="me-1"
/>
</template>
<VListItemTitle class="font-weight-medium">
{{ ev.event_name }}
</VListItemTitle>
<VListItemSubtitle class="text-caption mt-1">
{{ formatEventDates(ev.start_date, ev.end_date) }}
</VListItemSubtitle>
<template #append>
<div class="d-flex align-center gap-2">
<VChip
:color="statusColor(ev.person_status)"
size="small"
label
>
{{ statusLabel(ev.person_status) }}
</VChip>
<VBtn
variant="text"
color="primary"
size="small"
@click="viewEvent(ev.event_id)"
>
Bekijk
</VBtn>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
<!-- No events -->
<VAlert
v-else
type="info"
variant="tonal"
>
Je bent nog niet aangemeld voor een evenement.
</VAlert>
</VWindowItem>
<!-- Security tab -->
<VWindowItem value="security">
<!-- Password change -->
<VCard class="mb-4">
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
<VCardText>
<VAlert
v-if="passwordError"
type="error"
variant="tonal"
density="compact"
class="mb-4"
>
{{ passwordError }}
</VAlert>
<VForm @submit.prevent="savePassword">
<VRow>
<VCol cols="12">
<VTextField
v-model="passwordForm.current_password"
label="Huidig wachtwoord"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="showCurrentPassword ? 'text' : 'password'"
:append-inner-icon="showCurrentPassword ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.current_password"
placeholder="············"
@click:append-inner="showCurrentPassword = !showCurrentPassword"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="passwordForm.password"
label="Nieuw wachtwoord"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="showNewPassword ? 'text' : 'password'"
:append-inner-icon="showNewPassword ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password"
placeholder="············"
@click:append-inner="showNewPassword = !showNewPassword"
/>
</VCol>
<VCol
cols="12"
sm="6"
>
<VTextField
v-model="passwordForm.password_confirmation"
label="Bevestig nieuw wachtwoord"
variant="outlined"
density="comfortable"
hide-details="auto"
:type="showConfirmPassword ? 'text' : 'password'"
:append-inner-icon="showConfirmPassword ? 'tabler-eye-off' : 'tabler-eye'"
:error-messages="passwordFieldErrors.password_confirmation"
placeholder="············"
@click:append-inner="showConfirmPassword = !showConfirmPassword"
/>
</VCol>
</VRow>
<div class="d-flex justify-end mt-4">
<VBtn
type="submit"
color="primary"
:loading="updatePasswordMutation.isPending.value"
>
Wachtwoord wijzigen
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<!-- Security / MFA -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center">
<VIcon
icon="tabler-shield-lock"
class="me-2"
/>
Tweestapsverificatie
</VCardTitle>
<VCardText>
<template v-if="!isMfaEnabled">
<p class="text-body-2 text-medium-emphasis mb-4">
Bescherm je account met tweestapsverificatie.
</p>
<div class="d-flex gap-2 flex-wrap">
<VBtn
variant="tonal"
prepend-icon="tabler-device-mobile"
@click="showTotpSetup = true"
>
Authenticator app
</VBtn>
<VBtn
variant="tonal"
prepend-icon="tabler-mail"
@click="showEmailSetup = true"
>
E-mailcode
</VBtn>
</div>
</template>
<template v-else>
<!-- Per-method status -->
<div class="d-flex flex-column gap-3 mb-4">
<!-- TOTP row -->
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VAvatar
color="primary"
variant="tonal"
size="32"
rounded
>
<VIcon
icon="tabler-device-mobile"
size="18"
/>
</VAvatar>
<div>
<span class="text-body-2 font-weight-medium">Authenticator app</span>
<div class="d-flex align-center gap-1 mt-1">
<VChip
:color="totpConfigured ? 'success' : 'default'"
variant="tonal"
size="x-small"
:prepend-icon="totpConfigured ? 'tabler-check' : undefined"
>
{{ totpConfigured ? 'Actief' : 'Niet ingesteld' }}
</VChip>
<VChip
v-if="totpConfigured && preferredMethod === 'totp'"
color="primary"
variant="tonal"
size="x-small"
>
Primair
</VChip>
</div>
</div>
</div>
<VBtn
v-if="totpConfigured && preferredMethod !== 'totp'"
variant="text"
size="small"
color="primary"
:loading="setPreferredMethodMutation.isPending.value"
@click="handleSetPreferred('totp')"
>
Als primair
</VBtn>
</div>
<!-- Email row -->
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VAvatar
color="primary"
variant="tonal"
size="32"
rounded
>
<VIcon
icon="tabler-mail"
size="18"
/>
</VAvatar>
<div>
<span class="text-body-2 font-weight-medium">E-mailcode</span>
<div class="d-flex align-center gap-1 mt-1">
<VChip
color="success"
variant="tonal"
size="x-small"
prepend-icon="tabler-check"
>
Actief
</VChip>
<VChip
v-if="preferredMethod === 'email'"
color="primary"
variant="tonal"
size="x-small"
>
Primair
</VChip>
</div>
</div>
</div>
<VBtn
v-if="preferredMethod !== 'email'"
variant="text"
size="small"
color="primary"
:loading="setPreferredMethodMutation.isPending.value"
@click="handleSetPreferred('email')"
>
Als primair
</VBtn>
</div>
</div>
<VBtn
variant="text"
color="error"
size="small"
prepend-icon="tabler-shield-off"
class="mb-4"
@click="showDisableDialog = true"
>
Uitschakelen
</VBtn>
<!-- Trusted devices -->
<template v-if="trustedDevices && trustedDevices.length > 0">
<p class="text-subtitle-2 mt-4 mb-2">
Vertrouwde apparaten
</p>
<VList density="compact">
<VListItem
v-for="device in trustedDevices"
:key="device.id"
class="px-0"
>
<VListItemTitle class="text-body-2">
{{ device.device_name ?? 'Onbekend apparaat' }}
</VListItemTitle>
<VListItemSubtitle class="text-caption">
IP: {{ device.ip_address }}
</VListItemSubtitle>
<template #append>
<VBtn
variant="text"
size="small"
color="error"
icon="tabler-trash"
@click="handleRevokeDevice(device.id)"
/>
</template>
</VListItem>
</VList>
<VBtn
v-if="trustedDevices.length > 1"
variant="tonal"
size="small"
color="error"
class="mt-2"
@click="handleRevokeAllDevices"
>
Alle apparaten intrekken
</VBtn>
</template>
</template>
</VCardText>
</VCard>
<!-- MFA setup dialogs -->
<MfaTotpSetupDialog
v-model="showTotpSetup"
@completed="onMfaSetupCompleted"
/>
<MfaEmailSetupDialog
v-model="showEmailSetup"
:user-email="authStore.user?.email ?? ''"
@completed="onMfaSetupCompleted"
/>
<MfaDisableDialog
v-model="showDisableDialog"
:current-method="mfaStatus?.method ?? null"
@disabled="onMfaDisabled"
/>
</VWindowItem>
</VWindow>
<!-- Snackbar -->
<VSnackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="4000"
>
{{ snackbarMessage }}
</VSnackbar>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
definePage({
name: 'volunteer-register-info',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
</script>
<template>
<VContainer
class="py-12"
style="max-inline-size: 640px;"
>
<VCard
class="pa-6 pa-sm-8"
variant="flat"
>
<h1 class="text-h4 mb-4">
Aanmelden als vrijwilliger
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
Vrijwilligers melden zich aan via een persoonlijke link die door de organisatie wordt gedeeld
(bijvoorbeeld op de website van het evenement of in een uitnodiging per e-mail).
</p>
<p class="text-body-1 text-medium-emphasis mb-6">
Heb je al een Crewli-account? Log dan in om je aanmeldingen te volgen.
</p>
<div class="d-flex flex-wrap gap-3">
<VBtn
color="primary"
to="/login"
>
Inloggen
</VBtn>
<VBtn
variant="tonal"
to="/"
>
Startpagina
</VBtn>
</div>
</VCard>
</VContainer>
</template>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { useAllMyShifts } from '@/composables/api/usePortalShifts'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AllMyShiftsAssignment } from '@/types/portal-shift'
definePage({
name: 'portal-shifts',
meta: {
layout: 'portal',
requiresAuth: true,
},
})
const auth = useAuthStore()
const { data: eventGroups, isLoading, isError, refetch } = useAllMyShifts()
const statusConfig: Record<string, { label: string; color: string }> = {
pending_approval: { label: 'In afwachting', color: 'warning' },
approved: { label: 'Bevestigd', color: 'success' },
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('nl-NL', {
weekday: 'long',
day: 'numeric',
month: 'long',
})
}
function getStatusConfig(assignment: AllMyShiftsAssignment) {
return statusConfig[assignment.status] ?? { label: assignment.status, color: 'default' }
}
</script>
<template>
<VRow justify="center">
<VCol
cols="12"
md="8"
lg="6"
>
<h5 class="text-h5 mb-6">
Mijn diensten
</h5>
<!-- Not authenticated -->
<VAlert
v-if="!auth.isAuthenticated"
type="info"
variant="tonal"
>
<VIcon
start
icon="tabler-login"
/>
Log in om je diensten te bekijken.
</VAlert>
<template v-else>
<!-- Loading -->
<template v-if="isLoading">
<VSkeletonLoader
v-for="n in 3"
:key="n"
type="card"
class="mb-4"
/>
</template>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
variant="tonal"
class="mb-4"
>
Er ging iets mis bij het ophalen van je diensten.
<template #append>
<VBtn
variant="text"
size="small"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Empty state -->
<VCard
v-else-if="!eventGroups?.length"
variant="flat"
class="text-center pa-8"
>
<VAvatar
size="64"
color="primary"
variant="tonal"
class="mb-4"
>
<VIcon
icon="tabler-calendar-off"
size="32"
/>
</VAvatar>
<p class="text-body-1 text-medium-emphasis mb-4">
Je hebt nog geen diensten toegewezen gekregen.
</p>
<VBtn
color="primary"
variant="tonal"
to="/evenementen"
>
Bekijk je evenementen
</VBtn>
</VCard>
<!-- Shift groups -->
<template v-else>
<div
v-for="eventGroup in eventGroups"
:key="eventGroup.event.id"
class="mb-8"
>
<!-- Event header -->
<div class="d-flex align-center gap-2 mb-4">
<VIcon
icon="tabler-calendar-event"
size="20"
color="primary"
/>
<span class="text-subtitle-1 font-weight-bold">
{{ eventGroup.event.name }}
</span>
</div>
<!-- Date groups -->
<div
v-for="dateGroup in eventGroup.assignments"
:key="dateGroup.date"
class="mb-4"
>
<div class="text-subtitle-2 text-medium-emphasis mb-2">
{{ dateGroup.date_label ?? formatDate(dateGroup.date) }}
</div>
<VCard
v-for="assignment in dateGroup.shifts"
:key="assignment.id"
variant="outlined"
class="mb-2 shift-card"
:class="`shift-card--${assignment.status}`"
>
<VCardItem>
<template #prepend>
<VIcon
v-if="assignment.shift.section_icon"
:icon="assignment.shift.section_icon"
size="24"
color="primary"
/>
<VAvatar
v-else
size="32"
color="primary"
variant="tonal"
>
{{ assignment.shift.section_name[0] }}
</VAvatar>
</template>
<VCardTitle class="text-subtitle-1 font-weight-bold">
{{ assignment.shift.title }}
</VCardTitle>
<VCardSubtitle>{{ assignment.shift.section_name }}</VCardSubtitle>
<template #append>
<VChip
:color="getStatusConfig(assignment).color"
size="small"
variant="tonal"
>
{{ getStatusConfig(assignment).label }}
</VChip>
</template>
</VCardItem>
<VCardText class="pt-0">
<div class="d-flex flex-wrap gap-x-4 gap-y-1 text-body-2">
<span>
<VIcon
icon="tabler-clock"
size="14"
class="me-1"
/>
{{ assignment.shift.start_time }} - {{ assignment.shift.end_time }}
</span>
<span v-if="assignment.shift.report_time">
<VIcon
icon="tabler-alert-circle"
size="14"
class="me-1"
/>
Aanwezig: {{ assignment.shift.report_time }}
</span>
<span v-if="assignment.shift.location">
<VIcon
icon="tabler-map-pin"
size="14"
class="me-1"
/>
{{ assignment.shift.location.name }}
</span>
</div>
</VCardText>
</VCard>
</div>
</div>
</template>
</template>
</VCol>
</VRow>
</template>
<style scoped>
.shift-card {
border-inline-start: 3px solid transparent;
}
.shift-card--approved {
border-inline-start-color: rgb(var(--v-theme-success));
}
.shift-card--pending_approval {
border-inline-start-color: rgb(var(--v-theme-warning));
}
</style>

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import authV1BottomShape from '@images/svg/auth-v1-bottom-shape.svg?raw'
import authV1TopShape from '@images/svg/auth-v1-top-shape.svg?raw'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import PasswordRequirements from '@/components/auth/PasswordRequirements.vue'
import { apiClient } from '@/lib/axios'
definePage({
name: 'set-password',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute()
const router = useRouter()
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
const token = ref(typeof route.query.token === 'string' ? route.query.token : '')
const password = ref('')
const passwordConfirmation = ref('')
const showPassword = ref(false)
const showPasswordConfirmation = ref(false)
const errorMessage = ref('')
const isSubmitting = ref(false)
const passwordReqsRef = ref<InstanceType<typeof PasswordRequirements>>()
const confirmationError = computed(() => {
if (!passwordConfirmation.value) return ''
if (password.value !== passwordConfirmation.value) return 'Wachtwoorden komen niet overeen'
return ''
})
const canSubmit = computed(() =>
password.value.length > 0
&& passwordConfirmation.value.length > 0
&& password.value === passwordConfirmation.value
&& (passwordReqsRef.value?.allMet ?? false),
)
async function onSubmit(): Promise<void> {
errorMessage.value = ''
if (!token.value || !email.value) {
errorMessage.value = 'Ongeldige activatielink. Neem contact op met de organisatie.'
return
}
if (!canSubmit.value) return
isSubmitting.value = true
try {
await apiClient.post('/auth/reset-password', {
email: email.value.trim(),
password: password.value,
password_confirmation: passwordConfirmation.value,
token: token.value,
})
await router.replace({ path: '/login', query: { activated: '1' } })
}
catch (error: unknown) {
const ax = error as { response?: { status?: number; data?: { message?: string } } }
if (ax.response?.status === 404 || ax.response?.status === 422)
errorMessage.value = ax.response?.data?.message ?? 'Activatielink ongeldig of verlopen. Neem contact op met de organisatie.'
else
errorMessage.value = 'Er ging iets mis. Probeer het later opnieuw.'
}
finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<div class="position-relative my-sm-16">
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1TopShape })"
class="text-primary auth-v1-top-shape d-none d-sm-block"
/>
<VNodeRenderer
:nodes="h('div', { innerHTML: authV1BottomShape })"
class="text-primary auth-v1-bottom-shape d-none d-sm-block"
/>
<VCard
class="auth-card"
max-width="460"
:class="$vuetify.display.smAndUp ? 'pa-6' : 'pa-2'"
>
<VCardItem class="justify-center">
<VCardTitle>
<RouterLink to="/">
<div class="app-logo">
<VNodeRenderer :nodes="themeConfig.app.logo" />
<h1 class="app-logo-title">
{{ themeConfig.app.title }}
</h1>
</div>
</RouterLink>
</VCardTitle>
</VCardItem>
<VCardText>
<h4 class="text-h4 mb-1">
Stel je wachtwoord in
</h4>
<p class="mb-0">
Welkom bij Crewli! Kies een wachtwoord om je account te activeren.
</p>
</VCardText>
<VCardText>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
>
{{ errorMessage }}
</VAlert>
<VForm @submit.prevent="onSubmit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="password"
autofocus
label="Wachtwoord"
placeholder="············"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:append-inner-icon="showPassword ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPassword = !showPassword"
/>
<PasswordRequirements
ref="passwordReqsRef"
:password="password"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="passwordConfirmation"
label="Bevestig wachtwoord"
placeholder="············"
:type="showPasswordConfirmation ? 'text' : 'password'"
autocomplete="new-password"
:error-messages="confirmationError ? [confirmationError] : undefined"
:append-inner-icon="showPasswordConfirmation ? 'tabler-eye-off' : 'tabler-eye'"
@click:append-inner="showPasswordConfirmation = !showPasswordConfirmation"
/>
</VCol>
<VCol cols="12">
<VBtn
block
type="submit"
:loading="isSubmitting"
:disabled="!canSubmit"
>
Account activeren
</VBtn>
</VCol>
<VCol cols="12">
<RouterLink
class="d-flex align-center justify-center"
to="/login"
>
<VIcon
icon="tabler-chevron-left"
size="20"
class="me-1 flip-in-rtl"
/>
<span>Terug naar inloggen</span>
</RouterLink>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</div>
</template>
<style lang="scss">
@use "@core/scss/template/pages/page-auth";
</style>

View File

@@ -0,0 +1,571 @@
<script setup lang="ts">
import { emailValidator } from '@core/utils/validators'
import FieldRenderer from '@/components/public-form/FieldRenderer.vue'
import FormConfirmation from '@/components/public-form/FormConfirmation.vue'
import FormErrorState from '@/components/public-form/FormErrorState.vue'
import FormStepper from '@/components/public-form/FormStepper.vue'
import SubmitterDetails from '@/components/public-form/SubmitterDetails.vue'
import { extractErrorBody, useFetchPublicFormSchema } from '@/composables/api/usePublicForm'
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
import { useFormDraft } from '@/composables/useFormDraft'
import { isStepValid, useFormSteps } from '@form-schema/composables/useFormSteps'
import { formatFieldValue } from '@form-schema/composables/formatFieldValue'
import { providePublicFormLocale, providePublicFormToken } from '@/composables/publicFormInjection'
import { FormFieldType } from '@form-schema/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@form-schema/types/formBuilder'
definePage({
name: 'public-form-register',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute('public-form-register')
const token = computed(() => {
const raw = route.params.public_token
if (Array.isArray(raw)) return raw[0] ?? ''
return raw ?? ''
})
const tokenRef = computed<string | null>(() => token.value || null)
// Provide the (always-present) string token ref to AVAILABILITY_PICKER /
// SECTION_PRIORITY renderers so they can fetch their sibling endpoints
// without prop drilling through FieldRenderer.
providePublicFormToken(token)
const schemaQuery = useFetchPublicFormSchema(tokenRef)
// Surface the schema's locale to option-bearing field renderers so they
// can resolve per-option translations[locale] over the default label.
// Defaults to 'nl' until the schema resolves.
const formLocale = computed<string>(() => schemaQuery.data.value?.locale ?? 'nl')
providePublicFormLocale(formLocale)
// Sibling endpoints — fetched at page level so the review step and
// FormConfirmation can human-label AVAILABILITY_PICKER /
// SECTION_PRIORITY values via formatFieldValue. Shares the same
// 5-minute TanStack Query cache used by the field components, so
// this is a free hit when those fields are rendered on screen.
const timeSlotsQuery = usePublicFormTimeSlots(token)
const sectionsQuery = usePublicFormSections(token)
const draft = useFormDraft(tokenRef, {
locale: 'nl',
})
// Start the draft as soon as the schema resolves successfully.
watch(() => schemaQuery.data.value?.id, async id => {
if (id && !draft.submission.value) {
await draft.start()
}
}, { immediate: true })
const steps = useFormSteps(schemaQuery.data)
const currentStep = ref(0)
const justSubmitted = ref(false)
const showSaveToast = ref(false)
const rateLimitedToast = ref(false)
const submitterToast = ref(false)
const serverFieldErrors = ref<Record<string, string[]>>({})
const submitterErrors = ref<{ name?: string; email?: string }>({})
const submitterValid = computed(() => {
const hasName = draft.submitterName.value.trim().length > 0
const hasEmail = draft.submitterEmail.value.trim().length > 0
const emailOk = emailValidator(draft.submitterEmail.value) === true
return hasName && hasEmail && emailOk
})
const activeStep = computed(() => steps.value[currentStep.value])
const isActiveStepValid = computed(() =>
activeStep.value ? isStepValid(activeStep.value, draft.values.value, submitterValid.value) : true,
)
const saveStatusText = computed(() => {
if (draft.isSaving.value) return 'Opslaan...'
if (draft.lastSavedAt.value) {
const hh = String(draft.lastSavedAt.value.getHours()).padStart(2, '0')
const mm = String(draft.lastSavedAt.value.getMinutes()).padStart(2, '0')
return `Concept opgeslagen om ${hh}:${mm}`
}
return ''
})
const terminalErrorCode = computed<FormErrorCode | null>(() => {
const err = schemaQuery.error.value
if (!err) return null
const body = extractErrorBody(err)
const axiosErr = err as { response?: { status?: number } }
const status = axiosErr.response?.status
const code = body?.code as FormErrorCode | undefined
if (code === 'TOKEN_EXPIRED' || code === 'TOKEN_REVOKED'
|| code === 'SCHEMA_NOT_FOUND' || code === 'SCHEMA_UNPUBLISHED') {
return code
}
if (status === 404) return 'SCHEMA_NOT_FOUND'
if (status === 410) return 'TOKEN_EXPIRED'
return null
})
const submitErrorCode = computed<FormErrorCode | null>(() => {
const err = draft.submitError.value
if (!err) return null
const body = extractErrorBody(err)
const code = body?.code as FormErrorCode | undefined
if (code === 'SUBMISSION_ALREADY_SUBMITTED'
|| code === 'RATE_LIMITED'
|| code === 'SCHEMA_UNPUBLISHED') {
return code
}
return null
})
function onFieldValue(slug: string, value: unknown): void {
draft.setValue(slug, value)
if (serverFieldErrors.value[slug]) {
const next = { ...serverFieldErrors.value }
delete next[slug]
serverFieldErrors.value = next
}
}
function onFieldBlur(slug: string, value: unknown): void {
draft.saveField(slug, value)
}
async function nextStep(): Promise<void> {
if (!isActiveStepValid.value) return
if (currentStep.value < steps.value.length - 1) {
currentStep.value++
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
function prevStep(): void {
if (currentStep.value > 0) {
currentStep.value--
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
async function onSaveDraft(): Promise<void> {
await draft.saveDraftNow()
if (!draft.saveError.value) {
showSaveToast.value = true
}
else {
const body = extractErrorBody(draft.saveError.value)
if (body?.code === 'RATE_LIMITED') rateLimitedToast.value = true
}
}
function onSubmitterNameUpdate(v: string): void {
draft.setSubmitterName(v)
if (submitterErrors.value.name) submitterErrors.value = { ...submitterErrors.value, name: undefined }
}
function onSubmitterEmailUpdate(v: string): void {
draft.setSubmitterEmail(v)
if (submitterErrors.value.email) submitterErrors.value = { ...submitterErrors.value, email: undefined }
}
function validateSubmitter(): boolean {
const errs: { name?: string; email?: string } = {}
const name = draft.submitterName.value.trim()
const email = draft.submitterEmail.value.trim()
if (!name) errs.name = 'Vul je naam in.'
if (!email) errs.email = 'Vul je e-mailadres in.'
else if (emailValidator(email) !== true) errs.email = 'Vul een geldig e-mailadres in.'
submitterErrors.value = errs
return !errs.name && !errs.email
}
async function onSubmit(): Promise<void> {
serverFieldErrors.value = {}
if (!validateSubmitter()) {
const idx = steps.value.findIndex(s => s.kind === 'submitter')
if (idx >= 0) currentStep.value = idx
submitterToast.value = true
return
}
const result = await draft.submitForm()
if (result) {
justSubmitted.value = true
window.scrollTo({ top: 0 })
return
}
const err = draft.submitError.value
const body = extractErrorBody(err)
if (body?.errors) serverFieldErrors.value = body.errors
if (body?.code === 'RATE_LIMITED') rateLimitedToast.value = true
}
function serverErrorFor(slug: string): string[] {
return serverFieldErrors.value[slug] ?? serverFieldErrors.value[`values.${slug}`] ?? []
}
function retryFetch(): void {
void schemaQuery.refetch()
}
function answerableForReview(field: PublicFormField): boolean {
return field.field_type !== FormFieldType.HEADING
&& field.field_type !== FormFieldType.PARAGRAPH
}
function formatReviewValue(field: PublicFormField): string {
return formatFieldValue(
field,
draft.values.value[field.slug],
timeSlotsQuery.data.value,
sectionsQuery.data.value,
)
}
</script>
<template>
<div class="public-form-page">
<!-- Terminal fetch error -->
<FormErrorState
v-if="terminalErrorCode"
:error-code="terminalErrorCode"
:show-retry="false"
/>
<!-- Generic fetch error (retryable) -->
<FormErrorState
v-else-if="schemaQuery.isError.value && !schemaQuery.data.value"
@retry="retryFetch"
/>
<!-- Loading -->
<div
v-else-if="schemaQuery.isLoading.value || !schemaQuery.data.value"
class="d-flex justify-center pa-4"
>
<VCard
flat
:max-width="720"
class="w-100 pa-4"
>
<VSkeletonLoader type="article" />
</VCard>
</div>
<!-- Already-submitted terminal -->
<FormErrorState
v-else-if="submitErrorCode === 'SUBMISSION_ALREADY_SUBMITTED'"
error-code="SUBMISSION_ALREADY_SUBMITTED"
:show-retry="false"
/>
<!-- Post-submit confirmation -->
<FormConfirmation
v-else-if="justSubmitted"
:steps="steps"
:values="draft.values.value"
:submitter-name="draft.submitterName.value"
:submitter-email="draft.submitterEmail.value"
:identity-match="draft.submission.value?.identity_match ?? null"
:duplicate-submission="draft.submission.value?.duplicate_submission ?? null"
/>
<!-- Data state -->
<div v-else>
<!-- Save-status top bar -->
<div
v-if="saveStatusText || draft.saveError.value"
class="d-flex justify-end px-4 pt-3"
>
<VChip
v-if="saveStatusText"
size="small"
variant="tonal"
:color="draft.isSaving.value ? 'primary' : 'success'"
>
<template
v-if="draft.isSaving.value"
#prepend
>
<VProgressCircular
indeterminate
size="14"
width="2"
class="me-2"
/>
</template>
{{ saveStatusText }}
</VChip>
<VChip
v-else-if="draft.saveError.value"
size="small"
variant="tonal"
color="warning"
>
<VIcon
start
icon="tabler-cloud-off"
size="16"
/>
Opslaan mislukt
</VChip>
</div>
<VContainer class="public-form-container">
<VCard
v-if="schemaQuery.data.value"
flat
class="pa-4 mb-4"
>
<h1 class="text-h5 mb-1">
{{ schemaQuery.data.value.name }}
</h1>
<p
v-if="schemaQuery.data.value.description"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ schemaQuery.data.value.description }}
</p>
</VCard>
<!-- Stepper navigation -->
<VCard
flat
class="pa-4 mb-4"
>
<FormStepper
v-model:current-step="currentStep"
:steps="steps"
:is-active-step-valid="isActiveStepValid"
/>
</VCard>
<!-- Current step -->
<VCard
flat
class="pa-4 pa-sm-6"
>
<div
v-if="activeStep"
class="mb-6"
>
<p class="text-caption text-medium-emphasis mb-1">
Stap {{ currentStep + 1 }} van {{ steps.length }}
</p>
<h2 class="text-h5 mb-1">
{{ activeStep.title }}
</h2>
<p
v-if="activeStep.subtitle"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ activeStep.subtitle }}
</p>
</div>
<!-- Submitter step -->
<SubmitterDetails
v-if="activeStep && activeStep.kind === 'submitter'"
:name="draft.submitterName.value"
:email="draft.submitterEmail.value"
:errors="submitterErrors"
@update:name="onSubmitterNameUpdate"
@update:email="onSubmitterEmailUpdate"
@blur="draft.saveDraftNow"
/>
<!-- Review step -->
<div v-else-if="activeStep && activeStep.kind === 'review'">
<VRow>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
Naam
</p>
<p class="text-body-2 mb-0">
{{ draft.submitterName.value || '—' }}
</p>
</VCol>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
E-mailadres
</p>
<p class="text-body-2 mb-0">
{{ draft.submitterEmail.value || '—' }}
</p>
</VCol>
</VRow>
<template
v-for="step in steps"
:key="step.key"
>
<template v-if="step.kind !== 'submitter' && step.kind !== 'review'">
<VDivider class="my-5" />
<h3 class="text-subtitle-1 font-weight-medium mb-3">
{{ step.title }}
</h3>
<VRow>
<VCol
v-for="field in step.fields.filter(answerableForReview)"
:key="field.id"
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
{{ field.label }}
</p>
<p class="text-body-2 mb-0">
{{ formatReviewValue(field) }}
</p>
</VCol>
</VRow>
</template>
</template>
</div>
<!-- Content steps -->
<div v-else-if="activeStep">
<VRow>
<FieldRenderer
v-for="field in activeStep.fields"
:key="field.id"
:field="field"
:model-value="draft.values.value[field.slug]"
:all-values="draft.values.value"
:error-messages="serverErrorFor(field.slug)"
@update:model-value="v => onFieldValue(field.slug, v)"
@blur="onFieldBlur(field.slug, draft.values.value[field.slug])"
/>
</VRow>
</div>
<VDivider class="my-6" />
<div class="d-flex flex-wrap justify-space-between align-center ga-3">
<VBtn
v-if="currentStep > 0"
variant="outlined"
color="secondary"
class="form-btn-sentence-case"
@click="prevStep"
>
<VIcon
icon="tabler-arrow-left"
start
/>
Vorige
</VBtn>
<div v-else />
<div class="d-flex flex-wrap ga-3">
<VBtn
v-if="activeStep && activeStep.kind !== 'review'"
variant="text"
color="primary"
class="form-btn-sentence-case"
:loading="draft.isSaving.value"
:disabled="!draft.submission.value"
@click="onSaveDraft"
>
Sla op als concept
</VBtn>
<VBtn
v-if="currentStep < steps.length - 1"
color="primary"
class="form-btn-sentence-case"
:disabled="!isActiveStepValid"
@click="nextStep"
>
Volgende
<VIcon
icon="tabler-arrow-right"
end
/>
</VBtn>
<VBtn
v-else
color="success"
class="form-btn-sentence-case"
:loading="draft.isSubmitting.value"
@click="onSubmit"
>
<VIcon
icon="tabler-send"
start
/>
Verstuur
</VBtn>
</div>
</div>
</VCard>
</VContainer>
</div>
<VSnackbar
v-model="showSaveToast"
:timeout="2500"
color="success"
location="top"
>
Concept opgeslagen.
</VSnackbar>
<VSnackbar
v-model="rateLimitedToast"
:timeout="4000"
color="warning"
location="top"
>
Even geduld, we proberen het zo opnieuw.
</VSnackbar>
<VSnackbar
v-model="submitterToast"
:timeout="3000"
color="error"
location="top"
>
Vul eerst je contactgegevens in
</VSnackbar>
</div>
</template>
<style scoped>
.public-form-container {
max-inline-size: 960px;
}
.public-form-page {
min-block-size: 100dvh;
}
/* Portal theme capitalizes VBtn labels by default; this page uses
sentence-case Dutch labels ("Sla op als concept", not title-case). */
.form-btn-sentence-case {
text-transform: none !important;
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
name: 'register-success',
meta: {
layout: 'portal',
requiresAuth: false,
navMode: 'platform',
},
})
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)
</script>
<template>
<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="d-flex align-center justify-center fill-height">
<h3 class="text-h5 text-white font-weight-bold">
{{ eventName }}
</h3>
</div>
</VImg>
<!-- 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.
Daarin vind je een link om je account te activeren.
</p>
<div class="d-flex flex-wrap justify-center gap-4">
<VBtn
v-if="isAuthenticated"
to="/evenementen"
color="primary"
prepend-icon="tabler-calendar-event"
>
Ga naar je evenementen
</VBtn>
<VBtn
v-else
to="/"
color="primary"
variant="tonal"
prepend-icon="tabler-home"
>
Terug naar startpagina
</VBtn>
</div>
</VCard>
</VContainer>
</div>
</template>