Add Billet entity, BilletDesign, ticket designer, CRUD billets, commissions
- Create Billet entity: name, position, priceHT, quantity (nullable=unlimited), isGeneratedBillet, hasDefinedExit, notBuyable, type (billet/reservation_brocante/vote), stripeProductId, description, picture (VichUploader), category (ManyToOne CASCADE) - Create BilletDesign entity (OneToOne Event): accentColor, invitationTitle, invitationColor - Billet CRUD: add/edit/delete with access control, Stripe product sync on connected account - Billet reorder: drag & drop with position field, refactored sortable.js for both categories and billets - Ticket designer tab (custom offer only): accent color, invitation title/color, live iframe preview - A4 ticket preview: 4 zones (HG infos+billet, HD affiche, BG association, BD sortie+invitation), fake QR code SVG - Commission calculator JS: live breakdown of E-Ticket fee, Stripe fee (1.5%+0.25EUR), net amount - Sales recap on categories tab: qty sold, total HT, total commissions, total net - DisableProfilerSubscriber: disable web profiler toolbar on preview iframe - CSP: allow self in frame-src and frame-ancestors for preview iframe - Flysystem: dedicated billets.storage for billet images - Upload accept restricted to png/jpeg/webp/gif (no HEIC) - Makefile: add force_sql_dev command - CLAUDE.md: add rule to never modify existing migrations - Consolidate all migrations into single Version20260321111125 - Tests: BilletTest (20), BilletDesignTest (6), DisableProfilerSubscriberTest (5), billet-designer.test.js (7), commission-calculator.test.js (7), AccountControllerTest billet CRUD tests (11) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ Plateforme destinée aux associations pour la vente de tickets événementiels,
|
||||
## Règles
|
||||
|
||||
- **Interdiction** de supprimer des fichiers sans autorisation explicite de l'utilisateur.
|
||||
- **Interdiction** de modifier une migration déjà générée. Toujours créer une nouvelle migration pour corriger.
|
||||
- En cas de doute, toujours poser la question avant d'agir.
|
||||
- Toujours pousser sur `main`, ne jamais créer de branche.
|
||||
- Toujours demander avant de push.
|
||||
|
||||
3
Makefile
3
Makefile
@@ -75,6 +75,9 @@ migration_dev: ## Genere une migration via Docker dev
|
||||
migrate_dev: ## Execute les migrations via Docker dev
|
||||
docker compose -f docker-compose-dev.yml exec php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
force_sql_dev: ## Force la mise a jour du schema de la base de donnees dev
|
||||
docker compose -f docker-compose-dev.yml exec php php bin/console doctrine:schema:update --force
|
||||
|
||||
migrate_prod: ## Execute les migrations en prod via Docker
|
||||
docker compose -f docker-compose-prod.yml exec php php bin/console doctrine:migrations:migrate --no-interaction --env=prod
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { initCookieConsent } from "./modules/cookie-consent.js"
|
||||
import { initEventMap } from "./modules/event-map.js"
|
||||
import { initCopyUrl } from "./modules/copy-url.js"
|
||||
import { initSortable } from "./modules/sortable.js"
|
||||
import { initBilletDesigner } from "./modules/billet-designer.js"
|
||||
import { initCommissionCalculator } from "./modules/commission-calculator.js"
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMobileMenu()
|
||||
@@ -15,4 +17,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initCopyUrl()
|
||||
initEventMap()
|
||||
initSortable()
|
||||
initBilletDesigner()
|
||||
initCommissionCalculator()
|
||||
})
|
||||
|
||||
64
assets/modules/billet-designer.js
Normal file
64
assets/modules/billet-designer.js
Normal file
@@ -0,0 +1,64 @@
|
||||
export function initBilletDesigner() {
|
||||
const designer = document.getElementById('billet-designer')
|
||||
if (!designer) return
|
||||
|
||||
const previewUrl = designer.dataset.previewUrl
|
||||
const saveUrl = designer.dataset.saveUrl
|
||||
if (!previewUrl) return
|
||||
|
||||
const iframe = document.getElementById('billet-preview-frame')
|
||||
const reloadBtn = document.getElementById('billet-reload-preview')
|
||||
const saveBtn = document.getElementById('billet-save-design')
|
||||
if (!iframe) return
|
||||
|
||||
const inputs = designer.querySelectorAll('input[name]')
|
||||
|
||||
function buildPreviewUrl() {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.type === 'checkbox') {
|
||||
params.set(input.name, input.checked ? '1' : '0')
|
||||
} else {
|
||||
params.set(input.name, input.value)
|
||||
}
|
||||
}
|
||||
|
||||
return previewUrl + '?' + params.toString()
|
||||
}
|
||||
|
||||
function reloadPreview() {
|
||||
iframe.src = buildPreviewUrl()
|
||||
}
|
||||
|
||||
function saveDesign() {
|
||||
if (!saveUrl) return
|
||||
|
||||
const formData = new FormData()
|
||||
for (const input of inputs) {
|
||||
if (input.type === 'checkbox') {
|
||||
formData.set(input.name, input.checked ? '1' : '0')
|
||||
} else {
|
||||
formData.set(input.name, input.value)
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.fetch(saveUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
for (const input of inputs) {
|
||||
input.addEventListener('input', reloadPreview)
|
||||
input.addEventListener('change', reloadPreview)
|
||||
}
|
||||
|
||||
if (reloadBtn) {
|
||||
reloadBtn.addEventListener('click', reloadPreview)
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', saveDesign)
|
||||
}
|
||||
}
|
||||
39
assets/modules/commission-calculator.js
Normal file
39
assets/modules/commission-calculator.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export function initCommissionCalculator() {
|
||||
const calculator = document.getElementById('commission-calculator')
|
||||
if (!calculator) return
|
||||
|
||||
const priceInput = document.getElementById('billet_price')
|
||||
if (!priceInput) return
|
||||
|
||||
const eticketRate = Number.parseFloat(calculator.dataset.eticketRate) || 0
|
||||
const stripeRate = Number.parseFloat(calculator.dataset.stripeRate) || 1.5
|
||||
const stripeFixed = Number.parseFloat(calculator.dataset.stripeFixed) || 0.25
|
||||
|
||||
const calcPrice = document.getElementById('calc-price')
|
||||
const calcEticket = document.getElementById('calc-eticket')
|
||||
const calcStripe = document.getElementById('calc-stripe')
|
||||
const calcTotal = document.getElementById('calc-total')
|
||||
const calcNet = document.getElementById('calc-net')
|
||||
|
||||
function formatEur(value) {
|
||||
return value.toFixed(2).replace('.', ',') + ' \u20AC'
|
||||
}
|
||||
|
||||
function update() {
|
||||
const price = Number.parseFloat(priceInput.value) || 0
|
||||
|
||||
const eticketFee = price * (eticketRate / 100)
|
||||
const stripeFee = price > 0 ? price * (stripeRate / 100) + stripeFixed : 0
|
||||
const totalFee = eticketFee + stripeFee
|
||||
const net = price - totalFee
|
||||
|
||||
calcPrice.textContent = formatEur(price)
|
||||
calcEticket.textContent = '- ' + formatEur(eticketFee)
|
||||
calcStripe.textContent = '- ' + formatEur(stripeFee)
|
||||
calcTotal.textContent = '- ' + formatEur(totalFee)
|
||||
calcNet.textContent = formatEur(Math.max(0, net))
|
||||
}
|
||||
|
||||
priceInput.addEventListener('input', update)
|
||||
update()
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
export function initSortable() {
|
||||
const list = document.getElementById('categories-list')
|
||||
if (!list) return
|
||||
|
||||
function makeSortable(list, itemSelector, idAttr) {
|
||||
const reorderUrl = list.dataset.reorderUrl
|
||||
if (!reorderUrl) return
|
||||
|
||||
let dragEl = null
|
||||
|
||||
list.addEventListener('dragstart', (e) => {
|
||||
dragEl = e.target.closest('[data-id]')
|
||||
dragEl = e.target.closest(itemSelector)
|
||||
if (dragEl) {
|
||||
dragEl.classList.add('opacity-50')
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -24,7 +21,7 @@ export function initSortable() {
|
||||
|
||||
list.addEventListener('dragover', (e) => {
|
||||
e.preventDefault()
|
||||
const target = e.target.closest('[data-id]')
|
||||
const target = e.target.closest(itemSelector)
|
||||
if (target && target !== dragEl) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
const mid = rect.top + rect.height / 2
|
||||
@@ -38,8 +35,8 @@ export function initSortable() {
|
||||
|
||||
list.addEventListener('drop', (e) => {
|
||||
e.preventDefault()
|
||||
const items = list.querySelectorAll('[data-id]')
|
||||
const order = Array.from(items).map(el => Number.parseInt(el.dataset.id, 10))
|
||||
const items = list.querySelectorAll(itemSelector)
|
||||
const order = Array.from(items).map(el => Number.parseInt(el.getAttribute(idAttr), 10))
|
||||
|
||||
globalThis.fetch(reorderUrl, {
|
||||
method: 'POST',
|
||||
@@ -48,7 +45,18 @@ export function initSortable() {
|
||||
})
|
||||
})
|
||||
|
||||
list.querySelectorAll('[data-id]').forEach(el => {
|
||||
list.querySelectorAll(itemSelector).forEach(el => {
|
||||
el.setAttribute('draggable', 'true')
|
||||
})
|
||||
}
|
||||
|
||||
export function initSortable() {
|
||||
const categoriesList = document.getElementById('categories-list')
|
||||
if (categoriesList) {
|
||||
makeSortable(categoriesList, '[data-id]', 'data-id')
|
||||
}
|
||||
|
||||
document.querySelectorAll('.billets-list').forEach(list => {
|
||||
makeSortable(list, '[data-billet-id]', 'data-billet-id')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ flysystem:
|
||||
options:
|
||||
directory: '%kernel.project_dir%/public/uploads/events'
|
||||
|
||||
billets.storage:
|
||||
adapter: 'local'
|
||||
options:
|
||||
directory: '%kernel.project_dir%/public/uploads/billets'
|
||||
|
||||
logos.storage:
|
||||
adapter: 'local'
|
||||
options:
|
||||
|
||||
@@ -20,8 +20,9 @@ nelmio_security:
|
||||
enabled: false
|
||||
report-uri: '%router.request_context.base_url%/my-csp-report'
|
||||
frame-ancestors:
|
||||
- 'none'
|
||||
- 'self'
|
||||
frame-src:
|
||||
- 'self'
|
||||
- 'https://stripe.com'
|
||||
- 'https://*.stripe.com'
|
||||
- 'https://js.stripe.com'
|
||||
|
||||
@@ -10,6 +10,11 @@ vich_uploader:
|
||||
upload_destination: default.storage
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
|
||||
billet_image:
|
||||
uri_prefix: /uploads/billets
|
||||
upload_destination: billets.storage
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
|
||||
organizer_logo:
|
||||
uri_prefix: /uploads/logos
|
||||
upload_destination: logos.storage
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260320214602 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE category (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_64C19C171F7E88B ON category (event_id)');
|
||||
$this->addSql('ALTER TABLE category ADD CONSTRAINT FK_64C19C171F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE category DROP CONSTRAINT FK_64C19C171F7E88B');
|
||||
$this->addSql('DROP TABLE category');
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260320221953 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE category ADD is_hidden BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE category DROP is_hidden');
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use Doctrine\Migrations\AbstractMigration;
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260320201224 extends AbstractMigration
|
||||
final class Version20260321111125 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
@@ -20,6 +20,12 @@ final class Version20260320201224 extends AbstractMigration
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE billet (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, price_ht INT NOT NULL, quantity INT DEFAULT NULL, is_generated_billet BOOLEAN NOT NULL, has_defined_exit BOOLEAN NOT NULL, not_buyable BOOLEAN NOT NULL, type VARCHAR(30) NOT NULL, stripe_product_id VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, picture_name VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, category_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_1F034AF612469DE2 ON billet (category_id)');
|
||||
$this->addSql('CREATE TABLE billet_design (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, accent_color VARCHAR(7) NOT NULL, invitation_title VARCHAR(255) NOT NULL, invitation_color VARCHAR(7) NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_D2CBA64371F7E88B ON billet_design (event_id)');
|
||||
$this->addSql('CREATE TABLE category (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, position INT NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, is_hidden BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, event_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_64C19C171F7E88B ON category (event_id)');
|
||||
$this->addSql('CREATE TABLE email_tracking (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, message_id VARCHAR(64) NOT NULL, recipient VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, state VARCHAR(10) NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, opened_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_A31A7D55537A1329 ON email_tracking (message_id)');
|
||||
$this->addSql('CREATE TABLE event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, description TEXT DEFAULT NULL, address VARCHAR(255) NOT NULL, zipcode VARCHAR(10) NOT NULL, city VARCHAR(255) NOT NULL, event_main_picture_name VARCHAR(255) DEFAULT NULL, is_online BOOLEAN NOT NULL, is_secret BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, account_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
@@ -36,6 +42,9 @@ final class Version20260320201224 extends AbstractMigration
|
||||
$this->addSql('CREATE INDEX IDX_8D93D649D70210F4 ON "user" (parent_organizer_id)');
|
||||
$this->addSql('CREATE TABLE messenger_messages (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
|
||||
$this->addSql('ALTER TABLE billet ADD CONSTRAINT FK_1F034AF612469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE billet_design ADD CONSTRAINT FK_D2CBA64371F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE category ADD CONSTRAINT FK_64C19C171F7E88B FOREIGN KEY (event_id) REFERENCES event (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE event ADD CONSTRAINT FK_3BAE0AA79B6B5FBA FOREIGN KEY (account_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE payout ADD CONSTRAINT FK_4E2EA902876C4DDA FOREIGN KEY (organizer_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D649D70210F4 FOREIGN KEY (parent_organizer_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
@@ -44,9 +53,15 @@ final class Version20260320201224 extends AbstractMigration
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE billet DROP CONSTRAINT FK_1F034AF612469DE2');
|
||||
$this->addSql('ALTER TABLE billet_design DROP CONSTRAINT FK_D2CBA64371F7E88B');
|
||||
$this->addSql('ALTER TABLE category DROP CONSTRAINT FK_64C19C171F7E88B');
|
||||
$this->addSql('ALTER TABLE event DROP CONSTRAINT FK_3BAE0AA79B6B5FBA');
|
||||
$this->addSql('ALTER TABLE payout DROP CONSTRAINT FK_4E2EA902876C4DDA');
|
||||
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D649D70210F4');
|
||||
$this->addSql('DROP TABLE billet');
|
||||
$this->addSql('DROP TABLE billet_design');
|
||||
$this->addSql('DROP TABLE category');
|
||||
$this->addSql('DROP TABLE email_tracking');
|
||||
$this->addSql('DROP TABLE event');
|
||||
$this->addSql('DROP TABLE messenger_log');
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\BilletDesign;
|
||||
use App\Entity\Category;
|
||||
use App\Entity\Event;
|
||||
use App\Entity\Payout;
|
||||
@@ -350,9 +352,24 @@ class AccountController extends AbstractController
|
||||
['position' => 'ASC'],
|
||||
);
|
||||
|
||||
$billets = [];
|
||||
$soldCounts = [];
|
||||
foreach ($categories as $category) {
|
||||
$categoryBillets = $em->getRepository(Billet::class)->findBy(['category' => $category], ['position' => 'ASC']);
|
||||
$billets[$category->getId()] = $categoryBillets;
|
||||
foreach ($categoryBillets as $billet) {
|
||||
// TODO: replace with real sold count from tickets entity
|
||||
$soldCounts[$billet->getId()] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('account/edit_event.html.twig', [
|
||||
'event' => $event,
|
||||
'categories' => $categories,
|
||||
'billets' => $billets,
|
||||
'sold_counts' => $soldCounts,
|
||||
'commission_rate' => $user->getCommissionRate() ?? 0,
|
||||
'billet_design' => $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]),
|
||||
'breadcrumbs' => [
|
||||
self::BREADCRUMB_HOME,
|
||||
self::BREADCRUMB_ACCOUNT,
|
||||
@@ -508,6 +525,243 @@ class AccountController extends AbstractController
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/categorie/{categoryId}/billet/ajouter', name: 'app_account_event_add_billet', methods: ['GET', 'POST'])]
|
||||
public function addBillet(Event $event, int $categoryId, Request $request, EntityManagerInterface $em, StripeService $stripeService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$category = $em->getRepository(Category::class)->find($categoryId);
|
||||
if (!$category || $category->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$billet = new Billet();
|
||||
$billet->setCategory($category);
|
||||
$billet->setPosition($em->getRepository(Billet::class)->count(['category' => $category]));
|
||||
$billet->setName(trim($request->request->getString('name')));
|
||||
$billet->setPriceHT((int) round((float) $request->request->getString('price_ht') * 100));
|
||||
$qty = $request->request->getString('quantity');
|
||||
$billet->setQuantity('' === $qty ? null : (int) $qty);
|
||||
$billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet'));
|
||||
$billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit'));
|
||||
$billet->setNotBuyable($request->request->getBoolean('not_buyable'));
|
||||
$billet->setType($request->request->getString('type', 'billet'));
|
||||
$billet->setDescription(trim($request->request->getString('description')) ?: null);
|
||||
|
||||
$pictureFile = $request->files->get('picture');
|
||||
if ($pictureFile) {
|
||||
$billet->setPictureFile($pictureFile);
|
||||
}
|
||||
|
||||
$em->persist($billet);
|
||||
|
||||
if ($user->getStripeAccountId()) {
|
||||
try {
|
||||
$productId = $stripeService->createProduct($billet, $user->getStripeAccountId());
|
||||
$billet->setStripeProductId($productId);
|
||||
} catch (\Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Billet ajoute avec succes.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
return $this->render('account/add_billet.html.twig', [
|
||||
'event' => $event,
|
||||
'category' => $category,
|
||||
'breadcrumbs' => [
|
||||
self::BREADCRUMB_HOME,
|
||||
self::BREADCRUMB_ACCOUNT,
|
||||
['name' => $event->getTitle(), 'url' => '/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'],
|
||||
['name' => 'Ajouter un billet'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet/{billetId}/modifier', name: 'app_account_event_edit_billet', methods: ['GET', 'POST'])]
|
||||
public function editBillet(Event $event, int $billetId, Request $request, EntityManagerInterface $em, StripeService $stripeService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$billet->setName(trim($request->request->getString('name')));
|
||||
$billet->setPriceHT((int) round((float) $request->request->getString('price_ht') * 100));
|
||||
$qty = $request->request->getString('quantity');
|
||||
$billet->setQuantity('' === $qty ? null : (int) $qty);
|
||||
$billet->setIsGeneratedBillet($request->request->getBoolean('is_generated_billet'));
|
||||
$billet->setHasDefinedExit($request->request->getBoolean('has_defined_exit'));
|
||||
$billet->setNotBuyable($request->request->getBoolean('not_buyable'));
|
||||
$billet->setType($request->request->getString('type', 'billet'));
|
||||
$billet->setDescription(trim($request->request->getString('description')) ?: null);
|
||||
|
||||
$pictureFile = $request->files->get('picture');
|
||||
if ($pictureFile) {
|
||||
$billet->setPictureFile($pictureFile);
|
||||
}
|
||||
|
||||
if ($user->getStripeAccountId()) {
|
||||
try {
|
||||
if ($billet->getStripeProductId()) {
|
||||
$stripeService->updateProduct($billet, $user->getStripeAccountId());
|
||||
} else {
|
||||
$productId = $stripeService->createProduct($billet, $user->getStripeAccountId());
|
||||
$billet->setStripeProductId($productId);
|
||||
}
|
||||
} catch (\Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Billet modifie avec succes.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
return $this->render('account/edit_billet.html.twig', [
|
||||
'event' => $event,
|
||||
'billet' => $billet,
|
||||
'breadcrumbs' => [
|
||||
self::BREADCRUMB_HOME,
|
||||
self::BREADCRUMB_ACCOUNT,
|
||||
['name' => $event->getTitle(), 'url' => '/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories'],
|
||||
['name' => 'Modifier un billet'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet/{billetId}/supprimer', name: 'app_account_event_delete_billet', methods: ['POST'])]
|
||||
public function deleteBillet(Event $event, int $billetId, EntityManagerInterface $em, StripeService $stripeService): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if (!$billet || $billet->getCategory()->getEvent()->getId() !== $event->getId()) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if ($billet->getStripeProductId() && $user->getStripeAccountId()) {
|
||||
try {
|
||||
$stripeService->deleteProduct($billet->getStripeProductId(), $user->getStripeAccountId());
|
||||
} catch (\Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
$em->remove($billet);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Billet supprime avec succes.');
|
||||
|
||||
return $this->redirectToRoute('app_account_edit_event', ['id' => $event->getId(), 'tab' => 'categories']);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet/reorder', name: 'app_account_event_reorder_billets', methods: ['POST'])]
|
||||
public function reorderBillets(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$order = json_decode($request->getContent(), true);
|
||||
if (\is_array($order)) {
|
||||
foreach ($order as $position => $billetId) {
|
||||
$billet = $em->getRepository(Billet::class)->find($billetId);
|
||||
if ($billet && $billet->getCategory()->getEvent()->getId() === $event->getId()) {
|
||||
$billet->setPosition($position);
|
||||
}
|
||||
}
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet-preview', name: 'app_account_event_billet_preview', methods: ['GET'])]
|
||||
public function billetPreview(Event $event, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$response = $this->render('account/billet_preview.html.twig', [
|
||||
'event' => $event,
|
||||
'user' => $user,
|
||||
'bg_color' => '#ffffff',
|
||||
'text_color' => '#111111',
|
||||
'accent_color' => $request->query->getString('accent_color', '#4f46e5'),
|
||||
'show_logo' => true,
|
||||
'show_invitation' => true,
|
||||
'invitation_title' => $request->query->getString('invitation_title', 'Invitation'),
|
||||
'invitation_color' => $request->query->getString('invitation_color', '#d4a017'),
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/billet-design', name: 'app_account_event_save_billet_design', methods: ['POST'])]
|
||||
public function saveBilletDesign(Event $event, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
if ($event->getAccount()->getId() !== $user->getId()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$design = $em->getRepository(BilletDesign::class)->findOneBy(['event' => $event]);
|
||||
if (!$design) {
|
||||
$design = new BilletDesign();
|
||||
$design->setEvent($event);
|
||||
$em->persist($design);
|
||||
}
|
||||
|
||||
$design->setAccentColor($request->request->getString('accent_color', '#4f46e5'));
|
||||
$design->setInvitationTitle($request->request->getString('invitation_title', 'Invitation'));
|
||||
$design->setInvitationColor($request->request->getString('invitation_color', '#d4a017'));
|
||||
$design->setUpdatedAt(new \DateTimeImmutable());
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/mon-compte/evenement/{id}/en-ligne', name: 'app_account_toggle_event_online', methods: ['POST'])]
|
||||
public function toggleEventOnline(Event $event, EntityManagerInterface $em, EventIndexService $eventIndex): Response
|
||||
{
|
||||
|
||||
254
src/Entity/Billet.php
Normal file
254
src/Entity/Billet.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BilletRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Vich\UploaderBundle\Mapping\Attribute as Vich;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BilletRepository::class)]
|
||||
#[Vich\Uploadable]
|
||||
class Billet
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Category $category = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $priceHT = 0;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $quantity = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isGeneratedBillet = true;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $hasDefinedExit = false;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $notBuyable = false;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
private string $type = 'billet';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $stripeProductId = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[Vich\UploadableField(mapping: 'billet_image', fileNameProperty: 'pictureName')]
|
||||
private ?File $pictureFile = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $pictureName = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCategory(): ?Category
|
||||
{
|
||||
return $this->category;
|
||||
}
|
||||
|
||||
public function setCategory(?Category $category): static
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriceHT(): int
|
||||
{
|
||||
return $this->priceHT;
|
||||
}
|
||||
|
||||
public function setPriceHT(int $priceHT): static
|
||||
{
|
||||
$this->priceHT = $priceHT;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriceHTDecimal(): float
|
||||
{
|
||||
return $this->priceHT / 100;
|
||||
}
|
||||
|
||||
public function getQuantity(): ?int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(?int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isUnlimited(): bool
|
||||
{
|
||||
return null === $this->quantity;
|
||||
}
|
||||
|
||||
public function isGeneratedBillet(): bool
|
||||
{
|
||||
return $this->isGeneratedBillet;
|
||||
}
|
||||
|
||||
public function setIsGeneratedBillet(bool $isGeneratedBillet): static
|
||||
{
|
||||
$this->isGeneratedBillet = $isGeneratedBillet;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasDefinedExit(): bool
|
||||
{
|
||||
return $this->hasDefinedExit;
|
||||
}
|
||||
|
||||
public function setHasDefinedExit(bool $hasDefinedExit): static
|
||||
{
|
||||
$this->hasDefinedExit = $hasDefinedExit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isNotBuyable(): bool
|
||||
{
|
||||
return $this->notBuyable;
|
||||
}
|
||||
|
||||
public function setNotBuyable(bool $notBuyable): static
|
||||
{
|
||||
$this->notBuyable = $notBuyable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStripeProductId(): ?string
|
||||
{
|
||||
return $this->stripeProductId;
|
||||
}
|
||||
|
||||
public function setStripeProductId(?string $stripeProductId): static
|
||||
{
|
||||
$this->stripeProductId = $stripeProductId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPictureFile(): ?File
|
||||
{
|
||||
return $this->pictureFile;
|
||||
}
|
||||
|
||||
public function setPictureFile(?File $pictureFile): static
|
||||
{
|
||||
$this->pictureFile = $pictureFile;
|
||||
|
||||
if ($pictureFile) {
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPictureName(): ?string
|
||||
{
|
||||
return $this->pictureName;
|
||||
}
|
||||
|
||||
public function setPictureName(?string $pictureName): static
|
||||
{
|
||||
$this->pictureName = $pictureName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
}
|
||||
101
src/Entity/BilletDesign.php
Normal file
101
src/Entity/BilletDesign.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BilletDesignRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: BilletDesignRepository::class)]
|
||||
class BilletDesign
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Event::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Event $event = null;
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
private string $accentColor = '#4f46e5';
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $invitationTitle = 'Invitation';
|
||||
|
||||
#[ORM\Column(length: 7)]
|
||||
private string $invitationColor = '#d4a017';
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEvent(): ?Event
|
||||
{
|
||||
return $this->event;
|
||||
}
|
||||
|
||||
public function setEvent(?Event $event): static
|
||||
{
|
||||
$this->event = $event;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccentColor(): string
|
||||
{
|
||||
return $this->accentColor;
|
||||
}
|
||||
|
||||
public function setAccentColor(string $accentColor): static
|
||||
{
|
||||
$this->accentColor = $accentColor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInvitationTitle(): string
|
||||
{
|
||||
return $this->invitationTitle;
|
||||
}
|
||||
|
||||
public function setInvitationTitle(string $invitationTitle): static
|
||||
{
|
||||
$this->invitationTitle = $invitationTitle;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInvitationColor(): string
|
||||
{
|
||||
return $this->invitationColor;
|
||||
}
|
||||
|
||||
public function setInvitationColor(string $invitationColor): static
|
||||
{
|
||||
$this->invitationColor = $invitationColor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
36
src/EventSubscriber/DisableProfilerSubscriber.php
Normal file
36
src/EventSubscriber/DisableProfilerSubscriber.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profiler;
|
||||
|
||||
class DisableProfilerSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ?Profiler $profiler = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::RESPONSE => 'onKernelResponse',
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$route = $event->getRequest()->attributes->getString('_route');
|
||||
|
||||
if ('app_account_event_billet_preview' === $route && $this->profiler) {
|
||||
$this->profiler->disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Repository/BilletDesignRepository.php
Normal file
18
src/Repository/BilletDesignRepository.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\BilletDesign;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<BilletDesign>
|
||||
*/
|
||||
class BilletDesignRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BilletDesign::class);
|
||||
}
|
||||
}
|
||||
18
src/Repository/BilletRepository.php
Normal file
18
src/Repository/BilletRepository.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Billet;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Billet>
|
||||
*/
|
||||
class BilletRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Billet::class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\User;
|
||||
use Stripe\Event;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
@@ -96,6 +97,44 @@ class StripeService
|
||||
return $link->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
public function createProduct(Billet $billet, string $connectedAccountId): string
|
||||
{
|
||||
$product = $this->stripe->products->create([
|
||||
'name' => $billet->getName(),
|
||||
'description' => $billet->getDescription(),
|
||||
'default_price_data' => [
|
||||
'currency' => 'eur',
|
||||
'unit_amount' => $billet->getPriceHT(),
|
||||
],
|
||||
], ['stripe_account' => $connectedAccountId]);
|
||||
|
||||
return $product->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
public function updateProduct(Billet $billet, string $connectedAccountId): void
|
||||
{
|
||||
$this->stripe->products->update($billet->getStripeProductId(), [
|
||||
'name' => $billet->getName(),
|
||||
'description' => $billet->getDescription(),
|
||||
], ['stripe_account' => $connectedAccountId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Stripe API
|
||||
*/
|
||||
public function deleteProduct(string $productId, string $connectedAccountId): void
|
||||
{
|
||||
$this->stripe->products->update($productId, [
|
||||
'active' => false,
|
||||
], ['stripe_account' => $connectedAccountId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Simple getter
|
||||
*/
|
||||
|
||||
30
templates/account/_billet_commission.html.twig
Normal file
30
templates/account/_billet_commission.html.twig
Normal file
@@ -0,0 +1,30 @@
|
||||
<div id="commission-calculator" class="border-2 border-gray-900 p-4 bg-gray-50 space-y-3" data-eticket-rate="{{ app.user.commissionRate ?? 0 }}" data-stripe-rate="1.5" data-stripe-fixed="0.25">
|
||||
<h3 class="text-xs font-black uppercase tracking-widest mb-2">Detail des commissions</h3>
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-600">Prix de vente HT</span>
|
||||
<span class="font-black" id="calc-price">0,00 €</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-600">Commission E-Ticket ({{ app.user.commissionRate ?? 0 }}%)</span>
|
||||
<span class="font-black text-red-600" id="calc-eticket">- 0,00 €</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-bold text-gray-600">Commission Stripe (1.5% + 0,25 €)</span>
|
||||
<span class="font-black text-red-600" id="calc-stripe">- 0,00 €</span>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-300">
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="font-black uppercase">Total commissions</span>
|
||||
<span class="font-black text-red-600" id="calc-total">- 0,00 €</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-base border-t-2 border-gray-900 pt-3">
|
||||
<span class="font-black uppercase">Montant percu</span>
|
||||
<span class="font-black text-green-600 text-lg" id="calc-net">0,00 €</span>
|
||||
</div>
|
||||
</div>
|
||||
80
templates/account/add_billet.html.twig
Normal file
80
templates/account/add_billet.html.twig
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Ajouter un billet - {{ category.name }} - E-Ticket{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="page-container">
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'categories'}) }}" class="inline-flex items-center gap-2 text-sm font-black uppercase tracking-widest text-gray-500 hover:text-gray-900 transition-colors mb-8">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"/></svg>
|
||||
Retour aux categories
|
||||
</a>
|
||||
|
||||
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Ajouter un billet</h1>
|
||||
<p class="font-bold text-gray-600 italic mb-8">{{ event.title }} — {{ category.name }}</p>
|
||||
|
||||
{% for message in app.flashes('error') %}
|
||||
<div class="flash-error"><p class="font-black text-sm">{{ message }}</p></div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="card-brutal">
|
||||
<form method="post" action="{{ path('app_account_event_add_billet', {id: event.id, categoryId: category.id}) }}" enctype="multipart/form-data" class="form-col">
|
||||
<div>
|
||||
<label for="billet_type" class="text-xs font-black uppercase tracking-widest form-label">Type</label>
|
||||
<select id="billet_type" name="type" class="form-input focus:border-indigo-600">
|
||||
<option value="billet">Billet</option>
|
||||
<option value="reservation_brocante">Reservation brocante</option>
|
||||
<option value="vote">Vote</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_name" class="text-xs font-black uppercase tracking-widest form-label">Nom du billet</label>
|
||||
<input type="text" id="billet_name" name="name" required class="form-input focus:border-indigo-600" placeholder="Ex: Entree adulte, Pass VIP...">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_price" class="text-xs font-black uppercase tracking-widest form-label">Prix HT (€)</label>
|
||||
<input type="number" id="billet_price" name="price_ht" required min="0" step="0.01" class="form-input focus:border-indigo-600" placeholder="Ex: 15.00">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_quantity" class="text-xs font-black uppercase tracking-widest form-label">Quantite disponible (vide = illimite)</label>
|
||||
<input type="number" id="billet_quantity" name="quantity" min="1" class="form-input focus:border-indigo-600" placeholder="Illimite">
|
||||
</div>
|
||||
|
||||
{% include 'account/_billet_commission.html.twig' %}
|
||||
|
||||
<div>
|
||||
<label for="billet_description" class="text-xs font-black uppercase tracking-widest form-label">Description</label>
|
||||
<textarea id="billet_description" name="description" rows="3" class="form-input focus:border-indigo-600" placeholder="Description optionnelle du billet..."></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_picture" class="text-xs font-black uppercase tracking-widest form-label">Image</label>
|
||||
<input type="file" id="billet_picture" name="picture" accept="image/png, image/jpeg, image/webp, image/gif" class="form-file">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billet_generated" name="is_generated_billet" value="1" class="w-5 h-5 border-2 border-gray-900 cursor-pointer" checked>
|
||||
<label for="billet_generated" class="text-sm font-black uppercase tracking-widest cursor-pointer">Generer un billet PDF</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billet_exit" name="has_defined_exit" value="1" class="w-5 h-5 border-2 border-gray-900 cursor-pointer">
|
||||
<label for="billet_exit" class="text-sm font-black uppercase tracking-widest cursor-pointer">Sortie definitive</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billet_not_buyable" name="not_buyable" value="1" class="w-5 h-5 border-2 border-gray-900 cursor-pointer">
|
||||
<label for="billet_not_buyable" class="text-sm font-black uppercase tracking-widest cursor-pointer">Non achetable</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Ajouter le billet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
391
templates/account/billet_preview.html.twig
Normal file
391
templates/account/billet_preview.html.twig
Normal file
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@page { size: A4; margin: 0; }
|
||||
body {
|
||||
width: 595px;
|
||||
height: 842px;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
background: {{ bg_color }};
|
||||
color: {{ text_color }};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticket {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 220px;
|
||||
grid-template-rows: 1fr auto;
|
||||
}
|
||||
|
||||
/* ====== HG : infos evenement + billet ====== */
|
||||
.zone-hg {
|
||||
padding: 28px 20px 20px 32px;
|
||||
border-right: 3px solid {{ accent_color }};
|
||||
border-bottom: 3px solid {{ accent_color }};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.event-title {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.5px;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.event-badge {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
color: {{ accent_color }};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid {{ text_color }}10;
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label {
|
||||
width: 65px;
|
||||
flex-shrink: 0;
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.info-value {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 3px;
|
||||
background: {{ accent_color }};
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.billet-name {
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.billet-price {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
color: {{ accent_color }};
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
.meta-label {
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.meta-value {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.billet-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.exit-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border: 2px solid;
|
||||
}
|
||||
.exit-definitive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border-color: #991b1b;
|
||||
}
|
||||
.exit-libre {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #166534;
|
||||
}
|
||||
.invitation-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 8px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid {{ text_color }}12;
|
||||
}
|
||||
.qr-box {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px solid {{ text_color }}18;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
.qr-placeholder {
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
.ref-block { text-align: right; }
|
||||
.ref-label {
|
||||
font-size: 7px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.ref-value {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ====== HD : affiche ====== */
|
||||
.zone-hd {
|
||||
border-bottom: 3px solid {{ accent_color }};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: {{ text_color }}05;
|
||||
overflow: hidden;
|
||||
}
|
||||
.zone-hd img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.poster-placeholder {
|
||||
font-size: 9px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.15;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ====== BAS : infos association (pleine largeur) ====== */
|
||||
.zone-bottom {
|
||||
grid-column: 1 / -1;
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: {{ accent_color }};
|
||||
color: #fff;
|
||||
}
|
||||
.org-logo {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.org-logo-placeholder {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border: 2px dashed rgba(255,255,255,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 6px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.org-info { flex: 1; min-width: 0; }
|
||||
.org-name {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
.org-details {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.org-contact {
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
opacity: 0.5;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.powered {
|
||||
font-size: 6px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.4;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ticket">
|
||||
<!-- HG : infos evenement + billet -->
|
||||
<div class="zone-hg">
|
||||
<div class="event-title">{{ event.title }}</div>
|
||||
<div class="event-badge">Billet d'entree</div>
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">Date</div>
|
||||
<div class="info-value">{{ event.startAt|date('d/m/Y') }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">Horaires</div>
|
||||
<div class="info-value">{{ event.startAt|date('H:i') }} — {{ event.endAt|date('H:i') }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">Lieu</div>
|
||||
<div class="info-value">{{ event.address }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">Ville</div>
|
||||
<div class="info-value">{{ event.zipcode }} {{ event.city }}</div>
|
||||
</div>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<div class="billet-name">Nom du billet</div>
|
||||
<div class="billet-price">00,00 € HT</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<div class="meta-label">Categorie</div>
|
||||
<div class="meta-value">Categorie</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-label">Date d'achat</div>
|
||||
<div class="meta-value">{{ "now"|date('d/m/Y H:i') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-label">Acheteur</div>
|
||||
<div class="meta-value">Prenom Nom</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="meta-label">E-mail</div>
|
||||
<div class="meta-value">email@exemple.fr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="billet-badges">
|
||||
<div class="exit-badge exit-definitive">Sortie definitive</div>
|
||||
{% if show_invitation %}
|
||||
<div class="invitation-badge" style="background: {{ invitation_color }};">{{ invitation_title }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="qr-section">
|
||||
<div class="qr-box">
|
||||
<svg viewBox="0 0 21 21" width="110" height="110" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="21" height="21" fill="#fff"/>
|
||||
<g fill="#111">
|
||||
<rect x="0" y="0" width="7" height="1"/><rect x="0" y="1" width="1" height="5"/><rect x="6" y="1" width="1" height="5"/><rect x="0" y="6" width="7" height="1"/>
|
||||
<rect x="2" y="2" width="3" height="3"/>
|
||||
<rect x="14" y="0" width="7" height="1"/><rect x="14" y="1" width="1" height="5"/><rect x="20" y="1" width="1" height="5"/><rect x="14" y="6" width="7" height="1"/>
|
||||
<rect x="16" y="2" width="3" height="3"/>
|
||||
<rect x="0" y="14" width="7" height="1"/><rect x="0" y="15" width="1" height="5"/><rect x="6" y="15" width="1" height="5"/><rect x="0" y="20" width="7" height="1"/>
|
||||
<rect x="2" y="16" width="3" height="3"/>
|
||||
<rect x="8" y="0" width="1" height="1"/><rect x="10" y="0" width="1" height="1"/><rect x="12" y="0" width="1" height="1"/>
|
||||
<rect x="8" y="2" width="1" height="1"/><rect x="10" y="2" width="2" height="1"/>
|
||||
<rect x="9" y="4" width="1" height="1"/><rect x="11" y="4" width="2" height="1"/>
|
||||
<rect x="8" y="6" width="1" height="1"/><rect x="11" y="6" width="1" height="1"/>
|
||||
<rect x="8" y="8" width="1" height="1"/><rect x="10" y="8" width="1" height="1"/><rect x="12" y="8" width="1" height="1"/><rect x="14" y="8" width="1" height="1"/><rect x="16" y="8" width="1" height="1"/><rect x="18" y="8" width="1" height="1"/><rect x="20" y="8" width="1" height="1"/>
|
||||
<rect x="0" y="8" width="1" height="1"/><rect x="2" y="8" width="1" height="1"/><rect x="4" y="8" width="1" height="1"/><rect x="6" y="8" width="1" height="1"/>
|
||||
<rect x="9" y="9" width="1" height="1"/><rect x="11" y="9" width="1" height="1"/><rect x="15" y="9" width="1" height="1"/><rect x="17" y="9" width="1" height="1"/><rect x="19" y="9" width="1" height="1"/>
|
||||
<rect x="8" y="10" width="1" height="1"/><rect x="10" y="10" width="1" height="1"/><rect x="13" y="10" width="1" height="1"/><rect x="16" y="10" width="1" height="1"/><rect x="20" y="10" width="1" height="1"/>
|
||||
<rect x="9" y="11" width="2" height="1"/><rect x="12" y="11" width="1" height="1"/><rect x="14" y="11" width="1" height="1"/><rect x="17" y="11" width="1" height="1"/><rect x="19" y="11" width="1" height="1"/>
|
||||
<rect x="8" y="12" width="1" height="1"/><rect x="11" y="12" width="1" height="1"/><rect x="13" y="12" width="1" height="1"/><rect x="15" y="12" width="1" height="1"/><rect x="18" y="12" width="1" height="1"/><rect x="20" y="12" width="1" height="1"/>
|
||||
<rect x="9" y="14" width="1" height="1"/><rect x="12" y="14" width="1" height="1"/><rect x="15" y="14" width="1" height="1"/><rect x="17" y="14" width="1" height="1"/><rect x="19" y="14" width="1" height="1"/>
|
||||
<rect x="8" y="15" width="1" height="1"/><rect x="10" y="15" width="1" height="1"/><rect x="14" y="15" width="1" height="1"/><rect x="16" y="15" width="1" height="1"/><rect x="20" y="15" width="1" height="1"/>
|
||||
<rect x="9" y="16" width="1" height="1"/><rect x="11" y="16" width="2" height="1"/><rect x="15" y="16" width="1" height="1"/><rect x="18" y="16" width="1" height="1"/>
|
||||
<rect x="8" y="17" width="1" height="1"/><rect x="10" y="17" width="1" height="1"/><rect x="14" y="17" width="1" height="1"/><rect x="17" y="17" width="1" height="1"/><rect x="19" y="17" width="1" height="1"/>
|
||||
<rect x="9" y="18" width="1" height="1"/><rect x="12" y="18" width="1" height="1"/><rect x="16" y="18" width="1" height="1"/><rect x="20" y="18" width="1" height="1"/>
|
||||
<rect x="8" y="20" width="1" height="1"/><rect x="11" y="20" width="1" height="1"/><rect x="13" y="20" width="1" height="1"/><rect x="15" y="20" width="1" height="1"/><rect x="18" y="20" width="1" height="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ref-block">
|
||||
<div class="ref-label">Reference</div>
|
||||
<div class="ref-value">ETICKET-XXXX-XXXX-XXXX</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HD : affiche -->
|
||||
<div class="zone-hd">
|
||||
{% if event.eventMainPictureName %}
|
||||
<img src="{{ ('/uploads/events/' ~ event.eventMainPictureName) | imagine_filter('large') }}" alt="{{ event.title }}">
|
||||
{% else %}
|
||||
<div class="poster-placeholder">Affiche<br>evenement</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- BAS : infos association (pleine largeur) -->
|
||||
<div class="zone-bottom">
|
||||
{% if show_logo and user.logoName %}
|
||||
<img src="{{ ('/uploads/logos/' ~ user.logoName) | imagine_filter('organizer_logo') }}" alt="Logo" class="org-logo">
|
||||
{% elseif show_logo %}
|
||||
<div class="org-logo-placeholder">Logo</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="org-info">
|
||||
<div class="org-name">{{ user.companyName ?? (user.firstName ~ ' ' ~ user.lastName) }}</div>
|
||||
{% if user.address %}
|
||||
<div class="org-details">{{ user.address }}{% if user.postalCode %}, {{ user.postalCode }}{% endif %}{% if user.city %} {{ user.city }}{% endif %}</div>
|
||||
{% endif %}
|
||||
{% if user.phone or user.website %}
|
||||
<div class="org-contact">
|
||||
{% if user.phone %}{{ user.phone }}{% endif %}
|
||||
{% if user.phone and user.website %} — {% endif %}
|
||||
{% if user.website %}{{ user.website }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="powered">E-Ticket<br>by E-Cosplay</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<div>
|
||||
<label for="event_main_picture" class="text-xs font-black uppercase tracking-widest form-label">Image principale</label>
|
||||
<input type="file" id="event_main_picture" name="event_main_picture" accept="image/*" class="form-file">
|
||||
<input type="file" id="event_main_picture" name="event_main_picture" accept="image/png, image/jpeg, image/webp, image/gif" class="form-file">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
83
templates/account/edit_billet.html.twig
Normal file
83
templates/account/edit_billet.html.twig
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block title %}Modifier {{ billet.name }} - E-Ticket{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="page-container">
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'categories'}) }}" class="inline-flex items-center gap-2 text-sm font-black uppercase tracking-widest text-gray-500 hover:text-gray-900 transition-colors mb-8">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M15 19l-7-7 7-7"/></svg>
|
||||
Retour aux categories
|
||||
</a>
|
||||
|
||||
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">Modifier le billet</h1>
|
||||
<p class="font-bold text-gray-600 italic mb-8">{{ event.title }} — {{ billet.category.name }}</p>
|
||||
|
||||
{% for message in app.flashes('error') %}
|
||||
<div class="flash-error"><p class="font-black text-sm">{{ message }}</p></div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="card-brutal">
|
||||
<form method="post" action="{{ path('app_account_event_edit_billet', {id: event.id, billetId: billet.id}) }}" enctype="multipart/form-data" class="form-col">
|
||||
<div>
|
||||
<label for="billet_type" class="text-xs font-black uppercase tracking-widest form-label">Type</label>
|
||||
<select id="billet_type" name="type" class="form-input focus:border-indigo-600">
|
||||
<option value="billet" {{ billet.type == 'billet' ? 'selected' : '' }}>Billet</option>
|
||||
<option value="reservation_brocante" {{ billet.type == 'reservation_brocante' ? 'selected' : '' }}>Reservation brocante</option>
|
||||
<option value="vote" {{ billet.type == 'vote' ? 'selected' : '' }}>Vote</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_name" class="text-xs font-black uppercase tracking-widest form-label">Nom du billet</label>
|
||||
<input type="text" id="billet_name" name="name" required class="form-input focus:border-indigo-600" value="{{ billet.name }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_price" class="text-xs font-black uppercase tracking-widest form-label">Prix HT (€)</label>
|
||||
<input type="number" id="billet_price" name="price_ht" required min="0" step="0.01" class="form-input focus:border-indigo-600" value="{{ billet.priceHTDecimal }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_quantity" class="text-xs font-black uppercase tracking-widest form-label">Quantite disponible (vide = illimite)</label>
|
||||
<input type="number" id="billet_quantity" name="quantity" min="1" class="form-input focus:border-indigo-600" value="{{ billet.quantity }}" placeholder="Illimite">
|
||||
</div>
|
||||
|
||||
{% include 'account/_billet_commission.html.twig' %}
|
||||
|
||||
<div>
|
||||
<label for="billet_description" class="text-xs font-black uppercase tracking-widest form-label">Description</label>
|
||||
<textarea id="billet_description" name="description" rows="3" class="form-input focus:border-indigo-600">{{ billet.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billet_picture" class="text-xs font-black uppercase tracking-widest form-label">Changer l'image</label>
|
||||
<input type="file" id="billet_picture" name="picture" accept="image/png, image/jpeg, image/webp, image/gif" class="form-file">
|
||||
{% if billet.pictureName %}
|
||||
<p class="text-xs text-gray-500 font-bold mt-1">Image actuelle : {{ billet.pictureName }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billet_generated" name="is_generated_billet" value="1" class="w-5 h-5 border-2 border-gray-900 cursor-pointer" {{ billet.generatedBillet ? 'checked' : '' }}>
|
||||
<label for="billet_generated" class="text-sm font-black uppercase tracking-widest cursor-pointer">Generer un billet PDF</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billet_exit" name="has_defined_exit" value="1" class="w-5 h-5 border-2 border-gray-900 cursor-pointer" {{ billet.definedExit ? 'checked' : '' }}>
|
||||
<label for="billet_exit" class="text-sm font-black uppercase tracking-widest cursor-pointer">Sortie definitive</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="billet_not_buyable" name="not_buyable" value="1" class="w-5 h-5 border-2 border-gray-900 cursor-pointer" {{ billet.notBuyable ? 'checked' : '' }}>
|
||||
<label for="billet_not_buyable" class="text-sm font-black uppercase tracking-widest cursor-pointer">Non achetable</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -79,7 +79,10 @@
|
||||
{% set current_tab = app.request.query.get('tab', 'info') %}
|
||||
<div class="flex flex-wrap overflow-x-auto mb-8">
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'info'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'info' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Informations</a>
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'categories'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'categories' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Categories / Billets</a>
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'categories'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'categories' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Categories</a>
|
||||
{% if is_granted('ROLE_ROOT') or app.user.offer == 'custom' %}
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'billets'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'billets' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Billets</a>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'stats'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 border-r-0 {{ current_tab == 'stats' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Statistiques</a>
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id, tab: 'settings'}) }}" class="flex-1 min-w-[100px] text-center py-3 border-3 border-gray-900 {{ current_tab == 'settings' ? 'bg-yellow-400' : 'bg-white' }} font-black uppercase text-xs tracking-widest transition-all">Parametres</a>
|
||||
</div>
|
||||
@@ -129,7 +132,7 @@
|
||||
|
||||
<div>
|
||||
<label for="event_main_picture" class="text-xs font-black uppercase tracking-widest form-label">Changer l'affiche</label>
|
||||
<input type="file" id="event_main_picture" name="event_main_picture" accept="image/*" class="form-file">
|
||||
<input type="file" id="event_main_picture" name="event_main_picture" accept="image/png, image/jpeg, image/webp, image/gif" class="form-file">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -185,30 +188,171 @@
|
||||
{% if categories|length > 0 %}
|
||||
<div id="categories-list" data-reorder-url="{{ path('app_account_event_reorder_categories', {id: event.id}) }}">
|
||||
{% for category in categories %}
|
||||
<div class="flex flex-wrap items-center gap-4 p-4 border-2 border-gray-900 mb-2 bg-white cursor-move" data-id="{{ category.id }}">
|
||||
<span class="text-gray-400 cursor-grab">☰</span>
|
||||
<span class="font-black text-sm uppercase flex-1">{{ category.name }}</span>
|
||||
<span class="text-xs font-bold text-gray-400">{{ category.startAt|date('d/m/Y H:i') }} — {{ category.endAt|date('d/m/Y H:i') }}</span>
|
||||
{% if category.hidden %}
|
||||
<span class="badge-yellow text-xs font-black uppercase">Masquee</span>
|
||||
{% elseif category.active %}
|
||||
<span class="badge-green text-xs font-black uppercase">Active</span>
|
||||
{% else %}
|
||||
<span class="badge-red text-xs font-black uppercase">Inactive</span>
|
||||
<div class="border-2 border-gray-900 mb-2 bg-white" data-id="{{ category.id }}">
|
||||
<div class="flex flex-wrap items-center gap-4 p-4 cursor-move">
|
||||
<span class="text-gray-400 cursor-grab">☰</span>
|
||||
<span class="font-black text-sm uppercase flex-1">{{ category.name }}</span>
|
||||
<span class="text-xs font-bold text-gray-400">{{ category.startAt|date('d/m/Y H:i') }} — {{ category.endAt|date('d/m/Y H:i') }}</span>
|
||||
{% if category.hidden %}
|
||||
<span class="badge-yellow text-xs font-black uppercase">Masquee</span>
|
||||
{% elseif category.active %}
|
||||
<span class="badge-green text-xs font-black uppercase">Active</span>
|
||||
{% else %}
|
||||
<span class="badge-red text-xs font-black uppercase">Inactive</span>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_account_event_edit_category', {id: event.id, categoryId: category.id}) }}" class="px-2 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-gray-100 transition-all">✎</a>
|
||||
<form method="post" action="{{ path('app_account_event_delete_category', {id: event.id, categoryId: category.id}) }}" data-confirm="Supprimer cette categorie ?" class="inline">
|
||||
<button type="submit" class="px-2 py-1 border-2 border-red-800 bg-red-600 text-white text-xs font-black uppercase cursor-pointer hover:bg-red-800 transition-all">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% set category_billets = billets[category.id] ?? [] %}
|
||||
{% if category_billets|length > 0 %}
|
||||
<div class="border-t-2 border-gray-900 bg-gray-50 px-4 py-3 billets-list" data-reorder-url="{{ path('app_account_event_reorder_billets', {id: event.id}) }}">
|
||||
{% for billet in category_billets %}
|
||||
{% set sold = sold_counts[billet.id] ?? 0 %}
|
||||
<div class="flex flex-wrap items-center gap-3 py-2 {{ not loop.last ? 'border-b border-gray-200' : '' }} cursor-move" data-billet-id="{{ billet.id }}">
|
||||
<span class="text-gray-400 cursor-grab">☰</span>
|
||||
{% if billet.pictureName %}
|
||||
<img src="{{ ('/uploads/billets/' ~ billet.pictureName) | imagine_filter('thumbnail') }}" alt="{{ billet.name }}" class="w-10 h-10 object-cover border border-gray-300">
|
||||
{% endif %}
|
||||
<span class="font-bold text-sm flex-1">{{ billet.name }}</span>
|
||||
<span class="font-black text-sm text-indigo-600">{{ billet.priceHTDecimal|number_format(2, ',', ' ') }} € HT</span>
|
||||
<span class="text-[10px] font-bold text-gray-400">{{ billet.unlimited ? 'Illimite' : billet.quantity ~ ' places' }}</span>
|
||||
<span class="text-[10px] font-bold text-gray-600">{{ sold }} vendu{{ sold > 1 ? 's' : '' }}</span>
|
||||
{% if billet.generatedBillet %}
|
||||
<span class="badge-green text-[10px] font-black uppercase">Billet</span>
|
||||
{% else %}
|
||||
<span class="badge-red text-[10px] font-black uppercase">Sans billet</span>
|
||||
{% endif %}
|
||||
{% if billet.definedExit %}
|
||||
<span class="badge-yellow text-[10px] font-black uppercase">Sortie def.</span>
|
||||
{% endif %}
|
||||
{% if billet.notBuyable %}
|
||||
<span class="badge-red text-[10px] font-black uppercase">Non achetable</span>
|
||||
{% endif %}
|
||||
{% if billet.type != 'billet' %}
|
||||
<span class="badge-yellow text-[10px] font-black uppercase">{{ billet.type == 'reservation_brocante' ? 'Brocante' : 'Vote' }}</span>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_account_event_edit_billet', {id: event.id, billetId: billet.id}) }}" class="px-2 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-gray-100 transition-all">✎</a>
|
||||
<form method="post" action="{{ path('app_account_event_delete_billet', {id: event.id, billetId: billet.id}) }}" data-confirm="Supprimer ce billet ?" class="inline">
|
||||
<button type="submit" class="px-2 py-1 border-2 border-red-800 bg-red-600 text-white text-xs font-black uppercase cursor-pointer hover:bg-red-800 transition-all">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_account_event_edit_category', {id: event.id, categoryId: category.id}) }}" class="px-2 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-gray-100 transition-all">✎</a>
|
||||
<form method="post" action="{{ path('app_account_event_delete_category', {id: event.id, categoryId: category.id}) }}" data-confirm="Supprimer cette categorie ?" class="inline">
|
||||
<button type="submit" class="px-2 py-1 border-2 border-red-800 bg-red-600 text-white text-xs font-black uppercase cursor-pointer hover:bg-red-800 transition-all">✕</button>
|
||||
</form>
|
||||
|
||||
<div class="border-t-2 border-gray-900 px-4 py-3 bg-gray-50">
|
||||
<a href="{{ path('app_account_event_add_billet', {id: event.id, categoryId: category.id}) }}" class="inline-flex items-center gap-2 px-3 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all">
|
||||
+ Ajouter un billet
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% set total_sold = 0 %}
|
||||
{% set total_ht = 0 %}
|
||||
{% set total_commission = 0 %}
|
||||
{% set total_net = 0 %}
|
||||
{% for cat_billets in billets %}
|
||||
{% for billet in cat_billets %}
|
||||
{% set sold = sold_counts[billet.id] ?? 0 %}
|
||||
{% set line_ht = billet.priceHTDecimal * sold %}
|
||||
{% set eticket_fee = line_ht * (commission_rate / 100) %}
|
||||
{% set stripe_fee = sold > 0 ? (line_ht * 0.015) + (0.25 * sold) : 0 %}
|
||||
{% set line_commission = eticket_fee + stripe_fee %}
|
||||
{% set total_sold = total_sold + sold %}
|
||||
{% set total_ht = total_ht + line_ht %}
|
||||
{% set total_commission = total_commission + line_commission %}
|
||||
{% set total_net = total_net + (line_ht - line_commission) %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="card-brutal overflow-hidden mt-4">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Recapitulatif ventes</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="border-2 border-gray-900 p-4 text-center">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Qt vendue</div>
|
||||
<div class="text-2xl font-black">{{ total_sold }}</div>
|
||||
</div>
|
||||
<div class="border-2 border-gray-900 p-4 text-center">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Total HT</div>
|
||||
<div class="text-2xl font-black text-indigo-600">{{ total_ht|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
<div class="border-2 border-gray-900 p-4 text-center">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Total commissions</div>
|
||||
<div class="text-2xl font-black text-red-600">{{ total_commission|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
<div class="border-2 border-gray-900 p-4 text-center bg-green-50">
|
||||
<div class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Total percu</div>
|
||||
<div class="text-2xl font-black text-green-600">{{ total_net|number_format(2, ',', ' ') }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<p class="text-gray-400 font-bold text-sm text-center py-8">Aucune categorie. Ajoutez-en une pour commencer a vendre des billets.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elseif current_tab == 'billets' and (is_granted('ROLE_ROOT') or app.user.offer == 'custom') %}
|
||||
|
||||
{% set bd = billet_design %}
|
||||
<div class="flex flex-col lg:flex-row gap-6" id="billet-designer" data-preview-url="{{ path('app_account_event_billet_preview', {id: event.id}) }}" data-save-url="{{ path('app_account_event_save_billet_design', {id: event.id}) }}">
|
||||
<div class="w-full lg:w-[350px] flex-shrink-0">
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Personnalisation</h2>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label for="design_accent_color" class="text-xs font-black uppercase tracking-widest form-label">Couleur d'accent</label>
|
||||
<input type="color" id="design_accent_color" name="accent_color" value="{{ bd ? bd.accentColor : '#4f46e5' }}" class="w-full h-10 border-2 border-gray-900 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-300">
|
||||
|
||||
<div>
|
||||
<label for="design_invitation_title" class="text-xs font-black uppercase tracking-widest form-label">Titre invitation</label>
|
||||
<input type="text" id="design_invitation_title" name="invitation_title" value="{{ bd ? bd.invitationTitle : 'Invitation' }}" class="form-input focus:border-indigo-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="design_invitation_color" class="text-xs font-black uppercase tracking-widest form-label">Couleur fond invitation</label>
|
||||
<input type="color" id="design_invitation_color" name="invitation_color" value="{{ bd ? bd.invitationColor : '#d4a017' }}" class="w-full h-10 border-2 border-gray-900 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-300">
|
||||
|
||||
<button type="button" id="billet-save-design" class="w-full btn-brutal font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
|
||||
Sauvegarder le design
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="card-brutal overflow-hidden">
|
||||
<div class="section-header flex justify-between items-center">
|
||||
<h2 class="text-[10px] font-black uppercase tracking-widest text-white">Apercu du billet — A4</h2>
|
||||
<button type="button" id="billet-reload-preview" class="px-3 py-1 bg-white text-gray-900 text-[10px] font-black uppercase tracking-widest border-2 border-white hover:bg-gray-100 transition-all cursor-pointer">
|
||||
↻ Recharger
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-gray-100 p-4 flex justify-center">
|
||||
<iframe id="billet-preview-frame" src="{{ path('app_account_event_billet_preview', {id: event.id}) }}" class="bg-white shadow-lg" style="width: 595px; height: 842px; border: 1px solid #ccc; transform-origin: top center;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elseif current_tab == 'stats' %}
|
||||
<div class="card-brutal">
|
||||
<div class="p-12 text-center">
|
||||
|
||||
@@ -1101,6 +1101,197 @@ class AccountControllerTest extends WebTestCase
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testAddBilletPage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
$category = $this->createCategory($em, $event);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testAddBilletSubmit(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
$category = $this->createCategory($em, $event);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter', [
|
||||
'name' => 'Entree VIP',
|
||||
'price_ht' => '1500',
|
||||
'is_generated_billet' => '1',
|
||||
'description' => 'Acces backstage',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories');
|
||||
|
||||
$billet = $em->getRepository(\App\Entity\Billet::class)->findOneBy(['name' => 'Entree VIP']);
|
||||
self::assertNotNull($billet);
|
||||
self::assertSame(1500, $billet->getPriceHT());
|
||||
self::assertTrue($billet->isGeneratedBillet());
|
||||
self::assertSame('Acces backstage', $billet->getDescription());
|
||||
}
|
||||
|
||||
public function testAddBilletDeniedForOtherUser(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
$other = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $owner);
|
||||
$category = $this->createCategory($em, $event);
|
||||
|
||||
$client->loginUser($other);
|
||||
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testAddBilletCategoryNotFound(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/999999/billet/ajouter');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testEditBilletPage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
$category = $this->createCategory($em, $event);
|
||||
$billet = $this->createBillet($em, $category);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testEditBilletSubmit(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
$category = $this->createCategory($em, $event);
|
||||
$billet = $this->createBillet($em, $category);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier', [
|
||||
'name' => 'Entree Premium',
|
||||
'price_ht' => '2500',
|
||||
'is_generated_billet' => '1',
|
||||
'description' => 'Acces VIP',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories');
|
||||
|
||||
$em->refresh($billet);
|
||||
self::assertSame('Entree Premium', $billet->getName());
|
||||
self::assertSame(2500, $billet->getPriceHT());
|
||||
}
|
||||
|
||||
public function testEditBilletDeniedForOtherUser(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
$other = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $owner);
|
||||
$category = $this->createCategory($em, $event);
|
||||
$billet = $this->createBillet($em, $category);
|
||||
|
||||
$client->loginUser($other);
|
||||
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testEditBilletNotFound(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/999999/modifier');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testDeleteBillet(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$user = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $user);
|
||||
$category = $this->createCategory($em, $event);
|
||||
$billet = $this->createBillet($em, $category);
|
||||
$billetId = $billet->getId();
|
||||
|
||||
$client->loginUser($user);
|
||||
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billetId.'/supprimer');
|
||||
|
||||
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories');
|
||||
|
||||
self::assertNull($em->getRepository(\App\Entity\Billet::class)->find($billetId));
|
||||
}
|
||||
|
||||
public function testDeleteBilletDeniedForOtherUser(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
$other = $this->createUser(['ROLE_ORGANIZER'], true);
|
||||
|
||||
$event = $this->createEvent($em, $owner);
|
||||
$category = $this->createCategory($em, $event);
|
||||
$billet = $this->createBillet($em, $category);
|
||||
|
||||
$client->loginUser($other);
|
||||
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/supprimer');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function createBillet(EntityManagerInterface $em, \App\Entity\Category $category, string $name = 'Test Billet', int $priceHT = 1000): \App\Entity\Billet
|
||||
{
|
||||
$billet = new \App\Entity\Billet();
|
||||
$billet->setName($name);
|
||||
$billet->setCategory($category);
|
||||
$billet->setPriceHT($priceHT);
|
||||
$em->persist($billet);
|
||||
$em->flush();
|
||||
|
||||
return $billet;
|
||||
}
|
||||
|
||||
private function createEvent(EntityManagerInterface $em, User $user): \App\Entity\Event
|
||||
{
|
||||
$event = new \App\Entity\Event();
|
||||
|
||||
69
tests/Entity/BilletDesignTest.php
Normal file
69
tests/Entity/BilletDesignTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\BilletDesign;
|
||||
use App\Entity\Event;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BilletDesignTest extends TestCase
|
||||
{
|
||||
public function testDefaults(): void
|
||||
{
|
||||
$design = new BilletDesign();
|
||||
|
||||
self::assertNull($design->getId());
|
||||
self::assertNull($design->getEvent());
|
||||
self::assertSame('#4f46e5', $design->getAccentColor());
|
||||
self::assertSame('Invitation', $design->getInvitationTitle());
|
||||
self::assertSame('#d4a017', $design->getInvitationColor());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $design->getUpdatedAt());
|
||||
}
|
||||
|
||||
public function testSetAndGetEvent(): void
|
||||
{
|
||||
$design = new BilletDesign();
|
||||
$event = new Event();
|
||||
$result = $design->setEvent($event);
|
||||
|
||||
self::assertSame($event, $design->getEvent());
|
||||
self::assertSame($design, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetAccentColor(): void
|
||||
{
|
||||
$design = new BilletDesign();
|
||||
$result = $design->setAccentColor('#ff0000');
|
||||
|
||||
self::assertSame('#ff0000', $design->getAccentColor());
|
||||
self::assertSame($design, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetInvitationTitle(): void
|
||||
{
|
||||
$design = new BilletDesign();
|
||||
$result = $design->setInvitationTitle('VIP Pass');
|
||||
|
||||
self::assertSame('VIP Pass', $design->getInvitationTitle());
|
||||
self::assertSame($design, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetInvitationColor(): void
|
||||
{
|
||||
$design = new BilletDesign();
|
||||
$result = $design->setInvitationColor('#00ff00');
|
||||
|
||||
self::assertSame('#00ff00', $design->getInvitationColor());
|
||||
self::assertSame($design, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetUpdatedAt(): void
|
||||
{
|
||||
$design = new BilletDesign();
|
||||
$date = new \DateTimeImmutable('2026-01-01');
|
||||
$result = $design->setUpdatedAt($date);
|
||||
|
||||
self::assertSame($date, $design->getUpdatedAt());
|
||||
self::assertSame($design, $result);
|
||||
}
|
||||
}
|
||||
227
tests/Entity/BilletTest.php
Normal file
227
tests/Entity/BilletTest.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Billet;
|
||||
use App\Entity\Category;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
|
||||
class BilletTest extends TestCase
|
||||
{
|
||||
public function testNewBilletDefaults(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
|
||||
self::assertNull($billet->getId());
|
||||
self::assertNull($billet->getName());
|
||||
self::assertNull($billet->getCategory());
|
||||
self::assertNull($billet->getDescription());
|
||||
self::assertNull($billet->getPictureName());
|
||||
self::assertNull($billet->getPictureFile());
|
||||
self::assertNull($billet->getUpdatedAt());
|
||||
self::assertSame(0, $billet->getPosition());
|
||||
self::assertSame(0, $billet->getPriceHT());
|
||||
self::assertNull($billet->getQuantity());
|
||||
self::assertTrue($billet->isUnlimited());
|
||||
self::assertTrue($billet->isGeneratedBillet());
|
||||
self::assertFalse($billet->hasDefinedExit());
|
||||
self::assertFalse($billet->isNotBuyable());
|
||||
self::assertSame('billet', $billet->getType());
|
||||
self::assertNull($billet->getStripeProductId());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $billet->getCreatedAt());
|
||||
}
|
||||
|
||||
public function testSetAndGetCategory(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$category = new Category();
|
||||
$result = $billet->setCategory($category);
|
||||
|
||||
self::assertSame($category, $billet->getCategory());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetName(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setName('Entree VIP');
|
||||
|
||||
self::assertSame('Entree VIP', $billet->getName());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetPriceHT(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setPriceHT(1500);
|
||||
|
||||
self::assertSame(1500, $billet->getPriceHT());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testGetPriceHTDecimal(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$billet->setPriceHT(1500);
|
||||
|
||||
self::assertSame(15.0, $billet->getPriceHTDecimal());
|
||||
}
|
||||
|
||||
public function testGetPriceHTDecimalZero(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
|
||||
self::assertSame(0.0, $billet->getPriceHTDecimal());
|
||||
}
|
||||
|
||||
public function testSetAndGetIsGeneratedBillet(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setIsGeneratedBillet(false);
|
||||
|
||||
self::assertFalse($billet->isGeneratedBillet());
|
||||
self::assertSame($billet, $result);
|
||||
|
||||
$billet->setIsGeneratedBillet(true);
|
||||
self::assertTrue($billet->isGeneratedBillet());
|
||||
}
|
||||
|
||||
public function testSetAndGetHasDefinedExit(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setHasDefinedExit(true);
|
||||
|
||||
self::assertTrue($billet->hasDefinedExit());
|
||||
self::assertSame($billet, $result);
|
||||
|
||||
$billet->setHasDefinedExit(false);
|
||||
self::assertFalse($billet->hasDefinedExit());
|
||||
}
|
||||
|
||||
public function testSetAndGetDescription(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setDescription('Acces backstage inclus');
|
||||
|
||||
self::assertSame('Acces backstage inclus', $billet->getDescription());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testSetDescriptionNull(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$billet->setDescription('Test');
|
||||
$billet->setDescription(null);
|
||||
|
||||
self::assertNull($billet->getDescription());
|
||||
}
|
||||
|
||||
public function testSetAndGetPictureName(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setPictureName('billet-vip.jpg');
|
||||
|
||||
self::assertSame('billet-vip.jpg', $billet->getPictureName());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testSetPictureFileUpdatesTimestamp(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
self::assertNull($billet->getUpdatedAt());
|
||||
|
||||
$file = $this->createMock(File::class);
|
||||
$result = $billet->setPictureFile($file);
|
||||
|
||||
self::assertSame($file, $billet->getPictureFile());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $billet->getUpdatedAt());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetPosition(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setPosition(3);
|
||||
|
||||
self::assertSame(3, $billet->getPosition());
|
||||
self::assertSame($billet, $result);
|
||||
}
|
||||
|
||||
public function testSetAndGetQuantity(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setQuantity(100);
|
||||
|
||||
self::assertSame(100, $billet->getQuantity());
|
||||
self::assertFalse($billet->isUnlimited());
|
||||
self::assertSame($billet, $result);
|
||||
|
||||
$billet->setQuantity(null);
|
||||
self::assertNull($billet->getQuantity());
|
||||
self::assertTrue($billet->isUnlimited());
|
||||
}
|
||||
|
||||
public function testSetAndGetNotBuyable(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setNotBuyable(true);
|
||||
|
||||
self::assertTrue($billet->isNotBuyable());
|
||||
self::assertSame($billet, $result);
|
||||
|
||||
$billet->setNotBuyable(false);
|
||||
self::assertFalse($billet->isNotBuyable());
|
||||
}
|
||||
|
||||
public function testSetAndGetType(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setType('reservation_brocante');
|
||||
|
||||
self::assertSame('reservation_brocante', $billet->getType());
|
||||
self::assertSame($billet, $result);
|
||||
|
||||
$billet->setType('vote');
|
||||
self::assertSame('vote', $billet->getType());
|
||||
}
|
||||
|
||||
public function testSetAndGetStripeProductId(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$result = $billet->setStripeProductId('prod_abc123');
|
||||
|
||||
self::assertSame('prod_abc123', $billet->getStripeProductId());
|
||||
self::assertSame($billet, $result);
|
||||
|
||||
$billet->setStripeProductId(null);
|
||||
self::assertNull($billet->getStripeProductId());
|
||||
}
|
||||
|
||||
public function testSetPictureFileNullDoesNotUpdateTimestamp(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$billet->setPictureFile(null);
|
||||
|
||||
self::assertNull($billet->getUpdatedAt());
|
||||
self::assertNull($billet->getPictureFile());
|
||||
}
|
||||
|
||||
public function testSetCategoryNull(): void
|
||||
{
|
||||
$billet = new Billet();
|
||||
$billet->setCategory(null);
|
||||
|
||||
self::assertNull($billet->getCategory());
|
||||
}
|
||||
|
||||
public function testGetCreatedAt(): void
|
||||
{
|
||||
$before = new \DateTimeImmutable();
|
||||
$billet = new Billet();
|
||||
$after = new \DateTimeImmutable();
|
||||
|
||||
self::assertGreaterThanOrEqual($before, $billet->getCreatedAt());
|
||||
self::assertLessThanOrEqual($after, $billet->getCreatedAt());
|
||||
}
|
||||
}
|
||||
85
tests/EventSubscriber/DisableProfilerSubscriberTest.php
Normal file
85
tests/EventSubscriber/DisableProfilerSubscriberTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\EventSubscriber\DisableProfilerSubscriber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profiler;
|
||||
|
||||
class DisableProfilerSubscriberTest extends TestCase
|
||||
{
|
||||
public function testSubscribedEvents(): void
|
||||
{
|
||||
$events = DisableProfilerSubscriber::getSubscribedEvents();
|
||||
|
||||
self::assertArrayHasKey(KernelEvents::RESPONSE, $events);
|
||||
}
|
||||
|
||||
public function testDisablesProfilerOnBilletPreviewRoute(): void
|
||||
{
|
||||
$profiler = $this->createMock(Profiler::class);
|
||||
$profiler->expects(self::once())->method('disable');
|
||||
|
||||
$subscriber = new DisableProfilerSubscriber($profiler);
|
||||
|
||||
$request = new Request();
|
||||
$request->attributes->set('_route', 'app_account_event_billet_preview');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response());
|
||||
|
||||
$subscriber->onKernelResponse($event);
|
||||
}
|
||||
|
||||
public function testDoesNotDisableProfilerOnOtherRoutes(): void
|
||||
{
|
||||
$profiler = $this->createMock(Profiler::class);
|
||||
$profiler->expects(self::never())->method('disable');
|
||||
|
||||
$subscriber = new DisableProfilerSubscriber($profiler);
|
||||
|
||||
$request = new Request();
|
||||
$request->attributes->set('_route', 'app_home');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response());
|
||||
|
||||
$subscriber->onKernelResponse($event);
|
||||
}
|
||||
|
||||
public function testHandlesNullProfiler(): void
|
||||
{
|
||||
$subscriber = new DisableProfilerSubscriber(null);
|
||||
|
||||
$request = new Request();
|
||||
$request->attributes->set('_route', 'app_account_event_billet_preview');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response());
|
||||
|
||||
// No exception thrown
|
||||
$subscriber->onKernelResponse($event);
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testIgnoresSubRequests(): void
|
||||
{
|
||||
$profiler = $this->createMock(Profiler::class);
|
||||
$profiler->expects(self::never())->method('disable');
|
||||
|
||||
$subscriber = new DisableProfilerSubscriber($profiler);
|
||||
|
||||
$request = new Request();
|
||||
$request->attributes->set('_route', 'app_account_event_billet_preview');
|
||||
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, new Response());
|
||||
|
||||
$subscriber->onKernelResponse($event);
|
||||
}
|
||||
}
|
||||
96
tests/js/billet-designer.test.js
Normal file
96
tests/js/billet-designer.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { initBilletDesigner } from '../../assets/modules/billet-designer.js'
|
||||
|
||||
describe('initBilletDesigner', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('does nothing without designer element', () => {
|
||||
expect(() => initBilletDesigner()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does nothing without preview url', () => {
|
||||
document.body.innerHTML = '<div id="billet-designer"></div>'
|
||||
expect(() => initBilletDesigner()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does nothing without iframe', () => {
|
||||
document.body.innerHTML = '<div id="billet-designer" data-preview-url="/preview"></div>'
|
||||
expect(() => initBilletDesigner()).not.toThrow()
|
||||
})
|
||||
|
||||
it('reloads iframe on color input change', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="billet-designer" data-preview-url="/preview">
|
||||
<input type="color" name="bg_color" value="#ffffff">
|
||||
<iframe id="billet-preview-frame" src="/preview"></iframe>
|
||||
</div>
|
||||
`
|
||||
|
||||
initBilletDesigner()
|
||||
|
||||
const input = document.querySelector('input[name="bg_color"]')
|
||||
input.value = '#ff0000'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
const iframe = document.getElementById('billet-preview-frame')
|
||||
expect(iframe.src).toContain('bg_color=%23ff0000')
|
||||
})
|
||||
|
||||
it('reloads iframe on checkbox change', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="billet-designer" data-preview-url="/preview">
|
||||
<input type="checkbox" name="show_logo" checked>
|
||||
<iframe id="billet-preview-frame" src="/preview"></iframe>
|
||||
</div>
|
||||
`
|
||||
|
||||
initBilletDesigner()
|
||||
|
||||
const checkbox = document.querySelector('input[name="show_logo"]')
|
||||
checkbox.checked = false
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
const iframe = document.getElementById('billet-preview-frame')
|
||||
expect(iframe.src).toContain('show_logo=0')
|
||||
})
|
||||
|
||||
it('includes all inputs in preview url', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="billet-designer" data-preview-url="/preview">
|
||||
<input type="color" name="bg_color" value="#ffffff">
|
||||
<input type="color" name="text_color" value="#111111">
|
||||
<input type="checkbox" name="show_logo" checked>
|
||||
<iframe id="billet-preview-frame" src="/preview"></iframe>
|
||||
</div>
|
||||
`
|
||||
|
||||
initBilletDesigner()
|
||||
|
||||
const input = document.querySelector('input[name="bg_color"]')
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
const iframe = document.getElementById('billet-preview-frame')
|
||||
expect(iframe.src).toContain('bg_color=%23ffffff')
|
||||
expect(iframe.src).toContain('text_color=%23111111')
|
||||
expect(iframe.src).toContain('show_logo=1')
|
||||
})
|
||||
|
||||
it('reloads on reload button click', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="billet-designer" data-preview-url="/preview">
|
||||
<input type="color" name="bg_color" value="#aabbcc">
|
||||
<iframe id="billet-preview-frame" src="/preview"></iframe>
|
||||
<button id="billet-reload-preview"></button>
|
||||
</div>
|
||||
`
|
||||
|
||||
initBilletDesigner()
|
||||
|
||||
document.getElementById('billet-reload-preview').click()
|
||||
|
||||
const iframe = document.getElementById('billet-preview-frame')
|
||||
expect(iframe.src).toContain('bg_color=%23aabbcc')
|
||||
})
|
||||
})
|
||||
88
tests/js/commission-calculator.test.js
Normal file
88
tests/js/commission-calculator.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { initCommissionCalculator } from '../../assets/modules/commission-calculator.js'
|
||||
|
||||
describe('initCommissionCalculator', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('does nothing without calculator element', () => {
|
||||
expect(() => initCommissionCalculator()).not.toThrow()
|
||||
})
|
||||
|
||||
it('does nothing without price input', () => {
|
||||
document.body.innerHTML = '<div id="commission-calculator" data-eticket-rate="5" data-stripe-rate="1.5" data-stripe-fixed="0.25"></div>'
|
||||
expect(() => initCommissionCalculator()).not.toThrow()
|
||||
})
|
||||
|
||||
function setupCalculator(eticketRate = '5', price = '') {
|
||||
document.body.innerHTML = `
|
||||
<input type="number" id="billet_price" value="${price}">
|
||||
<div id="commission-calculator" data-eticket-rate="${eticketRate}" data-stripe-rate="1.5" data-stripe-fixed="0.25">
|
||||
<span id="calc-price"></span>
|
||||
<span id="calc-eticket"></span>
|
||||
<span id="calc-stripe"></span>
|
||||
<span id="calc-total"></span>
|
||||
<span id="calc-net"></span>
|
||||
</div>
|
||||
`
|
||||
initCommissionCalculator()
|
||||
}
|
||||
|
||||
it('shows zero values when price is empty', () => {
|
||||
setupCalculator('5', '')
|
||||
|
||||
expect(document.getElementById('calc-price').textContent).toBe('0,00 \u20AC')
|
||||
expect(document.getElementById('calc-net').textContent).toBe('0,00 \u20AC')
|
||||
})
|
||||
|
||||
it('calculates commissions for 10 EUR with 5% eticket rate', () => {
|
||||
setupCalculator('5', '10')
|
||||
|
||||
// E-Ticket: 10 * 5% = 0.50
|
||||
// Stripe: 10 * 1.5% + 0.25 = 0.40
|
||||
// Total: 0.90
|
||||
// Net: 9.10
|
||||
expect(document.getElementById('calc-price').textContent).toBe('10,00 \u20AC')
|
||||
expect(document.getElementById('calc-eticket').textContent).toBe('- 0,50 \u20AC')
|
||||
expect(document.getElementById('calc-stripe').textContent).toBe('- 0,40 \u20AC')
|
||||
expect(document.getElementById('calc-total').textContent).toBe('- 0,90 \u20AC')
|
||||
expect(document.getElementById('calc-net').textContent).toBe('9,10 \u20AC')
|
||||
})
|
||||
|
||||
it('calculates with 0% eticket rate', () => {
|
||||
setupCalculator('0', '20')
|
||||
|
||||
// E-Ticket: 0
|
||||
// Stripe: 20 * 1.5% + 0.25 = 0.55
|
||||
// Net: 19.45
|
||||
expect(document.getElementById('calc-eticket').textContent).toBe('- 0,00 \u20AC')
|
||||
expect(document.getElementById('calc-stripe').textContent).toBe('- 0,55 \u20AC')
|
||||
expect(document.getElementById('calc-net').textContent).toBe('19,45 \u20AC')
|
||||
})
|
||||
|
||||
it('updates on input event', () => {
|
||||
setupCalculator('5', '0')
|
||||
|
||||
const input = document.getElementById('billet_price')
|
||||
input.value = '15'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
// E-Ticket: 15 * 5% = 0.75
|
||||
// Stripe: 15 * 1.5% + 0.25 = 0.475 → 0.48
|
||||
// Total: 1.225 → 1.23 (but floating point...)
|
||||
expect(document.getElementById('calc-price').textContent).toBe('15,00 \u20AC')
|
||||
expect(document.getElementById('calc-eticket').textContent).toBe('- 0,75 \u20AC')
|
||||
|
||||
const net = document.getElementById('calc-net').textContent
|
||||
expect(net).toContain('\u20AC')
|
||||
})
|
||||
|
||||
it('net is never negative', () => {
|
||||
setupCalculator('99', '0.01')
|
||||
|
||||
// With 99% commission the net would be very low or negative after stripe
|
||||
const net = document.getElementById('calc-net').textContent
|
||||
expect(net).toBe('0,00 \u20AC')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user