Kurzanmeldungen

This commit is contained in:
2026-09-10 11:11:20 +02:00
parent 6301c342e4
commit 70a57d10f5
31 changed files with 1753 additions and 184 deletions
@@ -0,0 +1,138 @@
<script setup>
import {computed} from 'vue'
import {useShortSignupForm} from './composables/useShortSignupForm.js'
import {
SHORT_STEP_PERSON,
SHORT_STEP_PARTICIPATION_OPTIONS,
SHORT_STEP_PHOTO,
SHORT_STEP_PAYMENT,
SHORT_STEP_SUMMARY,
stepVisible,
} from './composables/shortStepFlow.js'
import ShortStepPerson from './steps/ShortStepPerson.vue'
import ShortStepSummary from './steps/ShortStepSummary.vue'
import StepParticipationOptions from '../SignUpForm/steps/StepParticipationOptions.vue'
import StepPhotoPermissions from '../SignUpForm/steps/StepPhotoPermissions.vue'
import StepPaymentMethod from '../SignUpForm/steps/StepPaymentMethod.vue'
import SubmitSuccess from '../SignUpForm/after-submit/SubmitSuccess.vue'
import SubmitAlreadyExists from '../SignUpForm/after-submit/SubmitAlreadyExists.vue'
const props = defineProps({
event: Object,
participantData: Object,
})
const {
currentStep, goToStep, goNext, goBack, formData, isMinor, localGroups, needsLocalGroup,
submit, submitting, submitResult, submitError,
summaryLoading, summaryAmount, summaryAmountValue,
} = useShortSignupForm(props.event, props.participantData)
// Die aus dem langen Prozess übernommenen Schritte emittieren ihre Zielnummer aus jenem Flow mit. Hier zählt
// nur, dass "weiter" bzw. "zurück" gedrückt wurde -- die Reihenfolge kennt dieser Host.
const onNext = () => goNext()
const onBack = () => goBack()
const allSteps = [
{step: SHORT_STEP_PERSON, label: 'Person'},
{step: SHORT_STEP_PARTICIPATION_OPTIONS, label: 'Teilnahmeoptionen'},
{step: SHORT_STEP_PHOTO, label: 'Fotoerlaubnis'},
{step: SHORT_STEP_PAYMENT, label: 'Zahlungsart'},
{step: SHORT_STEP_SUMMARY, label: 'Zusammenfassung'},
]
// Nur die tatsächlich vorkommenden Schritte anzeigen, sonst zeigt die Leiste Schritte, die nie erscheinen.
const steps = computed(() =>
allSteps.filter(s => stepVisible(props.event, s.step, summaryAmountValue.value))
)
const currentIndex = computed(() => steps.value.findIndex(s => s.step === currentStep.value))
</script>
<template>
<div>
<!-- Nach Submit -->
<SubmitSuccess
v-if="submitResult?.status === 'success'"
:participant="submitResult?.participant"
:event="event"
/>
<SubmitAlreadyExists v-else-if="submitResult?.status === 'exists'" :event="event" />
<template v-else>
<!-- Fortschrittsleiste -->
<div class="signup-progress">
<div class="signup-progress-pills">
<template v-for="(s, index) in steps" :key="s.step">
<div v-if="index > 0" class="signup-progress-separator"></div>
<div
class="signup-pill"
:class="{
'signup-pill--active': currentStep === s.step,
'signup-pill--done': currentStep > s.step,
'signup-pill--upcoming': currentStep < s.step,
}"
@click="currentStep > s.step ? goToStep(s.step) : null"
>
<span v-if="currentStep > s.step" class="signup-pill__check"></span>
{{ s.label }}
</div>
</template>
</div>
<div class="signup-progress-bar">
<div
class="signup-progress-bar__fill"
:style="{ width: (steps.length > 1 ? (currentIndex / (steps.length - 1)) * 100 : 100) + '%' }"
></div>
</div>
<div class="signup-progress-mobile">
Schritt {{ currentIndex + 1 }} von {{ steps.length }}:
<strong>{{ steps.find(s => s.step === currentStep)?.label }}</strong>
</div>
</div>
<!-- Steps -->
<form @submit.prevent="submit">
<ShortStepPerson
v-if="currentStep === SHORT_STEP_PERSON"
:formData="formData" :event="event" :isMinor="isMinor"
:localGroups="localGroups" :needsLocalGroup="needsLocalGroup"
@next="onNext"
/>
<StepParticipationOptions
v-if="currentStep === SHORT_STEP_PARTICIPATION_OPTIONS"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<StepPhotoPermissions
v-if="currentStep === SHORT_STEP_PHOTO"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<StepPaymentMethod
v-if="currentStep === SHORT_STEP_PAYMENT"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<ShortStepSummary
v-if="currentStep === SHORT_STEP_SUMMARY"
:formData="formData"
:event="event"
:isMinor="isMinor"
:summaryAmount="summaryAmount"
:summaryAmountValue="summaryAmountValue"
:summaryLoading="summaryLoading"
:submitting="submitting"
:submitError="submitError"
@back="onBack"
@submit="submit"
/>
</form>
</template>
</div>
</template>
<!-- Unscoped und mit dem langen Anmeldeprozess geteilt. -->
<style src="../SignUpForm/signupForm.css"></style>
@@ -0,0 +1,37 @@
// Schrittreihenfolge der Kurzanmeldung. Zwei Schritte sind optional: die Teilnahmeoptionen entfallen, wenn für
// die Veranstaltung keine definiert sind, die Zahlungsart entfällt bei Betrag 0 oder wenn es ohnehin nur eine
// Zahlungsart gibt (die wird dann vorausgewählt).
export const SHORT_STEP_PERSON = 1
export const SHORT_STEP_PARTICIPATION_OPTIONS = 2
export const SHORT_STEP_PHOTO = 3
export const SHORT_STEP_PAYMENT = 4
export const SHORT_STEP_SUMMARY = 5
export function stepVisible(event, step, amountValue) {
if (step === SHORT_STEP_PARTICIPATION_OPTIONS) {
return (event.participationOptions?.length ?? 0) > 0
}
if (step === SHORT_STEP_PAYMENT) {
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
return amountValue > 0 && activeMethods.length > 1
}
return true
}
export function nextVisibleFrom(event, step, amountValue) {
let candidate = step + 1
while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, amountValue)) {
candidate++
}
return candidate
}
export function prevVisibleFrom(event, step, amountValue) {
let candidate = step - 1
while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, amountValue)) {
candidate--
}
return candidate
}
@@ -0,0 +1,155 @@
import {computed, reactive, ref} from 'vue'
import axios from 'axios'
import {differenceInYears, parseISO} from 'date-fns'
import {SHORT_STEP_PERSON, nextVisibleFrom, prevVisibleFrom} from './shortStepFlow.js'
export function useShortSignupForm(event, participantData) {
const currentStep = ref(SHORT_STEP_PERSON)
const submitting = ref(false)
const summaryLoading = ref(false)
const submitResult = ref(null) // null | { status: 'success'|'exists', participant: {} }
const submitError = ref('')
// Gibt es nur eine Zahlungsart, wird sie vorausgewählt und der Zahlungsschritt entfällt.
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
// Bei genau einem teilnehmenden Stamm wird dieser gesetzt, ohne danach zu fragen; erst ab zwei
// Stämmen erscheint das Auswahlfeld.
const localGroups = event.contributingLocalGroups ?? []
const needsLocalGroup = localGroups.length > 1
const formData = reactive({
userId: participantData.id,
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
geburtsdatum: participantData.birthday ?? '',
email_1: participantData.email ?? '',
telefon_1: participantData.phone ?? '',
localGroup: localGroups.length === 1 ? localGroups[0].id : (participantData.localGroup ?? '-1'),
ansprechpartner: '',
email_2: '',
allergien: participantData.allergies ?? '',
intolerances: participantData.intolerances ?? '',
first_aid: '-1',
badeerlaubnis: '-1',
// Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value }
participationOptions: {},
foto: {socialmedia: false, print: false, webseite: false, partner: false, intern: false},
paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null,
paymentOptions: {},
summary_information_correct: false,
summary_accept_terms: false,
legal_accepted: false,
payment: false,
})
// Minderjährigkeit steuert Kontaktperson, Erweiterte Erste Hilfe und Badeerlaubnis. Die Grenze von 18 Jahren
// entspricht Age::isfullAged() im Backend.
const isMinor = computed(() => {
if (!formData.geburtsdatum) {
return false
}
return differenceInYears(new Date(), parseISO(formData.geburtsdatum)) < 18
})
const summaryAmount = ref('')
const summaryAmountValue = ref(0)
const amountLoaded = ref(false)
// Der Betrag steht für die ganze Anmeldung fest (feste Teilnahmegruppe, fester Zeitraum) und wird deshalb
// genau einmal geholt. Er entscheidet, ob der Zahlungsschritt überhaupt vorkommt.
const ensureAmount = async () => {
if (amountLoaded.value) {
return
}
summaryLoading.value = true
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-calculate-amount')
summaryAmount.value = res.data.amount
summaryAmountValue.value = Number(res.data.amountValue ?? 0)
amountLoaded.value = true
if (summaryAmountValue.value <= 0) {
formData.paymentMethod = null
formData.paymentOptions = {}
}
} finally {
summaryLoading.value = false
}
}
const goToStep = (step) => {
currentStep.value = step
}
const goNext = async () => {
await ensureAmount()
goToStep(nextVisibleFrom(event, currentStep.value, summaryAmountValue.value))
}
const goBack = async () => {
await ensureAmount()
goToStep(prevVisibleFrom(event, currentStep.value, summaryAmountValue.value))
}
const submit = async () => {
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted) {
return
}
submitting.value = true
submitError.value = ''
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-signup', {
firstname: formData.vorname,
lastname: formData.nachname,
nickname: formData.pfadiname || null,
birthday: formData.geburtsdatum,
email: formData.email_1,
phone: formData.telefon_1,
localGroup: formData.localGroup !== '-1' ? formData.localGroup : null,
contactPerson: isMinor.value ? formData.ansprechpartner : null,
contactEmail: isMinor.value ? formData.email_2 : null,
allergies: formData.allergien || null,
intolerances: formData.intolerances || null,
firstAidPermission: isMinor.value && formData.first_aid !== '-1' ? formData.first_aid : null,
swimmingPermission: isMinor.value && formData.badeerlaubnis !== '-1' ? formData.badeerlaubnis : null,
foto: formData.foto,
participationOptions: formData.participationOptions,
paymentMethod: formData.paymentMethod,
paymentOptions: formData.paymentOptions,
})
submitResult.value = {
status: res.data.status,
participant: res.data.participant,
}
} catch (error) {
submitError.value = error.response?.data?.message
?? 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuche es später erneut.'
} finally {
submitting.value = false
}
}
return {
currentStep,
goToStep,
goNext,
goBack,
formData,
isMinor,
localGroups,
needsLocalGroup,
submit,
submitting,
submitResult,
submitError,
summaryLoading,
summaryAmount,
summaryAmountValue,
ensureAmount,
}
}
@@ -0,0 +1,218 @@
<script setup>
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
import {reactive} from "vue";
const props = defineProps({
formData: Object,
event: Object,
isMinor: Boolean,
localGroups: {type: Array, default: () => []},
needsLocalGroup: Boolean,
})
const emit = defineEmits(['next'])
const errors = reactive({
vorname: '',
nachname: '',
geburtsdatum: '',
localGroup: '',
email_1: '',
telefon_1: '',
ansprechpartner: '',
email_2: '',
first_aid: '',
badeerlaubnis: '',
})
// Nur sichtbare Felder werden geprüft -- Kontaktperson und Erlaubnisse gibt es bei Volljährigen nicht.
const next = () => {
Object.keys(errors).forEach(key => errors[key] = '')
let hasError = false
if (!props.formData.vorname) {
errors.vorname = 'Bitte einen Vornamen angeben.'
hasError = true
}
if (!props.formData.nachname) {
errors.nachname = 'Bitte einen Nachnamen angeben.'
hasError = true
}
if (!props.formData.geburtsdatum) {
errors.geburtsdatum = 'Bitte ein Geburtsdatum angeben.'
hasError = true
}
if (props.needsLocalGroup && (!props.formData.localGroup || props.formData.localGroup === '-1')) {
errors.localGroup = 'Bitte einen Stamm auswählen.'
hasError = true
}
if (!props.formData.email_1) {
errors.email_1 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (!props.formData.telefon_1) {
errors.telefon_1 = 'Bitte eine Telefonnummer angeben.'
hasError = true
}
if (props.isMinor) {
if (!props.formData.ansprechpartner) {
errors.ansprechpartner = 'Bitte eine Kontaktperson angeben.'
hasError = true
}
if (!props.formData.email_2) {
errors.email_2 = 'Bitte eine E-Mail-Adresse der Kontaktperson angeben.'
hasError = true
}
if (props.formData.first_aid === '-1') {
errors.first_aid = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung.'
hasError = true
}
if (props.event.swimmingPermissionRequired && props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung.'
hasError = true
}
}
if (hasError) {
return
}
emit('next')
}
</script>
<template>
<div>
<h3>Angaben zur Person</h3>
<table class="form-table">
<tr>
<td>Vorname:</td>
<td>
<input type="text" v-model="formData.vorname" />
<ErrorText :message="errors.vorname" />
</td>
</tr>
<tr>
<td>Pfadiname:</td>
<td>
<input type="text" v-model="formData.pfadiname" />
</td>
</tr>
<tr>
<td>Nachname:</td>
<td>
<input type="text" v-model="formData.nachname" />
<ErrorText :message="errors.nachname" />
</td>
</tr>
<tr>
<td>Geburtsdatum:</td>
<td>
<input type="date" v-model="formData.geburtsdatum" />
<ErrorText :message="errors.geburtsdatum" />
</td>
</tr>
<tr v-if="needsLocalGroup">
<td>Stamm:</td>
<td>
<select v-model="formData.localGroup">
<option value="-1">Bitte wählen</option>
<option v-for="group in localGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
<ErrorText :message="errors.localGroup" />
</td>
</tr>
<tr>
<td>E-Mail:</td>
<td>
<input type="email" v-model="formData.email_1" />
<ErrorText :message="errors.email_1" />
</td>
</tr>
<tr>
<td>Telefon:</td>
<td>
<input type="text" v-model="formData.telefon_1" />
<ErrorText :message="errors.telefon_1" />
</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Kontaktperson (Nachname, Vorname):</td>
<td>
<input type="text" v-model="formData.ansprechpartner" />
<ErrorText :message="errors.ansprechpartner" />
</td>
</tr>
<tr>
<td>E-Mail der Kontaktperson:</td>
<td>
<input type="email" v-model="formData.email_2" />
<ErrorText :message="errors.email_2" />
</td>
</tr>
</template>
<tr>
<td>Allergien:</td>
<td>
<input type="text" v-model="formData.allergien" />
</td>
</tr>
<tr>
<td>Unverträglichkeiten:</td>
<td>
<input type="text" v-model="formData.intolerances" />
</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Erweiterte Erste Hilfe erlaubt:</td>
<td>
<select v-model="formData.first_aid">
<option value="-1">Bitte wählen</option>
<option
v-for="firstAidPermission in event.firstAidPermissions"
:key="firstAidPermission.slug"
:value="firstAidPermission.slug">{{ firstAidPermission.name }}</option>
</select><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Nicht dringend-notwendige Erste-Hilfe-Maßnahmen, beinhaltet das Entfernen von Zecken und Splittern sowie das Kleben von Pflastern.
</span>
<ErrorText :message="errors.first_aid" />
</td>
</tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>
<select v-model="formData.badeerlaubnis">
<option value="-1">Bitte wählen</option>
<option
v-for="swimmingPermission in event.swimmingPermissions"
:key="swimmingPermission.slug"
:value="swimmingPermission.slug">{{ swimmingPermission.name }}</option>
</select>
<ErrorText :message="errors.badeerlaubnis" />
</td>
</tr>
</template>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup>
import {computed} from "vue";
const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION'
const props = defineProps({
formData: Object,
event: Object,
isMinor: Boolean,
summaryAmount: String,
summaryAmountValue: {type: Number, default: 0},
summaryLoading: Boolean,
submitting: Boolean,
submitError: {type: String, default: ''},
})
const emit = defineEmits(['back', 'submit'])
const selectedMethod = computed(() =>
(props.event.paymentMethods ?? []).find(m => m.slug === props.formData.paymentMethod) ?? null
)
// Die Überweisungs-Bestätigung wird nur bei Banküberweisung verlangt.
const requiresPaymentConfirmation = computed(() =>
props.formData.paymentMethod === PAYMENT_ACCOUNT_TRANSACTION
)
// Bestätigungstext ist ein admin-konfigurierbarer Freitext ({amount}-Platzhalter), sonst Default.
const paymentConfirmationText = computed(() => {
const configured = selectedMethod.value?.configuration?.summary_confirmation_text
const template = (configured && String(configured).trim() !== '')
? configured
: 'Ich bestätige, den Betrag von {amount} zu überweisen.'
return template.replaceAll('{amount}', props.summaryAmount ?? '')
})
// Gewählte Teilnahmeoptionen für die Zusammenfassung (Label statt Wert anzeigen).
const selectedParticipationOptions = computed(() =>
(props.event.participationOptions ?? []).map(question => {
const value = props.formData.participationOptions[question.key]
const option = (question.options ?? []).find(o => o.value === value)
return {label: question.title || question.label, value: option?.label ?? ''}
}).filter(entry => entry.value !== '')
)
// Der Stamm steht entweder fest (genau ein teilnehmender Stamm) oder wurde auf Seite 1 gewählt.
const localGroupName = computed(() =>
(props.event.contributingLocalGroups ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
)
const permissionName = (list, slug) =>
(list ?? []).find(entry => entry.slug === slug)?.name ?? '—'
const canSubmit = computed(() =>
props.formData.summary_information_correct
&& props.formData.summary_accept_terms
&& props.formData.legal_accepted
&& (!requiresPaymentConfirmation.value || props.formData.payment)
&& !props.submitting
)
</script>
<template>
<div>
<h3>Zusammenfassung</h3>
<div v-if="summaryLoading" style="color: #6b7280; padding: 20px 0;">Wird geladen</div>
<div v-else>
<table class="form-table" style="margin-bottom: 20px;">
<tr>
<td>Dein Name:</td>
<td>{{ formData.vorname }} {{ formData.nachname }}</td>
</tr>
<tr v-if="formData.pfadiname">
<td>Pfadiname:</td>
<td>{{ formData.pfadiname }}</td>
</tr>
<tr>
<td>Deine E-Mail:</td>
<td>{{ formData.email_1 }}</td>
</tr>
<tr>
<td>Deine Telefonnummer:</td>
<td>{{ formData.telefon_1 }}</td>
</tr>
<tr v-if="localGroupName">
<td>Stamm:</td>
<td>{{ localGroupName }}</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Kontaktperson:</td>
<td>{{ formData.ansprechpartner }}</td>
</tr>
<tr>
<td>E-Mail der Kontaktperson:</td>
<td>{{ formData.email_2 }}</td>
</tr>
<tr>
<td>Erweiterte Erste Hilfe:</td>
<td>{{ permissionName(event.firstAidPermissions, formData.first_aid) }}</td>
</tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>{{ permissionName(event.swimmingPermissions, formData.badeerlaubnis) }}</td>
</tr>
</template>
<tr>
<td>Allergien:</td>
<td>{{ formData.allergien || 'Keine Angabe' }}</td>
</tr>
<tr>
<td>Unverträglichkeiten:</td>
<td>{{ formData.intolerances || 'Keine Angabe' }}</td>
</tr>
<tr v-for="option in selectedParticipationOptions" :key="option.label">
<td>{{ option.label }}:</td>
<td>{{ option.value }}</td>
</tr>
<tr>
<td>Foto-Erlaubnis:</td>
<td>
<strong>Social Media:</strong> {{ formData.foto.socialmedia ? 'Ja' : 'Nein' }},
<strong>Printmedien:</strong> {{ formData.foto.print ? 'Ja' : 'Nein' }},
<strong>Webseite:</strong> {{ formData.foto.webseite ? 'Ja' : 'Nein' }},
<strong>Partnerorganisationen:</strong> {{ formData.foto.partner ? 'Ja' : 'Nein' }},
<strong>Interne Zwecke:</strong> {{ formData.foto.intern ? 'Ja' : 'Nein' }}
</td>
</tr>
<tr><td>Veranstaltung:</td><td><strong>{{ event.name }}</strong></td></tr>
<tr><td>Zeitraum:</td><td>{{ event.eventBegin }} {{ event.eventEnd }}</td></tr>
</table>
<h4 style="margin: 0 0 8px 0;">Kostenübersicht</h4>
<table class="form-table cost-table" style="margin-bottom: 20px;">
<tr class="cost-total">
<td><strong>Teilnahmebeitrag:</strong></td>
<td><strong>{{ summaryAmount }}</strong></td>
</tr>
</table>
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.summary_information_correct" />
Ich bestätige, dass alle Angaben korrekt sind.
</label>
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.summary_accept_terms" />
Ich akzeptiere die Teilnahmebedingungen.
</label>
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.legal_accepted" />
Ich stimme der Datenschutzerklärung zu.
</label>
<label v-if="requiresPaymentConfirmation" style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.payment" />
{{ paymentConfirmationText }}
</label>
</div>
<p v-if="submitError" style="color: #991b1b; font-weight: 600;">{{ submitError }}</p>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back')"> Zurück</button>
<button
type="submit"
class="btn-primary"
:disabled="!canSubmit"
style="background: #059669;"
>
{{ submitting ? 'Wird gesendet…' : 'Jetzt anmelden ✓' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.cost-table .cost-total td {
border-top: 1px solid #d1d5db;
padding-top: 8px;
}
</style>