Travail sur le tunnel de reservation etape final
This commit is contained in:
2
.env
2
.env
@@ -103,3 +103,5 @@ MAINTENANCE_ENABLED=false
|
||||
UMAMI_USER=api
|
||||
UMAMI_PASSWORD=Analytics_8962@
|
||||
CLOUDFLARE_DEPLOY=zG2jXpdDqlgZPSz7WwZSalWsEtn7-cQiNyrqaxts
|
||||
NOTIFUSE_ROOT_EMAIL="admin@example.com"
|
||||
NOTIFUSE_SECRET_KEY="your-secret-key-here"
|
||||
|
||||
98
assets/flow_reservation.js
Normal file
98
assets/flow_reservation.js
Normal file
@@ -0,0 +1,98 @@
|
||||
class ZipCodeCityUpdater {
|
||||
constructor(zipCodeInputSelector, citySelectSelector, apiUrl) {
|
||||
this.zipCodeInput = document.querySelector(zipCodeInputSelector);
|
||||
this.citySelect = document.querySelector(citySelectSelector); // Changed to citySelect
|
||||
this.apiUrl = apiUrl;
|
||||
this.timer = null;
|
||||
this.debounceTime = 500; // ms
|
||||
|
||||
// Store initial value from the select element (if any was pre-selected by Twig)
|
||||
this.initialCityValue = this.citySelect ? this.citySelect.value : '';
|
||||
|
||||
if (this.zipCodeInput && this.citySelect) {
|
||||
this.zipCodeInput.addEventListener('input', this.debounce(this.handleZipCodeChange.bind(this)));
|
||||
// Trigger on page load if a zip code is already present
|
||||
if (this.zipCodeInput.value) {
|
||||
this.handleZipCodeChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debounce(func) {
|
||||
return function(...args) {
|
||||
const context = this;
|
||||
clearTimeout(context.timer);
|
||||
context.timer = setTimeout(() => func.apply(context, args), context.debounceTime);
|
||||
};
|
||||
}
|
||||
|
||||
async handleZipCodeChange() {
|
||||
const zipCode = this.zipCodeInput.value.trim();
|
||||
|
||||
// Save current city selection if any, before clearing
|
||||
const currentlySelectedCity = this.citySelect.value;
|
||||
|
||||
// Clear existing options, but keep the default placeholder if any
|
||||
this.citySelect.innerHTML = '<option value="">Sélectionnez une ville</option>';
|
||||
this.citySelect.value = '';
|
||||
this.citySelect.disabled = true; // Disable until cities are loaded or cleared
|
||||
|
||||
if (zipCode.length !== 5 || !/^\d+$/.test(zipCode)) {
|
||||
// If zip code is invalid or empty, disable and reset select
|
||||
this.citySelect.disabled = false; // Re-enable for placeholder
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ zipCode: zipCode }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
this.citySelect.disabled = false; // Re-enable for placeholder
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.cities && data.cities.length > 0) {
|
||||
data.cities.forEach(city => {
|
||||
const option = document.createElement('option');
|
||||
option.value = city;
|
||||
option.textContent = city;
|
||||
this.citySelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Attempt to re-select the city that was previously selected, or the initial one
|
||||
if (data.cities.includes(currentlySelectedCity)) {
|
||||
this.citySelect.value = currentlySelectedCity;
|
||||
} else if (data.cities.includes(this.initialCityValue)) {
|
||||
this.citySelect.value = this.initialCityValue;
|
||||
} else if (data.cities.length === 1) {
|
||||
// Automatically select if only one city is returned and no previous match
|
||||
this.citySelect.value = data.cities[0];
|
||||
}
|
||||
this.citySelect.disabled = false; // Enable once options are loaded
|
||||
} else {
|
||||
// If no cities found, ensure select is enabled but shows only placeholder
|
||||
this.citySelect.disabled = false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching city:', error);
|
||||
this.citySelect.disabled = false; // Re-enable on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global initialization - run when the DOM is fully loaded or Turbo loads a new page
|
||||
document.addEventListener('turbo:load', () => {
|
||||
// Note: Use CSS selectors for querySelector
|
||||
new ZipCodeCityUpdater('input[name="billingZipCode"]', '#billingTownSelect', '/cities/lookup');
|
||||
new ZipCodeCityUpdater('input[name="zipCodeEvent"]', '#townEventSelect', '/cities/lookup');
|
||||
});
|
||||
@@ -5,7 +5,9 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
this.sidebarId = 'flow-reserve-sidebar';
|
||||
this.storageKey = 'pl_list';
|
||||
this.apiUrl = '/basket/json';
|
||||
this.checkAvailabilityUrl = '/produit/check/basket';
|
||||
this.isOpen = false;
|
||||
this.productsAvailable = true;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -15,10 +17,14 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
});
|
||||
|
||||
// Listen for updates to the cart from other components
|
||||
window.addEventListener('cart:updated', () => this.updateBadge());
|
||||
window.addEventListener('cart:updated', () => {
|
||||
this.updateBadge();
|
||||
this._checkProductAvailability(); // Re-check on cart update
|
||||
});
|
||||
|
||||
// Initial badge update
|
||||
this.updateBadge();
|
||||
this._checkProductAvailability();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +98,42 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
async _checkProductAvailability() {
|
||||
const ids = this.getList();
|
||||
if (ids.length === 0) {
|
||||
this.productsAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let dates = { start: null, end: null };
|
||||
try {
|
||||
dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}');
|
||||
} catch (e) {
|
||||
console.warn('Invalid reservation dates in localStorage for availability check');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.checkAvailabilityUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ids,
|
||||
start: dates.start,
|
||||
end: dates.end
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur réseau lors de la vérification de disponibilité');
|
||||
|
||||
const data = await response.json();
|
||||
this.productsAvailable = data.available;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la vérification de disponibilité:', error);
|
||||
this.productsAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
ensureSidebarExists() {
|
||||
if (document.getElementById(this.sidebarId)) return;
|
||||
|
||||
@@ -165,6 +207,23 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
|
||||
if (ids.length === 0) {
|
||||
this.renderEmpty(container, footer);
|
||||
this.productsAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Display warning if products are not available
|
||||
if (!this.productsAvailable) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-10 bg-red-100 border border-red-200 text-red-700 rounded-xl">
|
||||
<p class="font-bold mb-2">Attention :</p>
|
||||
<p>Certains produits de votre panier ne sont plus disponibles. Veuillez vérifier votre sélection.</p>
|
||||
</div>
|
||||
`;
|
||||
footer.innerHTML = `
|
||||
<button disabled class="block w-full py-4 bg-gray-300 text-gray-500 text-center rounded-2xl font-black uppercase italic tracking-widest cursor-not-allowed">
|
||||
Valider ma demande (Produits indisponibles)
|
||||
</button>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
1
public/communes-france-avec-polygon-2025.json
Normal file
1
public/communes-france-avec-polygon-2025.json
Normal file
File diff suppressed because one or more lines are too long
80388
public/simplified_communes_by_zip.json
Normal file
80388
public/simplified_communes_by_zip.json
Normal file
File diff suppressed because it is too large
Load Diff
116
src/Command/ProcessCommunesCommand.php
Normal file
116
src/Command/ProcessCommunesCommand.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:process-communes',
|
||||
description: 'Processes communes-france-avec-polygon-2025.json to create a simplified JSON file.',
|
||||
)]
|
||||
class ProcessCommunesCommand extends Command
|
||||
{
|
||||
private string $projectDir;
|
||||
|
||||
public function __construct(KernelInterface $kernel)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->projectDir = $kernel->getProjectDir();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$filesystem = new Filesystem();
|
||||
|
||||
$inputFilePath = $this->projectDir . '/public/communes-france-avec-polygon-2025.json';
|
||||
$outputFilePath = $this->projectDir . '/public/simplified_communes_by_zip.json'; // New output file name
|
||||
|
||||
if (!$filesystem->exists($inputFilePath)) {
|
||||
$io->error(sprintf('Input file not found: %s', $inputFilePath));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Reading input file: %s', $inputFilePath));
|
||||
$fileContent = file_get_contents($inputFilePath);
|
||||
|
||||
if ($fileContent === false) {
|
||||
$io->error(sprintf('Failed to read content from file: %s', $inputFilePath));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$decodedJson = json_decode($fileContent, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$io->error(sprintf('Failed to decode JSON from file: %s. Error: %s', $inputFilePath, json_last_error_msg()));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!isset($decodedJson['data']) || !is_array($decodedJson['data'])) {
|
||||
$io->error('JSON structure invalid: "data" key not found or not an array.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$simplifiedData = [];
|
||||
$processedCount = 0;
|
||||
|
||||
$io->progressStart(count($decodedJson['data']));
|
||||
|
||||
foreach ($decodedJson['data'] as $commune) {
|
||||
$io->progressAdvance();
|
||||
if (!isset($commune['codes_postaux']) || !isset($commune['nom_standard'])) {
|
||||
$io->warning(sprintf('Skipping commune due to missing "codes_postaux" or "nom_standard": %s', json_encode($commune)));
|
||||
continue;
|
||||
}
|
||||
|
||||
$city = $commune['nom_standard'];
|
||||
$zipCodes = explode(',', $commune['codes_postaux']);
|
||||
|
||||
foreach ($zipCodes as $zipCode) {
|
||||
$zipCode = trim($zipCode);
|
||||
if (!empty($zipCode)) {
|
||||
if (!isset($simplifiedData[$zipCode])) {
|
||||
$simplifiedData[$zipCode] = [];
|
||||
}
|
||||
if (!in_array($city, $simplifiedData[$zipCode])) {
|
||||
$simplifiedData[$zipCode][] = $city;
|
||||
}
|
||||
}
|
||||
}
|
||||
$processedCount++;
|
||||
}
|
||||
|
||||
$io->progressFinish();
|
||||
$io->info(sprintf('Processed %d communes.', $processedCount));
|
||||
|
||||
// Sort cities within each postal code array
|
||||
foreach ($simplifiedData as $zipCode => $cities) {
|
||||
sort($simplifiedData[$zipCode]);
|
||||
}
|
||||
// Optionally sort by zip code for consistent output
|
||||
ksort($simplifiedData);
|
||||
|
||||
|
||||
$outputJsonContent = json_encode($simplifiedData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if ($outputJsonContent === false) {
|
||||
$io->error(sprintf('Failed to encode simplified data to JSON. Error: %s', json_last_error_msg()));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($filesystem->dumpFile($outputFilePath, $outputJsonContent) === false) {
|
||||
$io->error(sprintf('Failed to write simplified JSON to file: %s', $outputFilePath));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Simplified JSON file created successfully at: %s', $outputFilePath));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,45 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
class ReserverController extends AbstractController
|
||||
{
|
||||
private KernelInterface $kernel;
|
||||
private ?array $simplifiedCommunes = null;
|
||||
|
||||
public function __construct(KernelInterface $kernel)
|
||||
{
|
||||
$this->kernel = $kernel;
|
||||
}
|
||||
|
||||
private function loadSimplifiedCommunes(): array
|
||||
{
|
||||
if ($this->simplifiedCommunes !== null) {
|
||||
return $this->simplifiedCommunes;
|
||||
}
|
||||
|
||||
$filePath = $this->kernel->getProjectDir() . '/public/simplified_communes_by_zip.json';
|
||||
if (!file_exists($filePath)) {
|
||||
// Log an error or throw an exception if the file doesn't exist
|
||||
// For now, return an empty array if file is missing
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
// Log an error or throw an exception if reading fails
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->simplifiedCommunes = json_decode($content, true);
|
||||
if ($this->simplifiedCommunes === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
// Log JSON decoding error
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->simplifiedCommunes;
|
||||
}
|
||||
#[Route('/robots.txt', name: 'robots_txt', defaults: ['_format' => 'txt'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -98,6 +134,76 @@ class ReserverController extends AbstractController
|
||||
return new JsonResponse(['dispo' => $isAvailable]);
|
||||
}
|
||||
|
||||
#[Route('/produit/check/basket', name: 'produit_check_basket', methods: ['POST'])]
|
||||
public function productCheckBasket(Request $request, ProductReserveRepository $productReserveRepository, ProductRepository $productRepository): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
$startStr = $data['start'] ?? null;
|
||||
$endStr = $data['end'] ?? null;
|
||||
|
||||
if (!is_array($ids) || empty($ids) || !$startStr || !$endStr) {
|
||||
return new JsonResponse(['available' => false, 'message' => 'Missing or invalid parameters'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$availability = $this->_checkProductsAvailability($ids, $startStr, $endStr, $productRepository, $productReserveRepository);
|
||||
|
||||
if (!$availability['allProductsAvailable']) {
|
||||
return new JsonResponse([
|
||||
'available' => false,
|
||||
'message' => 'Certains produits de votre panier ne sont pas disponibles aux dates sélectionnées.',
|
||||
'unavailable_products_ids' => $availability['unavailableProductIds']
|
||||
]);
|
||||
}
|
||||
|
||||
return new JsonResponse(['available' => true, 'message' => 'Tous les produits sont disponibles.']);
|
||||
}
|
||||
|
||||
private function _checkProductsAvailability(
|
||||
array $ids,
|
||||
?string $startStr,
|
||||
?string $endStr,
|
||||
ProductRepository $productRepository,
|
||||
ProductReserveRepository $productReserveRepository
|
||||
): array {
|
||||
$allProductsAvailable = true;
|
||||
$unavailableProductIds = [];
|
||||
|
||||
if (empty($ids) || !$startStr || !$endStr) {
|
||||
return ['allProductsAvailable' => false, 'unavailableProductIds' => $ids];
|
||||
}
|
||||
|
||||
try {
|
||||
$start = new \DateTimeImmutable($startStr);
|
||||
$end = new \DateTimeImmutable($endStr);
|
||||
} catch (\Exception $e) {
|
||||
return ['allProductsAvailable' => false, 'unavailableProductIds' => $ids];
|
||||
}
|
||||
|
||||
foreach ($ids as $productId) {
|
||||
$product = $productRepository->find($productId);
|
||||
if (!$product) {
|
||||
$allProductsAvailable = false;
|
||||
$unavailableProductIds[] = $productId;
|
||||
continue;
|
||||
}
|
||||
|
||||
$reserve = new ProductReserve();
|
||||
$reserve->setProduct($product);
|
||||
$reserve->setStartAt($start);
|
||||
$reserve->setEndAt($end);
|
||||
|
||||
$isAvailable = $productReserveRepository->checkAvailability($reserve);
|
||||
|
||||
if (!$isAvailable) {
|
||||
$allProductsAvailable = false;
|
||||
$unavailableProductIds[] = $productId;
|
||||
}
|
||||
}
|
||||
|
||||
return ['allProductsAvailable' => $allProductsAvailable, 'unavailableProductIds' => $unavailableProductIds];
|
||||
}
|
||||
|
||||
#[Route('/web-vitals', name: 'reservation_web-vitals', methods: ['POST'])]
|
||||
public function webVitals(Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
@@ -238,13 +344,87 @@ class ReserverController extends AbstractController
|
||||
AuthenticationUtils $authenticationUtils,
|
||||
OrderSessionRepository $repository,
|
||||
ProductRepository $productRepository,
|
||||
UploaderHelper $uploaderHelper
|
||||
UploaderHelper $uploaderHelper,
|
||||
ProductReserveRepository $productReserveRepository
|
||||
): Response {
|
||||
$session = $repository->findOneBy(['uuid' => $sessionId]);
|
||||
if (!$session) {
|
||||
return $this->render('revervation/session_lost.twig');
|
||||
}
|
||||
|
||||
$sessionData = $session->getProducts();
|
||||
$ids = $sessionData['ids'] ?? [];
|
||||
$startStr = $sessionData['start'] ?? null;
|
||||
$endStr = $sessionData['end'] ?? null;
|
||||
|
||||
// Check product availability
|
||||
$availability = $this->_checkProductsAvailability($ids, $startStr, $endStr, $productRepository, $productReserveRepository);
|
||||
|
||||
if (!$availability['allProductsAvailable']) {
|
||||
$this->addFlash('danger', 'Certains produits de votre panier ne sont plus disponibles. Veuillez vérifier votre réservation.');
|
||||
return $this->redirectToRoute('reservation_flow', ['sessionId' => $sessionId]);
|
||||
}
|
||||
|
||||
// Calcul de la durée
|
||||
$duration = 1;
|
||||
if ($startStr && $endStr) {
|
||||
try {
|
||||
$start = new \DateTimeImmutable($startStr);
|
||||
$end = new \DateTimeImmutable($endStr);
|
||||
if ($end >= $start) {
|
||||
$duration = $start->diff($end)->days + 1;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$duration = 1;
|
||||
}
|
||||
}
|
||||
|
||||
$products = [];
|
||||
if (!empty($ids)) {
|
||||
$products = $productRepository->findBy(['id' => $ids]);
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$totalHT = 0;
|
||||
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
|
||||
$tvaRate = $tvaEnabled ? 0.20 : 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
$price1Day = $product->getPriceDay();
|
||||
$priceSup = $product->getPriceSup() ?? 0.0;
|
||||
|
||||
// Calcul du coût total pour ce produit selon la durée
|
||||
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1));
|
||||
$productTotalTTC = $productTotalHT * (1 + $tvaRate);
|
||||
|
||||
$items[] = [
|
||||
'product' => $product,
|
||||
'image' => $uploaderHelper->asset($product, 'imageFile'),
|
||||
'price1Day' => $price1Day,
|
||||
'priceSup' => $priceSup,
|
||||
'totalPriceHT' => $productTotalHT,
|
||||
'totalPriceTTC' => $productTotalTTC,
|
||||
];
|
||||
|
||||
$totalHT += $productTotalHT;
|
||||
}
|
||||
|
||||
$totalTva = $totalHT * $tvaRate;
|
||||
$totalTTC = $totalHT + $totalTva;
|
||||
|
||||
return $this->render('revervation/flow_confirmed.twig', [
|
||||
'session' => $session,
|
||||
'cart' => [
|
||||
'items' => $items,
|
||||
'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null,
|
||||
'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null,
|
||||
'duration' => $duration,
|
||||
'totalHT' => $totalHT,
|
||||
'totalTva' => $totalTva,
|
||||
'totalTTC' => $totalTTC,
|
||||
'tvaEnabled' => $tvaEnabled,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/flow/{sessionId}', name: 'reservation_flow', methods: ['GET', 'POST'])]
|
||||
@@ -253,7 +433,8 @@ class ReserverController extends AbstractController
|
||||
AuthenticationUtils $authenticationUtils,
|
||||
OrderSessionRepository $repository,
|
||||
ProductRepository $productRepository,
|
||||
UploaderHelper $uploaderHelper
|
||||
UploaderHelper $uploaderHelper,
|
||||
ProductReserveRepository $productReserveRepository // Added dependency
|
||||
): Response {
|
||||
// This is the POST target for the login form, but also the GET page.
|
||||
// The authenticator handles the POST. For GET, we just render the page.
|
||||
@@ -267,6 +448,14 @@ class ReserverController extends AbstractController
|
||||
$startStr = $sessionData['start'] ?? null;
|
||||
$endStr = $sessionData['end'] ?? null;
|
||||
|
||||
// Check product availability
|
||||
$availability = $this->_checkProductsAvailability($ids, $startStr, $endStr, $productRepository, $productReserveRepository);
|
||||
|
||||
if (!$availability['allProductsAvailable']) {
|
||||
$this->addFlash('danger', 'Certains produits de votre panier ne sont plus disponibles. Veuillez vérifier votre sélection.');
|
||||
return $this->redirectToRoute('reservation');
|
||||
}
|
||||
|
||||
// Calcul de la durée
|
||||
$duration = 1;
|
||||
if ($startStr && $endStr) {
|
||||
@@ -682,6 +871,26 @@ class ReserverController extends AbstractController
|
||||
return $this->render('revervation/legal.twig');
|
||||
}
|
||||
|
||||
#[Route('/cities/lookup', name: 'api_cities_lookup', methods: ['POST'])]
|
||||
public function getCityByZipCode(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$zipCode = $data['zipCode'] ?? null;
|
||||
|
||||
if (!$zipCode) {
|
||||
return new JsonResponse(['error' => 'Missing zipCode parameter'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$simplifiedCommunes = $this->loadSimplifiedCommunes();
|
||||
$cities = $simplifiedCommunes[$zipCode] ?? [];
|
||||
|
||||
if (!empty($cities)) {
|
||||
return new JsonResponse(['cities' => $cities]);
|
||||
}
|
||||
|
||||
return new JsonResponse(['cities' => [], 'message' => 'City not found for this zip code'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
#[Route('/rgpd', name: 'reservation_rgpd')]
|
||||
public function revervationRgpd(): Response
|
||||
{
|
||||
|
||||
162
src/Service/NotifuseClient.php
Normal file
162
src/Service/NotifuseClient.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class NotifuseClient
|
||||
{
|
||||
private const BASE_URI = 'https://notifuse.esy-web.dev/';
|
||||
private const NOTIFICATION_ENDPOINT = 'api/notifications';
|
||||
private const LOGIN_ENDPOINT = 'api/user.rootSignin'; // New endpoint
|
||||
|
||||
private HttpClientInterface $httpClient;
|
||||
private LoggerInterface $logger;
|
||||
private ?string $rootEmail; // For login
|
||||
private ?string $rootSecretKey; // For signing login request
|
||||
private ?string $authToken = null; // Stored after successful login
|
||||
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
LoggerInterface $logger,
|
||||
string $rootEmail = null, // From env
|
||||
string $rootSecretKey = null // From env
|
||||
) {
|
||||
$this->httpClient = $httpClient;
|
||||
$this->logger = $logger;
|
||||
$this->rootEmail = $rootEmail;
|
||||
$this->rootSecretKey = $rootSecretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to log in to the Notifuse API and obtain an authentication token.
|
||||
*
|
||||
* @return bool True if login is successful and a token is obtained, false otherwise.
|
||||
*/
|
||||
public function login(): bool
|
||||
{
|
||||
if ($this->authToken !== null) {
|
||||
// Already logged in, no need to log in again
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->rootEmail === null || $this->rootSecretKey === null) {
|
||||
$this->logger->error('Notifuse root email or secret key is not configured for login.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$timestamp = time(); // Current Unix timestamp
|
||||
$messageToSign = "{$this->rootEmail}:{$timestamp}";
|
||||
$signature = hash_hmac('sha256', $messageToSign, $this->rootSecretKey);
|
||||
|
||||
$payload = [
|
||||
'email' => $this->rootEmail,
|
||||
'timestamp' => $timestamp,
|
||||
'signature' => $signature,
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', self::BASE_URI . self::LOGIN_ENDPOINT, [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$content = $response->toArray(false);
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300 && isset($content['token'])) {
|
||||
$this->authToken = $content['token'];
|
||||
$this->logger->info('Successfully logged into Notifuse API.');
|
||||
return true;
|
||||
} else {
|
||||
$this->logger->error('Notifuse login failed.', [
|
||||
'statusCode' => $statusCode,
|
||||
'response' => $content,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Exception during Notifuse login: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification via the Notifuse API.
|
||||
* It will attempt to log in if not already authenticated.
|
||||
*
|
||||
* @param string $recipient The recipient's identifier (e.g., email, phone number).
|
||||
* @param string $subject The subject of the notification.
|
||||
* @param string $message The content of the notification.
|
||||
* @param string $type The type of notification (e.g., 'email', 'sms').
|
||||
* @param array $additionalData Additional data to send with the notification.
|
||||
* @return bool True if the notification was sent successfully, false otherwise.
|
||||
*/
|
||||
public function sendNotification(
|
||||
string $recipient,
|
||||
string $subject,
|
||||
string $message,
|
||||
string $type,
|
||||
array $additionalData = []
|
||||
): bool {
|
||||
// Ensure authentication token is available
|
||||
if ($this->authToken === null && !$this->login()) {
|
||||
$this->logger->error('Cannot send Notifuse notification: failed to authenticate.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'recipient' => $recipient,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'type' => $type,
|
||||
];
|
||||
|
||||
if (!empty($additionalData)) {
|
||||
$payload = array_merge($payload, $additionalData);
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->authToken, // Use the obtained token
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('POST', self::BASE_URI . self::NOTIFICATION_ENDPOINT, [
|
||||
'headers' => $headers,
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$content = $response->toArray(false);
|
||||
|
||||
if ($statusCode >= 200 && $statusCode < 300) {
|
||||
$this->logger->info('Notifuse notification sent successfully.', [
|
||||
'recipient' => $recipient,
|
||||
'type' => $type,
|
||||
'statusCode' => $statusCode,
|
||||
'response' => $content
|
||||
]);
|
||||
return true;
|
||||
} else {
|
||||
$this->logger->error('Failed to send Notifuse notification.', [
|
||||
'recipient' => $recipient,
|
||||
'type' => $type,
|
||||
'statusCode' => $statusCode,
|
||||
'response' => $content
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Exception while sending Notifuse notification: ' . $e->getMessage(), [
|
||||
'recipient' => $recipient,
|
||||
'type' => $type,
|
||||
'exception' => $e
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,11 +188,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase tracking-wide mb-2">Ville</label>
|
||||
<input type="text" name="billingTown" value="{{ session.billingTown }}" required
|
||||
class="block w-full rounded-2xl border-slate-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-3 px-4 bg-slate-50 focus:bg-white transition-all placeholder-slate-400"
|
||||
placeholder="Paris">
|
||||
</div>
|
||||
</div>
|
||||
<select name="billingTown" id="billingTownSelect" required
|
||||
class="block w-full rounded-2xl border-slate-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-3 px-4 bg-slate-50 focus:bg-white transition-all">
|
||||
{% if session.billingTown %}
|
||||
<option value="{{ session.billingTown }}" selected>{{ session.billingTown }}</option>
|
||||
{% else %}
|
||||
<option value="">Sélectionnez une ville</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div> </div>
|
||||
</div>
|
||||
|
||||
{# Event Address #}
|
||||
@@ -222,10 +226,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase tracking-wide mb-2">Ville</label>
|
||||
<input type="text" name="townEvent" value="{{ session.townEvent }}" required
|
||||
class="block w-full rounded-2xl border-slate-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-3 px-4 bg-slate-50 focus:bg-white transition-all placeholder-slate-400">
|
||||
</div>
|
||||
</div>
|
||||
<select name="townEvent" id="townEventSelect" required
|
||||
class="block w-full rounded-2xl border-slate-200 shadow-sm focus:border-blue-500 focus:ring-blue-500 py-3 px-4 bg-slate-50 focus:bg-white transition-all">
|
||||
{% if session.townEvent %}
|
||||
<option value="{{ session.townEvent }}" selected>{{ session.townEvent }}</option>
|
||||
{% else %}
|
||||
<option value="">Sélectionnez une ville</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div> </div>
|
||||
</div>
|
||||
|
||||
{# Event Details #}
|
||||
@@ -296,3 +305,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ vite_asset('flow_reservation.js',{}) }}
|
||||
{% endblock %}
|
||||
|
||||
229
templates/revervation/flow_confirmed.twig
Normal file
229
templates/revervation/flow_confirmed.twig
Normal file
@@ -0,0 +1,229 @@
|
||||
|
||||
{% extends 'revervation/base.twig' %}
|
||||
|
||||
{% block title %}Confirmation de votre demande{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="max-w-7xl mx-auto px-4 py-12">
|
||||
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
|
||||
<h1 class="text-3xl font-black text-slate-900 uppercase italic mb-8 text-center">Confirmation de votre demande</h1>
|
||||
|
||||
{# --- CART DETAILS --- #}
|
||||
{% if cart is defined %}
|
||||
<div class="bg-slate-50 rounded-2xl p-6 border border-slate-100 mb-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="bg-indigo-100 p-3 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900">Détails de la réservation</h3>
|
||||
{% if cart.startDate and cart.endDate %}
|
||||
<p class="text-sm text-slate-600">Du <span class="font-medium text-slate-900">{{ cart.startDate|date('d/m/Y') }}</span> au <span class="font-medium text-slate-900">{{ cart.endDate|date('d/m/Y') }}</span> ({{ cart.duration }} jours)</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for item in cart.items %}
|
||||
<div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||||
{% if item.image %}
|
||||
<img src="{{ item.image }}" alt="{{ item.product.name }}" class="h-16 w-16 object-cover rounded-lg mr-4 bg-slate-100">
|
||||
{% else %}
|
||||
<div class="h-16 w-16 bg-slate-100 rounded-lg mr-4 flex items-center justify-center text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold text-slate-800">{{ item.product.name }}</h4>
|
||||
<p class="text-xs text-slate-500 line-clamp-1 mb-2">{{ item.product.description }}</p>
|
||||
|
||||
<div class="text-xs text-slate-600 bg-slate-50 p-2 rounded-lg border border-slate-100 inline-block">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span>1er jour : <strong class="text-slate-800">{{ item.price1Day|number_format(2, ',', ' ') }} €</strong></span>
|
||||
{% if cart.duration > 1 %}
|
||||
<span class="text-slate-300">|</span>
|
||||
<span>Jours supp. : <strong class="text-slate-800">{{ item.priceSup|number_format(2, ',', ' ') }} €</strong> <span class="text-slate-400">x {{ cart.duration - 1 }}</span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-slate-900">{{ item.totalPriceHT|number_format(2, ',', ' ') }} € HT</p>
|
||||
{% if cart.tvaEnabled %}
|
||||
<p class="text-xs text-slate-500">{{ item.totalPriceTTC|number_format(2, ',', ' ') }} € TTC</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-slate-500 py-4">Aucun produit sélectionné.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 border-t border-slate-200 pt-4 space-y-2">
|
||||
<div class="flex justify-between text-sm text-slate-600">
|
||||
<span>Total HT</span>
|
||||
<span class="font-medium">{{ cart.totalHT|number_format(2, ',', ' ') }} €</span>
|
||||
</div>
|
||||
{% if cart.tvaEnabled %}
|
||||
<div class="flex justify-between text-sm text-slate-600">
|
||||
<span>TVA (20%)</span>
|
||||
<span class="font-medium">{{ cart.totalTva|number_format(2, ',', ' ') }} €</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-lg font-black text-slate-900 pt-2 border-t border-slate-200 mt-2">
|
||||
<span>Total TTC</span>
|
||||
<span>{{ cart.totalTTC|number_format(2, ',', ' ') }} €</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-slate-50 rounded-2xl p-6 border border-slate-100 flex flex-col md:flex-row items-center md:items-start gap-6 mb-6">
|
||||
<div class="bg-blue-100 p-4 rounded-full flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 w-full text-center md:text-left">
|
||||
<h3 class="text-lg font-bold text-slate-900 mb-4">Informations Client</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-white p-3 rounded-xl border border-slate-200 shadow-sm">
|
||||
<span class="block text-xs text-slate-500 uppercase font-semibold tracking-wider">Nom complet</span>
|
||||
<span class="block text-slate-800 font-medium mt-1">{{ session.customer.name }} {{ session.customer.surname }}</span>
|
||||
</div>
|
||||
<div class="bg-white p-3 rounded-xl border border-slate-200 shadow-sm">
|
||||
<span class="block text-xs text-slate-500 uppercase font-semibold tracking-wider">Téléphone</span>
|
||||
<span class="block text-slate-800 font-medium mt-1">{{ session.customer.phone }}</span>
|
||||
</div>
|
||||
<div class="bg-white p-3 rounded-xl border border-slate-200 shadow-sm md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase font-semibold tracking-wider">Email</span>
|
||||
<span class="block text-slate-800 font-medium mt-1">{{ session.customer.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm mb-6">
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-6 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
Informations de l'événement
|
||||
</h3>
|
||||
|
||||
{# Billing Address #}
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-6 pb-2 border-b border-slate-100">
|
||||
<span class="bg-blue-100 text-blue-600 p-2 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
|
||||
</span>
|
||||
<h4 class="font-bold text-lg text-slate-900">Adresse de facturation</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Adresse complète</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.billingAddress }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Code Postal</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.billingZipCode }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Ville</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.billingTown }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Event Address #}
|
||||
<div class="mt-8">
|
||||
<div class="flex items-center gap-3 mb-6 pb-2 border-b border-slate-100">
|
||||
<span class="bg-blue-100 text-blue-600 p-2 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</span>
|
||||
<h4 class="font-bold text-lg text-slate-900">Lieu de l'événement</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Adresse de l'événement</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.adressEvent }}</span>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Complément d'adresse</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.adress2Event }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Code Postal</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.zipCodeEvent }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Ville</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.townEvent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Event Details #}
|
||||
<div class="mt-8">
|
||||
<div class="flex items-center gap-3 mb-6 pb-2 border-b border-slate-100">
|
||||
<span class="bg-blue-100 text-blue-600 p-2 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
</span>
|
||||
<h4 class="font-bold text-lg text-slate-900">Détails techniques</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Type d'événement</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.type }}</span>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Détails supplémentaires</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.details }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Type de sol</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.typeSol }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Pente</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.pente }}</span>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Accès (largeur portail, escaliers...)</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.access }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-xs text-slate-500 uppercase tracking-wide mb-2">Distance prise électrique (m)</span>
|
||||
<span class="block w-full rounded-2xl border-slate-200 shadow-sm py-3 px-4 bg-slate-50">{{ session.distancePower }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-6" role="alert">
|
||||
<p class="font-bold">Information importante sur la livraison</p>
|
||||
<p>Pour la livraison, des frais peuvent s'appliquer selon l'endroit et le type de structure réservée. Une fois que vous avez reconfirmé votre réservation, nos équipes vous retourneront un devis complet avec les frais de livraison si vous prévoyez cette option.</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 border-t border-slate-100 mt-8 flex flex-col md:flex-row justify-center gap-4">
|
||||
<a href="#" class="w-full md:w-auto px-8 py-4 bg-gray-200 text-gray-800 font-bold rounded-2xl shadow-sm hover:shadow-md hover:scale-[1.01] transition-all flex items-center justify-center gap-2 text-lg">
|
||||
Télécharger le devis
|
||||
</a>
|
||||
<a href="#" class="w-full md:w-auto px-8 py-4 bg-green-500 text-white font-bold rounded-2xl shadow-lg shadow-green-200 hover:shadow-xl hover:scale-[1.02] transition-all flex items-center justify-center gap-2 text-lg">
|
||||
Prendre les options de livraison
|
||||
</a>
|
||||
<button type="submit" class="w-full md:w-auto px-8 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white font-bold rounded-2xl shadow-lg shadow-blue-200 hover:shadow-xl hover:scale-[1.02] transition-all flex items-center justify-center gap-2 text-lg">
|
||||
Je confirme la commande
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -45,6 +45,7 @@ export default defineConfig({
|
||||
admin: resolve(__dirname, 'assets/admin.js'),
|
||||
error: resolve(__dirname, 'assets/error.js'),
|
||||
reserve: resolve(__dirname, 'assets/reserve.js'),
|
||||
flow_reservation: resolve(__dirname, 'assets/flow_reservation.js'),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user