Files
e-ticket/templates/home/event_detail.html.twig
Serreau Jovann 04927ec988 Complete TASK_CHECKUP: security, UX, tests, coverage, accessibility, config externalization
Billetterie:
- Partial refund support (STATUS_PARTIALLY_REFUNDED, refundedAmount field, migration)
- Race condition fix: PESSIMISTIC_WRITE lock on stock decrement in transaction
- Idempotency key on PaymentIntent::create, reuse existing PI if stripeSessionId set
- Disable checkout when event ended (server 400 + template hide)
- Webhook deduplication via cache (24h TTL on stripe event.id)
- Email validation (filter_var) in OrderController guest flow
- JSON cart validation (structure check before processing)
- Invitation expiration after 7 days (isExpired method + landing page message)
- Stripe Checkout fallback when JS fails to load (noscript + redirect)

Config externalization:
- Move Stripe fees (STRIPE_FEE_RATE, STRIPE_FEE_FIXED) and admin email (ADMIN_EMAIL) to .env/services.yaml
- Replace all hardcoded contact@e-cosplay.fr across 13 files
- MailerService: getAdminEmail()/getAdminFrom(), default $from=null resolves to admin

UX & Accessibility:
- ARIA tabs: role=tablist/tab/tabpanel, aria-selected, keyboard nav (arrows, Home, End)
- aria-label on cart +/- buttons and editor toolbar buttons
- tabindex=0 on editor toolbar buttons for keyboard access
- data-confirm handler in app.js (was only in admin.js)
- Cart error feedback on checkout failure
- Billet designer save feedback (loading/success/error states)
- Stock polling every 30s with rupture/low stock badges
- Back to event link on payment page

Security:
- HTML sanitizer: BLOCKED_TAGS list (script, style, iframe, svg, etc.) - content fully removed
- Stripe polling timeout (15s max) with fallback redirect
- Rate limiting on public order access (20/5min)
- .catch() on all fetch() calls (sortable, billet-designer)

Tests (92% PHP, 100% JS lines):
- PCOV added to dev Dockerfile
- Test DB setup: .env.test with DATABASE_URL, Redis auth, Meilisearch key
- Rate limiter disabled in test env
- Makefile: test_db_setup, test_db_reset, run_test_php, run_test_coverage_php/js
- New tests: InvitationFlowTest (21), AuditServiceTest (4), ExportServiceTest (9), InvoiceServiceTest (4)
- New tests: SuspendedUserSubscriberTest, RateLimiterSubscriberTest, MeilisearchServiceTest
- New tests: Stripe webhook payment_failed (6) + charge.refunded (6)
- New tests: BilletBuyer refund, User suspended, OrganizerInvitation expiration
- JS tests: stock polling (6), data-confirm (2), copy-url restore (1), editor ARIA (2), XSS (9), tabs keyboard (9)
- ESLint + PHP CS Fixer: 0 errors
- SonarQube exclusions aligned with vitest coverage config

Infra:
- Meilisearch consistency command (app:meilisearch:check-consistency --fix) + cron daily 3am
- MeilisearchService: getAllDocumentIds(), listIndexes()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:14:06 +01:00

249 lines
17 KiB
Twig

{% 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_type %}event{% 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="bg-[#fbfbfb] overflow-x-hidden italic font-sans">
<section class="relative 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-[8rem] md:text-[20rem] font-black uppercase leading-none block -rotate-12 translate-y-10">EVENT</span>
</div>
<div class="max-w-4xl mx-auto relative z-10">
<h1 class="text-4xl md:text-6xl font-black uppercase tracking-tighter leading-[0.85] mb-6">{{ event.title }}</h1>
<div class="flex flex-wrap gap-6 text-sm font-bold">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<span>{{ event.address }}, {{ event.zipcode }} {{ event.city }}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span>Du {{ event.startAt|date('d/m/Y H:i') }} au {{ event.endAt|date('d/m/Y H:i') }}</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<a href="{{ path('app_organizer_detail', {id: organizer.id, slug: organizer.slug}) }}" class="text-indigo-600 hover:underline">{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}</a>
</div>
</div>
</div>
</section>
<section class="py-12 px-4">
<div class="max-w-4xl mx-auto">
{% for message in app.flashes('success') %}
<div class="flash-success"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
{% for message in app.flashes('error') %}
<div class="flash-error"><p class="font-black text-sm">{{ message }}</p></div>
{% endfor %}
<div class="card-brutal overflow-hidden p-0">
<div class="flex flex-col md:flex-row">
<div class="md:w-[300px] flex-shrink-0">
{% if event.eventMainPictureName %}
<img src="{{ ('/uploads/events/' ~ event.eventMainPictureName) | imagine_filter('medium') }}" alt="{{ event.title }}" class="w-full max-h-[300px] object-contain">
{% else %}
<div class="w-full h-[250px] bg-gray-100 flex items-center justify-center">
<span class="text-5xl opacity-20">&#128247;</span>
</div>
{% endif %}
</div>
<div class="p-6 flex-1">
{% if event.description %}
<div class="prose prose-sm max-w-none font-bold text-gray-700 leading-relaxed">
{{ event.description|raw }}
</div>
{% else %}
<p class="text-gray-400 font-bold italic">Aucune description pour cet evenement.</p>
{% endif %}
</div>
</div>
</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}) }}">
<div class="section-header">
<h2 class="text-sm font-black uppercase tracking-widest text-white">Billetterie</h2>
</div>
{% if event_ended %}
<div class="p-6">
<p class="font-black uppercase text-red-600 text-center">Cet evenement est termine. La billetterie est fermee.</p>
</div>
{% endif %}
{% if not event_ended %}
<div class="p-6">
{% for category in categories %}
{% if category.active %}
{% set category_billets = billets[category.id] ?? [] %}
<div class="mb-6 {{ not loop.last ? 'border-b-2 border-gray-200 pb-6' : '' }}">
<h3 class="font-black uppercase text-sm tracking-widest mb-4">{{ category.name }}</h3>
{% if category_billets|length > 0 %}
<div class="space-y-3">
{% for billet in category_billets %}
<div class="border-2 border-gray-900 bg-white p-4" data-cart-item data-billet-id="{{ billet.id }}" data-price="{{ billet.priceHTDecimal }}" data-max="{{ billet.quantity ?? 0 }}">
<div class="flex flex-col md:flex-row md:items-center gap-4">
<div class="flex items-center gap-4 flex-1 min-w-0">
{% if billet.pictureName %}
<img src="{{ ('/uploads/billets/' ~ billet.pictureName) | imagine_filter('thumbnail') }}" alt="{{ billet.name }}" class="w-16 h-16 object-cover border border-gray-300 flex-shrink-0">
{% endif %}
<div class="min-w-0">
<p class="font-black uppercase text-sm">{{ billet.name }}</p>
{% if billet.description %}
<p class="text-xs text-gray-500 font-bold mt-1">{{ billet.description }}</p>
{% endif %}
<p class="text-[10px] font-bold mt-1" data-stock-label>
{% if not billet.unlimited and billet.quantity is not null %}
{% if billet.quantity == 0 %}
<span class="text-red-600">Rupture de stock</span>
{% elseif billet.quantity <= 10 %}
<span class="text-orange-500">Plus que {{ billet.quantity }} place{{ billet.quantity > 1 ? 's' : '' }} !</span>
{% else %}
<span class="text-gray-400">{{ billet.quantity }} place{{ billet.quantity > 1 ? 's' : '' }} disponible{{ billet.quantity > 1 ? 's' : '' }}</span>
{% endif %}
{% endif %}
</p>
</div>
</div>
<div class="flex items-center gap-4 flex-shrink-0">
<p class="font-black text-indigo-600 text-lg w-24 text-right">{{ billet.priceHTDecimal|number_format(2, ',', ' ') }} &euro;</p>
<div class="flex items-center border-2 border-gray-900">
<button type="button" data-cart-minus aria-label="Retirer un {{ billet.name }}" class="w-9 h-9 flex items-center justify-center font-black text-lg hover:bg-gray-100 transition-all cursor-pointer select-none">-</button>
<input type="number" data-cart-qty min="0" max="{{ billet.quantity ?? 99 }}" value="0" aria-label="Quantite {{ billet.name }}" class="w-12 h-9 text-center font-black text-sm border-x-2 border-gray-900 outline-none" readonly>
<button type="button" data-cart-plus aria-label="Ajouter un {{ billet.name }}" class="w-9 h-9 flex items-center justify-center font-black text-lg hover:bg-gray-100 transition-all cursor-pointer select-none">+</button>
</div>
<p class="font-black text-sm w-24 text-right" data-cart-line-total>0,00 &euro;</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-400 font-bold text-sm">Aucun billet disponible dans cette categorie.</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div class="border-t-3 border-gray-900 pt-6 mt-6">
<div class="flex items-center justify-between mb-4">
<span class="font-black uppercase text-sm tracking-widest">Total</span>
<span class="font-black text-2xl text-indigo-600" id="cart-total">0,00 &euro;</span>
</div>
<div class="flex items-center justify-between mb-6">
<span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Articles</span>
<span class="font-black text-sm" id="cart-count">0</span>
</div>
<div id="cart-error" class="hidden flash-error mb-4">
<p class="font-black text-sm" id="cart-error-text"></p>
</div>
<button type="button" id="cart-checkout" disabled data-order-url="{{ path('app_order_create', {id: event.id}) }}" class="w-full btn-brutal font-black uppercase text-sm tracking-widest bg-indigo-600 text-white hover:bg-indigo-800 transition-all disabled:opacity-30 disabled:cursor-not-allowed">
Commander
</button>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="card-brutal overflow-hidden p-0 mt-8">
<div class="section-header">
<h2 class="text-sm font-black uppercase tracking-widest text-white">Emplacement</h2>
</div>
<div id="event-map" class="w-full h-[300px]" data-address="{{ event.address }}, {{ event.zipcode }} {{ event.city }}"></div>
</div>
</div>
</section>
<section class="py-12 px-4">
<div class="max-w-4xl mx-auto flex flex-col lg:flex-row gap-8">
<div class="flex-1">
<div class="card-brutal overflow-hidden">
<div class="section-header">
<h2 class="text-sm font-black uppercase tracking-widest text-white">Organisateur</h2>
</div>
<div class="p-6">
<div class="flex items-center gap-4 mb-6">
{% if organizer.logoName %}
<div class="border-3 border-gray-900 overflow-hidden bg-white p-1 shadow-[4px_4px_0px_rgba(0,0,0,1)]">
<img src="{{ ('/uploads/logos/' ~ organizer.logoName) | imagine_filter('thumbnail') }}" alt="{{ organizer.companyName }}" class="h-[60px] w-auto object-contain">
</div>
{% else %}
<div class="w-16 h-16 bg-yellow-400 border-3 border-gray-900 flex items-center justify-center shadow-[4px_4px_0px_rgba(0,0,0,1)]">
<span class="text-2xl font-black">{{ organizer.firstName|first|upper }}{{ organizer.lastName|first|upper }}</span>
</div>
{% endif %}
<div>
<a href="{{ path('app_organizer_detail', {id: organizer.id, slug: organizer.slug}) }}" class="font-black text-lg uppercase tracking-tighter hover:text-indigo-600 transition-colors">{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}</a>
{% if organizer.city %}
<p class="text-xs font-bold text-gray-400 uppercase tracking-widest">{{ organizer.postalCode }} {{ organizer.city }}</p>
{% endif %}
</div>
</div>
{% include 'components/_social_icons.html.twig' with {organizer: organizer, class: 'flex-wrap mb-4'} only %}
{% if organizer.email %}
<p class="text-sm font-bold"><a href="mailto:{{ organizer.email }}" class="text-indigo-600 hover:underline">{{ organizer.email }}</a></p>
{% endif %}
</div>
</div>
</div>
<div class="flex-1">
<div class="card-brutal overflow-hidden">
<div class="section-header">
<h2 class="text-sm font-black uppercase tracking-widest text-white">Contacter l'organisateur</h2>
</div>
<div class="p-6">
<form method="post" action="{{ path('app_event_contact', {id: event.id}) }}" class="form-col">
<div class="form-row">
<div class="form-group">
<label for="contact_name" class="text-xs font-black uppercase tracking-widest form-label">Nom</label>
<input type="text" id="contact_name" name="name" required class="form-input focus:border-indigo-600" placeholder="Dupont">
</div>
<div class="form-group">
<label for="contact_firstname" class="text-xs font-black uppercase tracking-widest form-label">Prenom</label>
<input type="text" id="contact_firstname" name="firstname" required class="form-input focus:border-indigo-600" placeholder="Jean">
</div>
</div>
<div>
<label for="contact_email" class="text-xs font-black uppercase tracking-widest form-label">Email</label>
<input type="email" id="contact_email" name="email" required class="form-input focus:border-indigo-600" placeholder="jean.dupont@exemple.fr">
</div>
<div>
<label for="contact_message" class="text-xs font-black uppercase tracking-widest form-label">Message</label>
<textarea id="contact_message" name="message" required rows="4" class="form-textarea focus:border-indigo-600" placeholder="Votre question sur cet evenement..."></textarea>
</div>
<div>
<button type="submit" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Envoyer
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
</div>
{% endblock %}