Basic signup for events

This commit is contained in:
2026-03-21 21:02:15 +01:00
parent 23af267896
commit b8341890d3
74 changed files with 4046 additions and 947 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Event\Actions\CertificateOfConductionCheck;
use App\Enumerations\EfzStatus;
use Illuminate\Support\Facades\Http;
class CertificateOfConductionCheckCommand {
function __construct(public CertificateOfConductionCheckRequest $request)
{}
public function execute() : CertificateOfConductionCheckResponse {
$response = new CertificateOfConductionCheckResponse();
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()->name);
$apiResponse = Http::acceptJson()
->asJson()
->withoutVerifying()
->post(env('COC_CHECK_URL'), [
'firstName' => trim($this->request->participant->firstname),
'lastName' => $this->request->participant->lastname,
'nickname' => $this->request->participant->nickname,
'address' => trim($this->request->participant->address_1 . ' ' . $this->request->participant->address_2),
'zip' => $this->request->participant->zip,
'city' => $this->request->participant->city,
'birthday' => $this->request->participant->birthday->format('Y-m-d'),
'email' => $this->request->participant->email_1,
'localGroup' => $localGroup,
'checkForDate' => $this->request->participant->departure_date->format('Y-m-d'),
]);
if (! $apiResponse->successful()) {
return $response;
}
$responseParsed = $apiResponse->json();
if (($responseParsed['status'] ?? null) !== '200/ok') {
return $response;
}
$response->status = match ($responseParsed['efzStatus']) {
'NOT_REQUIRED' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
'CHECKED_VALID' => EfzStatus::EFZ_STATUS_CHECKED_VALID,
'CHECKED_INVALID' => EfzStatus::EFZ_STATUS_CHECKED_INVALID,
default => EfzStatus::EFZ_STATUS_NOT_CHECKED
};
return $response;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Event\Actions\CertificateOfConductionCheck;
use App\Models\EventParticipant;
class CertificateOfConductionCheckRequest {
function __construct(public EventParticipant $participant)
{
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Event\Actions\CertificateOfConductionCheck;
use App\Enumerations\EfzStatus;
class CertificateOfConductionCheckResponse {
public string $status;
public function __construct()
{
$this->status = EfzStatus::EFZ_STATUS_NOT_CHECKED;
}
}

View File

@@ -10,7 +10,7 @@ class SetParticipationFeesCommand {
$this->request = $request;
}
public function excetute() : SetParticipationFeesResponse {
public function execute() : SetParticipationFeesResponse {
$response = new SetParticipationFeesResponse();
$this->cleanBefore();
@@ -19,7 +19,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeFirst['type'],
'name' => $this->request->participationFeeFirst['name'],
'description' => $this->request->participationFeeFirst['description'],
'amount' => $this->request->participationFeeFirst['amount']->getAmount()
'amount_standard' => $this->request->participationFeeFirst['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeFirst['amount_reduced'] ? $this->request->participationFeeFirst['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeFirst['amount_solidarity'] ? $this->request->participationFeeFirst['amount_solidarity']->getAmount() : null,
]))->save();
if ($this->request->participationFeeSecond !== null) {
@@ -28,7 +30,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeSecond['type'],
'name' => $this->request->participationFeeSecond['name'],
'description' => $this->request->participationFeeSecond['description'],
'amount' => $this->request->participationFeeSecond['amount']->getAmount()
'amount_standard' => $this->request->participationFeeSecond['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeSecond['amount_reduced'] ? $this->request->participationFeeSecond['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeSecond['amount_solidarity'] ? $this->request->participationFeeSecond['amount_solidarity']->getAmount() : null,
]))->save();
}
@@ -38,7 +42,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeThird['type'],
'name' => $this->request->participationFeeThird['name'],
'description' => $this->request->participationFeeThird['description'],
'amount' => $this->request->participationFeeThird['amount']->getAmount()
'amount_standard' => $this->request->participationFeeThird['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeThird['amount_reduced'] ? $this->request->participationFeeThird['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeThird['amount_solidarity'] ? $this->request->participationFeeThird['amount_solidarity']->getAmount() : null,
]))->save();
}
@@ -48,7 +54,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeFourth['type'],
'name' => $this->request->participationFeeFourth['name'],
'description' => $this->request->participationFeeFourth['description'],
'amount' => $this->request->participationFeeFourth['amount']->getAmount()
'amount_standard' => $this->request->participationFeeFourth['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeFourth['amount_reduced'] ? $this->request->participationFeeFourth['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeFourth['amount_solidarity'] ? $this->request->participationFeeFourth['amount_solidarity']->getAmount() : null,
]))->save();
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Event\Actions\SignUp;
use App\Enumerations\EfzStatus;
use App\ValueObjects\Age;
use Illuminate\Support\Str;
class SignUpCommand {
public function __construct(public SignUpRequest $request) {
}
public function execute() : SignUpResponse {
$response = new SignUpResponse();
$participantAge = new Age($this->request->birthday);
$response->participant = $this->request->event->participants()->create(
[
'tenant' => $this->request->event->tenant,
'user_id' => $this->request->user_id,
'identifier' => Str::random(10),
'firstname' => $this->request->firstname,
'lastname' => $this->request->lastname,
'nickname' => $this->request->nickname,
'participation_type' => $this->request->participationType,
'local_group' => $this->request->localGroup->slug,
'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2,
'postcode' => $this->request->postcode,
'city' => $this->request->city,
'email_1' => $this->request->email_1,
'email_2' => $this->request->email_2,
'phone_1' => $this->request->phone_1,
'phone_2' => $this->request->phone_2,
'contact_person' => $this->request->contact_person,
'allergies' => $this->request->allergies,
'intolerances' => $this->request->intolerances,
'medications' => $this->request->medications,
'tetanus_vaccination' => $this->request->tetanus_vaccination,
'eating_habit' => $this->request->eating_habit,
'swimming_permission' => $participantAge->isfullAged() ? 'SWIMMING_PERMISSION_ALLOWED' : $this->request->swimming_permission,
'first_aid_permission' => $participantAge->isfullAged() ? 'FIRST_AID_PERMISSION_ALLOWED' : $this->request->first_aid_permission,
'foto_socialmedia' => $this->request->foto_socialmedia,
'foto_print' => $this->request->foto_print,
'foto_webseite' => $this->request->foto_webseite,
'foto_partner' => $this->request->foto_partner,
'foto_intern' => $this->request->foto_intern,
'arrival_date' => $this->request->arrival,
'departure_date' => $this->request->departure,
'arrival_eating' => $this->request->arrival_eating,
'departure_eating' => $this->request->departure_eating,
'notes' => $this->request->notes,
'amount' => $this->request->amount,
'payment_purpose' => $this->request->event->name . ' - Beitrag ' . $this->request->firstname . ' ' . $this->request->lastname,
'efz_status' => $participantAge->isfullAged() ? EfzStatus::EFZ_STATUS_NOT_CHECKED : EfzStatus::EFZ_STATUS_NOT_REQUIRED,
]
);
$response->success = true;
return $response;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Event\Actions\SignUp;
use App\Enumerations\ParticipationType;
use App\Models\Event;
use App\Models\Tenant;
use App\ValueObjects\Amount;
use DateTime;
class SignUpRequest {
function __construct(
public Event $event,
public ?int $user_id,
public string $firstname,
public string $lastname,
public ?string $nickname,
public string $participationType,
public Tenant $localGroup,
public DateTime $birthday,
public string $address_1,
public string $address_2,
public string $postcode,
public string $city,
public string $email_1,
public ?string $phone_1,
public ?string $email_2,
public ?string $phone_2,
public ?string $contact_person,
public ?string $allergies,
public ?string $intolerances,
public ?string $medications,
public ?DateTime $tetanus_vaccination,
public string $eating_habit,
public ?string $swimming_permission,
public ?string $first_aid_permission,
public bool $foto_socialmedia,
public bool $foto_print,
public bool $foto_webseite,
public bool $foto_partner,
public bool $foto_intern,
public DateTime $arrival,
public DateTime $departure,
public int $arrival_eating,
public int $departure_eating,
public ?string $notes,
public Amount $amount
) {
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Event\Actions\SignUp;
use App\Models\EventParticipant;
class SignUpResponse {
public bool $success;
public ?EventParticipant $participant;
public function __construct() {
$this->success = false;
$this->participant = null;
}
}

View File

@@ -20,6 +20,8 @@ class UpdateEventCommand {
$this->request->event->alcoholics_age = $this->request->alcoholicsAge;
$this->request->event->support_per_person = $this->request->supportPerPerson;
$this->request->event->support_flat = $this->request->flatSupport;
$this->request->event->send_weekly_report = $this->request->sendWeeklyReports;
$this->request->event->registration_allowed = $this->request->registrationAllowed;
$this->request->event->save();
$this->request->event->resetAllowedEatingHabits();

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class AvailableEventsController extends CommonController
{
public function __invoke(Request $request) : Response {
$events = [];
foreach ($this->events->getAvailable(false) as $event) {
$events[] = $event->toResource()->toArray($request);
};
$inertiaProvider = new InertiaProvider('Event/ListAvailable', ['events' => $events]);
return $inertiaProvider->render();
}
}

View File

@@ -87,7 +87,7 @@ class CreateController extends CommonController {
if ($wasSuccessful) {
return response()->json([
'status' => 'success',
'event' => new EventResource($costUnitUpdateRequest->event)->toArray()
'event' => new EventResource($costUnitUpdateRequest->event)->toArray($request)
]);
} else {
return response()->json([

View File

@@ -23,9 +23,9 @@ class DetailsController extends CommonController {
return new InertiaProvider('Event/Details', ['event' => $event])->render();
}
public function summary(int $eventId) : JsonResponse {
public function summary(int $eventId, Request $request) : JsonResponse {
$event = $this->events->getById($eventId);
return response()->json(['event' => new EventResource($event)->toArray()]);
return response()->json(['event' => $event->toResource()->toArray($request)]);
}
public function updateCommonSettings(int $eventId, Request $request) : JsonResponse {
@@ -78,7 +78,9 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnehmer',
'description' => $request->input('pft_1_description'),
'amount' => Amount::fromString($request->input('pft_1_amount'))
'amount_standard' => Amount::fromString($request->input('pft_1_amount_standard')),
'amount_reduced' => null !== $request->input('pft_1_amount_reduced') ? Amount::fromString($request->input('pft_1_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_1_amount_solidarity') ? Amount::fromString($request->input('pft_1_amount_solidarity')) : null
];
$participationFeeRequest = new SetParticipationFeesRequest($event, $participationFeeFirst);
@@ -88,7 +90,9 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_TEAM,
'name' => $event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_FIXED ? 'Kernteam' : 'Solidaritätsbeitrag',
'description' => $request->input('pft_2_description'),
'amount' => Amount::fromString($request->input('pft_2_amount'))
'amount_standard' => Amount::fromString($request->input('pft_2_amount_standard')),
'amount_reduced' => null !== $request->input('pft_2_amount_reduced') ? Amount::fromString($request->input('pft_2_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_2_amount_solidarity') ? Amount::fromString($request->input('pft_2_amount_solidarity')) : null
];
}
@@ -97,7 +101,9 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_VOLUNTEER,
'name' => $event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_FIXED ? 'Unterstützende' : 'Reduzierter Beitrag',
'description' => $event->participation_fee_type !== ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY ? $request->input('pft_3_description') : 'Nach Verfügbarkeit',
'amount' => Amount::fromString($request->input('pft_3_amount'))
'amount_standard' => Amount::fromString($request->input('pft_3_amount_standard')),
'amount_reduced' => null !== $request->input('pft_3_amount_reduced') ? Amount::fromString($request->input('pft_3_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_3_amount_solidarity') ? Amount::fromString($request->input('pft_3_amount_solidarity')) : null
];
}
@@ -106,12 +112,14 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_OTHER,
'name' => 'Sonstige',
'description' => $request->input('pft_4_description'),
'amount' => Amount::fromString($request->input('pft_4_amount'))
'amount_standard' => Amount::fromString($request->input('pft_4_amount_standard')),
'amount_reduced' => null !== $request->input('pft_4_amount_reduced') ? Amount::fromString($request->input('pft_4_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_4_amount_solidarity') ? Amount::fromString($request->input('pft_4_amount_solidarity')) : null
];
}
$participationFeeCommand = new SetParticipationFeesCommand($participationFeeRequest);
$response = $participationFeeCommand->excetute();
$response = $participationFeeCommand->execute();
return response()->json(['status' => $response->success ? 'success' : 'error']);
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckCommand;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckRequest;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Providers\InertiaProvider;
use App\Resources\EventResource;
use App\Resources\UserResource;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use http\Env\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SignupController extends CommonController {
public function __invoke(int $eventId, Request $request) {
$availableEvents = [];
foreach ($this->events->getAvailable(false) as $event) {
$availableEvents[] = $event->toResource()->toArray($request);
};
$event = $this->events->getById($eventId, false)?->toResource()->toArray($request);
$participantData = [
'firstname' => '',
'lastname' => '',
];
if (auth()->check()) {
$user = new UserResource(auth()->user())->toArray($request);
$participantData = [
'id' => $user['id'],
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'nickname' => $user['nickname'],
'email' => $user['email'],
'phone' => $user['phone'],
'postcode' => $user['postcode'],
'city' => $user['city'],
'address_1' => $user['address_1'],
'address_2' => $user['address_2'],
'birthday' => $user['birthday'],
'localGroup' => $user['localGroup'],
'allergies' => $user['allergies'],
'intolerances' => $user['intolerances'],
'eating_habit' => $user['eating_habits'],
'medications' => $user['medications'],
'tetanusVaccination' => $user['tetanus_vaccination'],
];
}
$inertiaProvider = new InertiaProvider('Event/Signup', [
'event' => $event,
'availableEvents' => $availableEvents,
'localGroups' => $event['contributingLocalGroups'],
'participantData' => $participantData,
]);
return $inertiaProvider->render();
}
public function signUp(int $eventId, Request $request) {
$event = $this->events->getById($eventId, false);
$eventResource = $event->toResource();
$registrationData = $request->input('registration_data');
$arrival = \DateTime::createFromFormat('Y-m-d', $registrationData['arrival']);
$departure = \DateTime::createFromFormat('Y-m-d', $registrationData['departure']);
$tetanusVaccination = $registrationData['tetanusVaccination'] ? \DateTime::createFromFormat('Y-m-d', $registrationData['tetanusVaccination']) : null;
// Steps:
// 1. Check, if bereits angemeldet
//
$amount = $eventResource->calculateAmount(
$registrationData['participationType'],
$registrationData['beitrag'],
$arrival,
$departure
);
$signupRequest = new SignUpRequest(
$event,$registrationData['userId'],
$registrationData['vorname'],
$registrationData['nachname'],
$registrationData['pfadiname'],
$registrationData['participationType'],
Tenant::findOrFail($registrationData['localGroup']),
\DateTime::createFromFormat('Y-m-d', $registrationData['geburtsdatum']),
$registrationData['address1'],
$registrationData['address2'],
$registrationData['plz'],
$registrationData['ort'],
$registrationData['email_1'],
$registrationData['telefon_1'],
$registrationData['email_2'],
$registrationData['telefon_2'],
$registrationData['ansprechpartner'],
$registrationData['allergien'],
$registrationData['intolerances'],
$registrationData['medikamente'],
$tetanusVaccination,
$registrationData['essgewohnheit'],
$registrationData['badeerlaubnis'],
$registrationData['first_aid'],
$registrationData['foto']['socialmedia'],
$registrationData['foto']['print'],
$registrationData['foto']['webseite'],
$registrationData['foto']['partner'],
$registrationData['foto']['intern'],
$arrival,
$departure,
$registrationData['anreise_essen'],
$registrationData['abreise_essen'],
$registrationData['anmerkungen'],
$amount
);
$signupCommand = new SignUpCommand($signupRequest);
$signupResponse = $signupCommand->execute();
// 4. Addons registrieren
$certificateOfConductionCheckRequest = new CertificateOfConductionCheckRequest($signupResponse->participant);
$certificateOfConductionCheckCommand = new CertificateOfConductionCheckCommand($certificateOfConductionCheckRequest);
$certificateOfConductionCheckResponse = $certificateOfConductionCheckCommand->execute();
$signupResponse->participant->efz_status = $certificateOfConductionCheckResponse->status;
$signupResponse->participant->save();
// 6. E-Mail senden & Bestätigung senden
return response()->json(
[
'participant' => $signupResponse->participant->toResource()->toArray($request),
'status' => 'success',
]
);
dd($eventId, $registrationData, $amount);
}
public function calculateAmount(int $eventId, Request $request, bool $forDisplay = true) : JsonResponse | float {
$event = $this->events->getById($eventId, false)->toResource();
return response()->json(['amount' =>
$event->calculateAmount(
$request->input('participationType'),
$request->input('beitrag'),
\DateTime::createFromFormat('Y-m-d', $request->input('arrival')),
\DateTime::createFromFormat('Y-m-d', $request->input('departure'))
)->toString()
]);
}
}

View File

@@ -2,6 +2,7 @@
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
@@ -9,6 +10,9 @@ Route::prefix('api/v1')
->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('event')->group(function () {
Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']);
Route::post('{eventId}/signup', [SignupController::class, 'signUp']);
Route::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']);

View File

@@ -1,12 +1,19 @@
<?php
use App\Domains\Event\Controllers\AvailableEventsController;
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('event')->group(function () {
Route::get('/available-events', AvailableEventsController::class);
Route::get('/{eventId}/signup', SignupController::class);
Route::middleware(['auth'])->group(function () {
Route::get('/new', CreateController::class);
Route::get('/details/{eventId}', DetailsController::class);

View File

@@ -0,0 +1,102 @@
<script setup>
import ShadowedBox from "../../../../Views/Components/ShadowedBox.vue";
const props = defineProps({
events: Array,
})
console.log(props.events)
</script>
<template>
<div style="width: 95%; margin: 20px auto;">
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;">
Aktuell sind keine Veranstaltungen verfügbar.
</div>
<shadowed-box
v-for="event in props.events"
:key="event.id"
style="padding: 24px; margin-bottom: 20px;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px;">
<div>
<h2 style="margin: 0 0 4px 0; font-size: 1.25rem;">{{ event.name }}</h2>
<span style="color: #6b7280; font-size: 0.9rem;">{{ event.postalCode }} {{ event.location }}</span>
</div>
<span
v-if="event.registrationAllowed"
style="background: #d1fae5; color: #065f46; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;"
>
Anmeldung offen
</span>
<span
v-else
style="background: #fee2e2; color: #991b1b; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;"
>
Anmeldung geschlossen
</span>
</div>
<hr style="margin: 16px 0; border: none; border-top: 1px solid #e5e7eb;" />
<table style="width: 100%; border-collapse: collapse;">
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Zeitraum</th>
<td style="padding: 6px 0; color: #111827;">{{ event.eventBegin }} {{ event.eventEnd }} ({{ event.duration }} Tage)</td>
</tr>
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Veranstaltungsort</th>
<td style="padding: 6px 0; color: #111827;">{{ event.postalCode }} {{ event.location }}</td>
</tr>
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Frühbuchen bis</th>
<td style="padding: 6px 0; color: #111827;">{{ event.earlyBirdEnd.formatted }}</td>
</tr>
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Anmeldeschluss</th>
<td style="padding: 6px 0; color: #111827;">{{ event.registrationFinalEnd.formatted }}</td>
</tr>
<tr v-if="event.email">
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Kontakt</th>
<td style="padding: 6px 0;">
<a :href="'mailto:' + event.email" style="color: #2563eb;">{{ event.email }}</a>
</td>
</tr>
</table>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;">
<a
:href="'/event/' + event.id + '/signup'"
style="
display: inline-block;
padding: 10px 24px;
background-color: #2563eb;
color: white;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
opacity: 1;
transition: background-color 0.2s;
"
:style="{ opacity: event.registrationAllowed ? '1' : '0.5', pointerEvents: event.registrationAllowed ? 'auto' : 'none' }"
>
Zur Anmeldung
</a>
</div>
</shadowed-box>
</div><div style="width: 95%; margin: 20px auto;">
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;">
Aktuell sind keine Veranstaltungen verfügbar.
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -16,19 +16,27 @@
const errors = reactive({})
const formData = reactive({
"pft_1_active": true,
"pft_1_amount": props.event.participationFee_1.amount,
"pft_1_amount_standard": props.event.participationFee_1.amount_standard_edit,
"pft_1_amount_reduced": props.event.participationFee_1.amount_reduced_edit,
"pft_1_amount_solidarity": props.event.participationFee_1.amount_solidarity_edit,
"pft_1_description": props.event.participationFee_1.description,
"pft_2_active": props.event.participationFee_2.active,
"pft_2_amount": props.event.participationFee_2.amount,
"pft_2_amount_standard": props.event.participationFee_2.amount_standard_edit,
"pft_2_amount_reduced": props.event.participationFee_2.amount_reduced_edit,
"pft_2_amount_solidarity": props.event.participationFee_2.amount_solidarity_edit,
"pft_2_description": props.event.participationFee_2.description,
"pft_3_active": props.event.participationFee_3.active,
"pft_3_amount": props.event.participationFee_3.amount,
"pft_3_amount_standard": props.event.participationFee_3.amount_standard_edit,
"pft_3_amount_reduced": props.event.participationFee_3.amount_reduced_edit,
"pft_3_amount_solidarity": props.event.participationFee_3.amount_solidarity_edit,
"pft_3_description": props.event.participationFee_3.description,
"pft_4_active": props.event.participationFee_4.active,
"pft_4_amount": props.event.participationFee_4.amount,
"pft_4_amount_standard": props.event.participationFee_4.amount_standard_edit,
"pft_4_amount_reduced": props.event.participationFee_4.amount_reduced_edit,
"pft_4_amount_solidarity": props.event.participationFee_4.amount_solidarity_edit,
"pft_4_description": props.event.participationFee_4.description,
'maxAmount': props.event.maxAmount,
@@ -38,17 +46,17 @@
function validateInput() {
var noErrors = true;
if (formData.pft_1_description === '' && !props.event.solidarityPayment) {
if (formData.pft_1_description === '') {
errors.pft_1_description = 'Eine Beschreibung für diese Gruppe ist erforderlich';
noErrors = false;
}
if (formData.pft_2_description === '' && formData.pft_2_active && !props.event.solidarityPayment) {
if (formData.pft_2_description === '' && formData.pft_2_active) {
errors.pft_2_description = 'Eine Beschreibung für diese Gruppe ist erforderlich';
noErrors = false;
}
if (formData.pft_3_description === '' && formData.pft_3_active && !props.event.solidarityPayment) {
if (formData.pft_3_description === '' && formData.pft_3_active) {
errors.pft_3_description = 'Eine Beschreibung für diese Gruppe ist erforderlich';
noErrors = false;
}
@@ -73,19 +81,27 @@
body: {
event_id: props.event.id,
pft_1_active: formData.pft_1_active,
pft_1_amount: formData.pft_1_amount,
pft_1_amount_standard: formData.pft_1_amount_standard,
pft_1_amount_reduced: formData.pft_1_amount_reduced,
pft_1_amount_solidarity: formData.pft_1_amount_solidarity,
pft_1_description: formData.pft_1_description,
pft_2_active: formData.pft_2_active,
pft_2_amount: formData.pft_2_amount,
pft_2_amount_standard: formData.pft_2_amount_standard,
pft_2_amount_reduced: formData.pft_2_amount_reduced,
pft_2_amount_solidarity: formData.pft_2_amount_solidarity,
pft_2_description: formData.pft_2_description,
pft_3_active: formData.pft_3_active,
pft_3_amount: formData.pft_3_amount,
pft_3_amount_standard: formData.pft_3_amount_standard,
pft_3_amount_reduced: formData.pft_3_amount_reduced,
pft_3_amount_solidarity: formData.pft_3_amount_solidarity,
pft_3_description: formData.pft_3_description,
pft_4_active: formData.pft_4_active,
pft_4_amount: formData.pft_4_amount,
pft_4_amount_standard: formData.pft_4_amount_standard,
pft_4_amount_reduced: formData.pft_4_amount_reduced,
pft_4_amount_solidarity: formData.pft_4_amount_solidarity,
pft_4_description: formData.pft_4_description,
maxAmount: formData.maxAmount,
@@ -114,29 +130,41 @@
<template>
<table style="width: 100%;">
<tr>
<td>Aktiv</td>
<td>Preisgruppe</td>
<td>Betrag</td>
<td>Beschreibung</td>
<td><h4>Aktiv</h4></td>
<td><h4>Preisgruppe</h4></td>
<td v-if="!props.event.solidarityPayment"><h4>Betrag</h4></td>
<td v-else><h4>Regulärer Beitrag</h4></td>
<td v-if="props.event.solidarityPayment"><h4>Reduzierter Beitrag</h4></td>
<td v-if="props.event.solidarityPayment"><h4>Solidaritätsbeitrag</h4></td>
<td><h4>Beschreibung</h4></td>
</tr>
<tr style="height: 65px; vertical-align: top">
<td>
<input type="checkbox" v-model="formData.participationFeeType_1" checked disabled/>
</td>
<td v-if="props.event.solidarityPayment">
Regulärer Beitrag
</td>
<td v-else>
<td>
Teilnehmende
</td>
<td>
<AmountInput v-model="formData.pft_1_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_1_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment">
<AmountInput v-model="formData.pft_1_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment">
<AmountInput v-model="formData.pft_1_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td>
<input v-if="!props.event.solidarityPayment" type="text" v-model="formData.pft_1_description" style="width: 300px;" />
<label v-else></label>
<input type="text" v-model="formData.pft_1_description" style="width: 300px;" />
<ErrorText :message="errors.pft_1_description" />
</td>
</tr>
@@ -145,24 +173,32 @@
<td>
<input id="use_pft_2" type="checkbox" v-model="formData.pft_2_active" :checked="formData.pft_2_active" />
</td>
<td v-if="props.event.solidarityPayment">
<label for="use_pft_2" style="cursor: default">
Solidaritätsbeitrag
</label>
</td>
<td v-else>
<td>
<label for="use_pft_2" style="cursor: default">
Kernteam
</label>
</td>
<td v-if="formData.pft_2_active">
<AmountInput v-model="formData.pft_2_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_2_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_2_active">
<AmountInput v-model="formData.pft_2_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_2_active">
<AmountInput v-model="formData.pft_2_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="formData.pft_2_active">
<input v-if="!props.event.solidarityPayment" type="text" v-model="formData.pft_2_description" style="width: 300px;" />
<label v-else></label>
<input type="text" v-model="formData.pft_2_description" style="width: 300px;" />
<ErrorText :message="errors.pft_2_description" />
</td>
</tr>
@@ -171,29 +207,36 @@
<td>
<input id="use_pft_3" type="checkbox" v-model="formData.pft_3_active" :checked="formData.pft_3_active" />
</td>
<td v-if="props.event.solidarityPayment">
<label for="use_pft_3" style="cursor: default">
Reduzierter Beitrag
</label>
</td>
<td v-else>
<td>
<label for="use_pft_3" style="cursor: default">
Unterstützende
</label>
</td>
<td v-if="formData.pft_3_active">
<AmountInput v-model="formData.pft_3_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_3_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_3_active">
<AmountInput v-model="formData.pft_3_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_3_active">
<AmountInput v-model="formData.pft_3_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="formData.pft_3_active">
<input v-if="!props.event.solidarityPayment" type="text" v-model="formData.pft_3_description" style="width: 300px;" />
<label v-else>Nach Verfügbarkeit</label>
<input type="text" v-model="formData.pft_3_description" style="width: 300px;" />
<ErrorText :message="errors.pft_3_description" />
</td>
</tr>
<tr style="height: 65px; vertical-align: top;" v-if="!props.event.solidarityPayment">
<tr style="height: 65px; vertical-align: top;">
<td>
<input id="use_pft_4" type="checkbox" v-model="formData.pft_4_active" :checked="formData.pft_4_active" />
</td>
@@ -203,10 +246,23 @@
</label>
</td>
<td v-if="formData.pft_4_active">
<AmountInput v-model="formData.pft_4_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_4_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_4_active">
<AmountInput v-model="formData.pft_4_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_4_active">
<AmountInput v-model="formData.pft_4_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="formData.pft_4_active">
<input type="text" v-model="formData.pft_4_description" style="width: 300px;" />
<ErrorText :message="errors.pft_4_description" />

View File

@@ -43,7 +43,11 @@ const steps = [
<template>
<div>
<!-- Nach Submit -->
<SubmitSuccess v-if="submitResult?.type === 'success'" :data="submitResult.data" />
<SubmitSuccess
v-if="submitResult?.status === 'success'"
:participant="submitResult?.participant"
:event="event"
/>
<SubmitAlreadyExists v-else-if="submitResult?.type === 'exists'" :data="submitResult.data" />
<template v-else>

View File

@@ -0,0 +1,12 @@
<script setup>
defineProps({ data: Object })
</script>
<template>
<div style="padding: 20px 0;">
<h3>{{ data.nicename }}</h3>
<p>{{ data.text_1 }}</p>
<p>{{ data.text_2 }}</p>
<a :href="data.email_link" style="color: #2563eb;">{{ data.email_text }}</a>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
const props = defineProps({
participant: Object,
event: Object,
})
console.log(props.event)
console.log(props.participant)
</script>
<template>
<div style="padding: 20px 0;">
<h3>Hallo {{ props.participant.nicename }},</h3>
<p>Vielen Dank für dein Interesse an der Veranstaltung {{event.name}}<br />Wir haben folgende Daten erhalten:</p>
<table class="form-table" style="margin-bottom: 20px;">
<tr><td>Anreise:</td><td>{{ props.participant.arrival }}</td></tr>
<tr><td>Abreise:</td><td>{{ props.participant.departure }}</td></tr>
<tr><td>Teilnahmegruppe:</td><td>{{ props.participant.participation_group }}</td></tr>
</table>
<div v-if="props.participant.efz_status === 'NOT_CHECKED'" style="font-weight: bold; color: #b45309; margin-bottom: 20px;">
Dein erweitertes Führungszeugnis konnte nicht automatisch geprüft werden. Bitte kontaktiere die Aktionsleitung.
</div>
<div v-else-if="props.participant.efz_status === 'CHECKED_INVALID'" style="font-weight: bold; color: #dc2626; margin-bottom: 20px;">
Du hast noch kein erweitertes Führungszeugnis hinterlegt. Bitte reiche es umgehend ein.
</div>
<template v-if="props.participant.needs_payment">
<table class="form-table" style="margin-bottom: 16px;">
<tr>
<td>Kontoinhaber:</td><td>{{ props.event.accountOwner }}</td>
<td rowspan="4" style="vertical-align: top; padding-left: 20px;" v-if="props.participant.identifier !== ''">
<img :src="'/print-girocode/' + props.participant.identifier" alt="GiroCode" style="max-width: 180px;" />
<span style="width: 180px; text-align: center; display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Giro-Code</span>
</td>
</tr>
<tr><td>IBAN:</td><td>{{ props.event.accountIban }}</td></tr>
<tr><td>Verwendungszweck:</td><td>{{ props.participant.payment_purpose }}</td></tr>
<tr><td>Betrag:</td><td><strong>{{ props.participant.amount_left_string }}</strong></td></tr>
</table>
<p>
Bitte beachte, dass deine Anmeldung erst nach Zahlungseiongang vollständig ist.<br />
Wenn dieser nicht bis zum {{ props.event.registrationFinalEnd.formatted }} erfolgt, kann deine Anmeldung storniert werden.<br /><br />
Solltest du den Beitrag bis zu diesem Datum nicht oder nur teilweise überweisen können, kontaktiere bitte die Aktionsleitung, damit wir eine gemeinsame Lösiung finden können.
</p>
</template>
<p v-else>
Du musst keinen Beitrag überweisen. Deine Anmeldung ist bestätigt.
</p>
<p>
Du erhältst innerhalb von 2 Stunden eine E-Mail mit weiteren Informationen.<br />
Kontakt: <a :href="'mailto:' + props.event.email" style="color: #2563eb;">{{ props.event.email }}</a>
</p>
</div>
</template>

View File

@@ -0,0 +1,98 @@
import { ref, reactive, computed } from 'vue'
import axios from 'axios'
export function useSignupForm(event, participantData) {
const currentStep = ref(1)
const submitting = ref(false)
const summaryLoading = ref(false)
const submitResult = ref(null) // null | { type: 'success'|'exists', data: {} }
const selectedAddons = reactive({})
console.log(participantData)
const formData = reactive({
eatingHabit: 'EATING_HABIT_VEGAN',
userId: participantData.id,
eventId: event.id,
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
localGroup: participantData.localGroup ?? '-1',
geburtsdatum: participantData.birthday ?? '',
address1: participantData.address_1 ?? '',
address2: participantData.address_2 ?? '',
plz: participantData.postcode ?? '',
ort: participantData.city ?? '',
telefon_1: participantData.phone ?? '',
email_1: participantData.email ?? '',
participationType: '',
ansprechpartner: '',
telefon_2: '',
email_2: '',
badeerlaubnis: '-1',
first_aid: '-1',
participant_group: '',
beitrag: 'regular',
arrival: event.eventBeginInternal?.split('T')[0] ?? '',
departure: event.eventEndInternal?.split('T')[0] ?? '',
anreise_essen: '1',
abreise_essen: '2',
foto: { socialmedia: false, print: false, webseite: false, partner: false, intern: false },
allergien: participantData.allergies ?? '',
intolerances: participantData.intolerances ?? '',
medikamente: participantData.medications ?? '',
tetanusVaccination: participantData.tetanusVaccination ?? '',
essgewohnheit: 'vegetarian',
anmerkungen: '',
summary_information_correct: false,
summary_accept_terms: false,
legal_accepted: false,
payment: false,
})
const summaryAmount = ref('')
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,
})
summaryAmount.value = res.data.amount
} finally {
summaryLoading.value = false
}
}
currentStep.value = step
}
const submit = async () => {
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted || !formData.payment) {
return
}
submitting.value = true
try {
const res = await axios.post('/api/v1/event/'+ event.id + '/signup', {
addons: selectedAddons,
registration_data: { ...formData },
})
submitResult.value = {
status: res.data.status,
participant: res.data.participant,
}
} finally {
submitting.value = false
}
}
return { currentStep, goToStep, formData, selectedAddons, submit, submitting, submitResult, summaryLoading, summaryAmount }
}

View File

@@ -0,0 +1,45 @@
<script setup>
const props = defineProps({ formData: Object, event: Object, selectedAddons: Object })
const emit = defineEmits(['next', 'back'])
</script>
<template>
<div>
<!-- Solidarbeitrag-Auswahl -->
<div v-if="event.solidarityPayment" style="margin-bottom: 20px;">
<h3>Beitrag</h3>
<label v-if="event.participationFee_1?.active" style="display: block; margin-bottom: 8px;">
<input type="radio" v-model="formData.beitrag" value="reduced" />
{{ event.participationFee_1.name }} ({{ event.participationFee_1.amount }} )
</label>
<label style="display: block; margin-bottom: 8px;">
<input type="radio" v-model="formData.beitrag" value="regular" />
{{ event.participationFee_2?.name ?? 'Regulärer Beitrag' }} ({{ event.participationFee_2?.amount }} )
</label>
<label v-if="event.participationFee_3?.active" style="display: block; margin-bottom: 8px;">
<input type="radio" v-model="formData.beitrag" value="social" />
{{ event.participationFee_3.name }} ({{ event.participationFee_3.amount }} )
</label>
</div>
<!-- Addons -->
<div v-if="event.addons?.length > 0">
<h3>Zusatzoptionen</h3>
<div v-for="addon in event.addons" :key="addon.id" style="margin-bottom: 16px; padding: 12px; background: #f8fafc; border-radius: 8px;">
<label style="display: flex; gap: 12px; cursor: pointer;">
<input type="checkbox" v-model="selectedAddons[addon.id]" style="margin-top: 4px;" />
<span>
<strong>{{ addon.name }}</strong>
<span style="display: block; color: #6b7280; font-size: 0.875rem;">Betrag: {{ addon.amount }}</span>
<span style="display: block; color: #374151; font-size: 0.875rem; margin-top: 4px;">{{ addon.description }}</span>
</span>
</label>
</div>
</div>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 5)"> Zurück</button>
<button type="button" class="btn-primary" @click="emit('next', 7)">Weiter </button>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script setup>
defineProps({ event: Object })
const emit = defineEmits(['next'])
</script>
<template>
<div>
<h3 style="margin: 0 0 6px 0; color: #111827;">Wer nimmt teil?</h3>
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle deine Altersgruppe aus.</p>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- Kind / Jugendliche:r -->
<div class="age-card" @click="emit('next', 2)">
<div class="age-card__badge">
<img :src="'/images/children.png'" alt="Abzeichen Kind" class="age-card__img" onerror="this.style.display='none'" />
<div class="age-card__badge-fallback">👦</div>
</div>
<div class="age-card__body">
<h4 class="age-card__title">Mein Kind anmelden:</h4>
<p class="age-card__desc">Mein Kind ist <strong>jünger als {{ event.alcoholicsAge }} Jahre.</strong></p>
</div>
</div>
<!-- Erwachsene:r -->
<div class="age-card" @click="emit('next', 3)">
<div class="age-card__badge">
<img :src="'/images/adults.png'" alt="Abzeichen Erwachsene" class="age-card__img" onerror="this.style.display='none'" />
<div class="age-card__badge-fallback">🧑</div>
</div>
<div class="age-card__body">
<h4 class="age-card__title">Mich selbst anmelden</h4>
<p class="age-card__desc">Ich bin <strong>{{ event.alcoholicsAge }} Jahre oder älter</strong>.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.age-card {
flex: 1;
min-width: 220px;
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;
}
.age-card:hover {
border-color: #2563eb;
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.12);
transform: translateY(-2px);
}
.age-card__badge {
position: relative;
width: 350px;
height: 200px;
margin-bottom: 16px;
}
.age-card__img {
width: 350px;
height: 200px;
object-fit: contain;
}
.age-card__badge-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: #e0f2fe;
border-radius: 50%;
}
/* Fallback ausblenden wenn Bild geladen ist */
.age-card__img:not([style*="display:none"]) + .age-card__badge-fallback {
display: none;
}
.age-card__body { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.age-card__title { margin: 0; font-size: 1.1rem; font-weight: 700; color: #111827; }
.age-card__desc { margin: 0; font-size: 0.9rem; color: #374151; }
.age-card__hint { margin: 0; font-size: 0.8rem; color: #6b7280; }
.age-card__cta {
margin-top: 10px;
display: inline-block;
padding: 6px 18px;
background: #2563eb;
color: white;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup>
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
</script>
<template>
<div>
<h3>Allergien & Ernährung</h3>
<table class="form-table">
<tr><td>Allergien:</td><td><input type="text" v-model="props.formData.allergien" /></td></tr>
<tr>
<td>
Letzte Teranus-Impfung:
<span style="display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Lass das Feld frei, wenn die Information nicht vorliegt oder du diese nicht mitteilen willst</span>
</td><td><input type="date" v-model="props.formData.tetanusVaccination" /></td></tr>
<tr><td>Unverträglichkeiten:</td><td><input type="text" v-model="props.formData.intolerances" /></td></tr>
<tr>
<td>
Medikamente:<br />
<span style="display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Bitte in ausreichender Menge mitbringen</span>
</td>
<td>
<input type="text" v-model="props.formData.medikamente" />
</td>
</tr>
<tr>
<td>Ernährungsweise:</td>
<td>
<select v-model="props.formData.eatingHabit">
<option
v-for="eatingHabit in props.event.eatingHabits"
:value="eatingHabit.data.slug">{{eatingHabit.data.name}}</option>
</select>
</td>
</tr>
<tr>
<td>Anmerkungen:</td>
<td><textarea rows="5" v-model="props.formData.anmerkungen" style="width: 100%;"></textarea></td>
</tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 7)"> Zurück</button>
<button type="button" class="btn-primary" @click="emit('next', 9)">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
const next = () => {
const arrival = new Date(props.formData.arrival)
arrival.setHours(0,0,0,0);
const departure = new Date(props.formData.departure)
const eventStart = new Date(props.event.eventBeginInternal)
const eventEnd = new Date(props.event.eventEndInternal)
arrival.setHours(0,0,0,0);
departure.setHours(0,0,0,0);
eventStart.setHours(0,0,0,0);
eventEnd.setHours(0,0,0,0);
if (arrival < eventStart) {
alert('Bitte gültige Anreise angeben innerhalb des Veranstaltungszeitraums wählen.')
return
}
if (arrival > eventEnd) {
alert('Bitte gültige Abreise angeben innerhalb des Veranstaltungszeitraums wählen.')
return
}
if (departure < arrival) {
alert('Abreise kann niht vor der Anreise liegen. Bitte korrigieren.')
return
}
const hasAddons = (props.event.addons?.length > 0) || props.event.solidarityPayment
emit('next', 5)
}
const back = () => emit('back', 3)
</script>
<template>
<div>
<h3>An- und Abreise</h3>
<table class="form-table">
<tr>
<td>Anreise:</td>
<td>
<input type="date" v-model="formData.arrival" /><br />
<select v-model="formData.anreise_essen" style="margin-top: 6px;">
<option value="1">Vor dem Abendessen</option>
<option value="2">Vor dem Mittagessen</option>
<option value="3">Vor dem Frühstück</option>
<option value="4">Keine Mahlzeit</option>
</select>
</td>
</tr>
<tr>
<td>Abreise:</td>
<td>
<input type="date" v-model="formData.departure" /><br />
<select v-model="formData.abreise_essen" style="margin-top: 6px;">
<option value="1">Nach dem Frühstück</option>
<option value="2">Nach dem Mittagessen</option>
<option value="3">Nach dem Abendessen</option>
<option value="4">Keine Mahlzeit</option>
</select>
</td>
</tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-secondary" @click="back"> Zurück</button>
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
import {reactive} from "vue";
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
const errors = reactive({
ansprechpartner: '',
telefon_2: '',
email_2: '',
badeerlaubnis: '',
first_aid: '',
})
const next = () => {
errors.ansprechpartner = ''
errors.telefon_2 = ''
errors.email_2 = ''
errors.badeerlaubnis = ''
errors.first_aid = ''
let hasError = false
if (!props.formData.ansprechpartner) {
errors.ansprechpartner = 'Bitte eine Kontaktperson angeben.'
hasError = true
}
if (!props.formData.telefon_2) {
errors.telefon_2 = 'Bitte eine Telefonnummer angeben.'
hasError = true
}
if (!props.formData.email_2) {
errors.email_2 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung'
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 (hasError) {
return
}
emit('next', 3)
}
</script>
<template>
<div>
<h3>Kontaktperson</h3>
<table class="form-table">
<tr>
<td>Name (Nachname, Vorname):</td>
<td>
<input type="text" v-model="formData.ansprechpartner" />
<ErrorText :message="errors.ansprechpartner" />
</td>
</tr>
<tr>
<td>Telefon:</td>
<td>
<input type="text" v-model="formData.telefon_2" />
<ErrorText :message="errors.telefon_2" />
</td>
</tr>
<tr>
<td>E-Mail:</td>
<td>
<input type="text" v-model="formData.email_2" />
<ErrorText :message="errors.email_2" />
</td>
</tr>
<tr>
<td>Badeerlaubnis:</td>
<td>
<select v-model="formData.badeerlaubnis">
<option value="-1">Bitte wählen</option>
<option
v-for="swimmingPermission in props.event.swimmingPermissions"
:value="swimmingPermission.slug">{{swimmingPermission.name}}</option>
</select>
<ErrorText :message="errors.badeerlaubnis" />
</td>
</tr>
<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 props.event.firstAidPermissions"
: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>
<td></td>
<td>
</td>
</tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import {reactive} from "vue";
const props = defineProps({ formData: Object, localGroups: Array })
const emit = defineEmits(['next', 'back'])
const errors = reactive({
vorname: '',
nachname: '',
geburtsdatum: '',
localGroup: '',
address1: '',
plz: '',
ort: '',
telefon_1: '',
email_1: '',
})
const next = () => {
errors.vorname = ''
errors.nachname = ''
errors.geburtsdatum = ''
errors.localGroup = ''
errors.address1 = ''
errors.plz = ''
errors.ort = ''
errors.telefon_1 = ''
errors.email_1 = ''
let hasError = false
if (!props.formData.vorname) {
errors.vorname = 'Bitte den Vornamen angeben.'
hasError = true
}
if (!props.formData.nachname) {
errors.nachname = 'Bitte den Nachnamen angeben.'
hasError = true
}
if (!props.formData.geburtsdatum) {
errors.geburtsdatum = 'Bitte das Geburtsdatum angeben.'
hasError = true
}
if (props.formData.localGroup === '-1') {
errors.localGroup = 'Bitte den Stamm auswählen.'
hasError = true
}
if (!props.formData.address1) {
errors.address1 = 'Bitte die Adresse angeben.'
hasError = true
}
if (!props.formData.plz) {
errors.plz = 'Bitte die Postleitzahl angeben.'
hasError = true
}
if (!props.formData.ort) {
errors.ort = 'Bitte den Ort angeben.'
hasError = true
}
if (!props.formData.email_1) {
errors.email_1 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (hasError) {
return
}
emit('next', 4)
}
</script>
<template>
<div>
<h3>Persönliche Daten</h3>
<table class="form-table">
<tr><td>Vorname:</td><td><input type="text" v-model="props.formData.vorname" /></td></tr>
<tr><td>Nachname:</td><td><input type="text" v-model="props.formData.nachname" /></td></tr>
<tr><td>Pfadiname:</td><td><input type="text" v-model="props.formData.pfadiname" /></td></tr>
<tr>
<td>Stamm:</td>
<td>
<select v-model="props.formData.localGroup">
<option value="-1">Bitte wählen</option>
<option v-for="lg in localGroups" :key="lg.id" :value="lg.id">{{ lg.name }}</option>
</select>
</td>
</tr>
<tr><td>Geburtsdatum:</td><td><input type="date" v-model="props.formData.geburtsdatum" /></td></tr>
<tr>
<td>Adresse:</td>
<td>
<input type="text" v-model="props.formData.address1" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="text" v-model="props.formData.address2" />
</td>
</tr>
<tr>
<td>PLZ, Ort:</td>
<td>
<input maxlength="5" type="text" v-model="props.formData.plz" style="width: 100px; margin-right: 8px;" />
<input type="text" v-model="props.formData.ort" style="width: calc(100% - 110px);" />
</td>
</tr>
<tr><td>Telefon:</td><td><input type="text" v-model="props.formData.telefon_1" /></td></tr>
<tr><td>E-Mail:</td><td><input type="text" v-model="props.formData.email_1" /></td></tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
const acceptAll = () => {
Object.keys(props.formData.foto).forEach(k => props.formData.foto[k] = true)
emit('next', 8)
}
const back = () => {
const hasAddons = (props.event.addons?.length > 0) || props.event.solidarityPayment
emit('back', hasAddons ? 6 : 5)
}
</script>
<template>
<div>
<h3>Fotoerlaubnis</h3>
<div v-for="[key, label] in [['socialmedia','Social Media'],['print','Printmedien'],['webseite','Webseite'],['partner','Partnerorganisationen'],['intern','Interne Zwecke']]"
:key="key"
style="margin-bottom: 10px;">
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.foto[key]" />
{{ label }}
</label>
</div>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="back"> Zurück</button>
<button type="button" class="btn-primary" style="background: #059669;" @click="acceptAll">Alle akzeptieren & weiter</button>
<button type="button" class="btn-primary" @click="emit('next', 8)">Weiter </button>
</div>
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup>
import { watch } from "vue";
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
watch(
() => props.formData.participationType,
(value) => {
if (!value) {
props.formData.beitrag = 'standard'
return
}
props.formData.beitrag = 'standard'
}
)
const nextStep = () => {
const hasAddons = (props.event.addons?.length ?? 0) > 0
emit('next', hasAddons ? 6 : 7)
}
</script>
<template>
<div>
<h3 v-if="event.solidarityPayment">Solidarbeitrag Teilnahmegruppe</h3>
<h3 v-else>Ich nehme teil als ...</h3>
<table style="width: 100%;">
<tr
v-for="participationType in props.event.participationTypes"
:key="participationType.type.slug"
style="vertical-align: top;"
>
<td style="width: 50px; padding-top: 6px;">
<input
:id="participationType.type.slug"
v-model="props.formData.participationType"
type="radio"
:value="participationType.type.slug"
/>
</td>
<td style="padding-bottom: 16px;">
<label :for="participationType.type.slug" style="line-height: 1.5; font-weight: 600; cursor: pointer;">
{{ participationType.type.name }}
</label><br />
<label
:for="participationType.type.slug"
style="line-height: 1.5; padding-left: 15px; font-style: italic; color: #606060; cursor: pointer;"
>
{{ participationType.description }}
</label>
<div
v-if="props.formData.participationType === participationType.type.slug"
style="margin-top: 10px; margin-left: 15px; padding: 12px 14px; background: #f8fafc; border-left: 3px solid #2563eb; border-radius: 6px;"
>
<template
v-if="participationType.amount_reduced !== null || participationType.amount_solidarity !== null"
>
<div style="margin-bottom: 8px; font-size: 0.95rem; font-weight: 600; color: #374151;">
Beitrag auswählen
</div>
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<input type="radio" v-model="props.formData.beitrag" value="standard" />
Standardbeitrag
<span style="color: #606060;">
({{ participationType.amount_standard.readable }}
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>)
</span>
</label>
<label
v-if="participationType.amount_reduced !== null"
style="display: block; margin-bottom: 8px; cursor: pointer;"
>
<input type="radio" v-model="props.formData.beitrag" value="reduced" />
Reduzierter Beitrag
<span style="color: #606060;">
({{ participationType.amount_reduced.readable }}
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>)
</span>
</label>
<label
v-if="participationType.amount_solidarity !== null"
style="display: block; margin-bottom: 0; cursor: pointer;"
>
<input type="radio" v-model="props.formData.beitrag" value="solidarity" />
Solidaritätsbeitrag
<span style="color: #606060;">
({{ participationType.amount_solidarity.readable }}
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>)
</span>
</label>
</template>
<template v-else>
<div style="font-size: 0.9rem; color: #606060;">
Standardbeitrag:
<strong>{{ participationType.amount_standard.readable }}</strong>
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>
</div>
</template>
</div>
</td>
</tr>
</table>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 3)"> Zurück</button>
<button
type="button"
v-if="props.formData.participationType"
class="btn-primary"
@click="nextStep"
>
Weiter
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import {format, parseISO} from "date-fns";
const props = defineProps({
formData: Object,
event: Object,
summaryAmount: String,
summaryLoading: Boolean,
submitting: Boolean,
})
const emit = defineEmits(['back', 'submit'])
function formatDate(dateString) {
if (!dateString) return ''
return format(parseISO(dateString), 'dd.MM.yyyy')
}
</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>Veranstaltung:</td><td><strong>{{ event.name }}</strong></td></tr>
<tr><td>Anreise:</td><td>{{ formatDate(formData.arrival) }}</td></tr>
<tr><td>Abreise:</td><td>{{ formatDate(formData.departure) }}</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 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.
</label>
</div>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 8)"> Zurück</button>
<button
type="submit"
class="btn-primary"
:disabled="!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted || !formData.payment || submitting"
style="background: #059669;"
>
{{ submitting ? 'Wird gesendet…' : 'Jetzt anmelden ✓' }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\EventParticipant;
use App\Providers\GiroCodeProvider;
use Illuminate\Http\Request;
class GiroCodeGetController {
public function __invoke(string $participantToken, Request $request)
{
$participant = EventParticipant::where(['identifier' => $participantToken])->first();
if (null === $participant) {
return response()->json(['message' => 'Participant not found'], 404);
}
$amount = $participant->amount;
$amount->subtractAmount($participant->amount_paid);
$girocodeProvider = new GiroCodeProvider(
$participant->event()->first()->account_owner,
$participant->event()->first()->account_iban,
$participant->amount->getAmount(),
$participant->payment_purpose
);
return response($girocodeProvider->create(), 200,
['Content-Type' => 'image/png']
);
}
}

View File

@@ -7,12 +7,14 @@ use App\Casts\AmountCast;
use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee;
use App\Resources\EventResource;
use App\Scopes\CommonModel;
use App\Scopes\InstancedModel;
use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property string $tenant
@@ -53,6 +55,7 @@ class Event extends InstancedModel
'tenant',
'cost_unit_id',
'name',
'identifier',
'location',
'postal_code',
'email',
@@ -169,4 +172,5 @@ class Event extends InstancedModel
public function participants() : hasMany {
return $this->hasMany(EventParticipant::class);
}
}

View File

@@ -20,6 +20,7 @@ class EventParticipant extends InstancedModel
'tenant',
'event_id',
'user_id',
'identifier',
'firstname',
'lastname',
@@ -42,6 +43,8 @@ class EventParticipant extends InstancedModel
'allergies',
'intolerances',
'medications',
'tetanus_vaccination',
'eating_habits',
'swimming_permission',
'first_aid_permission',
@@ -60,12 +63,14 @@ class EventParticipant extends InstancedModel
'notes',
'amount',
'amount_paid',
'payment_purpose',
'efz_status',
'unregistered_at',
];
protected $casts = [
'birthday' => 'datetime',
'tetanus_vaccination' => 'datetime',
'arrival_date' => 'datetime',
'departure_date' => 'datetime',
'unregistered_at' => 'datetime',
@@ -131,5 +136,21 @@ class EventParticipant extends InstancedModel
return $this->belongsTo(EfzStatus::class, 'efz_status', 'slug');
}
public function getOfficialName() : string {
return sprintf('%1$s %2$s', $this->firstname, $this->lastname);
}
public function getFullname() : string {
return sprintf('%1$1s %2$s %3$s',
$this->firstname,
$this->lastname,
$this->nickname !== null ? '(' . $this->nickname . ')' : '',
)
|>trim(...);
}
public function getNicename() : string {
return $this->nickname ?? $this->firstname;
}
}

View File

@@ -66,6 +66,7 @@ class User extends Authenticatable
'medications',
'allergies',
'intolerances',
'tetanus_vaccination',
'eating_habits',
'swimming_permission',
'first_aid_permission',

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class GiroCodeProvider {
function __construct(private string $recipient, private string $iban, private float $amount, private string $paymentPurpose) {}
public function create() {
$data = "BCD\n".
"001\n".
"1\n".
"SCT\n".
"\n".
"$this->recipient\n".
"$this->iban\n".
"EUR$this->amount\n".
"\n".
"$this->paymentPurpose";
return
QrCode::format('png')->size(300)->generate($data)
;
}
}

View File

@@ -135,7 +135,7 @@ class GlobalDataProvider {
}
$navigation['common'][] = ['url' => '/invoice/new', 'display' => 'Neue Abrechnung'];
$navigation['common'][] = ['url' => '/available-events', 'display' => 'Verfügbare Veranstaltungen'];
$navigation['common'][] = ['url' => '/event/available-events', 'display' => 'Verfügbare Veranstaltungen'];
return $navigation;
}

View File

@@ -4,6 +4,7 @@ namespace App\RelationModels;
use App\Casts\AmountCast;
use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Models\Tenant;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,14 +13,19 @@ class EventParticipationFee extends CommonModel
{
protected $table = 'event_participation_fees';
protected $fillable = ['tenant', 'type', 'name', 'description', 'amount'];
protected $casts = ['amount' => AmountCast::class];
protected $fillable = ['tenant', 'type', 'name', 'description', 'amount_standard', 'amount_reduced', 'amount_solidarity', 'active'];
protected $casts = [
'amount_standard' => AmountCast::class,
'amount_reduced' => AmountCast::class,
'amount_solidarity' => AmountCast::class,
];
public function tenant() : BelongsTo {
return $this->belongsTo(Tenant::class, 'tenant', 'slug');
}
public function type() : BelongsTo {
return $this->belongsTo(ParticipationFeeType::class, 'type', 'slug');
return $this->belongsTo(ParticipationType::class, 'type', 'slug');
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\CostUnit;
use App\Models\Event;
use App\Resources\CostUnitResource;
use Illuminate\Database\Eloquent\Collection;
use Psy\Readline\Hoa\EventBucket;
class EventRepository {
public function listAll() : array {
@@ -16,6 +17,17 @@ class EventRepository {
}
public function getAvailable(bool $accessCheck = true) : array {
return $this->getEventsByCriteria([
'archived' => false,
'registration_allowed' => true
],$accessCheck);
}
public function getForRegistration(int $id) : ?Event {
$events = self::getEventsByCriteria(['id' => $id, 'registration_allowed' => true]);
}
public function getById(int $id, bool $accessCheck = true) : ?Event {
$events = self::getEventsByCriteria(['id' => $id], $accessCheck);
return $events[0] ?? null;
@@ -50,7 +62,7 @@ class EventRepository {
/** @var Event $event */
foreach (Event::where($criteria)->get() as $event) {
if ($event->eventManagers()->where('user_id', $user->id)->exists() || $canSeeAll || !$accessCheck) {
if ($canSeeAll || !$accessCheck || $event->eventManagers()->where('user_id', $user->id)->exists()) {
$visibleEvents[] = $event;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Resources;
use App\Models\EventParticipant;
use Illuminate\Http\Resources\Json\JsonResource;
class EventParticipantResource extends JsonResource
{
function __construct(EventParticipant $participant)
{
parent::__construct($participant);
}
public function toArray($request) : array
{
$event = $this->resource->event;
return array_merge(
$this->resource->toArray(),
[
'needs_payment' => $this->resource->amount->getAmount() > 0 && $event->pay_direct,
'nicename' => $this->resource->getNicename(),
'arrival' => $this->resource->arrival_date->format('d.m.Y'),
'departure' => $this->resource->departure_date->format('d.m.Y'),
'amount_left_string' => $this->resource->amount->toString(),
]
);
}
}

View File

@@ -5,17 +5,20 @@ namespace App\Resources;
use App\Enumerations\ParticipationFeeType;
use App\Models\Event;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource {
class EventResource extends JsonResource{
private Event $event;
public function __construct(Event $event) {
$this->event = $event;
}
public function toArray() : array {
$duration = $this->event->end_date->diff($this->event->start_date)->d + 1;
public function toArray(Request $request) : array
{
$duration = $this->event->end_date->diff($this->event->start_date)->days + 1;
$returnArray = [
'id' => $this->event->id,
@@ -25,13 +28,31 @@ class EventResource {
'email' => $this->event->email,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
'alcoholicsAge' => $this->event->alcoholics_age,
'sendWeeklyReports' => $this->event->send_weekly_report,
'registrationAllowed' => $this->event->registration_allowed,
'earlyBirdEnd' => ['internal' => $this->event->early_bird_end->format('Y-m-d'), 'formatted' => $this->event->early_bird_end->format('d.m.Y')],
'registrationFinalEnd' => ['internal' => $this->event->registration_final_end->format('Y-m-d'), 'formatted' => $this->event->registration_final_end->format('d.m.Y')],
'refundAfterEarlyBirdEnd' => 100 - $this->event->early_bird_end_amount_increase,
];
$returnArray['swimmingPermissions'] = \App\Enumerations\SwimmingPermission::query()
->get()
->map(fn ($permission) => [
'slug' => $permission->slug,
'name' => $permission->name,
])
->toArray();
$returnArray['firstAidPermissions'] = \App\Enumerations\FirstAidPermission::query()
->get()
->map(fn ($permission) => [
'slug' => $permission->slug,
'name' => $permission->name,
])
->toArray();
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
@@ -58,52 +79,122 @@ class EventResource {
$returnArray['managers'] = $this->event->eventManagers()->get()->map(fn($user) => new UserResource($user))->toArray();
$returnArray['supportPersonCalced'] = $this->event->support_per_person->toString();
$returnArray['contributingLocalGroups'] = $this->event->localGroups()->get()->map(fn($localGroup) => new LocalGroupResource($localGroup))->toArray();
$returnArray['contributingLocalGroups'] = $this->event->localGroups
->map(fn ($localGroup) => new LocalGroupResource($localGroup)->toArray($request))
->toArray();
$returnArray['eatingHabits'] = $this->event->eatingHabits()->get()->map(
fn($eatingHabit) => new EatingHabitResource($eatingHabit))->toArray();
$returnArray['participationTypes'] = [];
$multiplier = $this->getMultiplier();
for ($i = 1; $i <= 4; $i++) {
$returnArray['participationFee_' . $i] = [
'active' => false,
'name' => '',
'description' => '',
'amount' => '0,00',
'type' => null
'amount_standard' => null,
'amount_reduced' => null,
'amount_solidarity' => null,
'type' => null,
];
if ($this->event->{'participation_fee_' . $i} === null) {
continue;
}
$participationFee = $this->event->{'participationFee' . $i}()->first();
$returnArray['participationFee_' . $i] = [
$feeType = [
'active' => true,
'amount' => $this->event->{'participationFee' . $i}()->first()->amount->getFormattedAmount(),
'name' => $this->event->{'participationFee' . $i}->first()->name,
'description' => $this->event->{'participationFee' . $i}()->first()->description,
'type' => $this->event->{'participationFee' . $i}->first()->type
'amount_standard_edit' => $participationFee->amount_standard->getFormattedAmount(),
'amount_standard' => [
'internal' => [
'amount' => $participationFee->amount_standard->getAmount(),
'currency' => $participationFee->amount_standard->getCurrency(),
],
'readable' => $participationFee->amount_standard->multiply($multiplier)->toString(),
],
'amount_reduced_edit' => $participationFee->amount_reduced === null ? null : $participationFee->amount_reduced->getFormattedAmount(),
'amount_reduced' => $participationFee->amount_reduced === null
? null
: [
'internal' => [
'amount' => $participationFee->amount_reduced->getAmount(),
'currency' => $participationFee->amount_reduced->getCurrency(),
],
'readable' => $participationFee->amount_reduced->multiply($multiplier)->toString(),
],
'amount_solidarity_edit' => $participationFee->amount_solidarity === null ? null : $participationFee->amount_solidarity->getFormattedAmount(),
'amount_solidarity' => $participationFee->amount_solidarity === null
? null
: [
'internal' => [
'amount' => $participationFee->amount_solidarity->getAmount(),
'currency' => $participationFee->amount_solidarity->getCurrency(),
],
'readable' => $participationFee->amount_solidarity->multiply($multiplier)->toString(),
],
'name' => $participationFee->name,
'description' => $participationFee->description,
'type' => (new ParticipationTypeResource($participationFee->type()->first()))->toArray($request),
];
if ($this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY) {
$returnArray['participationFee_1' . $i]['description'] = '';
$returnArray['participationFee_2' . $i]['description'] = '';
$returnArray['participationFee_3' . $i]['description'] = 'Nach Verfügbarkeit';
}
$returnArray['participationFee_' . $i] = $feeType;
$returnArray['participationTypes'][] = $feeType;
}
return $returnArray;
}
public function getMultiplier() : float {
$earlyBirdEnd = $this->event->early_bird_end;
if ($earlyBirdEnd > now()) {
return 1;
}
return 1 + $this->event->early_bird_end_amount_increase / 100;
}
public function calculateIncomes() : Amount {
$amount = new Amount(0, 'Euro');
$amount->addAmount($this->event->support_flat);
return $amount;
}
public function calculateAmount(
string $participationType,
string $feeType,
DateTime $arrival,
DateTime $departure
) : Amount {
$fee = collect([
$this->event->participationFee1,
$this->event->participationFee2,
$this->event->participationFee3,
$this->event->participationFee4,
])->filter(fn ($participationFee) => $participationFee !== null)
->first(fn ($participationFee) => $participationFee->type === $participationType);
if ($fee === null) {
return new Amount(0, 'Euro');
}
/** @var Amount $basicFee */
$basicFee = $fee['amount_' . $feeType];
$basicFee = $basicFee->multiply($this->getMultiplier());
if ($this->event->pay_per_day) {
$days = $arrival->diff($departure)->days;
$basicFee = $basicFee->multiply($days);
}
return $basicFee;
}
}

View File

@@ -6,19 +6,17 @@ use App\Models\Tenant;
use Illuminate\Http\Resources\Json\JsonResource;
class LocalGroupResource extends JsonResource {
private Tenant $tenant;
public function __construct(Tenant $tenant) {
$this->tenant = $tenant;
parent::__construct($tenant);
}
public function toArray($request) : array {
return [
'id' => $this->tenant->id,
'name' => $this->tenant->name,
'email' => $this->tenant->email,
'city' => $this->tenant->city,
'postalcode'=> $this->tenant->postcode
'id' => $this->resource->id,
'name' => $this->resource->name,
'email' => $this->resource->email,
'city' => $this->resource->city,
'postalcode' => $this->resource->postcode,
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Resources;
use App\RelationModels\EventParticipationFee;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ParticipationFeeTypeResource extends JsonResource
{
public function __construct(private EventParticipationFee $participationFee) {}
public function toArray(Request $request): array
{
$return = [
'type' => new ParticipationTypeResource($this->participationFee->type()->first())->toArray($request),
'name' => $this->participationFee->name,
'description' => $this->participationFee->description,
'amount_standard' => ['internal' => $this->participationFee->amount_standard, 'readable' => $this->participationFee->amount_standard->toString()],
'amount_reduced' => ['internal' => null, 'readable' => null],
'amount_solidarity' => ['internal' => null, 'readable' => null],
];
if ($this->participationFee->amount_reduced !== null) {
$return['amount_reduced'] = ['internal' => $this->participationFee->amount_reduced, 'readable' => $this->participationFee->amount_reduced->toString()];
}
if ($this->participationFee->amount_solidarity !== null) {
$return['amount_solidarity'] = ['internal' => $this->participationFee->amount_solidarity, 'readable' => $this->participationFee->amount_solidarity->toString()];
}
return $return;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Resources;
use App\Enumerations\ParticipationType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ParticipationTypeResource extends JsonResource
{
public function __construct(private ParticipationType $participationType) {}
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return ['slug' => $this->participationType->slug, 'name' => $this->participationType->name];
}
}

View File

@@ -17,8 +17,9 @@ class UserResource extends JsonResource {
$this->user->toArray(),
[
'nicename' => $this->user->getNicename(),
'fullname' => $this->user->getFullName()
]);
'fullname' => $this->user->getFullName(),
'localGroup' => $this->user->localGroup()->id,
]);
unset($data['password']);
unset($data['remember_token']);
@@ -39,6 +40,7 @@ class UserResource extends JsonResource {
'firstname' => $this->user->firstname,
'lastname' => $this->user->lastname,
'localGroup' => $this->user->localGroup()->name,
'localGroupId' => $this->user->localGroup()->id,
];
}
}

View File

@@ -3,8 +3,18 @@
namespace App\Scopes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class CommonModel extends Model
{
public function toResource(?string $resourceClass = null) : JsonResource {
$modelClass = class_basename($this); // z.B. "Event"
$resourceClass = "App\\Resources\\{$modelClass}Resource";
if (!class_exists($resourceClass)) {
throw new \RuntimeException("Resource {$resourceClass} not found.");
}
return new $resourceClass($this);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Scopes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class InstancedModel extends Model
{
@@ -10,4 +11,15 @@ abstract class InstancedModel extends Model
{
static::addGlobalScope(new SiteScope());
}
public function toResource(?string $resourceClass = null) : JsonResource {
$modelClass = class_basename($this); // z.B. "Event"
$resourceClass = "App\\Resources\\{$modelClass}Resource";
if (!class_exists($resourceClass)) {
throw new \RuntimeException("Resource {$resourceClass} not found.");
}
return new $resourceClass($this);
}
}

16
app/ValueObjects/Age.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace App\ValueObjects;
class Age {
function __construct(private \DateTime $birthday) {
}
public function getAge() : int {
return (new \DateTime())->diff($this->birthday)->y;
}
public function isfullAged() : bool {
return $this->getAge() >= 18;
}
}

View File

@@ -45,6 +45,11 @@ class Amount {
$this->amount -= $amount->getAmount();
}
public function multiply(float $factor) : Amount {
$this->amount *= $factor;
return $this;
}
public function getFormattedAmount() : string {
$value = number_format( round( $this->amount, 2 ), 2, ',', '.' );
return $value

View File

@@ -12,7 +12,12 @@
"laravel/tinker": "^2.10.1",
"ext-dom": "*",
"contelli/webdav-sync": "^1.0",
"ext-zip": "*"
"ext-zip": "*",
"simplesoftwareio/simple-qrcode": "*",
"dompdf/dompdf": "^3.0",
"setasign/fpdf": "^1.8",
"setasign/fpdi": "^2.6",
"maennchen/zipstream-php": "^3.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -21,11 +26,7 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3",
"dompdf/dompdf": "^3.0",
"setasign/fpdf": "^1.8",
"setasign/fpdi": "^2.6",
"maennchen/zipstream-php": "^3.1"
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {

1860
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@ return new class extends Migration
$table->string('medications')->nullable();
$table->string('allergies')->nullable();
$table->string('intolerances')->nullable();
$table->date('tetanus_vaccination')->nullable();
$table->string('eating_habits')->nullable();
$table->string('swimming_permission')->nullable();
$table->string('first_aid_permission')->nullable();

View File

@@ -26,8 +26,10 @@ return new class extends Migration {
$table->string('tenant');
$table->string('type');
$table->string('name');
$table->string('description')->default('');
$table->float('amount',2);
$table->string('description')->nullable()->default('');
$table->float('amount_standard',2);
$table->float('amount_reduced',2)->nullable();
$table->float('amount_solidarity', 2)->nullable();
$table->timestamps();
$table->foreign('type')->references('slug')->on('participation_types')->restrictOnDelete()->cascadeOnUpdate();

View File

@@ -22,8 +22,7 @@ return new class extends Migration {
$table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('user_id')->nullable()->constrained('users', 'id')->cascadeOnDelete()->cascadeOnUpdate();
$table->string('identifier');
$table->string('firstname');
$table->string('lastname');
$table->string('nickname')->nullable();
@@ -41,7 +40,9 @@ return new class extends Migration {
$table->string('contact_person')->nullable();
$table->string('allergies')->nullable();
$table->string('intolerances')->nullable();
$table->string('eating_habits')->nullable();
$table->string('medications')->nullable();
$table->date('tetanus_vaccination')->nullable();
$table->string('eating_habit')->nullable();
$table->string('swimming_permission')->nullable();
$table->string('first_aid_permission')->nullable();
$table->boolean('foto_socialmedia')->default(false);
@@ -56,6 +57,7 @@ return new class extends Migration {
$table->longText('notes')->nullable();
$table->float('amount', 2);
$table->float('amount_paid',0)->default(0);
$table->string('payment_purpose');
$table->string('efz_status');
$table->timestamps();
$table->dateTime('unregistered_at')->nullable();

View File

@@ -19,12 +19,16 @@ RUN apt-get install -y nginx \
libzip-dev \
libpng-dev \
libonig-dev \
libxml2-dev
libxml2-dev \
libmagickwand-dev \
imagemagick \
&& pecl install imagick \
&& docker-php-ext-enable imagick
#&& rm -rf /var/lib/apt/lists/* \
RUN mkdir -p /run/nginx
RUN docker-php-ext-install mysqli pdo pdo_mysql mbstring zip exif pcntl
RUN docker-php-ext-install mysqli pdo pdo_mysql mbstring zip exif pcntl gd
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y nodejs

5
justfile Normal file
View File

@@ -0,0 +1,5 @@
artisan *args:
docker exec -it mareike-mareike-app-1 php artisan {{args}}
composer *args:
docker exec -it mareike-mareike-app-1 composer {{args}}

View File

@@ -0,0 +1,14 @@
import React from 'react';
function AlreadyExistsContainer({ labels, participant_data }) {
return (
<p>
<h3>{participant_data.nicename}</h3>
{participant_data.text_1}<br />
{participant_data.text_2}<br />
<a href={participant_data.email_link}>{participant_data.email_text}</a> ( {participant_data.email_text} )
</p>
);
}
export default AlreadyExistsContainer;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import {__} from '../../../../assets/javascripts/library.js';
function EfzStatusMessage({ efzStatus }) {
if (efzStatus === 'NOT_CHECKED') {
return <p style={{fontWeight: 'bold'}}>
Dein erweitertes Führungszeugnis konnte nicht automatisch überprüft werden. Bitte kontaktiere die Aktionsleitung, da deine Teilnahme nur mit gültigem eFZ möglich ist.
</p>;
}
if (efzStatus === 'CHECKED_INVALID') {
return <p style={{fontWeight: 'bold'}}>
Du hast noch kein erweitertes Führungszeugnis bereitgestellt, sodass deine Teilnahme nicht möglich ist. Bitte reiche dein erweitertes Führungszeugnis umgehend ein,
da deine Teilnahme andernfalls storniert werden kann. Bitte setze dich mit der Aktionsleitung in Verbindung.
</p>;
}
return null; // default: nix anzeigen
}
function SuccessContainer({ participant_data }) {
return (
<p>
<h3>{__('Hello', 'solea')} {participant_data.nicename}</h3>
<p>
{participant_data.introduction}<br />
{__('We have received the following information:', 'solea')}
</p>
<table className="solea-payment-table">
<tr><td>{__('Arrival', 'solea')}:</td><td>{participant_data.arrival}</td></tr>
<tr><td>{__('Departure', 'solea')}:</td><td>{participant_data.departure}</td></tr>
<tr><td>{__('Participation group', 'solea')}:</td><td>{participant_data.participation_group}</td></tr>
</table>
{participant_data.needs_payment ? (
<p>
<table className="solea-payment-table">
<tr>
<td>{__('Account owner', 'solea')}:</td>
<td>{participant_data.account_owner}</td>
</tr>
<tr>
<td>{__('IBAN', 'solea')}:</td>
<td>{participant_data.account_iban}</td>
</tr>
<tr>
<td>{__('Purpose', 'solea')}:</td>
<td>{participant_data.payment_purpose}</td>
</tr>
<tr>
<td>{ __('Total amount', 'solea')}: </td>
<td>{participant_data.amount}</td>
</tr>
<tr>
<td colSpan="2">
{__('If your bank supports QR-Code based paying, use this code', 'solea')}<br />
<img className="girocode" src={participant_data.girocode_url} />
</td>
</tr>
</table>
<p>
{__( 'If payment is not possible or only partially possible within this period, please contact the event management.', 'solea' )}
</p>
</p>
) : (
<p>
{__('You do not have to pay the registration fee. This is the case if participation is supported, billing is done through your local group, or there are other decisions.', 'solea')}<br/>
{__('Your registration is confirmed now.', 'solea' ) }
</p>
)}
<EfzStatusMessage efzStatus={participant_data.efz_status} />
<p>
{__('You will receive an email with further information within 2 hours. If you do not receive this mail or have any questions about your registration, please contact the event management.', 'solea')}<br />
{__('You can contact us at', 'solea')}: {participant_data.event_email}
</p>
</p>
);
}
export default SuccessContainer;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { ArrivalDepratureValidator } from '../../../../assets/javascripts/registration-validator.js'
function AmountSelectorContainer({ labels, event_data }) {
return (
<table>
{event_data.amount_reduced !== '' && (
<tr>
<td>
<input type="radio"
name="beitrag" value="reduced" id="amount_reduced" />
<label htmlFor="amount_reduced">
{labels.addons.amount.reduced_amount} (
{event_data.amount_reduced})
</label>
</td>
</tr>
)}
<tr>
<td>
<input defaultChecked={"checked"} type="radio" name="beitrag" value="regular" id="amount_regular"/>
<label htmlFor="amount_regular">
{labels.addons.amount.regular_amount} (
{event_data.amount_participant})
</label>
</td>
</tr>
{event_data.amount_social !== '' && (
<tr>
<td>
<input name="beitrag" value="social" id="amount_social" type="radio"/>
<label htmlFor="amount_social">
{labels.addons.amount.social_amount} (
{event_data.amount_social})
</label>
</td>
</tr>
)}
</table>
);
}
export default AmountSelectorContainer;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import AmountSelectorContainer from "../components/amount-selector.jsx";
function AddonsContainer({ onStepClick, labels, event_data }) {
const handle_next_step = () => {
onStepClick(7);
}
return (
<p>
{event_data.registration_mode === 'solidarity' && (
<AmountSelectorContainer event_data={event_data} labels={labels} />
)}
{event_data.addons.length > 0 && (
<p>
<h3>{labels.addons.addons.available_addons}</h3>
<table>
{event_data.addons.map((addon, index) => (
<tr>
<td class="addon_checkbox">
<input type="checkbox"
name={"addons["+addon.id+"]"}
id={"addons_"+addon.id} />
</td>
<td class="addon_description">
<label for={"addons_"+addon.id}>
<span class="bold">{addon.name}</span><br />
<span class="small">{labels.common.amount}: {addon.amount}</span><br /><br />
{addon.description}<br /><br />
</label>
</td>
</tr>
))
}
</table>
</p>
)}
<input type="button" value={labels.common.go_back} onClick={() => onStepClick(5)} /> &nbsp;
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</p>
);
}
export default AddonsContainer;

36
legacy/partials/age.jsx Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react';
function AgeContainer({ onStepClick, labels, configuration }) {
return (
<div>
<div onClick={() => onStepClick(2)} className="solea_age_selector">
<div>
<h3>{labels.age.headline_children}</h3>
{labels.age.text_children}
</div>
<p className="solea_emblems_selection">
<img src={configuration.icon_children} class="solea_participant_icon" />
</p>
</div>
<div onClick={() => onStepClick(3)} className="solea_age_selector">
<div>
<h3>{labels.age.headline_adults}</h3>
{labels.age.text_adults}
</div>
<p className="solea_emblems_selection">
<img src={configuration.icon_adults} className="solea_participant_icon"/>
</p>
</div>
</div>
);
}
export default AgeContainer;

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { ContactDataValidator } from '../../../../assets/javascripts/registration-validator.js'
function AllergiesContainer({ onStepClick, labels, event_data, participant_data }) {
const handle_next_step = () => {
onStepClick(9);
}
return (
<table>
<tr>
<td colSpan="2">
<h3>{labels.headlines.allergies}</h3>
</td>
</tr>
<tr>
<td>{labels.allergies.allergies}:</td>
<td><input defaultValue={participant_data.allergies} type="text" name="allergien" /></td>
</tr>
<tr>
<td>{labels.allergies.intolerances}:</td>
<td><input defaultValue={participant_data.intolerances} type="text" name="intolerances" /></td>
</tr>
<tr>
<td>{labels.allergies.medications}:</td>
<td>
<input defaultValue={participant_data.medications} type="text" name="medikamente" />*<br />
{labels.allergies.medications_hint}
</td>
</tr>
<tr>
<td>{labels.allergies.eating_habits.headline}:</td>
<td>
<select name="essgewohnheit">
<option value="vegetarian">{labels.allergies.eating_habits.vegetarian}</option>
<option value="vegan">{labels.allergies.eating_habits.vegan}</option>
{event_data.enable_all_eating && (
<option value="all">{labels.allergies.eating_habits.meat}</option>
)}
</select>
</td>
</tr>
<tr>
<td>{labels.allergies.notices}:</td>
<td><textarea rows="15" name="anmerkungen"></textarea></td>
</tr>
<tr>
<td colSpan="2">
<input type="button" value={labels.common.go_back} onClick={() => onStepClick(7)} /> &nbsp;
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</td>
</tr>
</table>
);
}
export default AllergiesContainer;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { ArrivalDepratureValidator } from '../../../../assets/javascripts/registration-validator.js'
function ArrivalContainer({ onStepClick, labels, event_data }) {
const handle_next_step = () => {
if (ArrivalDepratureValidator(
new Date(event_data.date_begin),
new Date (event_data.date_end)
) ) {
if (event_data.addons.length > 0 || event_data.registration_mode === 'solidarity') {
onStepClick(6);
} else {
onStepClick(7);
}
}
}
return (
<table>
<tr>
<td colSpan="2">
<h3>{labels.headlines.arrival}</h3>
</td>
</tr>
<tr>
<td>{labels.arrival_data.arrival.headline}:</td>
<td>
<input type="date" name="anreise" id="anreise" defaultValue={event_data.date_begin} /><br />
<select name="anreise_essen">
<option Value="1" selected>{labels.arrival_data.arrival.before_dinner}</option>
<option value="2">{labels.arrival_data.arrival.before_lunch}</option>
<option value="3">{labels.arrival_data.arrival.before_breakfast}</option>
<option value="4">{labels.arrival_data.arrival.no_meal}</option>
</select>
</td>
</tr>
<tr>
<td>{labels.arrival_data.departure.headline}:</td>
<td>
<input type="date" name="abreise" id="abreise" defaultValue={event_data.date_end} /><br />
<select name="abreise_essen">
<option value="1">{labels.arrival_data.departure.after_breakfast}</option>
<option selected value="2">{labels.arrival_data.departure.after_lunch}</option>
<option value="3">{labels.arrival_data.departure.after_dinner}</option>
<option value="4">{labels.arrival_data.departure.no_meal}</option>
</select>
</td>
</tr>
<tr>
<td colSpan="2">
<input type="button" value={labels.common.go_back} onClick={() => onStepClick(4)} /> &nbsp;
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</td>
</tr>
</table>
);
}
export default ArrivalContainer;

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { ContactPersonValidator } from '../../../../assets/javascripts/registration-validator.js'
function ContactPersonContainer({ onStepClick, labels, participant_data }) {
const handle_next_step = () => {
if (ContactPersonValidator() ) {
onStepClick(3);
}
}
return (
<table>
<tr>
<td colSpan="2">
<h3>{labels.headlines.contactperson}</h3>
</td>
</tr>
<tr>
<td>{labels.common.lastname}, {labels.common.firstname}:</td>
<td><input type="text" name="ansprechpartner" id="ansprechpartner" /></td>
</tr>
<tr>
<td>{labels.common.telephone}:</td>
<td><input type="text" name="telefon_2" id="telefon_2" /></td>
</tr>
<tr>
<td>{labels.common.email}:</td>
<td><input type="text" name="email_2" id="email_2"/></td>
</tr>
<tr>
<td>{labels.swimming_permission.label}:</td>
<td>
<select name="badeerlaubnis" id={"swimming_permission"}>
<option value="-1">{labels.common.please_select}</option>
<option value="none">{labels.swimming_permission.none}</option>
<option value="partial">{labels.swimming_permission.partial}</option>
<option value="complete">{labels.swimming_permission.complete}</option>
</select>
</td>
</tr>
<tr>
<td>{labels.swimming_permission.first_aid_headline}:
</td>
<td>
<select id="first_aid" name="first_aid" required>
<option value="-1">{labels.common.please_select}</option>
<option value="1">{labels.swimming_permission.first_aid_yes}</option>
<option value="0">{labels.swimming_permission.first_aid_no}</option>
</select><br />
<label className="description">{labels.swimming_permission.first_aid_description}</label>
</td>
</tr>
<tr>
<td colSpan="2">
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</td>
</tr>
</table>
);
}
export default ContactPersonContainer;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { ContactDataValidator } from '../../../../assets/javascripts/registration-validator.js'
function PersonalDataContainer({ onStepClick, labels, participant_data, local_groups }) {
const handle_next_step = () => {
if (ContactDataValidator()) {
onStepClick(4);
}
}
return (
<table>
<tr>
<td colSpan="2">
<h3>{labels.headlines.personaldata}</h3>
</td>
</tr>
<tr>
<td>{labels.common.firstname};</td>
<td><input defaultValue={participant_data.firstname} type="text" name="vorname" id="vorname" /></td>
</tr>
<tr>
<td>{labels.common.lastname}:</td>
<td><input defaultValue={participant_data.lastname} type="text" name="nachname" id="nachname" /></td>
</tr>
<tr>
<td>{labels.common.nickname}</td>
<td><input defaultValue={participant_data.nickname} type="text" name="pfadiname" id="pfadiname" /></td>
</tr>
<tr>
<td>{labels.common.localgroup}</td>
<td>
<select id="localgroup" name="localgroup" required placeholder="<?php echo esc_html__( 'Please select', 'solea' ); ?>">
<option value="-1">{labels.common.please_select}</option>
{local_groups.map((local_group, index) => (
<option
value={local_group.id}
selected={participant_data.localgroup == local_group.id}
>{local_group.name}
</option>
))
}
</select>
</td>
</tr>
<tr>
<td>{labels.common.birthday}:</td>
<td><input defaultValue={participant_data.birthday} type="date" name="geburtsdatum" id="geburtsdatum" /></td>
</tr>
<tr>
<td>{labels.common.street}, {labels.common.housenumber}:</td>
<td>
<input type="text" defaultValue={participant_data.street} name="strasse" id="strasse" />
<input type="text" defaultValue={participant_data.number} name="hausnummer" id="hausnummer" />
</td>
</tr>
<tr>
<td>
{labels.common.postal_code}, {labels.common.city}:</td>
<td>
<input type="text" defaultValue={participant_data.zip} name="plz" id="plz" />
<input type="text" defaultValue={participant_data.city} name="ort" id="ort" />
</td>
</tr>
<tr>
<td>{labels.common.telephone}:</td>
<td><input defaultValue={participant_data.phone} type="text" name="telefon_1" id="telefon_1" /></td>
</tr>
<tr>
<td>{labels.common.email}:</td>
<td><input defaultValue={participant_data.email} type="text" name="email_1" id="email_1" /></td>
</tr>
<tr>
<td colSpan="2">
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</td>
</tr>
</table>
);
}
export default PersonalDataContainer;

View File

@@ -0,0 +1,77 @@
import React from 'react';
function PhotopermissionsContainer({ onStepClick, labels, event_data }) {
const accept_all_permissions = () => {
document.getElementById( 'foto_socialmedia' ).checked = true;
document.getElementById( 'foto_print' ).checked = true;
document.getElementById( 'foto_webseite' ).checked = true;
document.getElementById( 'foto_partner' ).checked = true;
document.getElementById( 'foto_intern' ).checked = true;
handle_next_step();
}
const handle_next_step = () => {
onStepClick(8);
}
const handle_previous_step = () => {
if (event_data.addons.length > 0 || event_data.registration_mode === 'solidarity') {
onStepClick(6);
} else {
onStepClick(5);
}
}
return (
<div>
<p class="phptopermission_container">
<h3>{labels.photopermissions.headline}</h3>
</p>
<p>
<input type="checkbox" name="foto[socialmedia]" value="active" id="foto_socialmedia"/>
<label
htmlFor="foto_socialmedia">{labels.photopermissions.socialmedia}
</label><br/>
</p>
<p>
<input type="checkbox" name="foto[print]" value="active" id="foto_print"/>
<label
htmlFor="foto_print">{labels.photopermissions.printmedia}
</label><br/>
</p>
<p>
<input type="checkbox" name="foto[webseite]" value="active" id="foto_webseite"/>
<label
htmlFor="foto_webseite">{labels.photopermissions.websites}
</label><br/>
</p>
<p>
<input type="checkbox" name="foto[partner]" value="active" id="foto_partner"/>
<label
htmlFor="foto_partner">{labels.photopermissions.partners}
</label><br/>
</p>
<p>
<input type="checkbox" name="foto[intern]" value="active" id="foto_intern"/>
<label
htmlFor="foto_intern">{labels.photopermissions.internalpurpose}
</label><br/>
</p>
<input class="acceptallbutton" type="button"
value={labels.photopermissions.acceptall}
onClick={() => accept_all_permissions()}
/>
<input type="button" value={labels.common.go_back} onClick={() => handle_previous_step()} /> &nbsp;
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</div>
);
}
export default PhotopermissionsContainer;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { ContactDataValidator } from '../../../../assets/javascripts/registration-validator.js'
function GroupBasedRegistrationContainer({ onStepClick, labels, participant_data, local_groups, event_data }) {
const handle_next_step = () => {
onStepClick(5);
}
return (
<table>
<input defaultChecked={"checked"} type="radio" name="beitrag" value="regular" id="amount_regular" hidden />
<tr>
<td colSpan="2"><h3>{labels.registration_mode.group_based.headline}:</h3></td>
</tr>
{event_data.possible_participation_groups.map((participation_group, index) => (
<tr>
<td colSpan="2" >
<input type="radio"
defaultChecked={'participant' === participation_group.name}
name="participant_group"
value={participation_group.name}
id={participation_group.name} />
<label for={participation_group.name}>
{participation_group.name_readable}
{participation_group.description !== '' && (
<span><br /><span class="solea-group-info-text">{participation_group.description}</span><br /><br /></span>
)}
</label>
</td>
</tr>
))
}
<tr>
<td colSpan="2">
<input type="button" value={labels.common.go_back} onClick={() => onStepClick(3)} /> &nbsp;
<input type="button" value={labels.common.next} onClick= {() => handle_next_step()} />
</td>
</tr>
</table>
);
}
export default GroupBasedRegistrationContainer;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { ContactDataValidator } from '../../../../assets/javascripts/registration-validator.js'
function SolidarityRegistrationContainer({ onStepClick, labels, participant_data, local_groups, event_data }) {
const handle_next_step = () => {
onStepClick(5);
}
return (
<table>
<tr>
<td colSpan="2">
<h3>{labels.headlines.solidarity_headline}</h3>
</td>
</tr>
<tr>
<td>{labels.registration_mode.solidarity.headline}...</td>
<td>
{event_data.possible_participation_groups.map((participation_group, index) => (
<span>
<input
class="registratration_mode_solidatory_checkbox"
type="radio"
name="participant_group"
value="team"
defaultChecked={'participant' === participation_group.name}
id={participation_group.name}
/>
<label for={participation_group.name}>...{participation_group.description}</label><br/>
</span>
))
}
</td>
</tr>
<tr>
<td colSpan="2">
<input type="button" value={labels.common.go_back} onClick={() => onStepClick(3)} /> &nbsp;
<input type="button" value={labels.common.next} onClick={() => handle_next_step()}/>
</td>
</tr>
</table>
);
}
export default SolidarityRegistrationContainer;

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { ContactDataValidator } from '../../../../assets/javascripts/registration-validator.js'
function SummaryContainer({ onStepClick, labels, participant_data, local_groups }) {
const handle_next_step = () => {
onStepClick(4);
if (ContactDataValidator()) {
onStepClick(4);
}
}
return (
<table>
<tr>
<td colSpan="2">
<h3>{labels.headlines.summary}</h3>
</td>
</tr>
<tr>
<td>{labels.summary.event_name}:</td>
<td><span id="summary_eventname" /></td>
</tr>
<tr>
<td>{labels.summary.arrival}</td>
<td><span id="summary_arrival" /></td>
</tr>
<tr>
<td>{labels.summary.departure}:</td>
<td><span id="summary_departure" /></td>
</tr>
<tr><td colSpan="2"><br /><br /></td></tr>
<tr>
<td colSpan="2">
<input type="checkbox" id="summary_information_correct"/>
<label htmlFor="summary_information_correct" id="summary_information_correct_label">
{labels.summary.information_correct}
</label>
</td>
</tr>
<tr>
<td colSpan="2">
<input type="checkbox" id="summary_accept_terms"/>
<label htmlFor="summary_accept_terms" id="summary_accept_terms_label">
{labels.summary.accept_terms}
</label>
</td>
</tr>
<tr>
<td colSpan="2">
<input type="checkbox" id="legal_accepted"/>
<label htmlFor="legal_accepted" id="legal_accepted_label">
{labels.summary.legal_acceptance}
</label>
</td>
</tr>
<tr>
<td colSpan="2">
<input type="checkbox" id="payment"/>
<label htmlFor="payment" id="payment_label">
{labels.summary.amount_text_1} <span class="bold" id="payment_information_label"></span> {labels.summary.amount_text_2}
</label>
</td>
</tr>
</table>
);
}
export default SummaryContainer;

267
legacy/signup-main.jsx Normal file
View File

@@ -0,0 +1,267 @@
import React from 'react';
import AgeContainer from './partials/age.jsx';
import ContactPersonContainer from './partials/contactperson.jsx';
import PersonalDataContainer from './partials/personaldata.jsx';
import SolidarityRegistrationContainer from './partials/registration-solidarity.jsx';
import GroupSelectionContainer from './partials/registration-groupbased.jsx';
import ArrivalContainer from './partials/arrival.jsx';
import AddonsContainer from "./partials/addons.jsx";
import PhotopermissionsContainer from "./partials/photopermissions.jsx";
import AllergiesContainer from "./partials/allergies.jsx";
import SummaryContainer from "./partials/summary.jsx";
import { FinalValidator } from '../../../assets/javascripts/registration-validator.js'
import { SelectedAddons } from '../../../assets/javascripts/registration-validator.js'
import SuccessContainer from "./after-submit/success.jsx";
import ReactDOM from "react-dom/client";
import AlreadyExistsContainer from "./after-submit/alreadyexist.jsx";
import { __ } from '../../../assets/javascripts/library.js';
const { configuration, labels, participant_data, event_data, local_groups } = window.solea_data;
function showStep(step) {
var steps = [
'step_1', // Age-Selector
'step_2', // Children only: Contact data
'step_3', // Personal data
'step_4', // Participation group
'step_5', // Arival & Departure
'step_6', // Addon information
'step_7', // Photo-Permissions
'step_8', // Allergies & Intolerances
'step_9' // Summary
];
for (var idx = 0; idx < steps.length; idx++) {
var currentElement = steps[idx];
document.getElementById(currentElement).classList.add('container_hidden');
document.getElementById(currentElement).classList.remove('container_visible');
}
document.getElementById('step_' + step).classList.remove('container_hidden');
document.getElementById('step_' + step).classList.add('container_visible');
document.getElementById('summary_loading').classList.remove('container_hidden');
document.getElementById('summary_loading').classList.add('container_visible');
document.getElementById('summary_content').classList.add('container_hidden');
document.getElementById('summary_content').classList.remove('container_visible');
if (step === 9) {
var [ayear, amonth, aday] = document.getElementById('anreise').value.split('-');
var [dyear, dmonth, dday] = document.getElementById('abreise').value.split('-');
document.getElementById('summary_eventname').innerHTML= event_data.event_name;
document.getElementById('summary_arrival').innerHTML=`${aday}.${amonth}.${ayear}`;
document.getElementById('summary_departure').innerHTML=`${dday}.${dmonth}.${dyear}`;
const response = fetch("/wp-json/solea/signup/preview_amount", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
anreise: document.getElementById('anreise').value,
abreise: document.getElementById('abreise').value,
event_id: event_data.id,
participation_group: document.querySelector('input[name="participant_group"]:checked').value,
selected_amount: document.querySelector('input[name="beitrag"]:checked').value,
addons: SelectedAddons(),
}),
})
.then((res) => res.json())
.then((data) => {
document.getElementById('payment_information_label').innerHTML = data.amount
document.getElementById('summary_loading').classList.add('container_hidden');
document.getElementById('summary_loading').classList.remove('container_visible');
document.getElementById('summary_content').classList.add('container_visible');
document.getElementById('summary_content').classList.remove('container_hidden');
});
}
}
function ParticipantSignup() {
let RegistrationMode;
switch (event_data.registration_mode) {
case 'solidarity':
RegistrationMode = SolidarityRegistrationContainer;
break;
case 'groupbased':
RegistrationMode = GroupSelectionContainer;
break;
}
const handleSubmit = (e) => {
e.preventDefault();
const form = e.target;
const data = new FormData(form);
if (!FinalValidator()) {
return;
}
const formData = Object.fromEntries(data.entries());
const response = fetch("/wp-json/solea/signup/do-register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
addons: SelectedAddons(),
registration_data: formData
})
})
.then((res) => res.json())
.then((data) => {
const container = document.getElementById('solea_registration_form_frame');
const root = ReactDOM.createRoot(container);
if (data.already_exists === true) {
root.render(<AlreadyExistsContainer participant_data={data.result} labels={labels} />);
} else {
root.render(<SuccessContainer participant_data={data.result} />);
}
});
}
return (
<div className="solea-event-registration-container">
<table class="solea-event-meta-data-table">
<tr>
<td>{__('Event location', 'solea')}:</td>
<td>{event_data.postalcode} {event_data.location}</td>
</tr>
<tr>
<td>{__('Event duration', 'solea')}:</td>
<td>{event_data.date_begin_formatted} - {event_data.date_end_formatted}</td>
</tr>
<tr>
<td>{__('Regular registration until', 'solea')}:</td>
<td>{event_data.last_minute_begin_formatted}</td>
</tr>
<tr>
<td>
{__('Extended registration until', 'solea')}:
</td>
<td>
{event_data.registration_end_formatted}
</td>
</tr>
<tr>
<td>
{__('Cancellation', 'solea')}:
</td>
<td>
100% {__('until', 'solea')} {event_data.last_minute_begin_formatted} {__('available', 'solea')}<br />
{event_data.reduced_return}% {__('until', 'solea')} {event_data.registration_end_formatted} {__('available', 'solea')}<br />
</td>
</tr>
<tr>
<td colSpan="2">
{ __('Do you have questions for registration? Please contact us by email:', 'solea') }&nbsp;
<a href={"mailto:" + event_data.event_email}>{event_data.event_email}</a>
</td>
</tr>
</table>
<div id="solea_registration_form_frame">
<form id="registration_form" onSubmit={handleSubmit}>
<input type="hidden" name="solea-user-id" value={participant_data.id} />
<input type="hidden" name="event-id" value={event_data.id} />
<div id="step_1" className="container_visible">
<AgeContainer
onStepClick={showStep}
labels={labels}
configuration={configuration}
/>
</div>
<div id="step_2" className="container_hidden">
<ContactPersonContainer
onStepClick={showStep}
labels={labels}
participant_data={participant_data}
/>
</div>
<div id="step_3" className="container_hidden">
<PersonalDataContainer
onStepClick={showStep}
labels={labels}
participant_data={participant_data}
local_groups={local_groups}
/>
</div>
<div id="step_4" className="container_hidden">
<RegistrationMode
onStepClick={showStep}
labels={labels}
participant_data={participant_data}
local_groups={local_groups}
event_data={event_data}
/>
</div>
<div id="step_5" className="container_hidden">
<ArrivalContainer
onStepClick={showStep}
labels={labels}
event_data={event_data}
local_groups={local_groups}
/>
</div>
<div id="step_6" className="container_hidden">
<AddonsContainer
onStepClick={showStep}
labels={labels}
event_data={event_data}
local_groups={local_groups}
/>
</div>
<div id="step_7" className="container_hidden">
<PhotopermissionsContainer
event_data={event_data}
onStepClick={showStep}
labels={labels}
/>
</div>
<div id="step_8" className="container_hidden">
<AllergiesContainer
onStepClick={showStep}
labels={labels}
participant_data={participant_data}
event_data={event_data}
/>
</div>
<div id="step_9" className="container_hidden">
<p id="summary_loading">Wird geladen</p>
<p id="summary_content" className="container_hidden">
<SummaryContainer
onStepClick={showStep}
labels={labels}
participant_data={participant_data}
event_data={event_data}
/>
<input type="button" value={labels.common.go_back} onClick={() => showStep(8)} /> &nbsp;
<input className="acceptallbutton" type="submit" value={labels.common.confirm}/>
</p>
</div>
</form>
</div>
</div>
);
}
export default ParticipantSignup;

View File

@@ -3,8 +3,24 @@
@source '../**/*.blade.php';
@source '../**/*.js';
@font-face {
font-family: Aleo;
src: url('../fonts/aleo-regular.ttf');
}
@font-face {
font-family: Immenhausen;
src: url('../fonts/immenhausen.ttf');
}
html {
background-color: #FAFAFB !important;
font-family: Aleo;
}
h1, h2, h3, h4, h5, h6 {
font-family: Immenhausen;
}
.content {

Binary file not shown.

Binary file not shown.

BIN
public/images/adults.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

BIN
public/images/children.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\Dashboard\Controllers\DashboardController;
use App\Http\Controllers\GiroCodeGetController;
use App\Http\Controllers\TestRenderInertiaProvider;
use App\Middleware\IdentifyTenant;
use App\Providers\CronController;
@@ -22,6 +23,7 @@ require_once __DIR__ . '/../app/Domains/Event/Routes/api.php';
Route::get('/execute-crons', [CronTaskHandleProvider::class, 'run']);
Route::get('/print-girocode/{participantToken}', GiroCodeGetController::class);
Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/', DashboardController::class);