feat(ReserverController): Gère les options de produits au panier et en session.

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.
```
This commit is contained in:
Serreau Jovann
2026-02-04 11:58:07 +01:00
parent d23e75034c
commit 900b55c07b
8 changed files with 361 additions and 55 deletions

View File

@@ -43,9 +43,24 @@ 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));

View File

@@ -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 = '<div class="mt-1 space-y-1 bg-slate-50 p-2 rounded-lg">';
product.options.forEach(opt => {
optionsHtml += `
<div class="flex justify-between text-[9px] text-slate-500 font-medium">
<span>+ ${opt.name}</span>
<span>${this.formatPrice(opt.price)}</span>
</div>
`;
});
optionsHtml += '</div>';
}
return `
<div class="flex gap-4 bg-white p-3 rounded-2xl shadow-sm border border-gray-100">
<div class="w-20 h-20 bg-gray-100 rounded-xl flex-shrink-0 overflow-hidden">
<img src="${product.image || '/provider/images/favicon.png'}" class="w-full h-full object-cover" alt="${product.name}">
@@ -336,6 +368,7 @@ export class FlowReserve extends HTMLAnchorElement {
<span>1J: ${this.formatPrice(product.priceHt1Day)} HT</span>
${product.priceHTSupDay ? `<span class="text-slate-300">|</span><span>Sup: ${this.formatPrice(product.priceHTSupDay)} HT</span>` : ''}
</div>
${optionsHtml}
</div>
<div class="flex justify-between items-end mt-2">
<span class="text-[#0782bc] font-black text-sm">${this.formatPrice(product.totalPriceTTC || product.totalPriceHT)} <span class="text-[9px] text-slate-400 font-bold">Total</span></span>
@@ -345,7 +378,8 @@ export class FlowReserve extends HTMLAnchorElement {
</div>
</div>
</div>
`).join('');
`;
}).join('');
container.innerHTML = `<div class="space-y-3">${datesHtml}${productsHtml}</div>`;
@@ -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
})

View File

@@ -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;
@@ -484,12 +547,45 @@ class ReserverController extends AbstractController
$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,11 +595,28 @@ 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
]);
}
}

View File

@@ -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;

View File

@@ -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') }}
<a is="flow-reserve" class="relative p-2 text-gray-600 hover:text-[#f39e36] transition-colors" aria-label="Panier">
@@ -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') }}
<a is="flow-reserve" class="block px-3 py-2 text-base font-medium text-gray-700 hover:bg-gray-50 rounded-xl flex items-center justify-between">
<span>Panier</span>

View File

@@ -39,7 +39,9 @@
{% 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-500 mb-2 prose prose-sm max-w-none">
{{ item.product.description|raw }}
</div>
<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">
@@ -50,6 +52,19 @@
{% endif %}
</div>
</div>
{# Linked Options #}
{% if item.options is defined and item.options|length > 0 %}
<div class="mt-2 space-y-1">
{% for opt in item.options %}
<div class="flex items-center gap-2 text-xs text-slate-500">
<span class="bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase">Option</span>
<span>{{ opt.name }}</span>
<span class="font-bold text-slate-700">+ {{ opt.price|number_format(2, ',', ' ') }} €</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="text-right">
<p class="font-bold text-slate-900">{{ item.totalPriceHT|number_format(2, ',', ' ') }} € HT</p>
@@ -61,6 +76,24 @@
{% else %}
<p class="text-center text-slate-500 py-4">Aucun produit sélectionné.</p>
{% endfor %}
{# Orphan Options #}
{% if cart.options is defined and cart.options|length > 0 %}
<div class="border-t border-slate-100 pt-4 mt-4">
<h4 class="text-sm font-bold text-slate-800 mb-2 uppercase tracking-wide">Autres options</h4>
{% for opt in cart.options %}
<div class="flex items-center justify-between bg-white p-3 rounded-xl border border-slate-200 shadow-sm mb-2">
<div class="flex items-center gap-3">
<div class="h-8 w-8 bg-indigo-50 rounded-lg flex items-center justify-center text-indigo-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
</div>
<span class="text-sm font-medium text-slate-700">{{ opt.name }}</span>
</div>
<span class="font-bold text-slate-900">{{ opt.price|number_format(2, ',', ' ') }} € HT</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mt-6 border-t border-slate-200 pt-4 space-y-2">

View File

@@ -146,7 +146,9 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for option in product.options %}
{% if option.isPublish %}
<div class="flex items-center gap-4 bg-white border border-slate-100 p-4 rounded-2xl shadow-sm hover:shadow-md transition-shadow">
<label class="relative flex items-start gap-4 bg-white border-2 border-transparent p-4 rounded-2xl shadow-sm hover:shadow-md transition-all cursor-pointer group has-[:checked]:border-[#f39e36] has-[:checked]:bg-amber-50/50">
<input type="checkbox" value="{{ option.id }}" data-option-id="{{ option.id }}" class="product-option-checkbox sr-only peer">
<div class="w-16 h-16 bg-slate-50 rounded-xl overflow-hidden flex-shrink-0">
{% if option.imageName %}
<img src="{{ vich_uploader_asset(option, 'imageFile') }}" alt="{{ option.name }}" class="w-full h-full object-cover">
@@ -156,15 +158,20 @@
</div>
{% endif %}
</div>
<div>
<h4 class="text-sm font-black text-slate-900 uppercase italic">{{ option.name }}</h4>
<div class="pr-2">
<h4 class="text-sm font-black text-slate-900 uppercase italic mb-1 group-hover:text-[#f39e36] peer-checked:text-[#f39e36] transition-colors">{{ option.name }}</h4>
{% if tvaEnabled %}
<span class="text-xs font-bold text-[#f39e36]">+ {{ (option.priceHt*1.20)|format_currency('EUR') }} TTC</span>
<span class="text-xs font-bold text-slate-500 peer-checked:text-slate-700">+ {{ (option.priceHt*1.20)|format_currency('EUR') }} TTC</span>
{% else %}
<span class="text-xs font-bold text-[#f39e36]">+ {{ option.priceHt|format_currency('EUR') }}</span>
<span class="text-xs font-bold text-slate-500 peer-checked:text-slate-700">+ {{ option.priceHt|format_currency('EUR') }}</span>
{% endif %}
</div>
</div>
{# Checkmark Icon visible when checked #}
<div class="absolute top-4 right-4 text-[#f39e36] opacity-0 peer-checked:opacity-100 transition-opacity">
<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.5" d="M5 13l4 4L19 7" /></svg>
</div>
</label>
{% endif %}
{% endfor %}
</div>

View File

@@ -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