Files
e-ticket/templates/pdf/billet.html.twig
Serreau Jovann 52cb19df8b Add BilletOrder entity, PDF generation, email with QR codes, public order page
- BilletOrder entity: individual tickets with unique ETICKET-XXXX reference,
  billetBuyer link, billet link, isScanned, scannedAt for entry control
- BilletOrderService: generates tickets after payment, creates A4 PDF with
  BilletDesign colors if present (default otherwise), real QR code via
  endroid/qr-code, event poster + org logo as base64, sends confirmation
  email with all ticket PDFs attached
- PDF template (pdf/billet.html.twig): A4 layout matching preview design,
  real QR code linking to /ticket/verify/{reference}
- Email template: order recap table, ticket references list, link to
  /ma-commande/{reference}
- Public order page /ma-commande/{reference}: no auth required, shows
  order details, ticket list with individual PDF download links
- Ticket verification page /ticket/verify/{reference}: shows valid/scanned
  status with ticket and event details
- Download route /ma-commande/{ref}/billet/{ticketRef}: generates PDF on-the-fly
- Migration for billet_order table with unique reference index
- BilletOrderTest: 8 tests, 24 assertions

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

197 lines
8.6 KiB
Twig

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
{% set ac = design ? design.accentColor : '#4f46e5' %}
{% set inv_color = design ? design.invitationColor : '#d4a017' %}
{% set inv_title = design ? design.invitationTitle : 'Invitation' %}
* { margin: 0; padding: 0; box-sizing: border-box; }
@page { size: A4; margin: 0; }
body {
width: 595px;
height: 842px;
font-family: 'Helvetica Neue', Arial, sans-serif;
background: #fff;
color: #111;
overflow: hidden;
}
.ticket {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.zone-top {
flex: 1;
display: flex;
min-height: 0;
}
.zone-hg {
flex: 1;
padding: 28px 20px 20px 32px;
border-right: 3px solid {{ ac }};
border-bottom: 3px solid {{ ac }};
display: flex;
flex-direction: column;
}
.zone-hd {
width: 220px;
flex-shrink: 0;
border-bottom: 3px solid {{ ac }};
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
overflow: hidden;
}
.zone-hd img {
max-width: 100%;
max-height: 100%;
}
.zone-bottom {
padding: 16px 32px;
display: flex;
align-items: center;
gap: 14px;
background: {{ ac }};
color: #fff;
}
.event-title { font-size: 20px; font-weight: 900; text-transform: uppercase; letter-spacing: -0.5px; line-height: 1.15; margin-bottom: 3px; }
.event-badge { font-size: 8px; font-weight: 700; color: {{ ac }}; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 14px; }
.info-row { display: flex; align-items: baseline; padding: 4px 0; border-bottom: 1px solid #11111110; }
.info-row:last-child { border-bottom: none; }
.info-label { width: 65px; flex-shrink: 0; font-size: 7px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; opacity: 0.4; }
.info-value { font-size: 11px; font-weight: 700; }
.separator { height: 3px; background: {{ ac }}; margin: 12px 0; }
.billet-name { font-size: 15px; font-weight: 900; text-transform: uppercase; margin-bottom: 2px; }
.billet-price { font-size: 20px; font-weight: 900; color: {{ ac }}; margin-bottom: 10px; }
.meta-grid { display: flex; gap: 20px; flex-wrap: wrap; }
.meta-label { font-size: 7px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; opacity: 0.4; }
.meta-value { font-size: 10px; font-weight: 700; margin-bottom: 4px; }
.billet-badges { display: flex; gap: 6px; margin-top: 10px; }
.exit-badge { padding: 4px 8px; font-size: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; border: 2px solid; }
.exit-definitive { background: #fee2e2; color: #991b1b; border-color: #991b1b; }
.exit-libre { background: #dcfce7; color: #166534; border-color: #166534; }
.invitation-badge { padding: 4px 8px; font-size: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #fff; }
.qr-section { margin-top: auto; display: flex; align-items: flex-end; justify-content: space-between; padding-top: 10px; border-top: 1px solid #11111112; }
.qr-box { width: 120px; height: 120px; display: flex; align-items: center; justify-content: center; background: #fff; }
.qr-box img { width: 120px; height: 120px; }
.ref-block { text-align: right; }
.ref-label { font-size: 7px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; opacity: 0.35; }
.ref-value { font-size: 9px; font-weight: 700; font-family: monospace; opacity: 0.55; }
.org-logo { width: 45px; height: 45px; object-fit: contain; flex-shrink: 0; }
.org-name { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1.5px; }
.org-details { font-size: 8px; font-weight: 600; opacity: 0.7; margin-top: 2px; }
.powered { font-size: 6px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; opacity: 0.4; margin-left: auto; text-align: right; }
</style>
</head>
<body>
<div class="ticket">
<div class="zone-top">
<div class="zone-hg">
<div class="event-title">{{ event.title }}</div>
<div class="event-badge">Billet d'entree</div>
<div class="info-row">
<div class="info-label">Date</div>
<div class="info-value">{{ event.startAt|date('d/m/Y') }}</div>
</div>
<div class="info-row">
<div class="info-label">Horaires</div>
<div class="info-value">{{ event.startAt|date('H:i') }}{{ event.endAt|date('H:i') }}</div>
</div>
<div class="info-row">
<div class="info-label">Lieu</div>
<div class="info-value">{{ event.address }}</div>
</div>
<div class="info-row">
<div class="info-label">Ville</div>
<div class="info-value">{{ event.zipcode }} {{ event.city }}</div>
</div>
<div class="separator"></div>
<div class="billet-name">{{ ticket.billetName }}</div>
<div class="billet-price">{{ ticket.unitPriceHTDecimal|number_format(2, ',', ' ') }} &euro; HT</div>
<div class="meta-grid">
<div>
<div class="meta-label">Categorie</div>
<div class="meta-value">{{ ticket.billet.category.name }}</div>
</div>
<div>
<div class="meta-label">Date d'achat</div>
<div class="meta-value">{{ order.paidAt ? order.paidAt|date('d/m/Y H:i') : order.createdAt|date('d/m/Y H:i') }}</div>
</div>
<div>
<div class="meta-label">Acheteur</div>
<div class="meta-value">{{ order.firstName }} {{ order.lastName }}</div>
</div>
<div>
<div class="meta-label">E-mail</div>
<div class="meta-value">{{ order.email }}</div>
</div>
</div>
<div class="billet-badges">
{% if ticket.billet.definedExit %}
<div class="exit-badge exit-definitive">Sortie definitive</div>
{% else %}
<div class="exit-badge exit-libre">Sortie libre</div>
{% endif %}
{% if design %}
<div class="invitation-badge" style="background: {{ inv_color }};">{{ inv_title }}</div>
{% endif %}
</div>
<div class="qr-section">
<div class="qr-box">
<img src="{{ qrBase64 }}" alt="QR Code">
</div>
<div class="ref-block">
<div class="ref-label">Reference</div>
<div class="ref-value">{{ ticket.reference }}</div>
<div class="ref-label" style="margin-top: 6px;">Commande</div>
<div class="ref-value">{{ order.reference }}</div>
</div>
</div>
</div>
<div class="zone-hd">
{% if posterBase64 %}
<img src="{{ posterBase64 }}" alt="{{ event.title }}">
{% endif %}
</div>
</div>
<div class="zone-bottom">
{% if logoBase64 %}
<img src="{{ logoBase64 }}" alt="Logo" class="org-logo">
{% endif %}
<div>
<div class="org-name">{{ organizer.companyName ?? (organizer.firstName ~ ' ' ~ organizer.lastName) }}</div>
{% if organizer.address %}
<div class="org-details">{{ organizer.address }}{% if organizer.postalCode %}, {{ organizer.postalCode }}{% endif %}{% if organizer.city %} {{ organizer.city }}{% endif %}</div>
{% endif %}
</div>
<div class="powered">E-Ticket<br>by E-Cosplay</div>
</div>
</div>
</body>
</html>