feat(reservation/contrat): Ajoute la gestion complète des contrats
```
This commit is contained in:
Serreau Jovann
2026-01-22 20:15:21 +01:00
parent afa6133907
commit 6656d56111
36 changed files with 2127 additions and 209 deletions

2
.gitignore vendored
View File

@@ -53,5 +53,7 @@ backup/*.sql
/public/images/**/*.jpeg
/public/images/**/*.webp
/public/images/*/*.png
/public/images/*/*.pdf
/public/pdf/**/*.pdf
/public/seo/*.xml

View File

@@ -11,6 +11,20 @@ Sentry.init({
tracesSampleRate: 1.0,
});
const initAutoRedirect = () => {
const container = document.getElementById('payment-check-container');
if (container && container.dataset.autoRedirect) {
const url = container.dataset.autoRedirect;
// On attend 5 secondes avant de rediriger via Turbo
setTimeout(() => {
// On vérifie que l'utilisateur est toujours sur la page de check
if (document.getElementById('payment-check-container')) {
Turbo.visit(url);
}
}, 10000);
}
}
// --- LOGIQUE DU LOADER TURBO ---
const initLoader = () => {
let loaderEl = document.getElementById('turbo-loader');
@@ -104,6 +118,7 @@ document.addEventListener('DOMContentLoaded', () => {
initLoader();
initMobileMenu();
initCatalogueSearch();
initAutoRedirect();
customElements.define('utm-event',UtmEvent)
customElements.define('utm-account',UtmAccount)
@@ -112,6 +127,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('turbo:load', () => {
initMobileMenu();
initCatalogueSearch();
initAutoRedirect();
});
// Nettoyage avant cache pour éviter les bugs au retour arrière

View File

@@ -4,7 +4,13 @@ security:
entity:
class: App\Entity\Account
property: email
reserve_account:
entity:
class: App\Entity\Customer
property: email
all_users:
chain:
providers: [app_account_provider, reserve_account]
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -12,7 +18,7 @@ security:
main:
lazy: true
provider: app_account_provider
provider: all_users
user_checker: App\Security\UserChecker
# --- AJOUT DE LA CONFIGURATION 2FA ---
@@ -31,6 +37,7 @@ security:
entry_point: App\Security\AuthenticationEntryPoint
custom_authenticator:
- App\Security\CustomerAuthenticator
- App\Security\LoginFormAuthenticator
- App\Security\KeycloakAuthenticator
@@ -40,15 +47,16 @@ security:
password_hashers:
App\Entity\Account: 'auto'
App\Entity\Customer: 'auto'
role_hierarchy:
ROLE_ROOT: [ROLE_ADMIN,ROLE_CLIENT_MAIN]
ROLE_CLIENT_MAIN: [ROLE_ADMIN]
ROLE_ROOT: [ROLE_ADMIN,ROLE_CLIENT_MAIN,ROLE_CUSTOMER]
ROLE_CLIENT_MAIN: [ROLE_ADMIN,ROLE_CUSTOMER]
access_control:
# Permettre l'accès aux pages 2FA même si on n'est pas encore pleinement "ROLE_ADMIN"
- { path: ^/2fa, roles: PUBLIC_ACCESS }
- { path: ^/gestion-contrat, roles: [ROLE_CUSTOMER] }
- { path: ^/crm, roles: [ROLE_ADMIN] }
- { path: ^/, roles: PUBLIC_ACCESS }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260122160120 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer ADD roles JSON DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD password VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD is_account_configured BOOLEAN DEFAULT false NOT NULL');
$this->addSql('ALTER TABLE customer ALTER email TYPE VARCHAR(180)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_81398E09E7927C74 ON customer (email)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP INDEX UNIQ_81398E09E7927C74');
$this->addSql('ALTER TABLE customer DROP roles');
$this->addSql('ALTER TABLE customer DROP password');
$this->addSql('ALTER TABLE customer DROP is_account_configured');
$this->addSql('ALTER TABLE customer ALTER email TYPE VARCHAR(255)');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260122161702 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer ADD verification_code VARCHAR(6) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD verification_code_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN customer.verification_code_expires_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE customer DROP verification_code');
$this->addSql('ALTER TABLE customer DROP verification_code_expires_at');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260122183540 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE product_reserve ADD contrat_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F1921823061F FOREIGN KEY (contrat_id) REFERENCES contrats (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_CE39F1921823061F ON product_reserve (contrat_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F1921823061F');
$this->addSql('DROP INDEX IDX_CE39F1921823061F');
$this->addSql('ALTER TABLE product_reserve DROP contrat_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260122184310 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats_payments ADD payment_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE contrats_payments ADD amount DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE contrats_payments ADD validate_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN contrats_payments.payment_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN contrats_payments.validate_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE contrats_payments DROP payment_at');
$this->addSql('ALTER TABLE contrats_payments DROP amount');
$this->addSql('ALTER TABLE contrats_payments DROP validate_at');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260122185430 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats_payments ADD card TEXT DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN contrats_payments.card IS \'(DC2Type:array)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE contrats_payments DROP card');
}
}

View File

@@ -4,16 +4,24 @@ namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Contrats;
use App\Entity\ContratsPayments;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Repository\ContratsRepository;
use App\Repository\CustomerRepository;
use App\Service\Mailer\Mailer;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -25,9 +33,241 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class ContratController extends AbstractController
{
#[Route('/gestion-contrat/{num}', name: 'gestion_contrat')]
public function gestionContrat()
{
#[Route('/contrat/payment/cancel/{id}', name: 'gestion_contrat_cancel')]
public function gestionContratCancel(
Contrats $contrat,
Request $request
): Response {
// On peut éventuellement ajouter un message flash pour informer l'utilisateur
$this->addFlash('info', 'Le paiement a été annulé. Votre réservation n\'est pas encore validée.');
return $this->render('reservation/contrat/cancel.twig', [
'contrat' => $contrat
]);
}
#[Route('/contrat/payment/success/{id}', name: 'gestion_contrat_success')]
public function gestionContratSuccess(
Contrats $contrat,
Request $request,
EntityManagerInterface $entityManager,
): Response {
$type = $request->query->get('type', 'accompte');
if ($type === "accompte") {
$pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'type' => 'accompte',
'contrat' => $contrat
]);
// Si le paiement est déjà marqué comme complété par le Webhook
if ($pl && $pl->getState() === "complete") {
return $this->render('reservation/contrat/success.twig', [
'contrat' => $contrat,
'type' => $type
]);
}
}
return $this->render('reservation/contrat/check.twig', [
'contrat' => $contrat,
'type' => $type
]);
}
#[Route('/reservation/gestion-contrat/{num}', name: 'gestion_contrat_view')]
public function gestionContratView(string $num,\App\Service\Stripe\Client $stripeClient,Client $client,Mailer $mailer,EntityManagerInterface $entityManager, Request $request, ContratsRepository $contratsRepository): Response
{
$contrat = $contratsRepository->findOneBy(['numReservation' => $num]);
if (null === $contrat) {
return $this->render('reservation/contrat/nofound.twig');
}
$customer = $contrat->getCustomer();
if (!$customer->isAccountConfigured()) {
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$customer->setVerificationCode($code);
$customer->setVerificationCodeExpiresAt(new \DateTimeImmutable('+15 minutes'));
$entityManager->flush();
// Envoi du mail (utilise ton service Mailer existant)
$mailer->send(
$customer->getEmail(),
$customer->getSurname().' '.$customer->getName(),
"[Ludikevent] Votre code de vérification",
"mails/customer/verification_code.twig",
['code' => $code, 'customer' => $customer]
);
$request->getSession()->set('config_customer_id', $customer->getId());
$request->getSession()->set('num_reservation', $num);
return $this->redirectToRoute('gestion_contrat_finish');
}
// Calcul de la durée
$dateStart = $contrat->getDateAt();
$dateEnd = $contrat->getEndAt();
$totalDays = $dateStart->diff($dateEnd)->days + 1;
$totalHT = 0;
$totalCaution = 0;
// Calcul des lignes (Location dégressive)
foreach ($contrat->getContratsLines() as $line) {
$priceLine = $line->getPrice1DayHt();
if ($totalDays > 1) {
$priceLine += ($line->getPriceSupDayHt() * ($totalDays - 1));
}
$totalHT += $priceLine;
$totalCaution += $line->getCaution();
}
// Ajout des options (Forfait)
foreach ($contrat->getContratsOptions() as $option) {
$totalHT += $option->getPrice();
}
// --- NOUVEAUX CALCULS ---
$arrhes = $totalHT * 0.25; // 25%
$solde = $totalHT;
if($request->query->has('act') && $request->query->get('act') === 'accomptePay') {
$pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'type' => 'accompte',
'contrat' => $contrat
]);
if(!$pl instanceof ContratsPayments) {
// SCÉNARIO 1 : PREMIÈRE CRÉATION
$result = $stripeClient->createPaymentAccompte($arrhes, $contrat);
$pl = new ContratsPayments();
$pl->setContrat($contrat);
$pl->setType('accompte');
$pl->setAmount($arrhes);
$pl->setPaymentAt(new \DateTimeImmutable('now'));
$pl->setState("created");
$pl->setPaymentId($result['id']); // On stocke l'ID de session Stripe
$entityManager->persist($pl);
$entityManager->flush();
return new RedirectResponse($result['url']);
} else {
// SCÉNARIO 2 : RÉCUPÉRATION OU RE-GÉNÉRATION (si expiré)
$result = $stripeClient->linkPaymentAccompte($arrhes, $contrat, $pl);
return new RedirectResponse($result['url']);
}
}
if($request->query->has('act') && $request->query->get('act') === 'cautionPay') {
$pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'type' => 'caution',
'contrat' => $contrat
]);
if(!$pl instanceof ContratsPayments) {
// SCÉNARIO 1 : PREMIÈRE CRÉATION
$result = $stripeClient->createPaymentCaution($totalCaution, $contrat);
$pl = new ContratsPayments();
$pl->setContrat($contrat);
$pl->setType('caution');
$pl->setAmount($totalCaution);
$pl->setPaymentAt(new \DateTimeImmutable('now'));
$pl->setState("created");
$pl->setPaymentId($result['id']); // On stocke l'ID de session Stripe
$entityManager->persist($pl);
$entityManager->flush();
return new RedirectResponse($result['url']);
} else {
// SCÉNARIO 2 : RÉCUPÉRATION OU RE-GÉNÉRATION (si expiré)
$result = $stripeClient->linkPaymentCaution($arrhes, $contrat, $pl);
return new RedirectResponse($result['url']);
}
}
/** @var ContratsPayments $paymentList */
$paymentList =[];
$paymentCaution =[];
foreach ($contrat->getContratsPayments() as $contratsPayment) {
if($contratsPayment->getType() != "caution") {
if($contratsPayment->getType() == "accompte" && $contratsPayment->getState() == "complete") {
$solde = $solde - $contratsPayment->getAmount();
} else {
$solde = $solde - $contratsPayment->getAmount();
}
$paymentList[] = $contratsPayment;
} else {
$paymentCaution[] = "";
}
}
return $this->render('reservation/contrat/view.twig', [
'contrat' => $contrat,
'days' => $totalDays,
'totalHT' => $totalHT,
'totalCaution' => $totalCaution,
'arrhes' => $arrhes,
'paymentList' => $paymentList,
'solde' => $solde,
'signUrl' => (!$contrat->isSigned())?$client->getLinkSign($contrat->getSignID()):null,
'signEvents' => ($contrat->getSignID() !="")?$client->eventSign($contrat):[],
'signedNumber' => ($contrat->isSigned())?$client->signedData($contrat): null,
]);
}
#[Route('/reservation/gestion-contrat/configuration', name: 'gestion_contrat_finish', priority: 5)]
public function gestionContratConfig(
Request $request,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
CustomerRepository $customerRepository,
Security $security // Injection du service Security
): Response {
$session = $request->getSession();
$customer = $session->get('config_customer_id') ? $customerRepository->find($session->get('config_customer_id')) : null;
if (!$customer || $customer->isAccountConfigured()) return $this->redirectToRoute('reservation');
if ($request->isMethod('POST')) {
$inputCode = $request->request->get('verification_code');
$password = $request->request->get('password');
// 1. Vérification du code
if ($inputCode !== $customer->getVerificationCode() || new \DateTimeImmutable() > $customer->getVerificationCodeExpiresAt()) {
$this->addFlash('danger', 'Code invalide ou expiré.');
return $this->render('reservation/contrat/finish_error.twig', ['customer' => $customer, 'error' => 'Code incorrect']);
}
// 2. Configuration du compte
$customer->setPassword($hasher->hashPassword($customer, $password));
$customer->setIsAccountConfigured(true);
$customer->setRoles(['ROLE_USER', 'ROLE_CUSTOMER']);
$customer->setVerificationCode(null);
$customer->setVerificationCodeExpiresAt(null);
$em->flush();
$security->login($customer, 'form_login', 'main');
return $this->render('reservation/contrat/finish_activate.twig', [
'customer' => $customer,
'link' => $this->generateUrl('gestion_contrat_view', ['num' => $session->get('num_reservation')])
]);
}
return $this->render('reservation/contrat/finish_config.twig', ['customer' => $customer]);
}
#[Route('/reservation/gestion-contrat',name: 'gestion_contrat')]
public function gestionContrat(string $num,Request $request,ContratsRepository $contratsRepository): Response
{
//espacce
}
}

View File

@@ -9,6 +9,7 @@ use App\Entity\ProductReserve;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Repository\ContratsRepository;
use App\Repository\DevisRepository;
use App\Service\Mailer\Mailer;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
@@ -36,12 +37,80 @@ class SignatureController extends AbstractController
public function appSignComplete(
Client $client,
DevisRepository $devisRepository,
ContratsRepository $contratsRepository,
EntityManagerInterface $entityManager,
Request $request,
Mailer $mailer,
): Response {
if ($request->get('type') === "contrat") {
dd();
$contrats = $contratsRepository->find($request->get('id'));
if (!$contrats) {
throw $this->createNotFoundException("Contrat introuvable.");
}
// On évite de retraiter un devis déjà marqué comme signé
if ($contrats->isSigned()) {
return $this->render('sign/contrat_sign_success.twig', ['contrat' => $contrats]);
}
$submiter = $client->getSubmiter($contrats->getSignID());
$submission = $client->getSubmition($submiter['submission_id']);
if ($submission['status'] === "completed") {
$contrats->setIsSigned(true);
$auditUrl = $submission['audit_log_url'];
$signedDocUrl = $submission['documents'][0]['url'];
try {
// 1. Gestion du PDF SIGNÉ
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
$signedContent = file_get_contents($signedDocUrl);
file_put_contents($tmpSigned, $signedContent);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$contrats->setDevisSignFile(new UploadedFile($tmpSigned, "sign-" . $contrats->getNumReservation() . ".pdf", "application/pdf", null, true));
// 2. Gestion de l'AUDIT LOG
$tmpAudit = sys_get_temp_dir() . '/audit_' . uniqid() . '.pdf';
$auditContent = file_get_contents($auditUrl);
file_put_contents($tmpAudit, $auditContent);
$contrats->setDevisAuditFile(new UploadedFile($tmpAudit, "audit-" . $contrats->getNumReservation() . ".pdf", "application/pdf", null, true));
// 3. Préparation des pièces jointes pour le mail (Le PDF signé est le plus important)
$attachments = [
new DataPart($signedContent, "Contrat -" . $contrats->getNumReservation() . "-Signe.pdf", "application/pdf"),
new DataPart($auditContent, "Certificat-Signature-" . $contrats->getNumReservation() . ".pdf", "application/pdf"),
];
$entityManager->persist($contrats);
$entityManager->flush();
// 5. Envoi du mail de confirmation avec le récapitulatif
$mailer->send(
$contrats->getCustomer()->getEmail(),
$contrats->getCustomer()->getName() . " " . $contrats->getCustomer()->getSurname(),
"[Ludikevent] Confirmation de signature - Contrat " . $contrats->getNumReservation(),
"mails/sign/signed_contrat.twig",
[
'contrats' => $contrats // Correction ici : passage de l'objet, pas d'un string
],
$attachments
);
$mailer->send(
"contact@ludikevent.fr",
"Ludikevent",
"[Intranet Ludikevent] Confirmation signature d'un client pour - Contrat " . $contrats->getNumReservation(),
"mails/sign/signed_contrat_notification.twig",
[
'contrats' => $contrats // Correction ici : passage de l'objet, pas d'un string
],
$attachments
);
} catch (\Exception $e) {
return new Response("Erreur lors de la récupération ou de l'envoi des documents : " . $e->getMessage(), 500);
}
}
return $this->render('sign/contrat_sign_success.twig', ['contrat' => $contrats]);
}
if ($request->get('type') === "devis") {
$devis = $devisRepository->find($request->get('id'));

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Controller;
use App\Entity\Account;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Entity\Product;
use App\Entity\ProductReserve;
use App\Logger\AppLogger;
use App\Repository\ProductRepository;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\Label\LabelAlignment;
use Endroid\QrCode\RoundBlockSizeMode;
use Endroid\QrCode\Writer\PngWriter;
class Webhooks extends AbstractController
{
#[Route(path: '/webhooks/payment-intent', name: 'webhooks_payment', options: ['sitemap' => false], methods: ['POST'])]
public function payment(ProductRepository $productRepository,Request $request, Client $client, EntityManagerInterface $entityManager): Response
{
// 1. Vérification de la signature via ton service
if ($client->checkWebhooks($request, 'payment')) {
// 2. Récupération des données JSON de Stripe
$payload = json_decode($request->getContent(), true);
if (!$payload) {
return new Response('Invalid JSON', 400);
}
if ($payload['type'] === 'checkout.session.completed') {
$session = $payload['data']['object'];
$paymentId = $session['id'] ?? null;
if ($paymentId) {
$pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy(['paymentId'=>$paymentId]);
if ($pl instanceof ContratsPayments) {
if($pl->getState() != "complete") {
$pl->setState("complete");
$pl->setValidateAt(new \DateTimeImmutable('now'));
$pl->setCard($client->paymentMethod($session['payment_intent']));
$entityManager->persist($pl);
if (!$pl->getContrat()->getDevis() instanceof Devis) {
foreach ($pl->getContrat()->getContratsLines() as $line) {
$pr = $productRepository->findOneBy(['name' => $line->getName()]);
if ($pr instanceof Product) {
$pres = new ProductReserve();
$pres->setProduct($pres->getProduct());
$pres->setStartAt($pl->getContrat()->getDateAt());
$pres->setEndAt($pl->getContrat()->getEndAt());
$pres->setContrat($pl->getContrat());
$entityManager->persist($pres);
}
}
} else {
foreach ($pl->getContrat()->getDevis()->getProductReserve() as $productReserve) {
$productReserve->setContrat($pl->getContrat());
}
$entityManager->persist($pl);
}
$entityManager->flush();
}
}
}
}
// Toujours renvoyer une 200 à Stripe pour confirmer la réception
return new Response('Event Handled', 200);
}
// Si la signature est invalide
return new Response('Invalid Signature', 400);
}
}

View File

@@ -129,11 +129,18 @@ class Contrats
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updateAt = null;
/**
* @var Collection<int, ProductReserve>
*/
#[ORM\OneToMany(targetEntity: ProductReserve::class, mappedBy: 'contrat')]
private Collection $productReserves;
public function __construct()
{
$this->contratsPayments = new ArrayCollection();
$this->contratsLines = new ArrayCollection();
$this->contratsOptions = new ArrayCollection();
$this->productReserves = new ArrayCollection();
}
public function getId(): ?int
@@ -751,6 +758,36 @@ class Contrats
return $this->adressEvent;
}
/**
* @return Collection<int, ProductReserve>
*/
public function getProductReserves(): Collection
{
return $this->productReserves;
}
public function addProductReserf(ProductReserve $productReserf): static
{
if (!$this->productReserves->contains($productReserf)) {
$this->productReserves->add($productReserf);
$productReserf->setContrat($this);
}
return $this;
}
public function removeProductReserf(ProductReserve $productReserf): static
{
if ($this->productReserves->removeElement($productReserf)) {
// set the owning side to null (unless already changed)
if ($productReserf->getContrat() === $this) {
$productReserf->setContrat(null);
}
}
return $this;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Entity;
use App\Repository\ContratsPaymentsRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ContratsPaymentsRepository::class)]
@@ -25,6 +26,18 @@ class ContratsPayments
#[ORM\Column(length: 255)]
private ?string $state = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paymentAt = null;
#[ORM\Column(nullable: true)]
private ?float $amount = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $validateAt = null;
#[ORM\Column(type: Types::ARRAY,nullable: true)]
private array $card = [];
public function getId(): ?int
{
return $this->id;
@@ -77,4 +90,52 @@ class ContratsPayments
return $this;
}
public function getPaymentAt(): ?\DateTimeImmutable
{
return $this->paymentAt;
}
public function setPaymentAt(\DateTimeImmutable $paymentAt): static
{
$this->paymentAt = $paymentAt;
return $this;
}
public function getAmount(): ?float
{
return $this->amount;
}
public function setAmount(?float $amount): static
{
$this->amount = $amount;
return $this;
}
public function getValidateAt(): ?\DateTimeImmutable
{
return $this->validateAt;
}
public function setValidateAt(\DateTimeImmutable $validateAt): static
{
$this->validateAt = $validateAt;
return $this;
}
public function getCard(): array
{
return $this->card;
}
public function setCard(array $card): static
{
$this->card = $card;
return $this;
}
}

View File

@@ -6,15 +6,42 @@ use App\Repository\CustomerRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
class Customer
class Customer implements UserInterface, PasswordAuthenticatedUserInterface
{
public const ROLE_CUSTOMER = 'ROLE_CUSTOMER';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// --- CHAMPS SÉCURITÉ & COMPTE ---
#[ORM\Column(length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column(nullable: true)]
private array $roles = [];
#[ORM\Column(nullable: true)]
private ?string $password = null;
#[ORM\Column(options: ["default" => false])]
private bool $isAccountConfigured = false;
#[ORM\Column(length: 6, nullable: true)]
private ?string $verificationCode = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $verificationCodeExpiresAt = null;
// --- CHAMPS IDENTITÉ ---
#[ORM\Column(length: 255)]
private ?string $civ = null;
@@ -27,9 +54,6 @@ class Customer
#[ORM\Column(length: 255)]
private ?string $phone = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
@@ -39,6 +63,8 @@ class Customer
#[ORM\Column(length: 255, nullable: true)]
private ?string $customerId = null;
// --- RELATIONS ---
/**
* @var Collection<int, CustomerAddress>
*/
@@ -69,58 +95,62 @@ class Customer
$this->devis = new ArrayCollection();
$this->productReserves = new ArrayCollection();
$this->contrats = new ArrayCollection();
// Configuration par défaut
$this->roles = [self::ROLE_CUSTOMER];
$this->isAccountConfigured = false;
}
// --- MÉTHODES INTERFACES (SECURITY) ---
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = self::ROLE_CUSTOMER;
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(?string $password): static
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
}
// --- GETTERS & SETTERS PROPRES ---
public function getId(): ?int
{
return $this->id;
}
public function getCiv(): ?string
public function isAccountConfigured(): bool
{
return $this->civ;
return $this->isAccountConfigured;
}
public function setCiv(string $civ): static
public function setIsAccountConfigured(bool $isAccountConfigured): static
{
$this->civ = $civ;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getSurname(): ?string
{
return $this->surname;
}
public function setSurname(string $surname): static
{
$this->surname = $surname;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(string $phone): static
{
$this->phone = $phone;
$this->isAccountConfigured = $isAccountConfigured;
return $this;
}
@@ -132,7 +162,50 @@ class Customer
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getCiv(): ?string
{
return $this->civ;
}
public function setCiv(string $civ): static
{
$this->civ = $civ;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getSurname(): ?string
{
return $this->surname;
}
public function setSurname(string $surname): static
{
$this->surname = $surname;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(string $phone): static
{
$this->phone = $phone;
return $this;
}
@@ -144,7 +217,6 @@ class Customer
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
@@ -156,7 +228,6 @@ class Customer
public function setSiret(?string $siret): static
{
$this->siret = $siret;
return $this;
}
@@ -168,43 +239,27 @@ class Customer
public function setCustomerId(?string $customerId): static
{
$this->customerId = $customerId;
return $this;
}
/**
* @return Collection<int, CustomerAddress>
*/
// --- LOGIQUE DES COLLECTIONS ---
/** @return Collection<int, CustomerAddress> */
public function getCustomerAddresses(): Collection
{
return $this->customerAddresses;
}
public function addCustomerAddress(CustomerAddress $customerAddress): static
public function addCustomerAddress(CustomerAddress $address): static
{
if (!$this->customerAddresses->contains($customerAddress)) {
$this->customerAddresses->add($customerAddress);
$customerAddress->setCustomer($this);
if (!$this->customerAddresses->contains($address)) {
$this->customerAddresses->add($address);
$address->setCustomer($this);
}
return $this;
}
public function removeCustomerAddress(CustomerAddress $customerAddress): static
{
if ($this->customerAddresses->removeElement($customerAddress)) {
// set the owning side to null (unless already changed)
if ($customerAddress->getCustomer() === $this) {
$customerAddress->setCustomer(null);
}
}
return $this;
}
/**
* @return Collection<int, Devis>
*/
/** @return Collection<int, Devis> */
public function getDevis(): Collection
{
return $this->devis;
@@ -216,55 +271,10 @@ class Customer
$this->devis->add($devi);
$devi->setCustomer($this);
}
return $this;
}
public function removeDevi(Devis $devi): static
{
if ($this->devis->removeElement($devi)) {
// set the owning side to null (unless already changed)
if ($devi->getCustomer() === $this) {
$devi->setCustomer(null);
}
}
return $this;
}
/**
* @return Collection<int, ProductReserve>
*/
public function getProductReserves(): Collection
{
return $this->productReserves;
}
public function addProductReserf(ProductReserve $productReserf): static
{
if (!$this->productReserves->contains($productReserf)) {
$this->productReserves->add($productReserf);
$productReserf->setCustomer($this);
}
return $this;
}
public function removeProductReserf(ProductReserve $productReserf): static
{
if ($this->productReserves->removeElement($productReserf)) {
// set the owning side to null (unless already changed)
if ($productReserf->getCustomer() === $this) {
$productReserf->setCustomer(null);
}
}
return $this;
}
/**
* @return Collection<int, Contrats>
*/
/** @return Collection<int, Contrats> */
public function getContrats(): Collection
{
return $this->contrats;
@@ -276,19 +286,38 @@ class Customer
$this->contrats->add($contrat);
$contrat->setCustomer($this);
}
return $this;
}
public function removeContrat(Contrats $contrat): static
/**
* @return string|null
*/
public function getVerificationCode(): ?string
{
if ($this->contrats->removeElement($contrat)) {
// set the owning side to null (unless already changed)
if ($contrat->getCustomer() === $this) {
$contrat->setCustomer(null);
}
return $this->verificationCode;
}
return $this;
/**
* @return \DateTimeImmutable|null
*/
public function getVerificationCodeExpiresAt(): ?\DateTimeImmutable
{
return $this->verificationCodeExpiresAt;
}
/**
* @param \DateTimeImmutable|null $verificationCodeExpiresAt
*/
public function setVerificationCodeExpiresAt(?\DateTimeImmutable $verificationCodeExpiresAt): void
{
$this->verificationCodeExpiresAt = $verificationCodeExpiresAt;
}
/**
* @param string|null $verificationCode
*/
public function setVerificationCode(?string $verificationCode): void
{
$this->verificationCode = $verificationCode;
}
}

View File

@@ -28,6 +28,9 @@ class ProductReserve
#[ORM\OneToOne(inversedBy: 'productReserve', cascade: ['persist', 'remove'])]
private ?Devis $devis = null;
#[ORM\ManyToOne(inversedBy: 'productReserves')]
private ?Contrats $contrat = null;
public function getId(): ?int
{
return $this->id;
@@ -92,4 +95,16 @@ class ProductReserve
return $this;
}
public function getContrat(): ?Contrats
{
return $this->contrat;
}
public function setContrat(?Contrats $contrat): static
{
$this->contrat = $contrat;
return $this;
}
}

View File

@@ -48,7 +48,7 @@ class ContratSubscriber
"mails/sign/contrat.twig",
[
'contrat' => $contrat,
'contratLink' => $_ENV['CONTRAT_BASEURL'] . $this->urlGenerator->generate('gestion_contrat', ['num' => $contrat->getNumReservation()])
'contratLink' => $_ENV['CONTRAT_BASEURL'] . $this->urlGenerator->generate('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
],
$attachments
);

View File

@@ -2,55 +2,58 @@
namespace App\Security;
use App\Entity\Account;
use App\Entity\Customer;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
{
/**
* @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface|mixed
*/
public $urlGenerator;
/**
* @var AccessDeniedHandler|mixed
*/
public $accessDeniedHandler;
public function __construct(
UrlGeneratorInterface $urlGenerator,
AccessDeniedHandler $accessDeniedHandler
) {
$this->urlGenerator = $urlGenerator;
$this->accessDeniedHandler = $accessDeniedHandler;
}
private readonly UrlGeneratorInterface $urlGenerator,
private readonly TokenStorageInterface $tokenStorage
) {}
public function start(Request $request, AuthenticationException $authException = null): Response
{
$previous = $authException !== null ? $authException->getPrevious() : null;
// Parque le composant security est un peu bête et ne renvoie pas un AccessDenied pour les utilisateur connecté avec un cookie
// On redirige le traitement de cette situation vers le AccessDeniedHandler
if ($authException instanceof InsufficientAuthenticationException &&
$previous instanceof AccessDeniedException &&
$authException->getToken() instanceof RememberMeToken
) {
return $this->accessDeniedHandler->handle($request, $previous);
}
$token = $this->tokenStorage->getToken();
$user = $token ? $token->getUser() : null;
$path = $request->getPathInfo();
// 1. Gestion des appels API / AJAX
if (in_array('application/json', $request->getAcceptableContentTypes())) {
return new JsonResponse(
['title' => "Vous n'avez pas les permissions suffisantes pour effectuer cette action"],
Response::HTTP_FORBIDDEN
);
return new JsonResponse(['error' => 'Accès restreint'], Response::HTTP_FORBIDDEN);
}
// 2. LOGIQUE POUR LE CRM (/crm)
// Seuls les objets Account (Admins) sont autorisés
if (str_starts_with($path, '/crm')) {
if ($user instanceof Customer) {
// Un client tente d'entrer dans le CRM -> Redirection Accueil
return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
// Si pas connecté du tout -> Redirection Login Admin (app_home dans ton cas)
return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
// 3. LOGIQUE POUR LA RÉSERVATION (/reservation)
// On autorise Account ET Customer
if (str_starts_with($path, '/reservation')) {
// Si l'utilisateur est un Account ou un Customer, il a le droit d'être ici
// Le contrôle d'accès (AccessControl) fera le reste.
// Si personne n'est connecté, on envoie vers le login client
if (!$user) {
return new RedirectResponse($this->urlGenerator->generate('reservation_login'));
}
}
// Par défaut, retour à l'accueil
return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Security;
use App\Entity\Customer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class CustomerAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public function __construct(
private UrlGeneratorInterface $urlGenerator,
private EntityManagerInterface $entityManager
) {}
public function supports(Request $request): bool
{
$host = $request->getHost();
$allowedHosts = ['reservation.ludikevent.fr', 'esyweb.local'];
// Supporte la route login si le host correspond
return $request->attributes->get('_route') === 'reservation_login'
&& $request->isMethod('POST')
&& in_array($host, $allowedHosts);
}
public function authenticate(Request $request): Passport
{
$email = $request->request->get('email', '');
$password = $request->request->get('password', '');
$csrfToken = $request->request->get('_csrf_token');
return new Passport(
new UserBadge($email),
new PasswordCredentials($password),
[
new CsrfTokenBadge('authenticate_customer', $csrfToken),
]
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
// Redirection après succès vers l'espace client
return new RedirectResponse($this->urlGenerator->generate('reservation'));
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate('reservation_login');
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Security;
use App\Entity\Account;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; // L'objet moderne pour les opérations de sécurité
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -13,7 +13,6 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
@@ -24,49 +23,42 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public const LOGIN_ROUTE = 'app_login';
public const LOGIN_ROUTE = 'app_home';
// Les propriétés typées sont la norme en Symfony 7 / PHP 8.2+
private EntityManagerInterface $entityManager;
private UrlGeneratorInterface $urlGenerator;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, Security $security)
{
$this->entityManager = $entityManager;
$this->urlGenerator = $urlGenerator;
$this->security = $security;
}
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly Security $security
) {}
public function supports(Request $request): bool
{
return ($request->attributes->get('_route') === self::LOGIN_ROUTE) && $request->isMethod('POST');
$host = $request->getHost();
$allowedAdminHosts = ['intranet.ludikevent.fr', 'esyweb.local'];
// On n'autorise la connexion que sur les domaines admin
return ($request->attributes->get('_route') === self::LOGIN_ROUTE)
&& $request->isMethod('POST')
&& in_array($host, $allowedAdminHosts);
}
/**
* @param Request $request La requête HTTP entrante
* @return Passport Le passeport contenant l'utilisateur et les informations d'authentification
*/
public function authenticate(Request $request): Passport
{
$email = (string) $request->request->get('_username', '');
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
return new Passport(
// 1. UserBadge: Charge l'utilisateur par l'email
new UserBadge($email, function(string $userIdentifier): Account {
// On force la recherche EXCLUSIVEMENT dans l'entité Account
$user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $userIdentifier]);
if (!$user) {
throw new CustomUserMessageAuthenticationException('Email ou mot de passe invalide.');
// Message générique pour ne pas confirmer l'existence d'un email
throw new CustomUserMessageAuthenticationException('Identifiants invalides.');
}
return $user;
}),
// 2. Credentials: Vérifie le mot de passe
new PasswordCredentials($request->request->get('_password', '')),
[
new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
@@ -74,21 +66,16 @@ class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
);
}
/**
* @param Request $request La requête actuelle
* @param TokenInterface $token Le jeton d'authentification
* @param string $firewallName Le nom du pare-feu utilisé ('main' dans votre cas)
* @return Response|null
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->urlGenerator->generate('app_home'));
// Si l'admin essayait d'accéder à une page précise (/crm/stats par ex) avant d'être connecté
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('app_crm'));
}
/**
* @param Request $request
* @return string L'URL de la page de connexion
*/
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate(self::LOGIN_ROUTE);

View File

@@ -227,4 +227,45 @@ class Client
return $this->getLinkSign($devis->getSignID());
}
public function eventSign(object $contrat): array
{
$events = [];
if ($contrat instanceof Contrats) {
$signId = $contrat->getSignID();
if (!$signId) return []; // Sécurité si pas d'ID de signature
$submiter = $this->getSubmiter($signId);
// Vérifier si submission_events existe pour éviter une erreur undefined index
if (!isset($submiter['submission_events'])) return [];
foreach ($submiter['submission_events'] as $event) {
$label = match($event['event_type']) {
'view_form' => "Contrat consulté",
'start_form' => "Début de procédure",
'complete_form' => "Contrat signé",
'decline_form' => "Signature refusée",
default => null
};
if ($label) {
$events[] = [
'event_type' => $label,
'event_timestamp' => new \DateTimeImmutable($event['event_timestamp'])
];
}
}
}
return $events;
}
public function signedData(Contrats $contrat) : string
{
$signId = $contrat->getSignID();
if (!$signId) return []; // Sécurité si pas d'ID de signature
$submiter = $this->getSubmiter($signId);
return $submiter['uuid']; // numéro de signature;
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Service\Stripe;
use App\Entity\Contrats;
use App\Entity\ContratsPayments;
use App\Entity\Customer;
use App\Entity\StripeConfig;
use App\Entity\Product;
@@ -242,7 +244,7 @@ class Client
],
'payment' => [
'url' => $baseUrl . '/webhooks/payment-intent',
'events' => ['payment_intent.succeeded', 'payment_intent.canceled']
'events' => ['payment_intent.succeeded', 'payment_intent.canceled','checkout.session.completed']
]
];
@@ -309,4 +311,193 @@ class Client
{
return $this->check()['state'];
}
/**
* Récupère le lien existant ou en génère un nouveau si expiré/inexistant
*/
public function linkPaymentAccompte(float $arrhes, Contrats $contrat, ContratsPayments $contratsPayments): array
{
$stripeSessionId = $contratsPayments->getPaymentId();
// 1. TENTATIVE DE RÉCUPÉRATION DU LIEN EXISTANT
if ($stripeSessionId) {
try {
$session = $this->client->checkout->sessions->retrieve($stripeSessionId);
// Si la session est active et non payée, on retourne l'URL existante
if ($session->status === 'open' && $session->payment_status === 'unpaid') {
return [
'state' => true,
'url' => $session->url,
'id' => $session->id
];
}
} catch (\Exception $e) {
// Si l'ID n'est pas trouvé chez Stripe, on continue pour en créer un nouveau
}
}
// 2. CRÉATION D'UN NOUVEAU LIEN (Si inexistant, expiré ou invalide)
$newSession = $this->createPaymentAccompte($arrhes, $contrat);
if ($newSession['state']) {
// On met à jour l'entité ContratsPayments avec le nouvel ID de session
$contratsPayments->setPaymentId($newSession['id']);
$this->em->persist($contratsPayments);
$this->em->flush();
}
return $newSession;
}
/**
* Ta fonction de création originale (utilisée en fallback)
*/
public function createPaymentAccompte(float $arrhes, Contrats $contrats): array
{
try {
$session = $this->client->checkout->sessions->create([
'customer' => $contrats->getCustomer()->getCustomerId(),
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => sprintf('Acompte Réservation #%s', $contrats->getNumReservation()),
'description' => 'Règlement de 25% pour validation du contrat Ludikevent',
],
'unit_amount' => (int)round($arrhes * 100),
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/success/' . $contrats->getId() . '?type=accompte',
'cancel_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/cancel/' . $contrats->getId() . '?type=accompte',
'metadata' => [
'contrat_id' => $contrats->getId(),
'type' => 'accompte'
]
]);
return [
'state' => true,
'url' => $session->url,
'id' => $session->id
];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
/**
* Vérifie l'authenticité d'un Webhook Stripe
* @return bool true si la signature est valide, false sinon
*/
public function checkWebhooks(\Symfony\Component\HttpFoundation\Request $request, string $configName): bool
{
// 1. Récupération de la config (Secret de signature)
$config = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $configName]);
if (!$config || !$config->getSecret()) {
return false;
}
$payload = $request->getContent();
$sigHeader = $request->headers->get('Stripe-Signature');
try {
// 2. Vérification de la signature via la librairie native Stripe
// Si la signature est invalide, une exception est levée
\Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
$config->getSecret()
);
return true;
} catch (\Exception $e) {
// Signature invalide, timestamp trop ancien, ou payload altéré
return false;
}
}
public function paymentMethod(mixed $paymentId)
{
$data = $this->client->paymentIntents->retrieve($paymentId);
$paymentMethod = $this->client->paymentMethods->retrieve($data->payment_method);
return $paymentMethod->toArray();
}
public function createPaymentCaution(float|int|null $totalCaution, Contrats $contrat): array
{
// On convertit le montant en centimes pour Stripe
$amountInCents = (int)($totalCaution * 100);
$session = $this->client->checkout->sessions->create([
'payment_method_types' => ['card'], // La pré-auth nécessite généralement une carte
'line_items' => [[
'price_data' => [
'currency' => 'eur',
'product_data' => [
'name' => 'Dépôt de garantie (Caution) - Réservation #' . $contrat->getNumReservation(),
'description' => 'Cette somme sera bloquée temporairement mais non débitée immédiatement.',
],
'unit_amount' => $amountInCents,
],
'quantity' => 1,
]],
'mode' => 'payment',
'payment_intent_data' => [
// IMPORTANT : 'manual' permet de bloquer les fonds sans les encaisser
'capture_method' => 'manual',
'metadata' => [
'contrat_id' => $contrat->getId(),
'type' => 'caution'
],
],
'success_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/success/' . $contrat->getId() . '?type=caution',
'cancel_url' => rtrim($this->stripeBaseUrl, '/') . '/contrat/payment/cancel/' . $contrat->getId() . '?type=caution'
]);
return [
'state' => true,
'url' => $session->url,
'id' => $session->id
];
}
public function linkPaymentCaution(float $arrhes,Contrats $contrat, ContratsPayments $pl)
{
$stripeSessionId = $pl->getPaymentId();
// 1. TENTATIVE DE RÉCUPÉRATION DU LIEN EXISTANT
if ($stripeSessionId) {
try {
$session = $this->client->checkout->sessions->retrieve($stripeSessionId);
// Si la session est active et non payée, on retourne l'URL existante
if ($session->status === 'open' && $session->payment_status === 'unpaid') {
return [
'state' => true,
'url' => $session->url,
'id' => $session->id
];
}
} catch (\Exception $e) {
// Si l'ID n'est pas trouvé chez Stripe, on continue pour en créer un nouveau
}
}
// 2. CRÉATION D'UN NOUVEAU LIEN (Si inexistant, expiré ou invalide)
$newSession = $this->createPaymentAccompte($arrhes, $contrat);
if ($newSession['state']) {
// On met à jour l'entité ContratsPayments avec le nouvel ID de session
$pl->setPaymentId($newSession['id']);
$this->em->persist($pl);
$this->em->flush();
}
return $newSession;
}
}

View File

@@ -2,22 +2,26 @@
namespace App\Twig;
use App\Entity\Contrats;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class StripeExtension extends AbstractExtension
{
public function __construct(private readonly Client $client)
public function __construct(private readonly Client $client,private readonly EntityManagerInterface $em)
{
}
public function getFilters()
{
return [
new TwigFilter('totalQuoto',[$this,'totalQuoto'])
new TwigFilter('totalQuoto',[$this,'totalQuoto']),
new TwigFilter('totalContrat',[$this,'totalContrat'])
];
}
@@ -52,13 +56,59 @@ class StripeExtension extends AbstractExtension
return (float) $totalHT;
}
public function totalContrat(Contrats $devis): float
{
$totalHT = 0;
// Calcul de la durée (différence entre dateDebut et dateFin)
$dateDebut = $devis->getDateAt();
$dateFin = $devis->getEndAt();
// Calcul du nombre de jours (minimum 1 jour)
$nbDays = 1;
if ($dateDebut && $dateFin) {
$diff = $dateDebut->diff($dateFin);
$nbDays = $diff->days + 1; // +1 pour inclure le jour de départ
}
// 1. Calcul des lignes de produits (Location)
foreach ($devis->getContratsLines() as $line) {
$price1Day = $line->getPrice1DayHt() ?? 0;
$priceSupHT = $line->getPriceSupDayHt() ?? 0;
// Calcul : J1 + (Jours restants * Prix Sup)
$lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
$totalHT += $lineTotalHT;
}
// 2. Calcul des options additionnelles
foreach ($devis->getContratsOptions() as $devisOption) {
$totalHT += $devisOption->getPrice() ?? 0;
}
return (float) $totalHT;
}
public function getFunctions()
{
return [
new TwigFunction('syncStripe', [$this, 'syncStripe']),
new TwigFunction('contratPaymentPay', [$this, 'contratPaymentPay']),
];
}
public function contratPaymentPay(Contrats $contrat,string $type): bool
{
if($type == "accompte") {
$pl = $this->em->getRepository(ContratsPayments::class)->findOneBy(['type'=>$type,'contrat'=>$contrat]);
if($pl instanceof ContratsPayments) {
return $pl->getState() == "complete";
}
}
return false;
}
public function syncStripe(): array
{
return $this->client->check();

View File

@@ -0,0 +1,48 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding="40px 20px" border-radius="20px">
<mj-column>
<mj-text align="center" font-weight="900" font-size="10px" color="#3b82f6" letter-spacing="2px" text-transform="uppercase">
Ludikevent • Sécurité
</mj-text>
<mj-text align="center" font-weight="900" font-style="italic" font-size="28px" color="#1e293b" text-transform="uppercase" padding-top="10px">
Vérification <span style="color: #3b82f6;">du compte</span>
</mj-text>
<mj-divider border-width="1px" border-color="#e2e8f0" width="100px" padding-top="20px" padding-bottom="20px" />
<mj-text font-size="15px" color="#475569" line-height="1.6">
Bonjour <strong>{{ datas.customer.surname }} {{ datas.customer.name }}</strong>,
</mj-text>
<mj-text font-size="15px" color="#475569" line-height="1.6">
Pour activer votre espace client, veuillez saisir le code de sécurité suivant :
</mj-text>
<mj-section background-color="#eff6ff" border="2px dashed #3b82f6" border-radius="15px" padding="30px">
<mj-column>
<mj-text align="center" font-size="12px" color="#3b82f6" text-transform="uppercase" font-weight="bold" letter-spacing="1px" padding="0">
Votre code secret
</mj-text>
<mj-text align="center" font-size="42px" font-weight="900" color="#1e293b" padding="10px 0 0 0" letter-spacing="10px">
{{ datas.code }}
</mj-text>
</mj-column>
</mj-section>
<mj-text align="center" font-size="12px" color="#94a3b8" padding-top="30px" line-height="1.4">
Ce code est strictement confidentiel et expirera dans <strong>15 minutes</strong>.
<br/>
Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.
</mj-text>
<mj-divider border-width="1px" border-color="#f1f5f9" width="50px" padding-top="20px" />
<mj-text align="center" font-size="11px" color="#94a3b8" padding-top="10px">
Besoin d'assistance ? Contactez-nous au <strong>06 14 17 24 47</strong>.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-top="0px">
<mj-column width="100%">
<mj-text font-size="24px" color="#10b981" font-family="Arial, sans-serif" font-weight="bold" align="center">
Signature confirmée !
</mj-text>
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" align="center">
Votre contrat <strong>#{{ datas.contrats.numReservation }}</strong> est désormais signé.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fffbeb" border="2px solid #fbbf24" border-radius="10px">
<mj-column width="100%">
<mj-text font-size="16px" color="#92400e" font-family="Arial, sans-serif" font-weight="bold" align="center">
⚠️ ACTION REQUISE : PAIEMENT DE L'ACOMPTE
</mj-text>
<mj-text font-size="14px" color="#b45309" font-family="Arial, sans-serif" line-height="1.5" align="center">
Pour valider définitivement votre réservation, vous disposez de <strong>3 jours</strong> pour effectuer le paiement de l'acompte.
<br/><br/>
Passé ce délai, votre réservation sera <strong>automatiquement annulée</strong> et les dates seront libérées.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column width="100%">
<mj-text font-size="14px" color="#555555" font-family="Arial, sans-serif">
Bonjour {{ datas.contrats.customer.name }},<br/><br/>
Nous avons bien reçu votre signature électronique. Vous trouverez en pièces jointes de cet e-mail :
</mj-text>
<mj-text font-size="13px" color="#555555" font-family="Arial, sans-serif">
<ul>
<li>Votre contrat signé au format PDF</li>
<li>Le certificat d'audit de la signature</li>
</ul>
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-top="20px">
<mj-column width="100%">
<mj-text font-size="14px" color="#475569" font-family="Arial, sans-serif" line-height="1.6">
Bonne nouvelle ! Le client <strong>{{ datas.contrats.customer.name }} {{ datas.contrats.customer.surname }}</strong> vient de signer numériquement son contrat.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px">
<mj-column width="90%" background-color="#f8fafc" border="1px solid #e2e8f0" border-radius="8px">
<mj-table font-family="Arial, sans-serif" font-size="14px" padding="15px">
<tr>
<td style="padding: 5px 0; color: #64748b;">N° Contrat :</td>
<td style="padding: 5px 0; text-align: right; font-weight: bold;">{{ datas.contrats.numReservation }}</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Client :</td>
<td style="padding: 5px 0; text-align: right;">{{ datas.contrats.customer.numReservation }}</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Montant HT :</td>
<td style="padding: 5px 0; text-align: right; font-weight: bold; color: #2563eb;">{{ (datas.contrats|totalContrat)|number_format(2, ',', ' ') }} €</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Date de signature :</td>
<td style="padding: 5px 0; text-align: right;">{{ "now"|date("d/m/Y à H:i") }}</td>
</tr>
</mj-table>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column width="100%">
<mj-text font-size="13px" color="#ef4444" font-family="Arial, sans-serif" font-style="italic" align="center">
Rappel : Le client a reçu l'instruction de régler l'acompte sous 3 jours.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends 'revervation/base.twig' %}
{% block title %}Paiement annulé - #{{ contrat.numReservation }}{% endblock %}
{% block body %}
<div class="min-h-screen bg-slate-50 flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="bg-white rounded-[3rem] p-10 shadow-xl border border-slate-100 text-center relative overflow-hidden">
{# Décoration subtile #}
<div class="absolute -top-10 -right-10 w-32 h-32 bg-amber-50 rounded-full blur-3xl"></div>
{# Icône Annulation #}
<div class="w-20 h-20 bg-amber-50 text-amber-500 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-sm border border-amber-100">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h1 class="text-3xl font-black uppercase italic text-slate-900 leading-tight">
Paiement <span class="text-amber-500">interrompu</span>
</h1>
<p class="mt-4 text-slate-500 font-medium italic text-sm leading-relaxed">
Le processus de règlement de l'acompte pour la réservation <span class="text-slate-900 font-bold">#{{ contrat.numReservation }}</span> a été annulé.
Aucun montant n'a été débité de votre compte.
</p>
<div class="mt-10 space-y-4">
{# Bouton Retour à la gestion #}
<a href="{{ path('gestion_contrat_view', {'num': contrat.numReservation}) }}"
class="group flex items-center justify-center gap-3 bg-slate-900 text-white px-8 py-5 rounded-2xl font-black uppercase italic hover:bg-blue-600 transition-all shadow-lg">
<svg class="w-5 h-5 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Retourner au contrat
</a>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">
Besoin d'aide ? Contactez-nous au 06 14 17 24 47
</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends 'revervation/base.twig' %}
{% block title %}Validation du paiement - Ludikevent{% endblock %}
{% block body %}
{# L'attribut data-auto-redirect contient l'URL de destination #}
<div class="min-h-screen bg-slate-50 flex items-center justify-center py-12 px-4"
id="payment-check-container"
data-auto-redirect="{{ path('gestion_contrat_success', {'id': contrat.id}) }}">
<div class="max-w-md w-full text-center">
<div class="bg-white rounded-[3rem] p-10 shadow-2xl border border-slate-100 relative overflow-hidden">
<div class="relative w-24 h-24 mx-auto mb-8">
<div class="absolute inset-0 border-4 border-blue-100 rounded-full"></div>
<div class="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin"></div>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="w-10 h-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<h1 class="text-2xl font-black uppercase italic text-slate-900 leading-tight">
Paiement <span class="text-blue-600">en cours</span>
</h1>
<p class="mt-4 text-slate-500 font-medium italic text-sm leading-relaxed">
Nous vérifions la confirmation de votre banque auprès de Stripe. <br>
<span class="text-slate-900 font-bold uppercase text-[10px] tracking-widest mt-2 block">Merci de patienter quelques instants...</span>
</p>
<div class="mt-10 p-4 bg-blue-50 rounded-2xl border border-blue-100">
<p class="text-[10px] text-blue-700 font-black uppercase tracking-tighter">
Réservation #{{ contrat.numReservation }}
</p>
</div>
</div>
<p class="mt-6 text-[10px] text-slate-400 font-bold uppercase tracking-widest italic">
Redirection automatique dans quelques secondes
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends 'revervation/base.twig' %}
{% block title %}Compte activé - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div class="max-w-md w-full bg-white p-10 rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-slate-100">
<div class="text-center">
<div class="inline-flex items-center justify-center w-24 h-24 bg-green-50 rounded-full mb-8 animate-in zoom-in duration-500">
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="text-3xl font-black italic uppercase text-slate-900 leading-tight mb-4">
Compte <br><span class="text-green-600">Activé !</span>
</h1>
<p class="text-slate-500 text-sm leading-relaxed mb-10">
Félicitations <strong>{{ customer.surname }}</strong> ! Votre espace client est désormais configuré. Vous pouvez maintenant accéder à votre contrat pour le consulter et le signer.
</p>
<div class="bg-slate-50 border border-slate-100 rounded-3xl p-6 mb-10 text-left">
<div class="flex items-center mb-4">
<div class="w-8 h-8 rounded-full bg-white flex items-center justify-center text-blue-600 shadow-sm mr-3">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path></svg>
</div>
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Rappel de vos accès</span>
</div>
<p class="text-xs text-slate-500">Identifiant : <span class="text-slate-900 font-bold">{{ customer.email }}</span></p>
</div>
<a href="{{ link }}" class="group relative flex items-center justify-center w-full py-5 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-200">
<span>Accéder à mon contrat</span>
<svg class="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends 'revervation/base.twig' %}
{% block title %}Configuration de votre compte - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div class="max-w-md w-full bg-white p-10 rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-slate-100">
<div class="text-center mb-10">
<span class="inline-block px-4 py-1.5 bg-blue-50 text-blue-600 text-[10px] font-black uppercase tracking-[0.2em] rounded-full mb-6">
Étape de sécurité
</span>
<h1 class="text-3xl font-black italic uppercase text-slate-900 leading-tight">
Activez votre <br><span class="text-blue-600">Espace Client</span>
</h1>
<p class="text-slate-500 text-sm mt-4 italic">
Ravi de vous revoir, {{ customer.surname }}. <br>
Un code vient de vous être envoyé par e-mail.
</p>
</div>
<form data-turbo="false" method="POST" class="space-y-8">
<div class="space-y-3">
<label class="block text-[10px] font-black uppercase tracking-widest text-slate-400 ml-2">
Code de vérification (6 chiffres)
</label>
<input type="text"
name="verification_code"
maxlength="6"
placeholder="000000"
required
class="w-full px-6 py-5 bg-blue-50/30 border-2 border-blue-100 focus:border-blue-500 focus:bg-white text-center text-3xl font-black tracking-[0.5em] text-blue-600 rounded-2xl transition-all outline-none placeholder:text-blue-100">
</div>
<div class="space-y-3">
<label class="block text-[10px] font-black uppercase tracking-widest text-slate-400 ml-2">
Choisissez votre mot de passe
</label>
<div class="relative">
<input type="password"
name="password"
required
placeholder="••••••••••••"
class="w-full px-6 py-5 bg-slate-50 border border-slate-100 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 rounded-2xl transition-all outline-none font-medium">
</div>
<p class="text-[10px] text-slate-400 italic ml-2">
Minimum 8 caractères conseillés.
</p>
</div>
<div class="pt-4">
<button type="submit" class="w-full py-5 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-blue-200 transform active:scale-[0.98]">
Activer mon accès
</button>
</div>
<div class="text-center space-y-4">
<p class="text-[10px] text-slate-400 font-medium px-4 leading-relaxed">
En activant votre compte, vous pourrez signer vos contrats et consulter vos factures en ligne.
</p>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends 'revervation/base.twig' %}
{% block title %}Erreur de vérification - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div class="max-w-md w-full bg-white p-10 rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-slate-100">
<div class="text-center">
<div class="inline-flex p-5 bg-red-50 rounded-3xl text-red-500 mb-8 animate-bounce">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1 class="text-2xl font-black italic uppercase text-slate-900 leading-tight mb-4">
Code <br><span class="text-red-500">Invalide</span>
</h1>
<div class="bg-red-50/50 border border-red-100 rounded-2xl p-4 mb-8">
<p class="text-red-600 font-bold text-sm">
{{ error|default('Le code de sécurité est incorrect ou a expiré (15 min).') }}
</p>
</div>
<p class="text-slate-500 text-sm leading-relaxed mb-10">
Pour des raisons de sécurité, l'accès à votre contrat nécessite une validation par mail valide. Veuillez vérifier votre saisie ou demander un nouveau code.
</p>
<div class="space-y-4">
{# Retour à la page de saisie #}
<a data-turbo="fals" href="{{ path('gestion_contrat_finish') }}" class="w-full py-5 bg-slate-900 hover:bg-slate-800 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-slate-200">
Réessayer la saisie
</a>
</div>
<div class="mt-12 pt-8 border-t border-slate-100">
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mb-2">Support Ludikevent</p>
<p class="text-slate-900 font-black italic">06 14 17 24 47</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends 'revervation/base.twig' %}
{% block title %}Contrat introuvable - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-[80vh] flex items-center justify-center p-6">
<div class="max-w-md w-full text-center">
{# --- ILLUSTRATION --- #}
<div class="mb-8 relative">
<div class="w-24 h-24 bg-blue-50 rounded-full flex items-center justify-center mx-auto text-blue-500 shadow-inner">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 9.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="absolute -top-2 -right-2 w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center text-amber-600 border-2 border-white shadow-sm">
<span class="font-bold text-lg">?</span>
</div>
</div>
{# --- TEXTE D'ERREUR --- #}
<h1 class="text-3xl font-black italic uppercase tracking-tighter text-slate-900 mb-4">
Oups ! <span class="text-blue-600">Contrat introuvable</span>
</h1>
<p class="text-slate-500 mb-10 leading-relaxed font-medium">
Désolé, nous ne parvenons pas à trouver le contrat correspondant à ce numéro.
Il a peut-être été expiré, supprimé ou l'adresse saisie est incorrecte.
</p>
{# --- BOUTON DE RETOUR --- #}
<div class="space-y-6">
<a href="{{ path('reservation') }}"
class="inline-flex items-center justify-center w-full py-5 px-8 bg-blue-600 hover:bg-blue-700 text-white font-black uppercase tracking-widest rounded-2xl transition-all shadow-xl shadow-blue-200 group">
<svg class="w-5 h-5 mr-3 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Accueil
</a>
<div class="pt-6 border-t border-slate-100">
<p class="text-[10px] text-slate-400 uppercase font-black tracking-[0.2em] mb-2">
Besoin d'assistance ?
</p>
<p class="text-slate-600 font-bold">06 14 17 24 47</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,59 @@
{% extends 'revervation/base.twig' %}
{% block title %}Paiement Validé - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-screen bg-slate-50 flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="bg-white rounded-[3rem] p-10 shadow-2xl border border-slate-100 text-center relative overflow-hidden">
{# Effet de fond vert subtil #}
<div class="absolute -top-10 -right-10 w-32 h-32 bg-green-50 rounded-full blur-3xl"></div>
{# Icône Succès Animée #}
<div class="relative w-24 h-24 mx-auto mb-8">
<div class="absolute inset-0 bg-green-100 rounded-full scale-110 animate-ping opacity-20"></div>
<div class="relative w-24 h-24 bg-green-500 rounded-3xl flex items-center justify-center text-white shadow-lg shadow-green-200">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<h1 class="text-3xl font-black uppercase italic text-slate-900 leading-tight">
Paiement <span class="text-green-600">Accepté !</span>
</h1>
<p class="mt-4 text-slate-500 font-medium italic text-sm leading-relaxed">
Votre acompte a été validé avec succès. Votre réservation <span class="text-slate-900 font-bold">#{{ contrat.numReservation }}</span> est désormais confirmée et le matériel vous est réservé.
</p>
<div class="mt-8 p-5 bg-slate-50 rounded-2xl border border-slate-100 flex items-center justify-between">
<div class="text-left">
<p class="text-[8px] font-black uppercase text-slate-400 tracking-widest">Référence</p>
<p class="text-xs font-black text-slate-900 uppercase italic">#{{ contrat.numReservation }}</p>
</div>
<div class="text-right">
<p class="text-[8px] font-black uppercase text-slate-400 tracking-widest">Statut</p>
<p class="text-[10px] font-black text-green-600 uppercase italic">Confirmé</p>
</div>
</div>
<div class="mt-10">
{# BOUTON RETOUR GESTION DU CONTRAT #}
<a href="{{ path('gestion_contrat_view', {'num': contrat.numReservation}) }}"
class="group flex items-center justify-center gap-3 bg-slate-900 text-white px-8 py-5 rounded-2xl font-black uppercase italic hover:bg-blue-600 transition-all shadow-xl shadow-slate-200">
<span class="text-sm">Gérer ma réservation</span>
<svg class="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</a>
</div>
</div>
<p class="mt-8 text-center text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em] italic">
Merci de votre confiance — Ludikevent
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,272 @@
{% extends 'revervation/base.twig' %}
{% block title %}Récapitulatif de réservation #{{ contrat.numReservation }}{% endblock %}
{% block body %}
<div class="min-h-screen bg-slate-50 py-12 px-4">
<div class="max-w-7xl mx-auto">
{# HEADER : NAVIGATION, TITRE & DATES #}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="md:col-span-2 flex items-center gap-6">
<a href="{{ path('reservation') }}" class="w-12 h-12 bg-white rounded-2xl border border-slate-100 flex items-center justify-center text-slate-400 hover:text-blue-600 transition-all shadow-sm">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</a>
<div class="flex flex-col md:flex-row md:items-center gap-4 md:gap-8">
<div>
<h1 class="text-3xl font-black italic uppercase text-slate-900 leading-none">Détails <span class="text-blue-600">Réservation</span></h1>
<p class="text-slate-400 text-[10px] font-black uppercase tracking-[0.2em] mt-2">Référence : #{{ contrat.numReservation }}</p>
</div>
<div class="flex items-center bg-white px-6 py-3 rounded-2xl border border-slate-100 shadow-sm gap-6">
<div class="text-center">
<p class="text-[8px] font-black uppercase text-slate-400 tracking-widest mb-1">Du</p>
<p class="text-sm font-black text-slate-900 uppercase italic">{{ contrat.dateAt|date('d/m/Y') }}</p>
</div>
<div class="flex flex-col items-center">
<div class="h-px w-8 bg-blue-200 mb-1"></div>
<span class="text-[9px] font-black text-blue-600 uppercase">{{ days }} J</span>
</div>
<div class="text-center">
<p class="text-[8px] font-black uppercase text-slate-400 tracking-widest mb-1">Au</p>
<p class="text-sm font-black text-slate-900 uppercase italic">{{ contrat.endAt|date('d/m/Y') }}</p>
</div>
</div>
</div>
</div>
<div class="bg-blue-600 rounded-[2rem] p-5 text-white flex items-center gap-4 shadow-lg shadow-blue-200">
<div class="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" 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"/></svg>
</div>
<div class="overflow-hidden">
<p class="text-[9px] font-black uppercase tracking-widest opacity-80 mb-1">Ville de l'événement</p>
<p class="font-black uppercase italic text-sm truncate">{{ contrat.townEvent }} ({{ contrat.zipCodeEvent }})</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-8 items-start">
{# COLONNE GAUCHE #}
<div class="lg:col-span-3 space-y-6">
{# STATUT SIGNATURE #}
{% if not contrat.signed %}
<div class="bg-amber-50 border border-amber-100 rounded-[2.5rem] p-8 flex items-start gap-6 shadow-sm">
<div class="w-14 h-14 bg-white rounded-2xl flex items-center justify-center text-amber-500 shadow-sm shrink-0 border border-amber-100">
<svg class="w-8 h-8 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<h3 class="text-amber-900 font-black uppercase italic text-lg leading-tight">Action requise : Signature</h3>
<p class="text-amber-700/80 text-sm mt-1 font-medium italic">Veuillez signer le contrat pour activer les options de paiement.</p>
</div>
</div>
{% else %}
<div class="bg-green-600 rounded-[3rem] p-10 text-white shadow-xl shadow-green-200/50 flex flex-col md:flex-row items-center justify-between gap-6 border-b-8 border-green-700">
<div class="flex items-center gap-6">
<div class="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center shadow-inner">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div>
<h2 class="text-2xl font-black uppercase italic leading-none">Contrat validé</h2>
<p class="text-green-100 text-sm font-medium mt-1">Signature enregistrée avec succès.</p>
</div>
</div>
</div>
{% endif %}
{# TABLEAU PRESTATIONS #}
<div class="bg-white rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/40 overflow-hidden">
<div class="p-8 border-b border-slate-50 flex justify-between items-center bg-slate-50/30">
<h2 class="text-xs font-black uppercase tracking-widest text-slate-900">Détail des prestations</h2>
<span class="bg-blue-600 text-white text-[10px] font-black px-4 py-1.5 rounded-full uppercase">{{ days }} Jours</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<tbody class="divide-y divide-slate-50">
{% for line in contrat.contratsLines %}
{% set priceLine = line.price1DayHt + (line.priceSupDayHt * (days - 1)) %}
<tr class="hover:bg-slate-50/30 transition-colors">
<td class="px-8 py-6">
<p class="font-black text-slate-900 uppercase text-sm leading-tight">{{ line.name }}</p>
<p class="text-[10px] text-slate-400 font-bold italic mt-1 uppercase">Caution : {{ line.caution|number_format(0, ',', ' ') }}€</p>
</td>
<td class="px-8 py-6 text-right font-black text-slate-900 italic text-lg">{{ priceLine|number_format(2, ',', ' ') }}€</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if not contrat.signed %}
<div class="space-y-4 pt-4">
<a href="{{ signUrl }}" class="group block w-full bg-slate-900 rounded-[2rem] p-8 text-white hover:bg-blue-600 transition-all shadow-xl hover:shadow-blue-200/50 relative overflow-hidden text-center">
<h3 class="text-2xl font-black uppercase italic">Signer le contrat</h3>
<p class="text-blue-300 text-[10px] font-black uppercase mt-1 tracking-widest">Étape obligatoire avant règlement</p>
</a>
</div>
{% endif %}
</div>
{# COLONNE DROITE : FINANCES #}
<div class="lg:col-span-2 space-y-6">
<div class="bg-white rounded-[2rem] p-10 border border-slate-100 shadow-sm text-center">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">Total Prestations HT</p>
<p class="text-5xl font-black text-slate-900 italic tracking-tighter">{{ totalHT|number_format(2, ',', ' ') }}€</p>
</div>
{# --- SECTION ACOMPTE --- #}
{% if not contratPaymentPay(contrat, 'accompte') %}
<div class="bg-white rounded-[2rem] border border-red-100 shadow-xl shadow-red-100/20 overflow-hidden">
<div class="bg-red-500 p-6 text-white flex items-center gap-4">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2"></path></svg>
</div>
<p class="text-sm font-black uppercase italic leading-none">Acompte à régler (25%)</p>
</div>
<div class="p-8 text-center">
<p class="text-4xl font-black text-slate-900 italic tracking-tighter">{{ arrhes|number_format(2, ',', ' ') }}€</p>
{% if contrat.signed %}
<a href="{{ path('gestion_contrat_view', {'num': contrat.numReservation,'act':'accomptePay'}) }}"
class="mt-6 inline-flex items-center justify-center gap-4 w-full bg-slate-900 text-white px-8 py-4 rounded-2xl font-black uppercase italic hover:bg-blue-600 transition-all shadow-lg">
<span>Payer l'acompte</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
</a>
{% else %}
<p class="mt-4 text-[10px] text-amber-600 font-bold uppercase italic tracking-widest leading-tight">Veuillez signer le contrat<br>pour débloquer le paiement</p>
{% endif %}
</div>
</div>
{% else %}
<div class="bg-white rounded-[2rem] border border-green-100 shadow-xl shadow-green-100/20 overflow-hidden">
<div class="bg-green-500 p-6 text-white flex items-center gap-4">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
</div>
<p class="text-sm font-black uppercase italic leading-none">Acompte encaissé</p>
</div>
<div class="p-6 space-y-4">
{% for payment in paymentList %}
<div class="flex flex-col gap-3 p-5 bg-slate-50 rounded-[1.5rem] border border-slate-100">
<div class="flex justify-between items-center">
<span class="text-[9px] font-black uppercase text-green-600 bg-green-100 px-2 py-0.5 rounded-full tracking-tighter">Paiement validé</span>
<span class="text-[9px] font-medium text-slate-400 italic">{{ payment.validateAt|date('d/m/Y à H:i') }}</span>
</div>
<div class="space-y-1">
<p class="text-[8px] font-black text-slate-400 uppercase tracking-widest">ID Transaction</p>
<p class="text-[10px] font-mono font-bold text-slate-600 bg-white px-2 py-1.5 rounded border border-slate-100 select-all">{{ payment.paymentId }}</p>
</div>
<div class="flex justify-between items-end pt-2 border-t border-slate-200/50">
<div>
<p class="text-[8px] font-black text-slate-400 uppercase tracking-widest mb-1">Moyen utilisé</p>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black uppercase text-slate-700 italic">
{# Affiche le nom propre (ex: Carte Bancaire, Klarna...) #}
{{ payment.card.method_label|default('Paiement Stripe') }}
</span>
{% if payment.card.type == "card" %}
<span class="text-[10px] text-slate-400 font-bold uppercase italic">
{# Affiche la marque et les 4 chiffres #}
({{ payment.card.card.brand|default('') }} **** {{ payment.card.card.last4|default('') }})
</span>
{# Badge optionnel pour le type de débit #}
{% if payment.card.card.funding == "debit" %}
<span class="text-[7px] bg-slate-100 text-slate-500 px-1 rounded">DEBIT</span>
{% endif %}
{% if payment.card.card.funding == "credit" %}
<span class="text-[7px] bg-slate-100 text-slate-500 px-1 rounded">CREDIT</span>
{% endif %}
{% endif %}
</div>
</div>
<p class="text-xl font-black text-slate-900 italic leading-none">{{ payment.amount|number_format(2, ',', ' ') }}€</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# --- SECTION CAUTION --- #}
{% if contratPaymentPay(contrat, 'accompte') %}
{% if not contratPaymentPay(contrat, 'caution') %}
<div class="bg-white rounded-[2rem] border border-red-100 shadow-xl shadow-red-100/20 overflow-hidden">
<div class="bg-red-500 p-6 text-white flex items-center gap-4">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 9v2m0 4h.01"></path></svg>
</div>
<p class="text-sm font-black uppercase italic leading-none">Caution à déposer</p>
</div>
<div class="p-8 text-center">
<p class="text-4xl font-black text-slate-900 italic tracking-tighter">{{ totalCaution|number_format(2, ',', ' ') }}€</p>
{% set canPayCaution = (date('now') >= contrat.dateAt.modify('-7 days')) %}
{% if canPayCaution %}
<a href="{{ path('gestion_contrat_view', {'num': contrat.numReservation,'act':'cautionPay'}) }}"
class="mt-6 inline-flex items-center justify-center gap-4 w-full bg-slate-900 text-white px-8 py-4 rounded-2xl font-black uppercase italic hover:bg-red-600 transition-all shadow-lg">
<span>Déposer la caution</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
</a>
{% else %}
<div class="mt-6 p-4 bg-slate-50 rounded-2xl border border-slate-100">
<p class="text-[10px] text-amber-600 font-black uppercase tracking-widest leading-tight">
Lien actif le {{ contrat.dateAt.modify('-7 days')|date('d/m/Y') }}
</p>
<p class="text-[9px] text-slate-400 italic mt-1">(7 jours avant le début)</p>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="bg-white rounded-[2rem] border border-green-100 shadow-xl shadow-green-100/20 overflow-hidden">
<div class="bg-green-500 p-6 text-white flex items-center gap-4">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>
</div>
<p class="text-sm font-black uppercase italic leading-none">Caution sécurisée</p>
</div>
<div class="p-6 space-y-4">
{% for payment in paymentCaution %}
<div class="flex flex-col gap-3 p-5 bg-slate-50 rounded-[1.5rem] border border-slate-100">
<div class="flex justify-between items-center">
<span class="text-[9px] font-black uppercase text-blue-600 bg-blue-100 px-2 py-0.5 rounded-full tracking-tighter">Empreinte OK</span>
<span class="text-[9px] font-medium text-slate-400 italic">{{ payment.validateAt|date('d/m/Y à H:i') }}</span>
</div>
<div class="space-y-1">
<p class="text-[8px] font-black text-slate-400 uppercase tracking-widest">ID Garantie</p>
<p class="text-[10px] font-mono font-bold text-slate-600 bg-white px-2 py-1.5 rounded border border-slate-100 select-all">{{ payment.paymentId }}</p>
</div>
<div class="flex justify-between items-end pt-2 border-t border-slate-200/50">
<div>
<p class="text-[8px] font-black text-slate-400 uppercase tracking-widest mb-1">Source</p>
<span class="text-[10px] font-black uppercase text-slate-700 italic">
{{ payment.card.method_label|default('Empreinte CB') }}
</span>
</div>
<p class="text-xl font-black text-slate-900 italic leading-none">{{ payment.amount|number_format(2, ',', ' ') }}€</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
{# SOLDE #}
<div class="bg-white rounded-[2rem] p-10 border border-slate-100 shadow-sm text-center">
<p class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Solde restant</p>
<p class="text-5xl font-black text-slate-900 italic tracking-tighter">{{ solde|number_format(2, ',', ' ') }}€</p>
<div class="mt-4 p-4 bg-slate-50 rounded-2xl">
<p class="text-[10px] text-slate-400 font-bold uppercase italic">À régler le jour de la prestation</p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -93,10 +93,11 @@
</head>
<body class="bg-gray-50 text-gray-900 font-sans antialiased min-h-screen flex flex-col">
{% if is_granted('ROLE_USER') %}
<utm-account id="{{ app.user.id }}" email="{{ app.user.email }}" name="{{ app.user.username }}"></utm-account>
<utm-event event="view_home"></utm-event>
{% if is_granted('ROLE_ADMIN') %}
<utm-account id="{{ app.user.id }}" email="{{ app.user.email }}" name="{{ app.user.username }}"></utm-account>
{% endif %}
{% if is_granted('ROLE_CUSTOMER') and 'gestion-contrat' not in app.request.pathInfo %}
<utm-account id="{{ app.user.id }}" email="{{ app.user.email }}" name="{{ app.user.name }}"></utm-account>
{% endif %}
{# --- NAVIGATION --- #}
<nav class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">

View File

@@ -22,6 +22,7 @@
{% endblock %}
{% block body %}
<utm-event event="view_home"></utm-event>
<div class="space-y-20 pb-20 bg-gray-50/50">
{# --- SECTION HERO --- #}

View File

@@ -0,0 +1,64 @@
{% extends 'base.twig' %}
{% block title %}Signature confirmée - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-2xl shadow-xl border border-slate-100 text-center">
{# Logo #}
<div class="flex justify-center">
<img src="{{ asset('provider/images/logo.png') }}" class="h-16 w-auto" alt="Ludikevent"/>
</div>
{# Icône de succès #}
<div class="flex justify-center">
<div class="rounded-full bg-emerald-100 p-3">
<svg class="h-12 w-12 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
{# Contenu principal #}
<div class="space-y-4">
<h1 class="text-2xl font-extrabold text-slate-900">
Signature effectuée !
</h1>
<p class="text-slate-500 text-sm">
La signature numérique du contrat <span class="font-bold text-blue-600">#{{ contrat.numReservation }}</span> a été validée avec succès.
</p>
</div>
{# ALERT PAIEMENT ACOMPTE #}
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 my-6 text-left">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-bold text-amber-800">Action requise : Paiement de l'acompte</p>
<p class="text-xs text-amber-700 mt-1">
Vous disposez de <strong>3 jours</strong> pour effectuer le paiement de l'acompte afin de valider définitivement votre réservation. Passé ce délai, votre réservation sera <strong>automatiquement annulée</strong>.
</p>
</div>
</div>
</div>
<hr class="border-slate-100">
{# Footer / Bouton retour #}
<div class="pt-6">
<p class="text-xs text-slate-400 mb-6 italic">
Une copie du contrat signé a été envoyée à : {{ contrat.customer.email }}
</p>
<a href="https://reservation.ludikevent.fr{{ path('gestion_contrat_view',{num:contrat.numReservation}) }}" class="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-xl text-white bg-blue-600 hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200 w-full">
Retour a la gestion du contrat
</a>
</div>
</div>
</div>
{% endblock %}