From 6b009a45112b7a3ee0ea676bcaeda03ca0f4117c Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Mon, 23 Mar 2026 15:11:53 +0100 Subject: [PATCH] Add social sharing buttons and QR code for events Public event page: - Share buttons: X (Twitter), Facebook, Instagram (copy link), TikTok (copy link), copy link - Buttons use url_encode for share URLs with event title + URL - Instagram/TikTok copy to clipboard (no direct share URL support) - Consistent brutal design with aria-labels Organizer dashboard: - Share X, Facebook, copy link buttons per event in events list - QR code download button per event - Route /mon-compte/evenement/{id}/qrcode: generates 400px PNG QR code via Endroid - QR code points to public event URL, downloaded as qrcode-{slug}.png JS module: - assets/modules/share.js: initShare() handles data-share-copy buttons - Copies URL to clipboard, shows checkmark for 1.5s then restores icon - 4 tests (no buttons, copy, checkmark restore, multiple buttons) Social icons already displayed via _social_icons.html.twig component Co-Authored-By: Claude Opus 4.6 (1M context) --- TASK_CHECKUP.md | 16 ++++++ assets/app.js | 2 + assets/modules/share.js | 12 +++++ src/Controller/AccountController.php | 32 ++++++++++++ templates/account/index.html.twig | 15 +++++- templates/home/event_detail.html.twig | 20 +++++++ tests/js/share.test.js | 75 +++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 assets/modules/share.js create mode 100644 tests/js/share.test.js diff --git a/TASK_CHECKUP.md b/TASK_CHECKUP.md index 4890119..31ec134 100644 --- a/TASK_CHECKUP.md +++ b/TASK_CHECKUP.md @@ -130,6 +130,22 @@ - [x] Ajouter des tests pour AuditService, ExportService, InvoiceService - [x] Ajouter des tests pour les webhooks Stripe (payment_failed, charge.refunded) +### Réseaux sociaux & Partage +#### Page détail événement (public) +- [x] Bouton partage X (Twitter) : lien pré-rempli avec titre + URL événement +- [x] Bouton partage Facebook : lien share dialog avec URL événement +- [x] Bouton partage Instagram : copier le lien (Instagram ne supporte pas le share URL direct) +- [x] Bouton partage TikTok : copier le lien (TikTok ne supporte pas le share URL direct) +- [x] Bouton copier le lien de l'événement + +#### Interface organisateur (/mon-compte) +- [x] Bouton partage X (Twitter) pour chaque événement dans la liste +- [x] Bouton partage Facebook pour chaque événement +- [x] Bouton copier le lien pour chaque événement +- [x] Bouton QR code événement : génération QR code PNG téléchargeable +- [x] Page /mon-compte/evenement/{id}/qrcode : route avec Endroid QR Code +- [x] Afficher les liens réseaux sociaux de l'orga (facebook, instagram, twitter, tiktok) sur la page événement (déjà en place via _social_icons.html.twig) + ### Infrastructure - [x] Configurer les crons pour les backups automatiques (DB + uploads, toutes les 30 min, rétention 1 jour) - [x] Ajouter le monitoring des queues Messenger (commande + cron toutes les heures + email admin) diff --git a/assets/app.js b/assets/app.js index cf6bed1..08ec54b 100644 --- a/assets/app.js +++ b/assets/app.js @@ -10,6 +10,7 @@ import { initBilletDesigner } from "./modules/billet-designer.js" import { initCommissionCalculator } from "./modules/commission-calculator.js" import { initCart } from "./modules/cart.js" import { initStripePayment } from "./modules/stripe-payment.js" +import { initShare } from "./modules/share.js" document.addEventListener('DOMContentLoaded', () => { initMobileMenu() @@ -23,6 +24,7 @@ document.addEventListener('DOMContentLoaded', () => { initCommissionCalculator() initCart() initStripePayment() + initShare() document.querySelectorAll('[data-confirm]').forEach(form => { form.addEventListener('submit', (e) => { diff --git a/assets/modules/share.js b/assets/modules/share.js new file mode 100644 index 0000000..c7cb157 --- /dev/null +++ b/assets/modules/share.js @@ -0,0 +1,12 @@ +export function initShare() { + document.querySelectorAll('[data-share-copy]').forEach(btn => { + btn.addEventListener('click', () => { + const url = btn.dataset.shareCopy + globalThis.navigator.clipboard.writeText(url).then(() => { + const original = btn.innerHTML + btn.innerHTML = '' + setTimeout(() => { btn.innerHTML = original }, 1500) + }) + }) + }) +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index f17ef9a..c045fd0 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -26,6 +26,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('ROLE_USER')] @@ -1059,6 +1060,37 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account', ['tab' => 'events']); } + #[Route('/mon-compte/evenement/{id}/qrcode', name: 'app_account_event_qrcode', methods: ['GET'])] + public function eventQrCode(Event $event, UrlGeneratorInterface $urlGenerator): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + if ($event->getAccount()->getId() !== $user->getId()) { + throw $this->createAccessDeniedException(); + } + + $eventUrl = $urlGenerator->generate('app_event_detail', [ + 'orgaSlug' => $user->getSlug(), + 'id' => $event->getId(), + 'eventSlug' => $event->getSlug(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $qrCode = (new \Endroid\QrCode\Builder\Builder( + writer: new \Endroid\QrCode\Writer\PngWriter(), + data: $eventUrl, + encoding: new \Endroid\QrCode\Encoding\Encoding('UTF-8'), + size: 400, + margin: 20, + ))->build(); + + return new Response($qrCode->getString(), 200, [ + 'Content-Type' => 'image/png', + 'Content-Disposition' => 'attachment; filename="qrcode-'.$event->getSlug().'.png"', + ]); + } + /** @codeCoverageIgnore Test helper, not used in production */ #[Route('/mon-compte/test-payout', name: 'app_account_test_payout', methods: ['POST'])] public function testPayout(EntityManagerInterface $em): Response diff --git a/templates/account/index.html.twig b/templates/account/index.html.twig index c478690..248ac53 100644 --- a/templates/account/index.html.twig +++ b/templates/account/index.html.twig @@ -301,8 +301,21 @@ {% endif %} -
+ {% set evt_url = url('app_event_detail', {orgaSlug: app.user.slug, id: event.id, eventSlug: event.slug}) %} +
Modifier + + + + + + + + + +
diff --git a/templates/home/event_detail.html.twig b/templates/home/event_detail.html.twig index ff12672..d23936d 100644 --- a/templates/home/event_detail.html.twig +++ b/templates/home/event_detail.html.twig @@ -71,6 +71,26 @@
+ {% set event_url = url('app_event_detail', {orgaSlug: organizer.slug, id: event.id, eventSlug: event.slug}) %} +
+ Partager + + + + + + + + + +
+ {% if categories|length > 0 %} {% set event_ended = event.endAt and event.endAt < date() %}
diff --git a/tests/js/share.test.js b/tests/js/share.test.js new file mode 100644 index 0000000..fe78f97 --- /dev/null +++ b/tests/js/share.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { initShare } from '../../assets/modules/share.js' + +describe('initShare', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('does nothing without share buttons', () => { + expect(() => initShare()).not.toThrow() + }) + + it('copies URL to clipboard on click', async () => { + document.body.innerHTML = ` + + ` + + const writeText = vi.fn().mockResolvedValue(undefined) + globalThis.navigator = { clipboard: { writeText } } + + initShare() + document.querySelector('[data-share-copy]').click() + + await new Promise(r => setTimeout(r, 10)) + + expect(writeText).toHaveBeenCalledWith('https://example.com/event/1') + }) + + it('shows checkmark after copy then restores', async () => { + vi.useFakeTimers() + + document.body.innerHTML = ` + + ` + + globalThis.navigator = { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } } + + initShare() + const btn = document.querySelector('[data-share-copy]') + const originalHtml = btn.innerHTML + btn.click() + + await vi.advanceTimersByTimeAsync(100) + + expect(btn.innerHTML).toContain('M5 13l4 4L19 7') + + vi.advanceTimersByTime(1500) + + expect(btn.innerHTML).toBe(originalHtml) + + vi.useRealTimers() + }) + + it('handles multiple share buttons', async () => { + document.body.innerHTML = ` + + + ` + + const writeText = vi.fn().mockResolvedValue(undefined) + globalThis.navigator = { clipboard: { writeText } } + + initShare() + + document.querySelectorAll('[data-share-copy]')[1].click() + + await new Promise(r => setTimeout(r, 10)) + + expect(writeText).toHaveBeenCalledWith('https://b.com') + }) +})