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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
12
assets/modules/share.js
Normal file
12
assets/modules/share.js
Normal file
@@ -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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>'
|
||||
setTimeout(() => { btn.innerHTML = original }, 1500)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -301,8 +301,21 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex gap-2 justify-end">
|
||||
{% set evt_url = url('app_event_detail', {orgaSlug: app.user.slug, id: event.id, eventSlug: event.slug}) %}
|
||||
<div class="flex gap-2 justify-end flex-wrap">
|
||||
<a href="{{ path('app_account_edit_event', {id: event.id}) }}" class="px-3 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase tracking-widest hover:bg-gray-100 transition-all">Modifier</a>
|
||||
<a href="https://twitter.com/intent/tweet?text={{ (event.title)|url_encode }}&url={{ evt_url|url_encode }}" target="_blank" rel="noopener" class="w-8 h-8 flex items-center justify-center border-2 border-gray-900 bg-black text-white hover:opacity-80 transition-all" aria-label="Partager sur X" title="Partager sur X">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ evt_url|url_encode }}" target="_blank" rel="noopener" class="w-8 h-8 flex items-center justify-center border-2 border-gray-900 bg-[#1877F2] text-white hover:opacity-80 transition-all" aria-label="Partager sur Facebook" title="Partager sur Facebook">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</a>
|
||||
<button type="button" class="w-8 h-8 flex items-center justify-center border-2 border-gray-900 bg-white hover:bg-gray-900 hover:text-white transition-all cursor-pointer" aria-label="Copier le lien" title="Copier le lien" data-share-copy="{{ evt_url }}">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
|
||||
</button>
|
||||
<a href="{{ path('app_account_event_qrcode', {id: event.id}) }}" class="w-8 h-8 flex items-center justify-center border-2 border-gray-900 bg-white hover:bg-gray-900 hover:text-white transition-all" aria-label="QR Code" title="Telecharger QR Code">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/></svg>
|
||||
</a>
|
||||
<form method="post" action="{{ path('app_account_delete_event', {id: event.id}) }}" data-confirm="Etes-vous sur de vouloir supprimer cet evenement ?" class="inline">
|
||||
<button type="submit" class="px-3 py-1 border-2 border-red-800 bg-red-600 text-white text-xs font-black uppercase tracking-widest hover:bg-red-800 transition-all cursor-pointer">Supprimer</button>
|
||||
</form>
|
||||
|
||||
@@ -71,6 +71,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set event_url = url('app_event_detail', {orgaSlug: organizer.slug, id: event.id, eventSlug: event.slug}) %}
|
||||
<div class="flex flex-wrap items-center gap-3 mt-6">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-gray-400">Partager</span>
|
||||
<a href="https://twitter.com/intent/tweet?text={{ (event.title ~ ' - ' ~ event.startAt|date('d/m/Y') ~ ' a ' ~ event.city)|url_encode }}&url={{ event_url|url_encode }}" target="_blank" rel="noopener" class="w-9 h-9 flex items-center justify-center border-2 border-gray-900 bg-black text-white hover:opacity-80 transition-all" aria-label="Partager sur X">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
</a>
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ event_url|url_encode }}" target="_blank" rel="noopener" class="w-9 h-9 flex items-center justify-center border-2 border-gray-900 bg-[#1877F2] text-white hover:opacity-80 transition-all" aria-label="Partager sur Facebook">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</a>
|
||||
<button type="button" class="w-9 h-9 flex items-center justify-center border-2 border-gray-900 bg-gradient-to-br from-purple-600 via-pink-500 to-orange-400 text-white hover:opacity-80 transition-all cursor-pointer" aria-label="Copier le lien pour Instagram" data-share-copy="{{ event_url }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="w-9 h-9 flex items-center justify-center border-2 border-gray-900 bg-black text-white hover:opacity-80 transition-all cursor-pointer" aria-label="Copier le lien pour TikTok" data-share-copy="{{ event_url }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="w-9 h-9 flex items-center justify-center border-2 border-gray-900 bg-white hover:bg-gray-900 hover:text-white transition-all cursor-pointer" aria-label="Copier le lien" data-share-copy="{{ event_url }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if categories|length > 0 %}
|
||||
{% set event_ended = event.endAt and event.endAt < date() %}
|
||||
<div class="card-brutal overflow-hidden p-0 mt-8" id="billetterie" data-stock-url="{{ path('app_event_stock', {id: event.id}) }}">
|
||||
|
||||
75
tests/js/share.test.js
Normal file
75
tests/js/share.test.js
Normal file
@@ -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 = `
|
||||
<button data-share-copy="https://example.com/event/1">
|
||||
<svg class="icon"></svg>
|
||||
</button>
|
||||
`
|
||||
|
||||
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 = `
|
||||
<button data-share-copy="https://example.com">
|
||||
<svg class="original"></svg>
|
||||
</button>
|
||||
`
|
||||
|
||||
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 = `
|
||||
<button data-share-copy="https://a.com"><svg></svg></button>
|
||||
<button data-share-copy="https://b.com"><svg></svg></button>
|
||||
`
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user