Add public events page, event detail route, copy URL button, organizer events list

- Add /evenements public page with Meilisearch search, KnpPaginator (12/page), event cards grid
- Add /evenement/{orgaSlug}/{id}-{eventSlug} public route with slug redirect
- Add Event::getSlug() method
- Update homepage stats with real event count
- Update organizer detail page to list their public events
- Update navbar: link Evenements to /evenements with active state
- Add copy URL button on edit event page (visible only when online)
- Add initCopyUrl() in app.js with clipboard API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-20 17:54:02 +01:00
parent fe33b80351
commit f70f0c2af9
9 changed files with 259 additions and 4 deletions

View File

@@ -4,9 +4,24 @@ import { initTabs } from "./modules/tabs.js"
import { registerEditor } from "./modules/editor.js"
import { initCookieConsent } from "./modules/cookie-consent.js"
function initCopyUrl() {
const btn = document.getElementById('copy-url-btn')
if (!btn) return
const url = document.getElementById('event-url')?.textContent?.trim()
if (!url) return
btn.addEventListener('click', () => {
globalThis.navigator.clipboard.writeText(url).then(() => {
btn.textContent = 'Copie !'
setTimeout(() => { btn.textContent = 'Copier le lien' }, 2000)
})
})
}
document.addEventListener('DOMContentLoaded', () => {
initMobileMenu()
initTabs()
registerEditor()
initCookieConsent()
initCopyUrl()
})

View File

@@ -2,9 +2,13 @@
namespace App\Controller;
use App\Entity\Event;
use App\Entity\User;
use App\Service\MeilisearchService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -18,19 +22,57 @@ class HomeController extends AbstractController
{
$allUsers = $em->getRepository(User::class)->findAll();
$organizers = \count(array_filter($allUsers, fn (User $u) => \in_array('ROLE_ORGANIZER', $u->getRoles(), true) && $u->isApproved()));
$eventsCount = \count($em->getRepository(Event::class)->findBy(['isOnline' => true, 'isSecret' => false]));
return $this->render('home/index.html.twig', [
'breadcrumbs' => [
self::BREADCRUMB_HOME,
],
'stats' => [
'events' => 0,
'events' => $eventsCount,
'organizers' => $organizers,
'tickets' => 0,
],
]);
}
#[Route('/evenements', name: 'app_events')]
public function events(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator, MeilisearchService $meilisearch): Response
{
$searchQuery = $request->query->getString('q', '');
if ('' !== $searchQuery) {
try {
$searchResults = $meilisearch->search('event_global', $searchQuery);
$eventIds = array_map(fn (array $hit) => $hit['id'], $searchResults['hits'] ?? []);
$eventsQuery = $eventIds
? $em->getRepository(Event::class)->findBy(['id' => $eventIds])
: [];
} catch (\Throwable) {
$eventsQuery = $em->getRepository(Event::class)->findBy(
['isOnline' => true, 'isSecret' => false],
['startAt' => 'ASC'],
);
}
} else {
$eventsQuery = $em->getRepository(Event::class)->findBy(
['isOnline' => true, 'isSecret' => false],
['startAt' => 'ASC'],
);
}
$events = $paginator->paginate($eventsQuery, $request->query->getInt('page', 1), 12);
return $this->render('home/events.html.twig', [
'events' => $events,
'searchQuery' => $searchQuery,
'breadcrumbs' => [
self::BREADCRUMB_HOME,
['name' => 'Evenements', 'url' => '/evenements'],
],
]);
}
#[Route('/organisateurs', name: 'app_organizers')]
public function organizers(EntityManagerInterface $em): Response
{
@@ -62,12 +104,56 @@ class HomeController extends AbstractController
], 301);
}
$events = $em->getRepository(Event::class)->findBy(
['account' => $organizer, 'isOnline' => true, 'isSecret' => false],
['startAt' => 'ASC'],
);
return $this->render('home/organizer_detail.html.twig', [
'organizer' => $organizer,
'events' => $events,
'breadcrumbs' => [
self::BREADCRUMB_HOME,
self::BREADCRUMB_ORGANIZERS,
['name' => $organizer->getCompanyName() ?? $organizer->getFirstName().' '.$organizer->getLastName(), 'url' => '/organisateur/'.$organizer->getId().'-'.$organizer->getSlug()],
],
]);
}
#[Route('/evenement/{orgaSlug}/{id}-{eventSlug}', name: 'app_event_detail', requirements: ['id' => '\d+', 'orgaSlug' => '[a-z0-9-]+', 'eventSlug' => '[a-z0-9-]+'])]
public function eventDetail(int $id, string $orgaSlug, string $eventSlug, EntityManagerInterface $em): Response
{
$event = $em->getRepository(Event::class)->find($id);
if (!$event || !$event->isOnline()) {
throw $this->createNotFoundException('Evenement introuvable.');
}
$organizer = $event->getAccount();
if ($orgaSlug !== $organizer->getSlug()) {
return $this->redirectToRoute('app_event_detail', [
'orgaSlug' => $organizer->getSlug(),
'id' => $event->getId(),
'eventSlug' => $event->getSlug(),
], 301);
}
if ($eventSlug !== $event->getSlug()) {
return $this->redirectToRoute('app_event_detail', [
'orgaSlug' => $organizer->getSlug(),
'id' => $event->getId(),
'eventSlug' => $event->getSlug(),
], 301);
}
return $this->render('home/event_detail.html.twig', [
'event' => $event,
'organizer' => $organizer,
'breadcrumbs' => [
self::BREADCRUMB_HOME,
self::BREADCRUMB_ORGANIZERS,
['name' => $organizer->getCompanyName() ?? $organizer->getFirstName().' '.$organizer->getLastName(), 'url' => '/organisateur/'.$organizer->getId().'-'.$organizer->getSlug()],
['name' => $event->getTitle(), 'url' => '/evenement/'.$organizer->getSlug().'/'.$event->getId().'-'.$event->getSlug()],
],
]);
}

View File

@@ -177,6 +177,16 @@ class Event
return $this;
}
public function getSlug(): string
{
$slug = mb_strtolower(trim($this->title ?? ''));
$slug = transliterator_transliterate('Any-Latin; Latin-ASCII', $slug) ?: $slug;
$slug = (string) preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
return '' === $slug ? 'evenement' : $slug;
}
public function isSecret(): bool
{
return $this->isSecret;

View File

@@ -64,6 +64,19 @@
</div>
</div>
{% if event.online %}
<div class="card-brutal-green mb-8 flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">URL publique</p>
<p class="text-sm font-mono font-bold break-all" id="event-url">{{ absolute_url(path('app_event_detail', {orgaSlug: event.account.slug, id: event.id, eventSlug: event.slug})) }}</p>
</div>
<button type="button" id="copy-url-btn" class="px-4 py-2 border-2 border-gray-900 bg-white font-black uppercase text-xs tracking-widest cursor-pointer hover:bg-gray-100 transition-all">
Copier le lien
</button>
</div>
<script type="application/json" id="copy-url-data">{{ absolute_url(path('app_event_detail', {orgaSlug: event.account.slug, id: event.id, eventSlug: event.slug})) }}</script>
{% endif %}
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-1 min-w-0">
<form method="post" action="{{ path('app_account_edit_event', {id: event.id}) }}" enctype="multipart/form-data" class="form-col">

View File

@@ -94,7 +94,7 @@
<div class="hidden lg:flex items-center space-x-1">
{% set current_route = app.request.attributes.get('_route') %}
<a href="{{ path('app_home') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_home' ? 'bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]' : 'hover:text-indigo-600' }}"><span itemprop="name">Accueil</span></a>
<a href="#" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all hover:text-indigo-600"><span itemprop="name">Evenements</span></a>
<a href="{{ path('app_events') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all {{ current_route in ['app_events', 'app_event_detail'] ? 'bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]' : 'hover:text-indigo-600' }}"><span itemprop="name">Evenements</span></a>
<a href="{{ path('app_organizers') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all {{ current_route in ['app_organizers', 'app_organizer_detail'] ? 'bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]' : 'hover:text-indigo-600' }}"><span itemprop="name">Organisateurs</span></a>
<a href="{{ path('app_contact') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_contact' ? 'bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]' : 'hover:text-indigo-600' }}"><span itemprop="name">Contact</span></a>
<a href="https://www.e-cosplay.fr" target="_blank" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all hover:text-indigo-600"><span itemprop="name">E-Cosplay</span></a>
@@ -124,7 +124,7 @@
<div id="mobile-menu" class="hidden lg:hidden border-t-4 border-gray-900 bg-white" role="menu">
<div class="p-4 space-y-2 uppercase font-black italic">
<a href="{{ path('app_home') }}" class="block p-3 border-2 {{ current_route == 'app_home' ? 'border-gray-900 bg-yellow-400' : 'border-transparent hover:border-gray-900 hover:bg-gray-50' }}" role="menuitem">Accueil</a>
<a href="#" class="block p-3 border-2 border-transparent hover:border-gray-900 hover:bg-gray-50" role="menuitem">Evenements</a>
<a href="{{ path('app_events') }}" class="block p-3 border-2 {{ current_route in ['app_events', 'app_event_detail'] ? 'border-gray-900 bg-yellow-400' : 'border-transparent hover:border-gray-900 hover:bg-gray-50' }}" role="menuitem">Evenements</a>
<a href="{{ path('app_organizers') }}" class="block p-3 border-2 {{ current_route in ['app_organizers', 'app_organizer_detail'] ? 'border-gray-900 bg-yellow-400' : 'border-transparent hover:border-gray-900 hover:bg-gray-50' }}" role="menuitem">Organisateurs</a>
<a href="{{ path('app_contact') }}" class="block p-3 border-2 {{ current_route == 'app_contact' ? 'border-gray-900 bg-yellow-400' : 'border-transparent hover:border-gray-900 hover:bg-gray-50' }}" role="menuitem">Contact</a>
<a href="https://www.e-cosplay.fr" target="_blank" class="block p-3 border-2 border-transparent hover:border-gray-900 hover:bg-gray-50" role="menuitem">E-Cosplay</a>

View File

@@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}{{ event.title }} - E-Ticket{% endblock %}
{% block description %}{{ event.title }} - {{ event.startAt|date('d/m/Y') }} a {{ event.city }}{% endblock %}
{% block og_image %}
{% if event.eventMainPictureName %}
<meta property="og:image" content="{{ absolute_url('/uploads/events/' ~ event.eventMainPictureName) }}">
{% else %}
<meta property="og:image" content="https://ticket.e-cosplay.fr/logo.png">
{% endif %}
{% endblock %}
{% block body %}
<div class="page-container">
<h1 class="text-3xl font-black uppercase tracking-tighter italic heading-page">{{ event.title }}</h1>
<p class="font-bold text-gray-500 italic mb-8">{{ event.startAt|date('d/m/Y') }} - {{ event.city }}</p>
<div class="card-brutal">
<p class="text-center text-gray-400 font-bold">Page evenement en cours de construction.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% extends 'base.html.twig' %}
{% block title %}Evenements - E-Ticket{% endblock %}
{% block description %}Decouvrez tous les evenements sur E-Ticket{% endblock %}
{% block body %}
<div class="bg-[#fbfbfb] overflow-x-hidden italic font-sans">
<section class="relative flex items-center justify-center bg-white border-b-8 border-gray-900 px-4 pt-20 pb-16">
<div class="absolute inset-0 opacity-[0.03] pointer-events-none select-none overflow-hidden">
<span class="text-[20rem] font-black uppercase leading-none block -rotate-12 translate-y-10">EVENTS</span>
</div>
<div class="max-w-7xl mx-auto relative z-10 text-center">
<h1 class="text-5xl md:text-8xl font-black uppercase tracking-tighter leading-[0.85] mb-4">
<span class="block">Nos evenements</span>
</h1>
<p class="max-w-2xl mx-auto text-xl font-bold text-gray-600 italic">
Decouvrez les evenements proposes par nos organisateurs.
</p>
</div>
</section>
<section class="py-8 px-4">
<div class="max-w-7xl mx-auto">
<form method="get" action="{{ path('app_events') }}" class="flex flex-wrap gap-3 items-end">
<div class="flex-1 min-w-[200px]">
<input type="text" name="q" value="{{ searchQuery }}" class="form-input" placeholder="Rechercher un evenement, une ville...">
</div>
<button type="submit" class="btn-brutal font-black uppercase text-xs tracking-widest hover:bg-indigo-600 hover:text-white transition-all">Rechercher</button>
{% if searchQuery %}
<a href="{{ path('app_events') }}" class="px-4 py-3 border-3 border-gray-900 bg-white font-black uppercase text-xs tracking-widest hover:bg-gray-100 transition-all">Effacer</a>
{% endif %}
</form>
</div>
</section>
<section class="py-8 px-4 pb-16">
<div class="max-w-7xl mx-auto">
{% if events|length > 0 %}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{% for event in events %}
<a href="{{ path('app_event_detail', {orgaSlug: event.account.slug, id: event.id, eventSlug: event.slug}) }}" class="group bg-white border-4 border-gray-900 shadow-[6px_6px_0px_rgba(0,0,0,1)] hover:translate-y-[-4px] hover:shadow-[6px_10px_0px_rgba(0,0,0,1)] transition-all overflow-hidden block">
{% if event.eventMainPictureName %}
<img src="{{ ('/uploads/events/' ~ event.eventMainPictureName) | imagine_filter('medium') }}" alt="{{ event.title }}" class="w-full h-[180px] object-cover">
{% else %}
<div class="w-full h-[180px] bg-gray-100 flex items-center justify-center">
<span class="text-4xl opacity-20">&#128197;</span>
</div>
{% endif %}
<div class="p-6">
<h2 class="text-xl font-black uppercase tracking-tighter group-hover:text-indigo-600 transition-colors mb-2">{{ event.title }}</h2>
<p class="text-sm font-bold text-gray-500 mb-1">{{ event.startAt|date('d/m/Y') }} &bull; {{ event.startAt|date('H:i') }} - {{ event.endAt|date('H:i') }}</p>
<p class="text-xs font-black uppercase tracking-widest text-gray-400">{{ event.zipcode }} {{ event.city }}</p>
<p class="text-xs font-bold text-gray-400 mt-2">{{ event.account.companyName ?? event.account.firstName ~ ' ' ~ event.account.lastName }}</p>
</div>
</a>
{% endfor %}
</div>
{% if events.getTotalItemCount > 12 %}
<div class="mt-8">
{{ knp_pagination_render(events) }}
</div>
{% endif %}
{% else %}
<div class="text-center py-16">
<div class="border-4 border-gray-900 bg-white shadow-[6px_6px_0px_rgba(0,0,0,1)] p-12 max-w-lg mx-auto">
<p class="text-gray-400 font-black text-lg uppercase">
{% if searchQuery %}
Aucun evenement trouve pour "{{ searchQuery }}"
{% else %}
Aucun evenement pour le moment
{% endif %}
</p>
</div>
</div>
{% endif %}
</div>
</section>
</div>
{% endblock %}

View File

@@ -129,12 +129,24 @@
<div class="max-w-4xl mx-auto">
<div class="bg-white border-4 border-gray-900 shadow-[6px_6px_0px_rgba(0,0,0,1)] overflow-hidden">
<div class="section-header">
<h2 class="text-sm font-black uppercase tracking-widest text-white">Evenements</h2>
<h2 class="text-sm font-black uppercase tracking-widest text-white">Evenements ({{ events|length }})</h2>
</div>
{% if events|length > 0 %}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-0">
{% for event in events %}
<a href="{{ path('app_event_detail', {orgaSlug: organizer.slug, id: event.id, eventSlug: event.slug}) }}" class="group p-6 border-b border-r border-gray-200 hover:bg-gray-50 transition-colors block">
<h3 class="font-black text-sm uppercase group-hover:text-indigo-600 transition-colors">{{ event.title }}</h3>
<p class="text-xs font-bold text-gray-500 mt-1">{{ event.startAt|date('d/m/Y') }} &bull; {{ event.startAt|date('H:i') }} - {{ event.endAt|date('H:i') }}</p>
<p class="text-xs font-bold text-gray-400 mt-1">{{ event.zipcode }} {{ event.city }}</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="p-12 text-center">
<p class="text-gray-400 font-black text-lg uppercase">Aucun evenement pour le moment</p>
<p class="text-gray-500 font-bold mt-2 italic text-sm">Les evenements de cet organisateur apparaitront ici.</p>
</div>
{% endif %}
</div>
</div>
</section>

View File

@@ -144,6 +144,19 @@ class EventTest extends TestCase
self::assertSame($event, $result);
}
public function testGetSlug(): void
{
$event = new Event();
$event->setTitle('Brocante de printemps 2026');
self::assertSame('brocante-de-printemps-2026', $event->getSlug());
}
public function testGetSlugEmpty(): void
{
$event = new Event();
self::assertSame('evenement', $event->getSlug());
}
public function testIsSecretDefaultFalse(): void
{
$event = new Event();