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,15 +43,30 @@ export class FlowAddToCart extends HTMLElement {
if (!response.ok) throw new Error('Network error'); if (!response.ok) throw new Error('Network error');
const data = await response.json(); const data = await response.json();
if (data.dispo) { if (data.dispo) {
// 4. Add to Cart // 4. Add to Cart
const list = JSON.parse(localStorage.getItem('pl_list') || '[]'); 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)) { if (!list.includes(this.productId)) {
list.push(this.productId); list.push(this.productId);
localStorage.setItem('pl_list', JSON.stringify(list)); localStorage.setItem('pl_list', JSON.stringify(list));
window.dispatchEvent(new CustomEvent('cart:updated')); window.dispatchEvent(new CustomEvent('cart:updated'));
} }
// Open Cart // Open Cart
const cart = document.querySelector('[is="flow-reserve"]'); const cart = document.querySelector('[is="flow-reserve"]');
if (cart) cart.open(); if (cart) cart.open();

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) { removeFromList(id) {
let list = this.getList(); let list = this.getList();
list = list.filter(itemId => itemId.toString() !== id.toString()); list = list.filter(itemId => itemId.toString() !== id.toString());
localStorage.setItem(this.storageKey, JSON.stringify(list)); 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')); window.dispatchEvent(new CustomEvent('cart:updated'));
this.refreshContent(); // Re-fetch and render this.refreshContent(); // Re-fetch and render
} }
@@ -211,6 +227,7 @@ export class FlowReserve extends HTMLAnchorElement {
footer.innerHTML = ''; footer.innerHTML = '';
const ids = this.getList(); const ids = this.getList();
const options = this.getOptions();
// Retrieve dates from localStorage // Retrieve dates from localStorage
let dates = { start: null, end: null }; let dates = { start: null, end: null };
@@ -248,6 +265,7 @@ export class FlowReserve extends HTMLAnchorElement {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
ids, ids,
options,
start: dates.start, start: dates.start,
end: dates.end 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="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"> <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}"> <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> <span>1J: ${this.formatPrice(product.priceHt1Day)} HT</span>
${product.priceHTSupDay ? `<span class="text-slate-300">|</span><span>Sup: ${this.formatPrice(product.priceHTSupDay)} HT</span>` : ''} ${product.priceHTSupDay ? `<span class="text-slate-300">|</span><span>Sup: ${this.formatPrice(product.priceHTSupDay)} HT</span>` : ''}
</div> </div>
${optionsHtml}
</div> </div>
<div class="flex justify-between items-end mt-2"> <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> <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> </div>
</div> </div>
`).join(''); `;
}).join('');
container.innerHTML = `<div class="space-y-3">${datesHtml}${productsHtml}</div>`; container.innerHTML = `<div class="space-y-3">${datesHtml}${productsHtml}</div>`;
@@ -396,6 +430,7 @@ export class FlowReserve extends HTMLAnchorElement {
e.preventDefault(); e.preventDefault();
const ids = this.getList(); const ids = this.getList();
const options = this.getOptions();
let dates = { start: null, end: null }; let dates = { start: null, end: null };
try { try {
dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}'); dates = JSON.parse(localStorage.getItem('reservation_dates') || '{}');
@@ -409,6 +444,7 @@ export class FlowReserve extends HTMLAnchorElement {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
ids, ids,
options,
start: dates.start, start: dates.start,
end: dates.end end: dates.end
}) })

View File

@@ -33,6 +33,7 @@ use App\Service\Pdf\DevisPdfService;
use App\Entity\Devis; use App\Entity\Devis;
use App\Entity\DevisLine; use App\Entity\DevisLine;
use App\Entity\CustomerAddress; use App\Entity\CustomerAddress;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ReserverController extends AbstractController class ReserverController extends AbstractController
{ {
@@ -318,10 +319,11 @@ class ReserverController extends AbstractController
} }
#[Route('/basket/json', name: 'reservation_basket_json', methods: ['POST'])] #[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); $data = json_decode($request->getContent(), true);
$ids = $data['ids'] ?? []; $ids = $data['ids'] ?? [];
$selectedOptionsMap = $data['options'] ?? [];
$startStr = $data['start'] ?? null; $startStr = $data['start'] ?? null;
$endStr = $data['end'] ?? null; $endStr = $data['end'] ?? null;
@@ -347,49 +349,108 @@ class ReserverController extends AbstractController
$foundIds = array_map(fn($p) => $p->getId(), $products); $foundIds = array_map(fn($p) => $p->getId(), $products);
$removedIds = array_values(array_diff($ids, $foundIds)); $removedIds = array_values(array_diff($ids, $foundIds));
$items = []; $items = [];
$totalHT = 0; $totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true"; $tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0; $tvaRate = $tvaEnabled ? 0.20 : 0;
foreach ($products as $product) { $rootOptions = [];
$price1Day = $product->getPriceDay(); $processedProductIds = [];
$priceSup = $product->getPriceSup() ?? 0.0;
// Calcul du coût total pour ce produit selon la durée foreach ($products as $product) {
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1)); $processedProductIds[] = $product->getId();
$productTotalTTC = $productTotalHT * (1 + $tvaRate); $price1Day = $product->getPriceDay();
$priceSup = $product->getPriceSup() ?? 0.0;
$items[] = [ // Calcul du coût total pour ce produit selon la durée
'id' => $product->getId(), $productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1));
'name' => $product->getName(),
'image' => $uploaderHelper->asset($product, 'imageFile'),
'priceHt1Day' => $price1Day,
'priceHTSupDay' => $priceSup,
'priceTTC1Day' => $price1Day * (1 + $tvaRate),
'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC,
];
$totalHT += $productTotalHT; // Traitement des options
} $productOptions = [];
$optionsTotalHT = 0;
$totalTva = $totalHT * $tvaRate; if (isset($selectedOptionsMap[$product->getId()])) {
$totalTTC = $totalHT + $totalTva; $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([ // Vérifier si l'option est réellement liée au produit
'start_date' => $startStr, if ($product->getOptions()->contains($option)) {
'end_date' => $endStr, $productOptions[] = $optData;
'products' => $items, $optionsTotalHT += $optPrice;
'unavailable_products_ids' => $removedIds, } else {
'total' => [ // Option demandée mais pas liée au produit -> Root options
'totalHT' => $totalHT, $rootOptions[] = $optData;
'totalTva' => $totalTva, // On ajoute quand même le prix ? Le user a dit "options non lier".
'totalTTC' => $totalTTC // 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'])] #[Route('/session', name: 'reservation_session_create', methods: ['POST'])]
public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository): Response public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository): Response
{ {
@@ -432,7 +493,8 @@ class ReserverController extends AbstractController
ProductRepository $productRepository, ProductRepository $productRepository,
UploaderHelper $uploaderHelper, UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository, ProductReserveRepository $productReserveRepository,
EntityManagerInterface $em EntityManagerInterface $em,
\App\Repository\OptionsRepository $optionsRepository
): Response { ): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]); $session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) { if (!$session) {
@@ -441,6 +503,7 @@ class ReserverController extends AbstractController
$sessionData = $session->getProducts(); $sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? []; $ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? [];
$startStr = $sessionData['start'] ?? null; $startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null; $endStr = $sessionData['end'] ?? null;
@@ -483,13 +546,46 @@ class ReserverController extends AbstractController
$totalHT = 0; $totalHT = 0;
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true"; $tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0; $tvaRate = $tvaEnabled ? 0.20 : 0;
$rootOptions = [];
$processedProductIds = [];
foreach ($products as $product) { foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$price1Day = $product->getPriceDay(); $price1Day = $product->getPriceDay();
$priceSup = $product->getPriceSup() ?? 0.0; $priceSup = $product->getPriceSup() ?? 0.0;
// Calcul du coût total pour ce produit selon la durée // Calcul du coût total pour ce produit selon la durée
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1)); $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); $productTotalTTC = $productTotalHT * (1 + $tvaRate);
$items[] = [ $items[] = [
@@ -499,10 +595,27 @@ class ReserverController extends AbstractController
'priceSup' => $priceSup, 'priceSup' => $priceSup,
'totalPriceHT' => $productTotalHT, 'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC, 'totalPriceTTC' => $productTotalTTC,
'options' => $productOptions
]; ];
$totalHT += $productTotalHT; $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; $totalTva = $totalHT * $tvaRate;
$totalTTC = $totalHT + $totalTva; $totalTTC = $totalHT + $totalTva;
@@ -511,6 +624,7 @@ class ReserverController extends AbstractController
'session' => $session, 'session' => $session,
'cart' => [ 'cart' => [
'items' => $items, 'items' => $items,
'options' => $rootOptions,
'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null, 'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null,
'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null, 'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null,
'duration' => $duration, 'duration' => $duration,
@@ -1018,4 +1132,84 @@ class ReserverController extends AbstractController
{ {
return $this->render('revervation/hosting.twig'); 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)] #[ORM\Column(type: Types::JSON)]
private array $products = []; private array $products = [];
#[ORM\Column(type: Types::JSON, nullable: true)]
private array $options = [];
#[ORM\ManyToOne(inversedBy: 'orderSessions')] #[ORM\ManyToOne(inversedBy: 'orderSessions')]
private ?Customer $customer = null; private ?Customer $customer = null;
@@ -79,6 +82,7 @@ class OrderSession
{ {
$this->createdAt = new \DateTimeImmutable(); $this->createdAt = new \DateTimeImmutable();
$this->products = []; $this->products = [];
$this->options = [];
$this->state = 'created'; $this->state = 'created';
} }
@@ -135,6 +139,18 @@ class OrderSession
return $this; return $this;
} }
public function getOptions(): array
{
return $this->options;
}
public function setOptions(?array $options): static
{
$this->options = $options;
return $this;
}
public function getCustomer(): ?Customer public function getCustomer(): ?Customer
{ {
return $this->customer; return $this->customer;

View File

@@ -92,6 +92,7 @@
{{ macros.nav_link('reservation_formules', 'nav.packages') }} {{ macros.nav_link('reservation_formules', 'nav.packages') }}
{{ macros.nav_link('/images/Catalogue.pdf', 'nav.pdf', true) }} {{ macros.nav_link('/images/Catalogue.pdf', 'nav.pdf', true) }}
{{ macros.nav_link('reservation_workflow', 'nav.how_to_book') }} {{ 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') }} {{ 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"> <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('reservation_formules', 'Nos Formules') }}
{{ macros.mobile_nav_link('/provider/Catalogue.pdf', 'Catalogue', true) }} {{ macros.mobile_nav_link('/provider/Catalogue.pdf', 'Catalogue', true) }}
{{ macros.mobile_nav_link('reservation_workflow', 'Comment reserver') }} {{ 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') }} {{ 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"> <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> <span>Panier</span>

View File

@@ -39,7 +39,9 @@
{% endif %} {% endif %}
<div class="flex-1"> <div class="flex-1">
<h4 class="font-bold text-slate-800">{{ item.product.name }}</h4> <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="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"> <div class="flex flex-wrap gap-x-3 gap-y-1">
@@ -50,6 +52,19 @@
{% endif %} {% endif %}
</div> </div>
</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>
<div class="text-right"> <div class="text-right">
<p class="font-bold text-slate-900">{{ item.totalPriceHT|number_format(2, ',', ' ') }} € HT</p> <p class="font-bold text-slate-900">{{ item.totalPriceHT|number_format(2, ',', ' ') }} € HT</p>
@@ -61,6 +76,24 @@
{% else %} {% else %}
<p class="text-center text-slate-500 py-4">Aucun produit sélectionné.</p> <p class="text-center text-slate-500 py-4">Aucun produit sélectionné.</p>
{% endfor %} {% 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>
<div class="mt-6 border-t border-slate-200 pt-4 space-y-2"> <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"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for option in product.options %} {% for option in product.options %}
{% if option.isPublish %} {% 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"> <div class="w-16 h-16 bg-slate-50 rounded-xl overflow-hidden flex-shrink-0">
{% if option.imageName %} {% if option.imageName %}
<img src="{{ vich_uploader_asset(option, 'imageFile') }}" alt="{{ option.name }}" class="w-full h-full object-cover"> <img src="{{ vich_uploader_asset(option, 'imageFile') }}" alt="{{ option.name }}" class="w-full h-full object-cover">
@@ -156,15 +158,20 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div> <div class="pr-2">
<h4 class="text-sm font-black text-slate-900 uppercase italic">{{ option.name }}</h4> <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 %} {% 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 %} {% 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 %} {% endif %}
</div> </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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -5,10 +5,13 @@ GREEN='\033[0;32m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
RESET='\033[0m' # Reset color to default RESET='\033[0m' # Reset color to default
echo "${CYAN}##########################${RESET}" sudo update-alternatives --set php /usr/bin/php8.4
echo "${CYAN}# E-COSPLAY UPDATE START #${RESET}" php bin/console app:git-log-update
echo "${CYAN}##########################${RESET}" echo "${CYAN}####################################${RESET}"
echo "${CYAN}# LUDIKEVENT INTRANET UPDATE START #${RESET}"
echo "${CYAN}####################################${RESET}"
ansible-playbook -i ansible/hosts.ini ansible/playbook.yml ansible-playbook -i ansible/hosts.ini ansible/playbook.yml
echo "${CYAN}##############${RESET}" echo "${CYAN}##############${RESET}"
echo "${CYAN}# END UPDATE #${RESET}" echo "${CYAN}# END UPDATE #${RESET}"
echo "${CYAN}##############${RESET}" echo "${CYAN}##############${RESET}"
sudo update-alternatives --set php /usr/bin/php8.3