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:
parent
f5ef5968eb
commit
fc2a2c7873
@ -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
33
.env
@ -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}
|
||||
###
|
@ -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
3
assets/bootstrap.js
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import { startStimulusApp } from "@symfony/stimulus-bundle";
|
||||
|
||||
const app = startStimulusApp();
|
15
assets/controllers.json
Normal file
15
assets/controllers.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"controllers": {
|
||||
"@symfony/ux-turbo": {
|
||||
"turbo-core": {
|
||||
"enabled": true,
|
||||
"fetch": "eager"
|
||||
},
|
||||
"mercure-turbo-stream": {
|
||||
"enabled": false,
|
||||
"fetch": "eager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoints": []
|
||||
}
|
117
assets/controllers/csrf_protection_controller.js
Normal file
117
assets/controllers/csrf_protection_controller.js
Normal 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";
|
204
assets/controllers/form_controller.js
Normal file
204
assets/controllers/form_controller.js
Normal 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
1
assets/icons/delete.svg
Normal 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
1
assets/icons/loader.svg
Normal 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
1
assets/icons/plus.svg
Normal 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 |
@ -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;
|
||||
}
|
||||
|
@ -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
2391
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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],
|
||||
];
|
||||
|
54
config/packages/doctrine.yaml
Normal file
54
config/packages/doctrine.yaml
Normal 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
|
6
config/packages/doctrine_migrations.yaml
Normal file
6
config/packages/doctrine_migrations.yaml
Normal 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
|
@ -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
|
||||
|
5
config/packages/twig_component.yaml
Normal file
5
config/packages/twig_component.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
twig_component:
|
||||
anonymous_template_directory: 'components/'
|
||||
defaults:
|
||||
# Namespace & directory for components
|
||||
App\Twig\Components\: 'components/'
|
11
config/packages/validator.yaml
Normal file
11
config/packages/validator.yaml
Normal 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
|
@ -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
0
migrations/.gitignore
vendored
Normal file
63
src/Controller/TicketController.php
Normal file
63
src/Controller/TicketController.php
Normal 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');
|
||||
}
|
||||
}
|
14
src/DataObjects/PersonalData.php
Normal file
14
src/DataObjects/PersonalData.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
22
src/DataObjects/TicketData.php
Normal file
22
src/DataObjects/TicketData.php
Normal 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 = ''
|
||||
) {
|
||||
}
|
||||
}
|
20
src/DataObjects/TicketFormData.php
Normal file
20
src/DataObjects/TicketFormData.php
Normal 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
0
src/Entity/.gitignore
vendored
Normal file
152
src/Entity/Customer.php
Normal file
152
src/Entity/Customer.php
Normal 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
66
src/Entity/Payment.php
Normal 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
81
src/Entity/Ticket.php
Normal 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
25
src/Enum/TicketData.php
Normal 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
0
src/Repository/.gitignore
vendored
Normal file
18
src/Repository/CustomerRepository.php
Normal file
18
src/Repository/CustomerRepository.php
Normal 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);
|
||||
}
|
||||
}
|
18
src/Repository/PaymentRepository.php
Normal file
18
src/Repository/PaymentRepository.php
Normal 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);
|
||||
}
|
||||
}
|
18
src/Repository/TicketRepository.php
Normal file
18
src/Repository/TicketRepository.php
Normal 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);
|
||||
}
|
||||
}
|
129
src/Service/TicketService.php
Normal file
129
src/Service/TicketService.php
Normal 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
19
src/Twig/Environment.php
Normal 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];
|
||||
}
|
||||
}
|
96
symfony.lock
96
symfony.lock
@ -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": {
|
||||
|
@ -13,7 +13,7 @@
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<body data-turbo="true">
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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>
|
||||
|
61
templates/ticket/_partials/_form.html.twig
Normal file
61
templates/ticket/_partials/_form.html.twig
Normal 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>
|
92
templates/ticket/index.html.twig
Normal file
92
templates/ticket/index.html.twig
Normal 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 %}
|
16
templates/ticket/success.html.twig
Normal file
16
templates/ticket/success.html.twig
Normal 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 %}
|
Loading…
x
Reference in New Issue
Block a user