add ticket purchase page

Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Reviewed-on: http://git.simonis.lol/projects/abiball/pulls/13
Reviewed-by: jank1619 <jan@kjan.email>
This commit is contained in:
Constantin Simonis 2025-01-31 10:05:25 +00:00
parent f5ef5968eb
commit fc2a2c7873
42 changed files with 3637 additions and 176 deletions

View File

@ -7,8 +7,8 @@ xdebug_enabled: false
additional_hostnames: []
additional_fqdns: []
database:
type: mariadb
version: "10.11"
type: postgres
version: "17"
use_dns_when_possible: true
composer_version: "2"
web_environment: []

33
.env
View File

@ -1,26 +1,19 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
### SYMFONY
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###
###
###> symfony/mailer ###
### MAILER
MAILER_DSN=smtp://${MAILER_USER:-null}:${MAILER_PASSWORD:-null}@${MAILER_HOST:-localhost}:${MAILER_PORT:-1025}
CONTACT_MAIL=${CONTACT_MAIL:-contact@localhost}
SENDER_MAIL=${SENDER_MAIL:-noreply@localhost}
###< symfony/mailer ###
###
### DATABASE
DATABASE_URL="postgresql://${DB_USER:-db}:${DB_PW:-db}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-db}?serverVersion=16&charset=utf8"
###
### STRIPE
STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY}
STRIPE_SECRET_KEY=${STRIPE_PUBLIC_KEY}
###

View File

@ -1,9 +1,2 @@
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
import "./bootstrap.js";
import "./styles/app.css";

3
assets/bootstrap.js vendored Normal file
View File

@ -0,0 +1,3 @@
import { startStimulusApp } from "@symfony/stimulus-bundle";
const app = startStimulusApp();

15
assets/controllers.json Normal file
View File

@ -0,0 +1,15 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View File

@ -0,0 +1,117 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_\/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
document.addEventListener(
"submit",
function (event) {
generateCsrfToken(event.target);
},
true,
);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener("turbo:submit-start", function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener("turbo:submit-end", function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken(formElement) {
const csrfField = formElement.querySelector(
'input[data-controller="csrf-protection"], input[name="_csrf_token"]',
);
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute("data-csrf-protection-cookie-value");
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute(
"data-csrf-protection-cookie-value",
(csrfCookie = csrfToken),
);
csrfField.defaultValue = csrfToken = btoa(
String.fromCharCode.apply(
null,
(window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)),
),
);
csrfField.dispatchEvent(new Event("change", { bubbles: true }));
}
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie =
csrfCookie +
"_" +
csrfToken +
"=" +
csrfCookie +
"; path=/; samesite=strict";
document.cookie =
window.location.protocol === "https:"
? "__Host-" + cookie + "; secure"
: cookie;
}
}
export function generateCsrfHeaders(formElement) {
const headers = {};
const csrfField = formElement.querySelector(
'input[data-controller="csrf-protection"], input[name="_csrf_token"]',
);
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute(
"data-csrf-protection-cookie-value",
);
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken(formElement) {
const csrfField = formElement.querySelector(
'input[data-controller="csrf-protection"], input[name="_csrf_token"]',
);
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute(
"data-csrf-protection-cookie-value",
);
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie =
csrfCookie +
"_" +
csrfField.value +
"=0; path=/; samesite=strict; max-age=0";
document.cookie =
window.location.protocol === "https:"
? "__Host-" + cookie + "; secure"
: cookie;
}
}
/* stimulusFetch: 'lazy' */
export default "csrf-protection-controller";

View File

@ -0,0 +1,204 @@
import { Controller } from "@hotwired/stimulus";
import { loadStripe } from "@stripe/stripe-js";
export default class extends Controller {
static targets = [
"key",
"submit",
"firstname",
"lastname",
"email",
"phone",
"ticketType",
"foodType",
"note",
];
stripe;
async connect() {
this.stripe = await loadStripe(this.keyTarget.textContent);
}
addEntry(event) {
event.preventDefault();
const template = this.element
.querySelector(".forms")
.firstElementChild.cloneNode(true);
template.querySelectorAll("select, input").forEach((element) => {
element.value = "";
if (element.id) {
element.id = `${element.id}_${Date.now()}`;
}
});
template.querySelectorAll("label").forEach((label) => {
const forAttribute = label.getAttribute("for");
if (forAttribute) {
label.setAttribute("for", `${forAttribute}_${Date.now()}`);
}
});
template.classList.add("opacity-0");
this.element.querySelector(".forms").appendChild(template);
requestAnimationFrame(() => {
template.classList.remove("opacity-0");
template.classList.add("transition-all", "duration-300");
});
}
removeEntry(event) {
event.preventDefault();
const forms = this.element.querySelector(".forms");
if (forms.childElementCount <= 1) {
return;
}
const ticketElement = event.currentTarget.closest(".bg-white\\/80");
if (!ticketElement) return;
ticketElement.classList.add("opacity-0", "transition-all", "duration-300");
setTimeout(() => {
ticketElement.remove();
}, 300);
}
submit(event) {
event.preventDefault();
if (!this.validateForm()) {
return;
}
this.submitTarget.querySelector("span").classList.add("hidden");
this.submitTarget.querySelector("svg.arrow").classList.add("hidden");
this.submitTarget.querySelector("svg.loader").classList.remove("hidden");
const tickets = this.element.querySelectorAll(".forms > div");
const personalData = this.getPersonalData();
const ticketData = this.getTicketData(tickets);
const formData = {
personal: personalData,
tickets: ticketData,
};
fetch("/ticket/submit", {
method: "POST",
body: JSON.stringify(formData),
headers: {
"Content-Type": "application/json",
},
}).then((response) => {
if (!response.ok) {
this.submitTarget.querySelector("svg.error").classList.add("hidden");
this.submitTarget
.querySelector("svg.loader")
.classList.remove("hidden");
this.submitTarget.querySelector("span").classList.remove("hidden");
this.showError(
"Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
);
} else {
response.json().then((data) => {
this.stripe.redirectToCheckout({
sessionId: data.id,
});
});
}
});
}
validateForm() {
let isValid = true;
["firstname", "lastname", "email"].forEach((field) => {
const target = this[`${field}Target`];
if (!target.value.trim()) {
this.showFieldError(target);
isValid = false;
}
});
this.element.querySelectorAll(".forms > div").forEach((ticket) => {
const selects = ticket.querySelectorAll("select[required]");
selects.forEach((select) => {
if (!select.value) {
this.showFieldError(select);
isValid = false;
}
});
});
return isValid;
}
showFieldError(element) {
element.classList.add(
"border-red-500",
"focus:border-red-500",
"focus:ring-red-200",
);
element.addEventListener(
"input",
() => {
element.classList.remove(
"border-red-500",
"focus:border-red-500",
"focus:ring-red-200",
);
},
{ once: true },
);
}
showError(message) {
const errorDiv = document.createElement("div");
errorDiv.className =
"fixed top-4 right-4 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-lg transform transition-all duration-500 opacity-0 translate-x-4";
errorDiv.innerHTML = `
<div class="flex items-center">
<div class="flex-shrink-0">
<twig:ux:icon name="mdi:alert-circle" class="w-5 h-5 text-red-400" />
</div>
<div class="ml-3">
<p class="text-sm text-red-700">${message}</p>
</div>
</div>
`;
document.body.appendChild(errorDiv);
requestAnimationFrame(() => {
errorDiv.classList.remove("opacity-0", "translate-x-4");
});
setTimeout(() => {
errorDiv.classList.add("opacity-0", "translate-x-4");
setTimeout(() => errorDiv.remove(), 500);
}, 5000);
}
getTicketData(tickets) {
return Array.from(tickets).map((ticket) => this.extractTicketData(ticket));
}
getPersonalData() {
return {
firstname: this.firstnameTarget.value.trim(),
lastname: this.lastnameTarget.value.trim(),
email: this.emailTarget.value.trim(),
phone: this.phoneTarget.value.trim(),
};
}
extractTicketData(ticket) {
return {
ticket: parseInt(ticket.querySelector('select[name="ticket"]').value),
food: parseInt(ticket.querySelector('select[name="food"]').value),
note: ticket.querySelector('input[name="note"]').value.trim(),
};
}
}

1
assets/icons/delete.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 11v6m-4-6v6M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M4 7h16M7 7l2-4h6l2 4"/></svg>

After

Width:  |  Height:  |  Size: 276 B

1
assets/icons/loader.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2A10 10 0 1 0 22 12A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8A8 8 0 0 1 12 20Z" opacity=".5"/><path fill="currentColor" d="M20 12h2A10 10 0 0 0 12 2V4A8 8 0 0 1 20 12Z"><animateTransform attributeName="transform" dur="1s" from="0 12 12" repeatCount="indefinite" to="360 12 12" type="rotate"/></path></svg>

After

Width:  |  Height:  |  Size: 418 B

1
assets/icons/plus.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 0C229.232 0 0 229.232 0 512c0 282.784 229.232 512 512 512c282.784 0 512-229.216 512-512C1024 229.232 794.784 0 512 0m0 961.008c-247.024 0-448-201.984-448-449.01c0-247.024 200.976-448 448-448s448 200.977 448 448s-200.976 449.01-448 449.01M736 480H544V288c0-17.664-14.336-32-32-32s-32 14.336-32 32v192H288c-17.664 0-32 14.336-32 32s14.336 32 32 32h192v192c0 17.664 14.336 32 32 32s32-14.336 32-32V544h192c17.664 0 32-14.336 32-32s-14.336-32-32-32"/></svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@ -2,3 +2,10 @@
@tailwind components;
@tailwind utilities;
.text-input {
@apply border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 outline-orange-500 focus:border-orange-500 transition-colors;
}
option:disabled {
display: none;
}

View File

@ -7,7 +7,14 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/annotations": "^2.0",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"php-flasher/flasher-noty-symfony": "^2.1",
"phpdocumentor/reflection-docblock": "^5.6",
"stripe/stripe-php": "^16.4",
"symfony/asset": "7.2.*",
"symfony/asset-mapper": "7.2.*",
"symfony/console": "7.2.*",
@ -16,8 +23,15 @@
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mailer": "7.2.*",
"symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/serializer": "7.2.*",
"symfony/twig-bundle": "7.2.*",
"symfony/ux-icons": "^2.22",
"symfony/ux-turbo": "^2.22",
"symfony/ux-twig-component": "^2.22",
"symfony/validator": "7.2.*",
"symfony/yaml": "7.2.*",
"symfonycasts/tailwind-bundle": "^0.7.1",
"twig/extra-bundle": "^2.12|^3.0",

2391
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,4 +9,10 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Flasher\Symfony\FlasherSymfonyBundle::class => ['all' => true],
Flasher\Noty\Symfony\FlasherNotySymfonyBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
];

View File

@ -0,0 +1,54 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@ -3,7 +3,8 @@ framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
session:
cookie_samesite: 'lax'
#esi: true
#fragments: true

View File

@ -0,0 +1,5 @@
twig_component:
anonymous_template_directory: 'components/'
defaults:
# Namespace & directory for components
App\Twig\Components\: 'components/'

View File

@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@ -16,4 +16,16 @@ return [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
],
'@stripe/stripe-js' => [
'version' => '5.6.0',
],
];

0
migrations/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,63 @@
<?php
namespace App\Controller;
use App\DataObjects\TicketFormData;
use App\Service\TicketService;
use Stripe\Stripe;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
final class TicketController extends AbstractController
{
public function __construct(
private TicketService $service,
private SerializerInterface $serializer,
) {
}
#[Route('/ticket', name: 'app_ticket')]
public function index(): Response
{
return $this->render('ticket/index.html.twig');
}
#[Route(path: '/ticket/submit', name: 'app_submit', methods: Request::METHOD_POST)]
public function submit(Request $request): Response
{
$ticketData = $this->serializer->deserialize($request->getContent(), TicketFormData::class, 'json');
return $this->json(['id' => $this->service->handleTicketData($ticketData)->id]);
}
#[Route(path: '/success', name: 'app_success', methods: Request::METHOD_GET)]
public function success(Request $request): Response
{
$sessionId = $request->query->get('session_id');
if (!$sessionId) {
noty()->error('Etwas ist schiefgelaufen');
return $this->redirectToRoute('app_ticket');
}
if (!$this->service->completePayment($sessionId)) {
noty()->error('Etwas ist schiefgelaufen');
return $this->redirectToRoute('app_ticket');
}
return $this->render('ticket/success.html.twig');
}
#[Route(path: '/cancelled', name: 'app_cancelled', methods: Request::METHOD_GET)]
public function cancel(): Response
{
noty()->error('Bezahlung abgebrochen');
return $this->redirectToRoute('app_ticket');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\DataObjects;
class PersonalData
{
public function __construct(
public string $firstname,
public string $lastname,
public string $email,
public string $phone,
) {
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\DataObjects;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
class TicketData
{
public function __construct(
#[Assert\Range(min: 1, max: 3)]
#[SerializedName('ticket')]
public int $ticketType = 0,
#[Assert\Range(min: 1, max: 3)]
#[SerializedName('food')]
public int $foodType = 0,
public string $note = ''
) {
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\DataObjects;
use Symfony\Component\Serializer\Attribute\SerializedName;
class TicketFormData
{
/**
* @param PersonalData $personal
* @param TicketData[] $tickets
*/
public function __construct(
#[SerializedName('personal')]
public PersonalData $personal,
#[SerializedName('tickets')]
public array $tickets,
) {
}
}

0
src/Entity/.gitignore vendored Normal file
View File

152
src/Entity/Customer.php Normal file
View File

@ -0,0 +1,152 @@
<?php
namespace App\Entity;
use App\Repository\CustomerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 255)]
private ?string $firstname = null;
#[ORM\Column(length: 255)]
private ?string $lastname = null;
/**
* @var Collection<int, Ticket>
*/
#[ORM\OneToMany(targetEntity: Ticket::class, mappedBy: 'customer', cascade: ['persist'], fetch: 'EAGER', orphanRemoval: true)]
private Collection $tickets;
#[ORM\OneToOne(mappedBy: 'customer', cascade: ['persist', 'remove'])]
private ?Payment $payment = null;
public function __construct()
{
$this->tickets = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getFirstname(): ?string
{
return $this->firstname;
}
public function setFirstname(string $firstname): static
{
$this->firstname = $firstname;
return $this;
}
public function getLastname(): ?string
{
return $this->lastname;
}
public function setLastname(string $lastname): static
{
$this->lastname = $lastname;
return $this;
}
/**
* @return Collection<int, Ticket>
*/
public function getTickets(): Collection
{
return $this->tickets;
}
public function addTicket(Ticket $ticket): static
{
if (!$this->tickets->contains($ticket)) {
$this->tickets->add($ticket);
$ticket->setCustomer($this);
}
return $this;
}
public function removeTicket(Ticket $ticket): static
{
if ($this->tickets->removeElement($ticket)) {
// set the owning side to null (unless already changed)
if ($ticket->getCustomer() === $this) {
$ticket->setCustomer(null);
}
}
return $this;
}
public function addTickets(array $tickets): static
{
foreach ($tickets as $ticket) {
$this->addTicket($ticket);
}
return $this;
}
public function getPayment(): ?Payment
{
return $this->payment;
}
public function setPayment(Payment $payment): static
{
// set the owning side of the relation if necessary
if ($payment->getCustomer() !== $this) {
$payment->setCustomer($this);
}
$this->payment = $payment;
return $this;
}
}

66
src/Entity/Payment.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace App\Entity;
use App\Repository\PaymentRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PaymentRepository::class)]
class Payment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $sessionId = null;
#[ORM\Column]
private ?bool $completed = null;
#[ORM\OneToOne(inversedBy: 'payment', cascade: ['persist', 'remove'], fetch: 'EAGER')]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
public function getId(): ?int
{
return $this->id;
}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function setSessionId(string $sessionId): static
{
$this->sessionId = $sessionId;
return $this;
}
public function isCompleted(): ?bool
{
return $this->completed;
}
public function setCompleted(bool $completed): static
{
$this->completed = $completed;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(Customer $customer): static
{
$this->customer = $customer;
return $this;
}
}

81
src/Entity/Ticket.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace App\Entity;
use App\Repository\TicketRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TicketRepository::class)]
class Ticket
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private ?int $type = null;
#[ORM\Column]
private ?int $foodType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $note = null;
#[ORM\ManyToOne(inversedBy: 'tickets')]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?int
{
return $this->type;
}
public function setType(int $type): static
{
$this->type = $type;
return $this;
}
public function getFoodType(): ?int
{
return $this->foodType;
}
public function setFoodType(int $foodType): static
{
$this->foodType = $foodType;
return $this;
}
public function getNote(): ?string
{
return $this->note;
}
public function setNote(?string $note): static
{
$this->note = $note;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): static
{
$this->customer = $customer;
return $this;
}
}

25
src/Enum/TicketData.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Enum;
class TicketData
{
public const TICKET_DATA = [
1 => [
'name' => 'Ticket',
'price' => 50,
],
2 => [
'name' => 'After-Show Ticket',
'price' => 20,
],
3 => [
'name' => 'Kind (6-12 Jahre)',
'price' => 0,
],
4 => [
'name' => 'Kind (0-6 Jahre)',
'price' => 0,
],
];
}

0
src/Repository/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Customer;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Customer>
*/
class CustomerRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Customer::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Payment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Payment>
*/
class PaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Payment::class);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\Ticket;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Ticket>
*/
class TicketRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Ticket::class);
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Service;
use App\DataObjects\TicketData;
use App\DataObjects\TicketFormData;
use App\Entity\Customer;
use App\Entity\Payment;
use App\Entity\Ticket;
use App\Enum\TicketData as TicketEnum;
use App\Repository\PaymentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Checkout\Session;
use Stripe\Stripe;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TicketService
{
public function __construct(
private readonly UrlGeneratorInterface $generator,
private readonly EntityManagerInterface $em,
private readonly PaymentRepository $paymentRepository,
#[Autowire(env: 'STRIPE_SECRET_KEY')]
string $stripeKey
) {
Stripe::setApiKey($stripeKey);
}
public function handleTicketData(TicketFormData $data): Session
{
$session = $this->createSession($this->getLineItems($data->tickets), $data->personal->email);
$this->saveTicketData($data, $session->id);
return $session;
}
public function saveTicketData(TicketFormData $data, string $sessionId): void
{
$payment = (new Payment())
->setSessionId($sessionId)
->setCompleted(false)
->setCustomer($this->createEntityFromData($data));
$this->em->persist($payment);
$this->em->flush();
}
public function completePayment(string $sessionId): bool
{
if (!$payment = $this->paymentRepository->findOneBy(['sessionId' => $sessionId])) {
return false;
}
$payment->setCompleted(true);
$this->em->flush();
return true;
}
private function getLineItems(array $tickets): array
{
$lineItems = [];
foreach ($tickets as $ticket) {
$ticketData = TicketEnum::TICKET_DATA[$ticket->ticketType];
$lineItems[] = [
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => $ticketData['name'],
],
'unit_amount' => $ticketData['price'] * 100,
],
'quantity' => 1,
];
}
return $lineItems;
}
private function createSession(array $lineItems, string $email): Session
{
return Session::create([
'line_items' => $lineItems,
'mode' => 'payment',
'customer_email' => $email,
'success_url' => $this->generator->generate('app_success', [], 0) . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $this->generator->generate('app_cancelled', [], 0),
]);
}
private function createEntityFromData(TicketFormData $ticketData): Customer
{
$personalData = $ticketData->personal;
$entity = (new Customer())
->setFirstname($personalData->firstname)
->setLastname($personalData->lastname)
->setEmail($personalData->email)
->setPhone($personalData->phone);
$entity->addTickets($this->createTicketEntities($ticketData->tickets));
$this->em->persist($entity);
$this->em->flush();
return $entity;
}
/**
* @param TicketData[] $tickets
*/
private function createTicketEntities(array $tickets): array
{
$entities = [];
foreach ($tickets as $ticket) {
$entities[] = (new Ticket())
->setType($ticket->ticketType)
->setFoodType($ticket->foodType)
->setNote($ticket->note);
}
return $entities;
}
}

19
src/Twig/Environment.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class Environment extends AbstractExtension
{
public function getFunctions(): array
{
return [new TwigFunction('env', $this->getVar(...))];
}
public function getVar(string $name): string
{
return $_ENV[$name];
}
}

View File

@ -1,4 +1,40 @@
{
"doctrine/annotations": {
"version": "2.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.10",
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
}
},
"doctrine/doctrine-bundle": {
"version": "2.13",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "8d96c0b51591ffc26794d865ba3ee7d193438a83"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"php-flasher/flasher-noty-symfony": {
"version": "v2.1.2"
},
@ -110,6 +146,21 @@
"config/routes.yaml"
]
},
"symfony/stimulus-bundle": {
"version": "2.22",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "3acc494b566816514a6873a89023a35440b6386d"
},
"files": [
"assets/bootstrap.js",
"assets/controllers.json",
"assets/controllers/csrf_protection_controller.js",
"assets/controllers/hello_controller.js"
]
},
"symfony/twig-bundle": {
"version": "7.2",
"recipe": {
@ -123,6 +174,51 @@
"templates/base.html.twig"
]
},
"symfony/ux-icons": {
"version": "2.22",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.17",
"ref": "803a3bbd5893f9584969ab8670290cdfb6a0a5b5"
},
"files": [
"assets/icons/symfony.svg"
]
},
"symfony/ux-turbo": {
"version": "2.22",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "c85ff94da66841d7ff087c19cbcd97a2df744ef9"
}
},
"symfony/ux-twig-component": {
"version": "2.22",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "67814b5f9794798b885cec9d3f48631424449a01"
},
"files": [
"config/packages/twig_component.yaml"
]
},
"symfony/validator": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.2",
"recipe": {

View File

@ -13,7 +13,7 @@
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>
<body data-turbo="true">
{% block body %}{% endblock %}
</body>
</html>

View File

@ -26,7 +26,7 @@
</div>
<div class="text-center space-y-6">
<a href="" class="inline-block bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white px-8 sm:px-10 md:px-12 py-4 sm:py-5 rounded-full text-base sm:text-lg font-semibold shadow-xl hover:shadow-2xl transition-all duration-300">
<a href="{{ path('app_ticket') }}" class="inline-block bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white px-8 sm:px-10 md:px-12 py-4 sm:py-5 rounded-full text-base sm:text-lg font-semibold shadow-xl hover:shadow-2xl transition-all duration-300">
Tickets kaufen
</a>
</div>

View File

@ -0,0 +1,61 @@
<div class="bg-white/80 backdrop-blur-sm rounded-2xl border border-gray-100 p-5 hover:shadow-lg transition-all duration-300 group">
<div class="grid grid-cols-12 gap-5">
<div class="relative col-span-5">
<div class="relative group">
<twig:ux:icon name="mingcute:ticket-fill" class="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-orange-400 pointer-events-none transition-colors group-hover:text-orange-500" />
<select name="ticket"
required
data-form-target="ticketType"
class="w-full pl-11 pr-10 py-3.5 rounded-xl appearance-none bg-white border border-gray-200 text-gray-800 font-medium text-[15px] focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all hover:border-orange-300">
<option value="" disabled selected>Bitte wählen</option>
<option value="1">All-Inclusive Ticket</option>
<option value="2">After-Show Ticket</option>
<option value="3">Kinder-Ticket (6-12 Jahre)</option>
<option value="4">Klein-Kind (0-6 Jahre)</option>
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-3.5 pointer-events-none">
<twig:ux:icon name="mdi:chevron-down" class="w-5 h-5 text-orange-400 transition-colors group-hover:text-orange-500" />
</div>
</div>
</div>
<div class="relative col-span-4">
<div class="relative group">
<twig:ux:icon name="mdi:food" class="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-orange-400 pointer-events-none transition-colors group-hover:text-orange-500" />
<select name="food"
required
data-form-target="foodType"
class="w-full pl-11 pr-10 py-3.5 rounded-xl appearance-none bg-white border border-gray-200 text-gray-800 font-medium text-[15px] focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all hover:border-orange-300">
<option value="" disabled selected>Bitte wählen</option>
<option value="1">Mit Fleisch</option>
<option value="2">Vegetarisch</option>
<option value="3">Vegan</option>
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-3.5 pointer-events-none">
<twig:ux:icon name="mdi:chevron-down" class="w-5 h-5 text-orange-400 transition-colors group-hover:text-orange-500" />
</div>
</div>
</div>
<div class="relative col-span-2">
<div class="relative group">
<twig:ux:icon name="mdi:note" class="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-orange-400 pointer-events-none transition-colors group-hover:text-orange-500" />
<input type="text"
placeholder="Optional"
name="note"
data-form-target="note"
class="w-full pl-11 pr-4 py-3.5 rounded-xl border border-gray-200 text-gray-800 text-[15px] placeholder-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all hover:border-orange-300" />
</div>
</div>
<div class="flex justify-center items-end col-span-1 pb-1">
<button data-action="form#removeEntry"
type="button"
class="group/btn p-3 hover:bg-red-50 rounded-xl transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-red-200"
title="Ticket entfernen">
<twig:ux:icon name="mingcute:delete-fill"
class="w-5 h-5 text-red-400 group-hover/btn:text-red-500 transition-colors" />
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,92 @@
{% extends 'base.html.twig' %}
{% block title %}Tickets kaufen{% endblock %}
{% block body %}
<div class="min-h-screen bg-white relative overflow-hidden">
<header class="w-full bg-white/90 backdrop-blur-md shadow-lg fixed top-0 z-50 border-b border-gray-100">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4 flex justify-between items-center">
<a href="{{ path('app_home') }}" class="flex items-center space-x-2 group">
<img src="{{ asset('images/logo.png') }}" alt="Logo" class="w-32 sm:w-36 md:w-40 h-auto group-hover:opacity-90 transition-opacity" />
</a>
<a href="{{ path('app_home') }}" class="bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white px-6 py-2.5 rounded-full text-sm font-medium shadow-md hover:shadow-lg transition-all duration-300 flex items-center space-x-2">
<twig:ux:icon name="line-md:home" class="w-4 h-4" />
<span>Zurück zur Startseite</span>
</a>
</div>
</header>
<main class="container mx-auto px-4 sm:px-6 lg:px-8 pt-24 sm:pt-28 md:pt-32 pb-16 sm:pb-20 flex flex-col items-center justify-center relative z-10">
<div class="w-full max-w-3xl">
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold text-center mb-8 tracking-tight leading-tight">
<span class="bg-clip-text text-transparent bg-gradient-to-r from-red-500 to-orange-500">Tickets kaufen</span>
</h1>
<div class="bg-white/90 backdrop-blur-md shadow-2xl rounded-3xl p-8 md:p-10 mb-12 transform transition-all duration-300 border border-gray-100">
<div data-controller="form" class="space-y-8">
<div class="hidden" data-form-target="key">{{ env('STRIPE_PUBLIC_KEY')}}</div>
<section>
<h2 class="text-2xl font-semibold mb-6 flex items-center space-x-3">
<twig:ux:icon name="mdi:user-circle" class="w-6 h-6 text-orange-500" />
<span>Persönliche Daten</span>
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div class="relative">
<twig:ux:icon name="mdi:user-circle" class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input name="firstname" data-form-target="firstname" class="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all" type="text" placeholder="Vorname*" required>
</div>
<div class="relative">
<twig:ux:icon name="mdi:user-circle" class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input name="lastname" data-form-target="lastname" class="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all" type="text" placeholder="Nachname*" required>
</div>
<div class="relative">
<twig:ux:icon name="mdi:envelope" class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input name="email" data-form-target="email" class="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all" type="email" placeholder="E-Mail*" required>
</div>
<div class="relative">
<twig:ux:icon name="ic:baseline-phone" class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input name="phone" data-form-target="phone" class="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-orange-500 focus:ring-2 focus:ring-orange-200 transition-all" type="tel" placeholder="Telefon (optional)">
</div>
</div>
</section>
<div class="h-px bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div>
<section>
<h2 class="text-2xl font-semibold mb-6 flex items-center space-x-3">
<twig:ux:icon name="mingcute:ticket-fill" class="w-6 h-6 text-orange-500" />
<span>Tickets</span>
</h2>
<div class="grid grid-cols-4 gap-4 mb-4 px-4 text-sm font-medium text-gray-600">
<div>Ticket</div>
<div>Ernährung</div>
<div>Anmerkungen</div>
<div class="text-center">Aktion</div>
</div>
<div class="forms space-y-4">
{% include 'ticket/_partials/_form.html.twig' %}
</div>
<div class="flex items-center justify-between mt-6 pt-4">
<button data-action="form#addEntry" type="button" class="flex items-center space-x-2 text-orange-500 hover:text-orange-600 transition-colors px-4 py-2 rounded-lg hover:bg-orange-50">
<twig:ux:icon name="line-md:plus-circle-filled" class="w-5 h-5" />
<span>Ticket hinzufügen</span>
</button>
<button type="submit" data-action="form#submit" data-form-target="submit" class="bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white px-8 py-3 rounded-xl text-sm font-medium shadow-md hover:shadow-lg transition-all duration-300 flex items-center space-x-2">
<span>Jetzt bestellen</span>
<twig:ux:icon name="line-md:arrow-right-circle" class="arrow w-4 h-4" />
<twig:ux:icon name="loader" class="loader hidden w-4 h-4 animate-spin" />
</button>
</div>
</section>
</div>
</div>
</div>
</main>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends 'base.html.twig' %}
{% block title %}
Success
{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="col-md-12">
<h1 class="text-center">Success</h1>
<p class="text-center">Your ticket has been successfully created.</p>
</div>
</div>
</div>
{% endblock %}