Compare commits

...

5 Commits

Author SHA1 Message Date
7f540e8bb5
wip 2025-02-13 16:44:25 +01:00
6abc15ef77
add some stuff 2025-02-13 16:44:23 +01:00
def4b27bcd
add success email 2025-02-13 16:43:35 +01:00
2ccba65185 add admin panel
Co-authored-by: Jan-Marlon Leibl <jleibl@proton.me>
Reviewed-on: #20
2025-02-09 14:59:31 +00:00
e3a67adf63 adjust form spacing (#21)
Reviewed-on: #21
Reviewed-by: Hop In, I Have Puppies AND WiFi <jleibl@noreply@simonis.lol>
Co-authored-by: Constantin Simonis <constantin@simonis.lol>
Co-committed-by: Constantin Simonis <constantin@simonis.lol>
2025-02-07 14:13:49 +00:00
38 changed files with 1875 additions and 39 deletions

5
.env
View File

@ -16,4 +16,9 @@ DATABASE_URL="postgresql://${DB_USER:-db}:${DB_PW:-db}@${DB_HOST:-db}:${DB_PORT:
### STRIPE ### STRIPE
STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY} STRIPE_PUBLIC_KEY=${STRIPE_PUBLIC_KEY}
STRIPE_SECRET_KEY=${STRIPE_PUBLIC_KEY} STRIPE_SECRET_KEY=${STRIPE_PUBLIC_KEY}
###
### ADMIN PANEL
USER_PASSWORD=${USER_PASSWORD}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
### ###

View File

@ -1,4 +1,6 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
APP_SECRET=5a866a6ab3ce4ef99240ba643868b123 APP_SECRET=5a866a6ab3ce4ef99240ba643868b123
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
USER_PASSWORD=\$2y\$13\$z/XlUykvakLzDR8TeFrQk.jmGuOKOcULlMY/m17aWmkY4f4NrIaam
ADMIN_PASSWORD=\$2y\$13\$z/XlUykvakLzDR8TeFrQk.jmGuOKOcULlMY/m17aWmkY4f4NrIaam

View File

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

View File

@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-bundle": "^2.13",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"easycorp/easyadmin-bundle": "^4.24",
"php-flasher/flasher-noty-symfony": "^2.1", "php-flasher/flasher-noty-symfony": "^2.1",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"stripe/stripe-php": "^16.4", "stripe/stripe-php": "^16.4",

1050
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -15,4 +15,6 @@ return [
Symfony\UX\Turbo\TurboBundle::class => ['all' => true], Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true], Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
]; ];

View File

@ -0,0 +1,55 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory:
memory:
users:
user: { password: '%env(USER_PASSWORD)%', roles: ['ROLE_ADMIN'] }
admin: { password: '%env(ADMIN_PASSWORD)%', roles: ['ROLE_SUPER_ADMIN'] }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
custom_authenticator: App\Security\AdminPanelAuthenticator
form_login:
login_path: /admin/login
check_path: /admin/login
logout:
path: /admin/logout
target: /admin/login
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
role_hierarchy:
ROLE_SUPER_ADMIN: ROLE_ADMIN
access_control:
- { path: ^/admin/login, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@ -0,0 +1,7 @@
framework:
default_locale: de
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- de
providers:

View File

@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Payment;
use App\Enum\TicketData;
use Doctrine\Common\Collections\Collection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TelephoneField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use App\Form\TicketType;
class CustomerCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Customer::class;
}
public function configureFields(string $pageName): iterable
{
yield TextField::new('firstname', 'Vorname');
yield TextField::new('lastname', 'Nachname');
yield EmailField::new('email', 'E-Mail');
yield TelephoneField::new('phone', 'Telefon');
yield AssociationField::new('payment', 'Total')
->setCrudController(PaymentCrudController::class)
->formatValue(fn(?Payment $payment) => ($payment?->getTotal() ?? 0.0) . ' €')
->hideOnIndex()
->hideOnForm();
yield CollectionField::new('tickets')
->allowAdd()
->allowDelete()
->setEntryType(TicketType::class)
->setFormTypeOptions(['by_reference' => false])
->setTemplatePath('admin/customer_tickets.html.twig')
->hideOnIndex();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->disable(Action::DELETE)
->setPermission(Action::NEW, 'ROLE_SUPER_ADMIN')
->setPermission(Action::EDIT, 'ROLE_SUPER_ADMIN');
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setPageTitle(Crud::PAGE_INDEX, 'Kunden')
->setPageTitle(Crud::PAGE_DETAIL, 'Kunden')
->showEntityActionsInlined();
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Payment;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
class PaymentCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Payment::class;
}
public function configureFields(string $pageName): iterable
{
yield AssociationField::new('customer', 'Kunde')
->setCrudController(CustomerCrudController::class)
->formatValue(fn($value, $payment) => $payment->getCustomer()->getEmail());
yield BooleanField::new('completed', 'Bezahlt')->renderAsSwitch(false);
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->disable(Action::DELETE)
->disable(Action::NEW)
->setPermission(Action::EDIT, 'ROLE_SUPER_ADMIN');
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setPageTitle(Crud::PAGE_INDEX, 'Zahlungen')
->setPageTitle(Crud::PAGE_DETAIL, 'Zahlung')
->showEntityActionsInlined();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
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\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route(path: '/admin/login', name: 'admin_login', methods: [Request::METHOD_GET, Request::METHOD_POST])]
public function login(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('admin/login.html.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError(),
]);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Ticket;
use App\Enum\FoodData;
use App\Enum\TicketData;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
class TicketCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Ticket::class;
}
public function configureFields(string $pageName): iterable
{
yield ChoiceField::new('type', 'Name')
->setChoices(TicketData::TYPES);
yield ChoiceField::new('foodType', 'Ernährung')
->setChoices(FoodData::TYPES);
yield AssociationField::new('customer', 'Kunde')
->setCrudController(CustomerCrudController::class)
->formatValue(fn(Customer $customer) => $customer->getEmail())
->hideOnForm();
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->disable(Action::DELETE)
->disable(Action::NEW)
->setPermission(Action::EDIT, 'ROLE_SUPER_ADMIN');
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setPageTitle(Crud::PAGE_INDEX, 'Tickets')
->setPageTitle(Crud::PAGE_DETAIL, 'Ticket')
->showEntityActionsInlined();
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Payment;
use App\Entity\Ticket;
use App\Repository\PaymentRepository;
use App\Repository\TicketRepository;
use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminDashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[AdminDashboard(routePath: '/admin', routeName: 'admin')]
class ViewController extends AbstractDashboardController
{
public function __construct(private readonly PaymentRepository $paymentRepository, private readonly TicketRepository $ticketRepository)
{
}
#[Route('/admin', name: 'admin')]
public function index(): Response
{
return $this->render('admin/dashboard.html.twig', [
'totalMoney' => $this->paymentRepository->getTotalMoneyMade(),
'foodData' => $this->ticketRepository->getFoodData(),
'totalTickets' => $this->ticketRepository->count(),
]);
}
public function configureDashboard(): Dashboard
{
return Dashboard::new()->setTitle('Abiball Admin Interface');
}
public function configureMenuItems(): iterable
{
yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');
yield MenuItem::section('Daten');
yield MenuItem::linkToCrud('Kunden', 'fa fa-users', Customer::class);
yield MenuItem::linkToCrud('Tickets', 'fa fa-ticket', Ticket::class);
yield MenuItem::linkToCrud('Zahlungen', 'fa fa-money', Payment::class);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SuccessController extends AbstractController
{
#[Route(path: '/success', name: 'app_success_page', methods: Request::METHOD_GET)]
public function __invoke(): Response
{
return $this->render('ticket/success.html.twig');
}
}

View File

@ -33,7 +33,7 @@ final class TicketController extends AbstractController
return $this->json(['id' => $this->service->handleTicketData($ticketData)->id]); return $this->json(['id' => $this->service->handleTicketData($ticketData)->id]);
} }
#[Route(path: '/success', name: 'app_success', methods: Request::METHOD_GET)] #[Route(path: '/success', name: 'app_order_success', methods: Request::METHOD_GET)]
public function success(Request $request): Response public function success(Request $request): Response
{ {
$sessionId = $request->query->get('session_id'); $sessionId = $request->query->get('session_id');
@ -50,7 +50,7 @@ final class TicketController extends AbstractController
return $this->redirectToRoute('app_ticket'); return $this->redirectToRoute('app_ticket');
} }
return $this->render('ticket/success.html.twig'); return $this->redirectToRoute('app_success_page');
} }
#[Route(path: '/cancelled', name: 'app_cancelled', methods: Request::METHOD_GET)] #[Route(path: '/cancelled', name: 'app_cancelled', methods: Request::METHOD_GET)]

View File

@ -0,0 +1,13 @@
<?php
namespace App\DataObjects;
class FoodData
{
public function __construct(
public int $totalMeat = 0,
public int $totalVegetarian = 0,
public int $totalVegan = 0,
) {
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\DataObjects;
use Symfony\Component\HttpFoundation\Request;
class LoginData
{
public function __construct(
public ?string $username = '',
public ?string $password = ''
) {
}
public static function fromRequest(Request $request): self
{
return new self(
$request->get('_username'),
$request->get('_password'),
);
}
}

View File

@ -8,7 +8,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CustomerRepository::class)] #[ORM\Entity(repositoryClass: CustomerRepository::class)]
class Customer class Customer implements \Stringable
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@ -149,4 +149,9 @@ class Customer
return $this; return $this;
} }
public function __toString(): string
{
return $this->firstname . ' ' . $this->lastname;
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Entity; namespace App\Entity;
use App\Enum\TicketData;
use App\Repository\PaymentRepository; use App\Repository\PaymentRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@ -63,4 +64,11 @@ class Payment
return $this; return $this;
} }
public function getTotal(): float
{
return $this->customer->getTickets()->reduce(function (float $total, Ticket $ticket) {
return $total + TicketData::TICKET_DATA[$ticket->getType()]['price'];
}, 0);
}
} }

View File

@ -2,11 +2,12 @@
namespace App\Entity; namespace App\Entity;
use App\Enum\TicketData;
use App\Repository\TicketRepository; use App\Repository\TicketRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TicketRepository::class)] #[ORM\Entity(repositoryClass: TicketRepository::class)]
class Ticket class Ticket implements \Stringable
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@ -78,4 +79,9 @@ class Ticket
return $this; return $this;
} }
public function __toString(): string
{
return TicketData::TICKET_DATA[$this->type]['name'];
}
} }

19
src/Enum/FoodData.php Normal file
View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enum;
class FoodData
{
public const FOOD_DATA = [
1 => 'Mit Fleisch',
2 => 'Vegetarisch',
3 => 'Vegan'
];
public const TYPES = [
'Mit Fleisch' => 1,
'Vegetarisch' => 2,
'Vegan' => 3,
];
}

View File

@ -22,4 +22,11 @@ class TicketData
'price' => 0, 'price' => 0,
], ],
]; ];
public const TYPES = [
'All-Inclusive Ticket' => 1,
'After-Show Ticket' => 2,
'Kind (6-12 Jahre)' => 3,
'Kind (0-6 Jahre)' => 4,
];
} }

48
src/Form/TicketType.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Form;
use App\Entity\Ticket;
use App\Enum\FoodData;
use App\Enum\TicketData;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TicketType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('type', ChoiceType::class, [
'label' => 'Ticket Type',
'choices' => TicketData::TYPES,
'row_attr' => [
'style' => 'flex: 1;',
],
])
->add('foodType', ChoiceType::class, [
'label' => 'Ernährung',
'choices' => FoodData::TYPES,
'row_attr' => [
'style' => 'flex: 1;',
],
])
->add('note', TextType::class, [
'label' => 'Notiz',
'required' => false,
'row_attr' => [
'style' => 'flex: 2;',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Ticket::class,
]);
}
}

View File

@ -15,4 +15,15 @@ class PaymentRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, Payment::class); parent::__construct($registry, Payment::class);
} }
public function getTotalMoneyMade(): float
{
$all = $this->findAll();
$total = 0.0;
foreach ($all as $payment) {
$total += $payment->getTotal();
}
return $total;
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Repository; namespace App\Repository;
use App\DataObjects\FoodData;
use App\Entity\Ticket; use App\Entity\Ticket;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -15,4 +16,22 @@ class TicketRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, Ticket::class); parent::__construct($registry, Ticket::class);
} }
public function getFoodData(): FoodData
{
$qb = $this->createQueryBuilder('t')
->select(
'SUM(CASE WHEN t.foodType = 1 THEN 1 ELSE 0 END) as totalMeat',
'SUM(CASE WHEN t.foodType = 2 THEN 1 ELSE 0 END) as totalVegetarian',
'SUM(CASE WHEN t.foodType = 3 THEN 1 ELSE 0 END) as totalVegan'
);
$result = $qb->getQuery()->getSingleResult();
return new FoodData(
(int) $result['totalMeat'],
(int) $result['totalVegetarian'],
(int) $result['totalVegan']
);
}
} }

View File

@ -0,0 +1,45 @@
<?php
namespace App\Security;
use App\DataObjects\LoginData;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class AdminPanelAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return str_starts_with($request->getRequestUri(), '/admin') && $request->isMethod(Request::METHOD_POST);
}
public function authenticate(Request $request): Passport
{
$data = LoginData::fromRequest($request);
if ($request->isMethod(Request::METHOD_POST) && (!$data->password || !$data->username)) {
throw new CustomUserMessageAuthenticationException();
}
return new Passport(new UserBadge($data->username), new PasswordCredentials($data->password), [new RememberMeBadge()]);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse('/admin');
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return null;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Payment;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
class TicketEmailService
{
public function __construct(
private readonly MailerInterface $mailer,
#[Autowire(env: 'SENDER_MAIL')]
private readonly string $senderMail
) {
}
public function sendSuccessEmail(Payment $payment): void
{
$mail = (new TemplatedEmail())
->htmlTemplate('email/order.html.twig')
->subject('Abiball Ticket')
->from(new Address($this->senderMail, 'Noreply'))
->to($payment->getCustomer()?->getEmail())
->context(['payment' => $payment]);
$this->mailer->send($mail);
}
}

View File

@ -21,6 +21,7 @@ class TicketService
private readonly UrlGeneratorInterface $generator, private readonly UrlGeneratorInterface $generator,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly PaymentRepository $paymentRepository, private readonly PaymentRepository $paymentRepository,
private readonly TicketEmailService $emailService,
#[Autowire(env: 'STRIPE_SECRET_KEY')] #[Autowire(env: 'STRIPE_SECRET_KEY')]
string $stripeKey string $stripeKey
) { ) {
@ -36,18 +37,6 @@ class TicketService
return $session; 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 public function completePayment(string $sessionId): bool
{ {
if (!$payment = $this->paymentRepository->findOneBy(['sessionId' => $sessionId])) { if (!$payment = $this->paymentRepository->findOneBy(['sessionId' => $sessionId])) {
@ -56,10 +45,24 @@ class TicketService
$payment->setCompleted(true); $payment->setCompleted(true);
$this->em->flush(); $this->em->flush();
dd();
$this->emailService->sendSuccessEmail($payment);
return true; return true;
} }
private 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();
}
private function getLineItems(array $tickets): array private function getLineItems(array $tickets): array
{ {
$lineItems = []; $lineItems = [];
@ -87,7 +90,7 @@ class TicketService
'line_items' => $lineItems, 'line_items' => $lineItems,
'mode' => 'payment', 'mode' => 'payment',
'customer_email' => $email, 'customer_email' => $email,
'success_url' => $this->generator->generate('app_success', [], 0) . '?session_id={CHECKOUT_SESSION_ID}', 'success_url' => $this->generator->generate('app_order_success', [], 0) . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $this->generator->generate('app_cancelled', [], 0), 'cancel_url' => $this->generator->generate('app_cancelled', [], 0),
]); ]);
} }

31
src/Twig/Ticket.php Normal file
View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Enum\FoodData;
use App\Enum\TicketData;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class Ticket extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('food', $this->getFoodName(...)),
new TwigFilter('ticket', $this->getTicket(...))
];
}
public function getFoodName(int $id): string
{
return FoodData::FOOD_DATA[$id] ?? 'N/A';
}
public function getTicket(int $id): array
{
return TicketData::TICKET_DATA[$id] ?? ['name' => 'N/A', 'price' => 'N/A'];
}
}

View File

@ -35,6 +35,15 @@
"migrations/.gitignore" "migrations/.gitignore"
] ]
}, },
"easycorp/easyadmin-bundle": {
"version": "4.24",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "b131e6cbfe1b898a508987851963fff485986285"
}
},
"php-flasher/flasher-noty-symfony": { "php-flasher/flasher-noty-symfony": {
"version": "v2.1.2" "version": "v2.1.2"
}, },
@ -146,6 +155,19 @@
"config/routes.yaml" "config/routes.yaml"
] ]
}, },
"symfony/security-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/stimulus-bundle": { "symfony/stimulus-bundle": {
"version": "2.22", "version": "2.22",
"recipe": { "recipe": {
@ -161,6 +183,19 @@
"assets/controllers/hello_controller.js" "assets/controllers/hello_controller.js"
] ]
}, },
"symfony/translation": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": { "symfony/twig-bundle": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {
@ -174,6 +209,15 @@
"templates/base.html.twig" "templates/base.html.twig"
] ]
}, },
"symfony/uid": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/ux-icons": { "symfony/ux-icons": {
"version": "2.22", "version": "2.22",
"recipe": { "recipe": {

View File

@ -0,0 +1,31 @@
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Ernährung</th>
<th>Preis</th>
<th>Anmerkung</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ticket in field.value %}
<tr>
<td>{{ (ticket.type | ticket)['name'] }}</td>
<td>{{ ticket.foodType | food }}</td>
<td>{{ (ticket.type | ticket)['price'] }}€</td>
<td>{{ ticket.note }}</td>
<td>
<a href="{{ path('admin', { entity: 'App\Entity\Ticket', action: 'detail', id: ticket.id }) }}"
class="btn btn-info btn-sm">
View
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center">No Orders Found</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -0,0 +1,34 @@
{% extends '@EasyAdmin/page/content.html.twig' %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Ticketwert insgesamt</h5>
<p class="card-text">{{ totalMoney }} €</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Essens Daten</h5>
<p class="card-text">Mit Fleisch: {{ foodData.totalMeat }}</p>
<p class="card-text">Vegetarisch: {{ foodData.totalVegetarian }}</p>
<p class="card-text">Vegan: {{ foodData.totalVegan }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Ticket Anzahl</h5>
<p class="card-text">{{ totalTickets }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-[#0a0a0a] px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-[90%] sm:max-w-md">
<div class="text-center mb-8">
<h2 class="text-xl sm:text-2xl font-medium text-gray-200">Abiball Admin Panel</h2>
</div>
{% if error %}
{{ error.message }}
{% endif %}
<form method="post" class="space-y-4 sm:space-y-6" action="{{ path('admin_login') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" />
<div>
<label for="username" class="block text-sm font-medium text-gray-300">Benutzername</label>
<div class="mt-1 relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<twig:ux:icon name="mdi:account" class="h-4 w-4 sm:h-5 sm:w-5 text-gray-500" />
</div>
<input type="text"
id="username"
name="_username"
required
class="block w-full pl-9 sm:pl-10 py-2 sm:py-2.5 text-sm sm:text-base bg-[#2a2a2a] border border-[#333333] text-gray-200 rounded-md focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 transition-colors"
placeholder="username" />
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300">Passwort</label>
<div class="mt-1 relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<twig:ux:icon name="mdi:lock" class="h-4 w-4 sm:h-5 sm:w-5 text-gray-500" />
</div>
<input type="password"
id="password"
name="_password"
required
class="block w-full pl-9 sm:pl-10 py-2 sm:py-2.5 text-sm sm:text-base bg-[#2a2a2a] border border-[#333333] text-gray-200 rounded-md focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500 transition-colors"
placeholder="••••••••" />
</div>
</div>
<hr>
<div>
<button type="submit"
class="w-full flex justify-center py-2 sm:py-2.5 px-4 text-sm sm:text-base bg-orange-500 hover:bg-orange-600 text-white font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[#0a0a0a] focus:ring-orange-500 transition-colors">
Anmelden
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Abiball Ticket</title>
</head>
<body>
Hallo {{ payment.customer.firstname }}
<br>
placeholder text
<br>
<br>
<hr>
{% for ticket in payment.customer.tickets %}
Ticket: {{ (ticket.type | ticket)['name'] }} ({{ (ticket.type | ticket)['price'] }}€)
<br>
Essen: {{ ticket.foodType | food }}
<br>
{% if ticket.note %}
Anmerkung: {{ ticket.note }}
<br>
{% endif %}
<hr>
{% endfor %}
<br>
Gesamtpreis: {{ payment.total }}
</body>
</html>

View File

@ -1,13 +1,13 @@
<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="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="grid grid-cols-12 gap-5">
<div class="relative col-span-5"> <div class="relative col-span-3">
<div class="relative group"> <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" /> <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" <select name="ticket"
required required
data-form-target="ticketType" 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"> 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="" disabled selected>Auswahl</option>
<option value="1">All-Inclusive Ticket</option> <option value="1">All-Inclusive Ticket</option>
<option value="2">After-Show Ticket</option> <option value="2">After-Show Ticket</option>
<option value="3">Kinder-Ticket (6-12 Jahre)</option> <option value="3">Kinder-Ticket (6-12 Jahre)</option>
@ -19,14 +19,14 @@
</div> </div>
</div> </div>
<div class="relative col-span-4"> <div class="relative col-span-3">
<div class="relative group"> <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" /> <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" <select name="food"
required required
data-form-target="foodType" 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"> 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="" disabled selected>Auswahl</option>
<option value="1">Mit Fleisch</option> <option value="1">Mit Fleisch</option>
<option value="2">Vegetarisch</option> <option value="2">Vegetarisch</option>
<option value="3">Vegan</option> <option value="3">Vegan</option>
@ -37,7 +37,7 @@
</div> </div>
</div> </div>
<div class="relative col-span-2"> <div class="relative col-span-5">
<div class="relative group"> <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" /> <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" <input type="text"

View File

@ -60,11 +60,10 @@
<span>Tickets</span> <span>Tickets</span>
</h2> </h2>
<div class="grid grid-cols-4 gap-4 mb-4 px-4 text-sm font-medium text-gray-600"> <div class="ml-5 grid grid-cols-4 gap-4 mb-4 px-4 text-sm font-medium text-gray-600">
<div>Ticket</div> <div>Ticket</div>
<div>Ernährung</div> <div>Ernährung</div>
<div>Anmerkungen</div> <div>Anmerkungen</div>
<div class="text-center">Aktion</div>
</div> </div>
<div class="forms space-y-4"> <div class="forms space-y-4">

0
translations/.gitignore vendored Normal file
View File