Payment methode now selectable
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { useSignupForm } from './composables/useSignupForm.js'
|
||||
import {useSignupForm} from './composables/useSignupForm.js'
|
||||
import StepAge from './steps/StepAge.vue'
|
||||
import StepContactPerson from './steps/StepContactPerson.vue'
|
||||
import StepPersonalData from './steps/StepPersonalData.vue'
|
||||
@@ -8,6 +8,7 @@ import StepArrival from './steps/StepArrival.vue'
|
||||
import StepAddons from './steps/StepAddons.vue'
|
||||
import StepPhotoPermissions from './steps/StepPhotoPermissions.vue'
|
||||
import StepAllergies from './steps/StepAllergies.vue'
|
||||
import StepPaymentMethod from './steps/StepPaymentMethod.vue'
|
||||
import StepSummary from './steps/StepSummary.vue'
|
||||
import SubmitSuccess from './after-submit/SubmitSuccess.vue'
|
||||
import SubmitAlreadyExists from './after-submit/SubmitAlreadyExists.vue'
|
||||
@@ -16,15 +17,14 @@ const props = defineProps({
|
||||
event: Object,
|
||||
participantData: Object,
|
||||
localGroups: Array,
|
||||
paymentMethod: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['registrationDone'])
|
||||
|
||||
const {
|
||||
currentStep, goToStep, formData, selectedAddons,
|
||||
submit, submitting, submitResult, summaryLoading, summaryAmount
|
||||
} = useSignupForm(props.event, props.participantData, props.paymentMethod)
|
||||
submit, submitting, submitResult, summaryLoading, summaryAmount, summaryAmountValue
|
||||
} = useSignupForm(props.event, props.participantData)
|
||||
|
||||
const steps = [
|
||||
{ step: 1, label: 'Alter' },
|
||||
@@ -35,7 +35,8 @@ const steps = [
|
||||
{ step: 6, label: 'Zusatzoptionen' },
|
||||
{ step: 7, label: 'Fotoerlaubnis' },
|
||||
{ step: 8, label: 'Allergien' },
|
||||
{ step: 9, label: 'Zusammenfassung' },
|
||||
{step: 9, label: 'Zahlungsart'},
|
||||
{step: 10, label: 'Zusammenfassung'},
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -94,11 +95,14 @@ const steps = [
|
||||
<StepAddons v-if="currentStep === 6" :formData="formData" :event="event" :selectedAddons="selectedAddons" @next="goToStep" @back="goToStep" />
|
||||
<StepPhotoPermissions v-if="currentStep === 7" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
|
||||
<StepAllergies v-if="currentStep === 8" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
|
||||
<StepPaymentMethod v-if="currentStep === 9" :formData="formData" :event="event" @next="goToStep"
|
||||
@back="goToStep"/>
|
||||
<StepSummary
|
||||
v-if="currentStep === 9"
|
||||
v-if="currentStep === 10"
|
||||
:formData="formData"
|
||||
:event="event"
|
||||
:summaryAmount="summaryAmount"
|
||||
:summaryAmountValue="summaryAmountValue"
|
||||
:summaryLoading="summaryLoading"
|
||||
:submitting="submitting"
|
||||
@back="goToStep"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import TextEditor from "../../../../../../Views/Components/TextEditor.vue";
|
||||
|
||||
// Generische, schema-getriebene Eingabefelder eines Zahlungsmoduls. Wiederverwendbar für künftige
|
||||
// Verfahren (SEPA, PayPal): das jeweilige Modul liefert das Schema, hier wird nur gerendert.
|
||||
const props = defineProps({
|
||||
// [{ name, label, type, required }] -- z.B. aus participantOptionsSchema einer Zahlungsmethode.
|
||||
schema: {type: Array, default: () => []},
|
||||
// v-model: Objekt { optionName: value }
|
||||
modelValue: {type: Object, default: () => ({})},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
function setValue(name, value) {
|
||||
emit('update:modelValue', {...props.modelValue, [name]: value})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="form-table">
|
||||
<tr v-for="option in schema" :key="option.name">
|
||||
<td>
|
||||
{{ option.label }}<span v-if="option.required" class="required-marker">*</span>:
|
||||
</td>
|
||||
<td>
|
||||
<TextEditor
|
||||
v-if="option.type === 'richtext'"
|
||||
:modelValue="modelValue[option.name] ?? ''"
|
||||
@update:modelValue="value => setValue(option.name, value)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
type="text"
|
||||
:value="modelValue[option.name] ?? ''"
|
||||
@input="event => setValue(option.name, event.target.value)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.required-marker {
|
||||
color: #ef4444;
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import {reactive, ref} from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export function useSignupForm(event, participantData, paymentMethod) {
|
||||
export function useSignupForm(event, participantData) {
|
||||
const currentStep = ref(1)
|
||||
const submitting = ref(false)
|
||||
const summaryLoading = ref(false)
|
||||
@@ -9,9 +9,13 @@ export function useSignupForm(event, participantData, paymentMethod) {
|
||||
|
||||
const selectedAddons = reactive({})
|
||||
|
||||
// Für die Aktion freigeschaltete Zahlungsmethoden; ist genau eine aktiv, wird sie vorausgewählt.
|
||||
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
|
||||
|
||||
const formData = reactive({
|
||||
eatingHabit: 'EATING_HABIT_VEGAN',
|
||||
paymentMethod: paymentMethod ?? null,
|
||||
paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null,
|
||||
paymentOptions: {},
|
||||
userId: participantData.id,
|
||||
eventId: event.id,
|
||||
vorname: participantData.firstname ?? '',
|
||||
@@ -52,33 +56,52 @@ export function useSignupForm(event, participantData, paymentMethod) {
|
||||
})
|
||||
|
||||
const summaryAmount = ref('')
|
||||
const summaryAmountValue = ref(0)
|
||||
|
||||
const fetchAmount = async () => {
|
||||
summaryLoading.value = true
|
||||
try {
|
||||
const res = await axios.post('/api/v1/event/' + event.id + '/calculate-amount', {
|
||||
arrival: formData.arrival,
|
||||
departure: formData.departure,
|
||||
event_id: event.id,
|
||||
participation_group: formData.participant_group,
|
||||
selected_amount: formData.beitrag,
|
||||
addons: selectedAddons,
|
||||
participationType: formData.participationType,
|
||||
beitrag: formData.beitrag,
|
||||
sibling: formData.sibling,
|
||||
})
|
||||
summaryAmount.value = res.data.amount
|
||||
summaryAmountValue.value = Number(res.data.amountValue ?? 0)
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Steps: ... 8 Allergien, 9 Zahlungsart, 10 Zusammenfassung.
|
||||
const STEP_PAYMENT = 9
|
||||
const STEP_SUMMARY = 10
|
||||
|
||||
const goToStep = async (step) => {
|
||||
if (step === 9) {
|
||||
summaryLoading.value = true
|
||||
summaryAmount.value = ''
|
||||
try {
|
||||
const res = await axios.post('/api/v1/event/' + event.id + '/calculate-amount', {
|
||||
arrival: formData.arrival,
|
||||
departure: formData.departure,
|
||||
event_id: event.id,
|
||||
participation_group: formData.participant_group,
|
||||
selected_amount: formData.beitrag,
|
||||
addons: selectedAddons,
|
||||
participationType: formData.participationType,
|
||||
beitrag: formData.beitrag,
|
||||
sibling: formData.sibling,
|
||||
})
|
||||
summaryAmount.value = res.data.amount
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
if (step === STEP_PAYMENT) {
|
||||
// Betrag ermitteln: Bei 0 € wird die Zahlungsart-Auswahl übersprungen.
|
||||
await fetchAmount()
|
||||
if (summaryAmountValue.value <= 0) {
|
||||
formData.paymentMethod = null
|
||||
formData.paymentOptions = {}
|
||||
currentStep.value = STEP_SUMMARY
|
||||
return
|
||||
}
|
||||
} else if (step === STEP_SUMMARY) {
|
||||
await fetchAmount()
|
||||
}
|
||||
currentStep.value = step
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted || !formData.payment) {
|
||||
// Zahlungs-Bestätigung wird im Summary-Schritt kontextabhängig erzwungen (nur bei Überweisung).
|
||||
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted) {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
@@ -96,5 +119,16 @@ export function useSignupForm(event, participantData, paymentMethod) {
|
||||
}
|
||||
}
|
||||
|
||||
return { currentStep, goToStep, formData, selectedAddons, submit, submitting, submitResult, summaryLoading, summaryAmount }
|
||||
return {
|
||||
currentStep,
|
||||
goToStep,
|
||||
formData,
|
||||
selectedAddons,
|
||||
submit,
|
||||
submitting,
|
||||
submitResult,
|
||||
summaryLoading,
|
||||
summaryAmount,
|
||||
summaryAmountValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import PaymentMethodInputs from "../components/PaymentMethodInputs.vue";
|
||||
import Icon from "../../../../../../Views/Components/Icon.vue";
|
||||
|
||||
const props = defineProps({formData: Object, event: Object})
|
||||
const emit = defineEmits(['next', 'back'])
|
||||
|
||||
// Für die Aktion freigeschaltete Zahlungsmethoden.
|
||||
const methods = computed(() => (props.event.paymentMethods ?? []).filter(m => m.active))
|
||||
|
||||
const selectedMethod = computed(() =>
|
||||
methods.value.find(m => m.slug === props.formData.paymentMethod) ?? null
|
||||
)
|
||||
|
||||
const participantSchema = computed(() => selectedMethod.value?.participantOptionsSchema ?? [])
|
||||
|
||||
// Font-Awesome-Icon je Zahlungsart (Fallback: generische Karte).
|
||||
function iconFor(slug) {
|
||||
if (slug === 'PAYMENT_ACCOUNT_TRANSACTION') return 'building-columns'
|
||||
if (slug === 'PAYMENT_NOT_DEFINED') return 'money-bill-wave'
|
||||
return 'credit-card'
|
||||
}
|
||||
|
||||
function selectMethod(slug) {
|
||||
props.formData.paymentMethod = slug
|
||||
// Eingaben zurücksetzen -- pro Methode eigene Felder.
|
||||
props.formData.paymentOptions = {}
|
||||
}
|
||||
|
||||
// "Weiter" ist erst möglich, wenn eine Methode gewählt und alle Pflicht-Eingaben befüllt sind.
|
||||
const canProceed = computed(() => {
|
||||
if (!props.formData.paymentMethod) return false
|
||||
return participantSchema.value
|
||||
.filter(o => o.required)
|
||||
.every(o => {
|
||||
const value = props.formData.paymentOptions[o.name]
|
||||
return value !== undefined && value !== null && String(value).trim() !== ''
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 style="margin: 0 0 6px 0; color: #111827;">Zahlungsweise</h3>
|
||||
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle, wie du deinen Teilnahmebeitrag
|
||||
begleichen möchtest.</p>
|
||||
|
||||
<div class="pay-card-row">
|
||||
<div
|
||||
v-for="method in methods"
|
||||
:key="method.slug"
|
||||
class="pay-card"
|
||||
:class="{ 'pay-card--active': formData.paymentMethod === method.slug }"
|
||||
role="radio"
|
||||
:aria-checked="formData.paymentMethod === method.slug"
|
||||
tabindex="0"
|
||||
@click="selectMethod(method.slug)"
|
||||
@keydown.enter.prevent="selectMethod(method.slug)"
|
||||
@keydown.space.prevent="selectMethod(method.slug)"
|
||||
>
|
||||
<div class="pay-card__badge">
|
||||
<Icon :name="iconFor(method.slug)"/>
|
||||
</div>
|
||||
<div class="pay-card__body">
|
||||
<h4 class="pay-card__title">{{ method.name }}</h4>
|
||||
<p v-if="method.description" class="pay-card__desc">{{ method.description }}</p>
|
||||
</div>
|
||||
<div class="pay-card__check" aria-hidden="true">✓</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="participantSchema.length > 0" class="pay-inputs">
|
||||
<PaymentMethodInputs :schema="participantSchema" v-model="formData.paymentOptions"/>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 8)">← Zurück</button>
|
||||
<button type="button" class="btn-primary" :disabled="!canProceed" @click="emit('next', 10)">Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pay-card-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pay-card {
|
||||
position: relative;
|
||||
flex: 1 1 280px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 28px 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pay-card:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.pay-card--active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.pay-card__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 1.6rem;
|
||||
color: #2563eb;
|
||||
background: #e0f2fe;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pay-card--active .pay-card__badge {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.pay-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pay-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.pay-card__desc {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.pay-card__check {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 0.12s, transform 0.12s;
|
||||
}
|
||||
|
||||
.pay-card--active .pay-card__check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.pay-inputs {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ─── Smartphone ─── */
|
||||
@media (max-width: 639px) {
|
||||
.pay-card-row {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pay-card {
|
||||
flex: 1 1 100%;
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.pay-card__badge {
|
||||
flex: 0 0 52px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.pay-card__body {
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pay-card__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,48 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {format, parseISO} from "date-fns";
|
||||
|
||||
const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION'
|
||||
|
||||
const props = defineProps({
|
||||
formData: Object,
|
||||
event: Object,
|
||||
summaryAmount: String,
|
||||
summaryAmountValue: {type: Number, default: 0},
|
||||
summaryLoading: Boolean,
|
||||
submitting: Boolean,
|
||||
})
|
||||
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 (AC #6).
|
||||
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 ?? '')
|
||||
})
|
||||
|
||||
// Zurück führt zur Zahlungsart (9), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (8).
|
||||
const backTarget = computed(() => props.summaryAmountValue > 0 ? 9 : 8)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
props.formData.summary_information_correct
|
||||
&& props.formData.summary_accept_terms
|
||||
&& props.formData.legal_accepted
|
||||
&& (!requiresPaymentConfirmation.value || props.formData.payment)
|
||||
&& !props.submitting
|
||||
)
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return ''
|
||||
return format(parseISO(dateString), 'dd.MM.yyyy')
|
||||
@@ -129,18 +162,18 @@ function eatingHabit() {
|
||||
<input type="checkbox" v-model="formData.legal_accepted" />
|
||||
Ich stimme der Datenschutzerklärung zu.
|
||||
</label>
|
||||
<label style="display: flex; gap: 10px; cursor: pointer;">
|
||||
<label v-if="requiresPaymentConfirmation" style="display: flex; gap: 10px; cursor: pointer;">
|
||||
<input type="checkbox" v-model="formData.payment" />
|
||||
Ich bestätige, den Betrag von <strong>{{ summaryAmount }}</strong> zu überweisen.
|
||||
{{ paymentConfirmationText }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 8)">← Zurück</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('back', backTarget)">← Zurück</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted || !formData.payment || submitting"
|
||||
:disabled="!canSubmit"
|
||||
style="background: #059669;"
|
||||
>
|
||||
{{ submitting ? 'Wird gesendet…' : 'Jetzt anmelden ✓' }}
|
||||
|
||||
Reference in New Issue
Block a user