From 900b55c07be40663b5651fc931b8b3871012c246 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 4 Feb 2026 11:58:07 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(ReserverController):=20G?= =?UTF-8?q?=C3=A8re=20les=20options=20de=20produits=20au=20panier=20et=20e?= =?UTF-8?q?n=20session.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la gestion des options de produits lors de l'ajout au panier et dans la session de réservation. Inclut des corrections pour les options orphelines. ``` --- assets/tools/FlowAddToCart.js | 23 +- assets/tools/FlowReserve.js | 42 +++- src/Controller/ReserverController.php | 270 +++++++++++++++++++--- src/Entity/OrderSession.php | 16 ++ templates/revervation/base.twig | 2 + templates/revervation/flow_confirmed.twig | 35 ++- templates/revervation/produit.twig | 19 +- update.sh | 9 +- 8 files changed, 361 insertions(+), 55 deletions(-) diff --git a/assets/tools/FlowAddToCart.js b/assets/tools/FlowAddToCart.js index 3c84a81..596b88e 100644 --- a/assets/tools/FlowAddToCart.js +++ b/assets/tools/FlowAddToCart.js @@ -43,15 +43,30 @@ export class FlowAddToCart extends HTMLElement { if (!response.ok) throw new Error('Network error'); const data = await response.json(); - if (data.dispo) { - // 4. Add to Cart - const list = JSON.parse(localStorage.getItem('pl_list') || '[]'); + if (data.dispo) { + // 4. Add to Cart + const list = JSON.parse(localStorage.getItem('pl_list') || '[]'); + + // --- Save Options --- + const selectedOptions = Array.from(document.querySelectorAll('.product-option-checkbox:checked')) + .map(cb => cb.value); + + const allOptions = JSON.parse(localStorage.getItem('pl_options') || '{}'); console.log(selectedOptions); + if (selectedOptions.length > 0) { + allOptions[this.productId] = selectedOptions; + } else { + delete allOptions[this.productId]; + } + + localStorage.setItem('pl_options', JSON.stringify(allOptions)); + // -------------------- + if (!list.includes(this.productId)) { list.push(this.productId); localStorage.setItem('pl_list', JSON.stringify(list)); window.dispatchEvent(new CustomEvent('cart:updated')); } - + // Open Cart const cart = document.querySelector('[is="flow-reserve"]'); if (cart) cart.open(); diff --git a/assets/tools/FlowReserve.js b/assets/tools/FlowReserve.js index b610170..4a9bd35 100644 --- a/assets/tools/FlowReserve.js +++ b/assets/tools/FlowReserve.js @@ -51,10 +51,26 @@ export class FlowReserve extends HTMLAnchorElement { } } + getOptions() { + try { + return JSON.parse(localStorage.getItem('pl_options') || '{}'); + } catch (e) { + return {}; + } + } + removeFromList(id) { let list = this.getList(); list = list.filter(itemId => itemId.toString() !== id.toString()); localStorage.setItem(this.storageKey, JSON.stringify(list)); + + // Remove options for this product + const options = this.getOptions(); + if (options[id]) { + delete options[id]; + localStorage.setItem('pl_options', JSON.stringify(options)); + } + window.dispatchEvent(new CustomEvent('cart:updated')); this.refreshContent(); // Re-fetch and render } @@ -211,6 +227,7 @@ export class FlowReserve extends HTMLAnchorElement { footer.innerHTML = ''; const ids = this.getList(); + const options = this.getOptions(); // Retrieve dates from localStorage let dates = { start: null, end: null }; @@ -248,6 +265,7 @@ export class FlowReserve extends HTMLAnchorElement { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, + options, start: dates.start, end: dates.end }) @@ -323,8 +341,22 @@ export class FlowReserve extends HTMLAnchorElement { `; } - // --- RENDER PRODUCTS --- - const productsHtml = data.products.map(product => ` + const productsHtml = data.products.map(product => { + let optionsHtml = ''; + if (product.options && product.options.length > 0) { + optionsHtml = '
'; + product.options.forEach(opt => { + optionsHtml += ` +
+ + ${opt.name} + ${this.formatPrice(opt.price)} +
+ `; + }); + optionsHtml += '
'; + } + + return `
${product.name} @@ -336,6 +368,7 @@ export class FlowReserve extends HTMLAnchorElement { 1J: ${this.formatPrice(product.priceHt1Day)} HT ${product.priceHTSupDay ? `|Sup: ${this.formatPrice(product.priceHTSupDay)} HT` : ''}
+ ${optionsHtml}
${this.formatPrice(product.totalPriceTTC || product.totalPriceHT)} Total @@ -345,7 +378,8 @@ export class FlowReserve extends HTMLAnchorElement {
- `).join(''); + `; + }).join(''); container.innerHTML = `
${datesHtml}${productsHtml}
`; @@ -396,6 +430,7 @@ export class FlowReserve extends HTMLAnchorElement { e.preventDefault(); const ids = this.getList(); + const options = this.getOptions(); let dates = { start: null, end: null }; try { dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}'); @@ -409,6 +444,7 @@ export class FlowReserve extends HTMLAnchorElement { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids, + options, start: dates.start, end: dates.end }) diff --git a/src/Controller/ReserverController.php b/src/Controller/ReserverController.php index 4daa221..866b280 100644 --- a/src/Controller/ReserverController.php +++ b/src/Controller/ReserverController.php @@ -33,6 +33,7 @@ use App\Service\Pdf\DevisPdfService; use App\Entity\Devis; use App\Entity\DevisLine; use App\Entity\CustomerAddress; +use Symfony\Contracts\HttpClient\HttpClientInterface; class ReserverController extends AbstractController { @@ -318,10 +319,11 @@ class ReserverController extends AbstractController } #[Route('/basket/json', name: 'reservation_basket_json', methods: ['POST'])] - public function basketJson(Request $request, ProductRepository $productRepository, UploaderHelper $uploaderHelper): Response + public function basketJson(Request $request, ProductRepository $productRepository, \App\Repository\OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper): Response { $data = json_decode($request->getContent(), true); $ids = $data['ids'] ?? []; + $selectedOptionsMap = $data['options'] ?? []; $startStr = $data['start'] ?? null; $endStr = $data['end'] ?? null; @@ -347,49 +349,108 @@ class ReserverController extends AbstractController $foundIds = array_map(fn($p) => $p->getId(), $products); $removedIds = array_values(array_diff($ids, $foundIds)); - $items = []; - $totalHT = 0; - $tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true"; - $tvaRate = $tvaEnabled ? 0.20 : 0; + $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; + $rootOptions = []; + $processedProductIds = []; - // Calcul du coût total pour ce produit selon la durée - $productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1)); - $productTotalTTC = $productTotalHT * (1 + $tvaRate); + foreach ($products as $product) { + $processedProductIds[] = $product->getId(); + $price1Day = $product->getPriceDay(); + $priceSup = $product->getPriceSup() ?? 0.0; - $items[] = [ - 'id' => $product->getId(), - 'name' => $product->getName(), - 'image' => $uploaderHelper->asset($product, 'imageFile'), - 'priceHt1Day' => $price1Day, - 'priceHTSupDay' => $priceSup, - 'priceTTC1Day' => $price1Day * (1 + $tvaRate), - 'totalPriceHT' => $productTotalHT, - 'totalPriceTTC' => $productTotalTTC, - ]; + // Calcul du coût total pour ce produit selon la durée + $productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1)); - $totalHT += $productTotalHT; - } + // Traitement des options + $productOptions = []; + $optionsTotalHT = 0; - $totalTva = $totalHT * $tvaRate; - $totalTTC = $totalHT + $totalTva; + if (isset($selectedOptionsMap[$product->getId()])) { + $optionIds = $selectedOptionsMap[$product->getId()]; + if (!empty($optionIds)) { + $optionsEntities = $optionsRepository->findBy(['id' => $optionIds]); + foreach ($optionsEntities as $option) { + $optPrice = $option->getPriceHt(); + $optData = [ + 'id' => $option->getId(), + 'name' => $option->getName(), + 'price' => $optPrice + ]; - return new JsonResponse([ - 'start_date' => $startStr, - 'end_date' => $endStr, - 'products' => $items, - 'unavailable_products_ids' => $removedIds, - 'total' => [ - 'totalHT' => $totalHT, - 'totalTva' => $totalTva, - 'totalTTC' => $totalTTC - ] - ]); - } + // Vérifier si l'option est réellement liée au produit + if ($product->getOptions()->contains($option)) { + $productOptions[] = $optData; + $optionsTotalHT += $optPrice; + } else { + // Option demandée mais pas liée au produit -> Root options + $rootOptions[] = $optData; + // On ajoute quand même le prix ? Le user a dit "options non lier". + // Généralement si c'est "non lié", c'est une erreur ou une option globale. + // On l'ajoute au root mais pas au total du produit. + // Faut-il l'ajouter au total général ? + // Supposons que oui, c'est un "extra". + $totalHT += $optPrice; + } + } + } + } + $productTotalHT += $optionsTotalHT; + $productTotalTTC = $productTotalHT * (1 + $tvaRate); + + $items[] = [ + 'id' => $product->getId(), + 'name' => $product->getName(), + 'image' => $uploaderHelper->asset($product, 'imageFile'), + 'priceHt1Day' => $price1Day, + 'priceHTSupDay' => $priceSup, + 'priceTTC1Day' => $price1Day * (1 + $tvaRate), + 'totalPriceHT' => $productTotalHT, + 'totalPriceTTC' => $productTotalTTC, + 'options' => $productOptions + ]; + + $totalHT += $productTotalHT; + } + + // Traiter les options pour les produits qui ne sont PAS dans le panier (orphelins de produit) + foreach ($selectedOptionsMap as $prodId => $optIds) { + // Si le produit n'a pas été traité (absent de $products car pas dans $ids ou pas trouvé) + if (!in_array($prodId, $processedProductIds) && !empty($optIds)) { + $optionsEntities = $optionsRepository->findBy(['id' => $optIds]); + foreach ($optionsEntities as $option) { + $optPrice = $option->getPriceHt(); + $rootOptions[] = [ + 'id' => $option->getId(), + 'name' => $option->getName(), + 'price' => $optPrice, + 'orphan_product_id' => $prodId // Info debug + ]; + $totalHT += $optPrice; + } + } + } + + $totalTva = $totalHT * $tvaRate; + $totalTTC = $totalHT + $totalTva; + + return new JsonResponse([ + 'start_date' => $startStr, + 'end_date' => $endStr, + 'products' => $items, + 'options' => $rootOptions, + 'unavailable_products_ids' => $removedIds, + 'total' => [ + 'totalHT' => $totalHT, + 'totalTva' => $totalTva, + 'totalTTC' => $totalTTC + ] + ]); + } #[Route('/session', name: 'reservation_session_create', methods: ['POST'])] public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository): Response { @@ -432,7 +493,8 @@ class ReserverController extends AbstractController ProductRepository $productRepository, UploaderHelper $uploaderHelper, ProductReserveRepository $productReserveRepository, - EntityManagerInterface $em + EntityManagerInterface $em, + \App\Repository\OptionsRepository $optionsRepository ): Response { $session = $repository->findOneBy(['uuid' => $sessionId]); if (!$session) { @@ -441,6 +503,7 @@ class ReserverController extends AbstractController $sessionData = $session->getProducts(); $ids = $sessionData['ids'] ?? []; + $selectedOptionsMap = $sessionData['options'] ?? []; $startStr = $sessionData['start'] ?? null; $endStr = $sessionData['end'] ?? null; @@ -483,13 +546,46 @@ class ReserverController extends AbstractController $totalHT = 0; $tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true"; $tvaRate = $tvaEnabled ? 0.20 : 0; + + $rootOptions = []; + $processedProductIds = []; foreach ($products as $product) { + $processedProductIds[] = $product->getId(); $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)); + + // Traitement des options + $productOptions = []; + $optionsTotalHT = 0; + + if (isset($selectedOptionsMap[$product->getId()])) { + $optionIds = $selectedOptionsMap[$product->getId()]; + if (!empty($optionIds)) { + $optionsEntities = $optionsRepository->findBy(['id' => $optionIds]); + foreach ($optionsEntities as $option) { + $optPrice = $option->getPriceHt(); + $optData = [ + 'id' => $option->getId(), + 'name' => $option->getName(), + 'price' => $optPrice + ]; + + if ($product->getOptions()->contains($option)) { + $productOptions[] = $optData; + $optionsTotalHT += $optPrice; + } else { + $rootOptions[] = $optData; + $totalHT += $optPrice; + } + } + } + } + + $productTotalHT += $optionsTotalHT; $productTotalTTC = $productTotalHT * (1 + $tvaRate); $items[] = [ @@ -499,10 +595,27 @@ class ReserverController extends AbstractController 'priceSup' => $priceSup, 'totalPriceHT' => $productTotalHT, 'totalPriceTTC' => $productTotalTTC, + 'options' => $productOptions ]; $totalHT += $productTotalHT; } + + // Traiter les options orphelines + foreach ($selectedOptionsMap as $prodId => $optIds) { + if (!in_array($prodId, $processedProductIds) && !empty($optIds)) { + $optionsEntities = $optionsRepository->findBy(['id' => $optIds]); + foreach ($optionsEntities as $option) { + $optPrice = $option->getPriceHt(); + $rootOptions[] = [ + 'id' => $option->getId(), + 'name' => $option->getName(), + 'price' => $optPrice + ]; + $totalHT += $optPrice; + } + } + } $totalTva = $totalHT * $tvaRate; $totalTTC = $totalHT + $totalTva; @@ -511,6 +624,7 @@ class ReserverController extends AbstractController 'session' => $session, 'cart' => [ 'items' => $items, + 'options' => $rootOptions, 'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null, 'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null, 'duration' => $duration, @@ -1018,4 +1132,84 @@ class ReserverController extends AbstractController { return $this->render('revervation/hosting.twig'); } + + #[Route('/estimer-la-livraison', name: 'reservation_estimate_delivery')] + public function estimateDelivery(Request $request, HttpClientInterface $client): Response + { + $form = $this->createFormBuilder() + ->add('address', TextType::class, ['required' => true]) + ->add('zipCode', TextType::class, ['required' => true]) + ->add('city', TextType::class, ['required' => true]) + ->getForm(); + + $form->handleRequest($request); + $estimation = null; + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + $query = sprintf('%s %s %s', $data['address'], $data['zipCode'], $data['city']); + + try { + $response = $client->request('GET', 'https://api-adresse.data.gouv.fr/search/', [ + 'query' => [ + 'q' => $query, + 'limit' => 1 + ] + ]); + + $content = $response->toArray(); + + if (!empty($content['features'])) { + $coords = $content['features'][0]['geometry']['coordinates']; + $lon = $coords[0]; + $lat = $coords[1]; + + // Point de départ + $startLat = 49.849; + $startLon = 3.286; + + // Formule de Haversine + $earthRadius = 6371; // km + + $dLat = deg2rad($lat - $startLat); + $dLon = deg2rad($lon - $startLon); + + $a = sin($dLat / 2) * sin($dLat / 2) + + cos(deg2rad($startLat)) * cos(deg2rad($lat)) * + sin($dLon / 2) * sin($dLon / 2); + + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + $distance = $earthRadius * $c; + $rate = 0.50; + $trips = 4; + + if ($distance <= 10) { + $estimation = 0.0; + $chargedDistance = 0.0; + } else { + $chargedDistance = $distance - 10; + $estimation = ($chargedDistance * $trips) * $rate; + } + + $details = [ + 'distance' => $distance, + 'chargedDistance' => $chargedDistance, + 'trips' => $trips, + 'rate' => $rate, + 'isFree' => ($distance <= 10) + ]; + } else { + $this->addFlash('warning', 'Adresse introuvable.'); + } + } catch (\Exception $e) { + $this->addFlash('error', 'Erreur lors du calcul de l\'itinéraire.'); + } + } + + return $this->render('revervation/estimate_delivery.twig', [ + 'form' => $form->createView(), + 'estimation' => $estimation, + 'details' => $details ?? null + ]); + } } diff --git a/src/Entity/OrderSession.php b/src/Entity/OrderSession.php index 5aa6952..d4f5c9f 100644 --- a/src/Entity/OrderSession.php +++ b/src/Entity/OrderSession.php @@ -21,6 +21,9 @@ class OrderSession #[ORM\Column(type: Types::JSON)] private array $products = []; + #[ORM\Column(type: Types::JSON, nullable: true)] + private array $options = []; + #[ORM\ManyToOne(inversedBy: 'orderSessions')] private ?Customer $customer = null; @@ -79,6 +82,7 @@ class OrderSession { $this->createdAt = new \DateTimeImmutable(); $this->products = []; + $this->options = []; $this->state = 'created'; } @@ -135,6 +139,18 @@ class OrderSession return $this; } + public function getOptions(): array + { + return $this->options; + } + + public function setOptions(?array $options): static + { + $this->options = $options; + + return $this; + } + public function getCustomer(): ?Customer { return $this->customer; diff --git a/templates/revervation/base.twig b/templates/revervation/base.twig index f0dcc1e..53ed81c 100644 --- a/templates/revervation/base.twig +++ b/templates/revervation/base.twig @@ -92,6 +92,7 @@ {{ macros.nav_link('reservation_formules', 'nav.packages') }} {{ macros.nav_link('/images/Catalogue.pdf', 'nav.pdf', true) }} {{ macros.nav_link('reservation_workflow', 'nav.how_to_book') }} + {{ macros.nav_link('reservation_estimate_delivery', 'Estimer la livraison') }} {{ macros.nav_link('reservation_contact', 'nav.contact') }} @@ -148,6 +149,7 @@ {{ macros.mobile_nav_link('reservation_formules', 'Nos Formules') }} {{ macros.mobile_nav_link('/provider/Catalogue.pdf', 'Catalogue', true) }} {{ macros.mobile_nav_link('reservation_workflow', 'Comment reserver') }} + {{ macros.mobile_nav_link('reservation_estimate_delivery', 'Estimer la livraison') }} {{ macros.mobile_nav_link('reservation_search', 'Rechercher') }} Panier diff --git a/templates/revervation/flow_confirmed.twig b/templates/revervation/flow_confirmed.twig index d04d5ed..a12d93a 100644 --- a/templates/revervation/flow_confirmed.twig +++ b/templates/revervation/flow_confirmed.twig @@ -39,7 +39,9 @@ {% endif %}

{{ item.product.name }}

-

{{ item.product.description }}

+
+ {{ item.product.description|raw }} +
@@ -50,6 +52,19 @@ {% endif %}
+ + {# Linked Options #} + {% if item.options is defined and item.options|length > 0 %} +
+ {% for opt in item.options %} +
+ Option + {{ opt.name }} + + {{ opt.price|number_format(2, ',', ' ') }} € +
+ {% endfor %} +
+ {% endif %}

{{ item.totalPriceHT|number_format(2, ',', ' ') }} € HT

@@ -61,6 +76,24 @@ {% else %}

Aucun produit sélectionné.

{% endfor %} + + {# Orphan Options #} + {% if cart.options is defined and cart.options|length > 0 %} +
+

Autres options

+ {% for opt in cart.options %} +
+
+
+ +
+ {{ opt.name }} +
+ {{ opt.price|number_format(2, ',', ' ') }} € HT +
+ {% endfor %} +
+ {% endif %}
diff --git a/templates/revervation/produit.twig b/templates/revervation/produit.twig index 8422c58..f8facd0 100644 --- a/templates/revervation/produit.twig +++ b/templates/revervation/produit.twig @@ -146,7 +146,9 @@
{% for option in product.options %} {% if option.isPublish %} -
+
-
-

{{ option.name }}

+
+

{{ option.name }}

{% if tvaEnabled %} - + {{ (option.priceHt*1.20)|format_currency('EUR') }} TTC + + {{ (option.priceHt*1.20)|format_currency('EUR') }} TTC {% else %} - + {{ option.priceHt|format_currency('EUR') }} + + {{ option.priceHt|format_currency('EUR') }} {% endif %}
-
+ + {# Checkmark Icon visible when checked #} +
+ +
+ {% endif %} {% endfor %}
diff --git a/update.sh b/update.sh index c4fdec8..c400486 100644 --- a/update.sh +++ b/update.sh @@ -5,10 +5,13 @@ GREEN='\033[0;32m' CYAN='\033[0;36m' RESET='\033[0m' # Reset color to default -echo "${CYAN}##########################${RESET}" -echo "${CYAN}# E-COSPLAY UPDATE START #${RESET}" -echo "${CYAN}##########################${RESET}" +sudo update-alternatives --set php /usr/bin/php8.4 +php bin/console app:git-log-update +echo "${CYAN}####################################${RESET}" +echo "${CYAN}# LUDIKEVENT INTRANET UPDATE START #${RESET}" +echo "${CYAN}####################################${RESET}" ansible-playbook -i ansible/hosts.ini ansible/playbook.yml echo "${CYAN}##############${RESET}" echo "${CYAN}# END UPDATE #${RESET}" echo "${CYAN}##############${RESET}" +sudo update-alternatives --set php /usr/bin/php8.3