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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
22
templates/home/event_detail.html.twig
Normal file
22
templates/home/event_detail.html.twig
Normal 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 %}
|
||||
84
templates/home/events.html.twig
Normal file
84
templates/home/events.html.twig
Normal 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">📅</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') }} • {{ 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 %}
|
||||
@@ -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') }} • {{ 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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user