diff --git a/CLAUDE.md b/CLAUDE.md index 3bf64aa..d1530da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Makefile b/Makefile index 1abb0af..ab597df 100644 --- a/Makefile +++ b/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 diff --git a/assets/app.js b/assets/app.js index 5b14289..a74f118 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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() }) diff --git a/assets/modules/billet-designer.js b/assets/modules/billet-designer.js new file mode 100644 index 0000000..4e901f7 --- /dev/null +++ b/assets/modules/billet-designer.js @@ -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) + } +} diff --git a/assets/modules/commission-calculator.js b/assets/modules/commission-calculator.js new file mode 100644 index 0000000..0dac5e9 --- /dev/null +++ b/assets/modules/commission-calculator.js @@ -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() +} diff --git a/assets/modules/sortable.js b/assets/modules/sortable.js index 8234860..2eb2be4 100644 --- a/assets/modules/sortable.js +++ b/assets/modules/sortable.js @@ -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') + }) +} diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index 7cfa2b7..54b7614 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -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: diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index d996a89..db400a1 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -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' diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 137dfc3..9b766c8 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -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 diff --git a/migrations/Version20260320214602.php b/migrations/Version20260320214602.php deleted file mode 100644 index 8b868e1..0000000 --- a/migrations/Version20260320214602.php +++ /dev/null @@ -1,34 +0,0 @@ -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'); - } -} diff --git a/migrations/Version20260320221953.php b/migrations/Version20260320221953.php deleted file mode 100644 index bb4c293..0000000 --- a/migrations/Version20260320221953.php +++ /dev/null @@ -1,31 +0,0 @@ -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'); - } -} diff --git a/migrations/Version20260320201224.php b/migrations/Version20260321111125.php similarity index 71% rename from migrations/Version20260320201224.php rename to migrations/Version20260321111125.php index 3949445..e95fb11 100644 --- a/migrations/Version20260320201224.php +++ b/migrations/Version20260321111125.php @@ -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'); diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index bfa7695..0c522cc 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -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 { diff --git a/src/Entity/Billet.php b/src/Entity/Billet.php new file mode 100644 index 0000000..92cdb86 --- /dev/null +++ b/src/Entity/Billet.php @@ -0,0 +1,254 @@ +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; + } +} diff --git a/src/Entity/BilletDesign.php b/src/Entity/BilletDesign.php new file mode 100644 index 0000000..1663a02 --- /dev/null +++ b/src/Entity/BilletDesign.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/src/EventSubscriber/DisableProfilerSubscriber.php b/src/EventSubscriber/DisableProfilerSubscriber.php new file mode 100644 index 0000000..35304b9 --- /dev/null +++ b/src/EventSubscriber/DisableProfilerSubscriber.php @@ -0,0 +1,36 @@ + '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(); + } + } +} diff --git a/src/Repository/BilletDesignRepository.php b/src/Repository/BilletDesignRepository.php new file mode 100644 index 0000000..57bdf26 --- /dev/null +++ b/src/Repository/BilletDesignRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletDesignRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, BilletDesign::class); + } +} diff --git a/src/Repository/BilletRepository.php b/src/Repository/BilletRepository.php new file mode 100644 index 0000000..f28b073 --- /dev/null +++ b/src/Repository/BilletRepository.php @@ -0,0 +1,18 @@ + + */ +class BilletRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Billet::class); + } +} diff --git a/src/Service/StripeService.php b/src/Service/StripeService.php index 8e8dc9e..ee92282 100644 --- a/src/Service/StripeService.php +++ b/src/Service/StripeService.php @@ -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 */ diff --git a/templates/account/_billet_commission.html.twig b/templates/account/_billet_commission.html.twig new file mode 100644 index 0000000..a4cfbf5 --- /dev/null +++ b/templates/account/_billet_commission.html.twig @@ -0,0 +1,30 @@ +
{{ event.title }} — {{ category.name }}
+ + {% for message in app.flashes('error') %} +{{ message }}
{{ event.title }} — {{ billet.category.name }}
+ + {% for message in app.flashes('error') %} +{{ message }}
Aucune categorie. Ajoutez-en une pour commencer a vendre des billets.
{% endif %}