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:
35
apps/app/src/pages/portal/advance/[token].vue
Normal file
35
apps/app/src/pages/portal/advance/[token].vue
Normal 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>
|
||||
182
apps/app/src/pages/portal/evenementen/[eventId].vue
Normal file
182
apps/app/src/pages/portal/evenementen/[eventId].vue
Normal 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>
|
||||
105
apps/app/src/pages/portal/evenementen/index.vue
Normal file
105
apps/app/src/pages/portal/evenementen/index.vue
Normal 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>
|
||||
859
apps/app/src/pages/portal/profiel.vue
Normal file
859
apps/app/src/pages/portal/profiel.vue
Normal 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>
|
||||
46
apps/app/src/pages/portal/registreren/index.vue
Normal file
46
apps/app/src/pages/portal/registreren/index.vue
Normal 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>
|
||||
237
apps/app/src/pages/portal/shifts/index.vue
Normal file
237
apps/app/src/pages/portal/shifts/index.vue
Normal 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>
|
||||
195
apps/app/src/pages/portal/wachtwoord-instellen.vue
Normal file
195
apps/app/src/pages/portal/wachtwoord-instellen.vue
Normal 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>
|
||||
571
apps/app/src/pages/register/[public_token].vue
Normal file
571
apps/app/src/pages/register/[public_token].vue
Normal 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>
|
||||
107
apps/app/src/pages/register/success.vue
Normal file
107
apps/app/src/pages/register/success.vue
Normal 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>
|
||||
Reference in New Issue
Block a user