feat(formule): Intègre la gestion des formules et packs libres dans le parcours de réservation et les documents.

This commit is contained in:
Serreau Jovann
2026-02-09 14:06:26 +01:00
parent 1f393fcf24
commit 822f187dfb
23 changed files with 734 additions and 92 deletions

View File

@@ -130,7 +130,9 @@ export class SearchOptionsFormule extends HTMLButtonElement {
const row = this.closest('.form-repeater__row'); const row = this.closest('.form-repeater__row');
if (row) { if (row) {
const nameInput = row.querySelector('input[name*="[product]"]'); const nameInput = row.querySelector('input[name*="[product]"]');
const nameId = row.querySelector('input[name*="[id]"]');
if(nameInput) nameInput.value = option.name; if(nameInput) nameInput.value = option.name;
if(nameId) nameId.value = option.id;
const fieldset = row.querySelector('fieldset'); const fieldset = row.querySelector('fieldset');
if (fieldset) { if (fieldset) {
@@ -275,8 +277,10 @@ export class SearchProductFormule extends HTMLButtonElement {
fillFormLine(product) { fillFormLine(product) {
const row = this.closest('.form-repeater__row'); const row = this.closest('.form-repeater__row');
if (row) { if (row) {
const nameId = row.querySelector('input[name*="[id]"]');
const nameInput = row.querySelector('input[name*="[product]"]'); const nameInput = row.querySelector('input[name*="[product]"]');
if(nameInput) nameInput.value = product.name; if(nameInput) nameInput.value = product.name;
if(nameId) nameId.value = product.id;
const fieldset = row.querySelector('fieldset'); const fieldset = row.querySelector('fieldset');
if (fieldset) { if (fieldset) {

View File

@@ -4,6 +4,8 @@ import { CookieBanner } from "./tools/CookieBanner.js";
import { FlowReserve } from "./tools/FlowReserve.js"; import { FlowReserve } from "./tools/FlowReserve.js";
import { FlowDatePicker } from "./tools/FlowDatePicker.js"; import { FlowDatePicker } from "./tools/FlowDatePicker.js";
import { FlowAddToCart } from "./tools/FlowAddToCart.js"; import { FlowAddToCart } from "./tools/FlowAddToCart.js";
import { FlowFormuleConfigurator } from "./tools/FlowFormuleConfigurator.js";
import { SubmitClearStorage } from "./tools/SubmitClearStorage.js";
import { LeafletMap } from "./tools/LeafletMap.js"; import { LeafletMap } from "./tools/LeafletMap.js";
import { LocalStorageClear } from "./tools/LocalStorageClear.js"; import { LocalStorageClear } from "./tools/LocalStorageClear.js";
import * as Turbo from "@hotwired/turbo"; import * as Turbo from "@hotwired/turbo";
@@ -265,6 +267,12 @@ const registerComponents = () => {
if(!customElements.get('flow-add-to-cart')) if(!customElements.get('flow-add-to-cart'))
customElements.define('flow-add-to-cart',FlowAddToCart) customElements.define('flow-add-to-cart',FlowAddToCart)
if(!customElements.get('flow-formule-configurator'))
customElements.define('flow-formule-configurator',FlowFormuleConfigurator)
if(!customElements.get('submit-clear-storage'))
customElements.define('submit-clear-storage',SubmitClearStorage, { extends: 'form' })
}; };
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -0,0 +1,190 @@
export class FlowFormuleConfigurator extends HTMLElement {
constructor() {
super();
this.counts = { structure: 0, alimentaire: 0, barnum: 0 };
this.selection = [];
this.blocked = false;
this.mode = 'free';
}
connectedCallback() {
try {
this.limits = JSON.parse(this.getAttribute('data-limits') || '{}');
this.prices = JSON.parse(this.getAttribute('data-prices') || '{}');
} catch (e) {
console.error('Invalid limits/prices JSON', e);
return;
}
this.formuleId = this.getAttribute('data-formule-id');
this.mode = this.getAttribute('data-mode') || 'free';
this.validateBtn = this.querySelector('#btn-validate-pack');
if (this.mode === 'pack') {
try {
this.selection = JSON.parse(this.getAttribute('data-preselected-ids') || '[]');
} catch (e) { console.error('Invalid preselected-ids JSON', e); }
} else {
this.querySelectorAll('.product-card').forEach(card => {
card.addEventListener('click', (e) => {
e.preventDefault();
this.toggleItem(card);
});
});
}
if (this.validateBtn) {
this.validateBtn.addEventListener('click', (e) => {
e.preventDefault();
this.validate();
});
}
this.checkDuration();
this.updateUI();
}
checkDuration() {
const datesStr = sessionStorage.getItem('reservation_dates');
if (!datesStr) return;
try {
const dates = JSON.parse(datesStr);
if (!dates.start || !dates.end) return;
const start = new Date(dates.start);
const end = new Date(dates.end);
const diffTime = Math.abs(end - start);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
let message = null;
let block = false;
if (diffDays > 5) {
message = `Attention : La durée de votre réservation (${diffDays} jours) dépasse la limite autorisée de 5 jours pour cette formule.`;
block = true;
} else if (diffDays >= 3 && diffDays <= 5) {
message = `Information : Pour une durée de ${diffDays} jours, le tarif "5 jours" sera appliqué automatiquement.`;
}
if (message) {
let msgContainer = this.querySelector('.formule-warning');
if (!msgContainer) {
msgContainer = document.createElement('div');
this.prepend(msgContainer);
}
msgContainer.className = `formule-warning p-4 mb-6 rounded-2xl text-sm font-bold text-center border-2 ${block ? 'bg-red-50 border-red-100 text-red-600' : 'bg-blue-50 border-blue-100 text-blue-600'}`;
msgContainer.innerHTML = message;
}
this.blocked = block;
this.updateUI();
} catch (e) { console.error(e); }
}
toggleItem(el) {
if (this.blocked || this.mode === 'pack') return;
const category = el.getAttribute('data-category');
const id = el.getAttribute('data-id');
const isSelected = el.getAttribute('data-selected') === 'true';
if (isSelected) {
this.deselect(el, category, id);
} else {
if (this.counts[category] < this.limits[category]) {
this.select(el, category, id);
} else {
this.shake(el);
}
}
this.updateUI();
}
select(el, category, id) {
el.setAttribute('data-selected', 'true');
el.classList.add('ring-4', 'ring-[#f39e36]', 'scale-95');
const indicator = el.querySelector('.selection-indicator');
if (indicator) {
indicator.classList.remove('opacity-0', 'group-hover:opacity-100');
indicator.classList.add('opacity-100');
indicator.querySelector('div')?.classList.remove('hidden');
}
this.counts[category]++;
this.selection.push(id);
}
deselect(el, category, id) {
el.setAttribute('data-selected', 'false');
el.classList.remove('ring-4', 'ring-[#f39e36]', 'scale-95');
const indicator = el.querySelector('.selection-indicator');
if (indicator) {
indicator.classList.remove('opacity-100');
indicator.classList.add('opacity-0', 'group-hover:opacity-100');
indicator.querySelector('div')?.classList.add('hidden');
}
this.counts[category]--;
this.selection = this.selection.filter(item => item !== id);
}
shake(el) {
el.classList.add('animate-pulse');
setTimeout(() => el.classList.remove('animate-pulse'), 500);
alert("Limite atteinte pour cette catégorie.");
}
updateUI() {
if (this.mode === 'free') {
['structure', 'alimentaire', 'barnum'].forEach(cat => {
const span = this.querySelector(`#count-${cat}`);
if (span) span.innerText = this.counts[cat];
});
}
const total = this.selection.length;
if (this.validateBtn) {
if (this.blocked) {
this.validateBtn.innerText = 'Durée non autorisée (> 5j)';
this.validateBtn.classList.add('opacity-50', 'pointer-events-none', 'translate-y-4', 'bg-red-600');
this.validateBtn.classList.remove('bg-slate-900', 'hover:bg-[#fc0e50]');
} else {
this.validateBtn.innerText = this.mode === 'pack' ? 'Réserver ce pack' : `Valider mon pack (${total})`;
this.validateBtn.classList.remove('bg-red-600');
this.validateBtn.classList.add('bg-slate-900', 'hover:bg-[#fc0e50]');
if (total > 0) {
this.validateBtn.classList.remove('opacity-50', 'pointer-events-none', 'translate-y-4');
} else {
this.validateBtn.classList.add('opacity-50', 'pointer-events-none', 'translate-y-4');
}
}
}
}
validate() {
if (this.blocked || this.selection.length === 0) return;
sessionStorage.setItem('active_formule', this.formuleId);
let list = [];
try {
list = JSON.parse(sessionStorage.getItem('pl_list') || '[]');
} catch(e) {}
const newItems = this.selection.filter(id => !list.includes(id));
if (newItems.length > 0) {
list = [...list, ...newItems];
sessionStorage.setItem('pl_list', JSON.stringify(list));
}
window.dispatchEvent(new CustomEvent('cart:updated'));
const cart = document.querySelector('[is="flow-reserve"]');
if (cart) cart.open();
}
}

View File

@@ -267,7 +267,8 @@ export class FlowReserve extends HTMLAnchorElement {
ids, ids,
options, options,
start: dates.start, start: dates.start,
end: dates.end end: dates.end,
formule: sessionStorage.getItem('active_formule')
}) })
}); });
@@ -362,6 +363,7 @@ export class FlowReserve extends HTMLAnchorElement {
</div> </div>
<div class="flex-1 flex flex-col justify-between"> <div class="flex-1 flex flex-col justify-between">
<div> <div>
${product.in_formule ? `<span class="inline-block px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-widest bg-blue-100 text-blue-600 mb-1">Inclus</span>` : ''}
<h4 class="font-black text-slate-900 leading-tight uppercase italic text-sm line-clamp-2">${product.name}</h4> <h4 class="font-black text-slate-900 leading-tight uppercase italic text-sm line-clamp-2">${product.name}</h4>
<div class="mt-1 flex flex-wrap gap-2 text-[10px] text-slate-500 font-bold uppercase"> <div class="mt-1 flex flex-wrap gap-2 text-[10px] text-slate-500 font-bold uppercase">
<span>1J: ${this.formatPrice(product.priceHt1Day)} HT</span> <span>1J: ${this.formatPrice(product.priceHt1Day)} HT</span>
@@ -395,13 +397,23 @@ export class FlowReserve extends HTMLAnchorElement {
const total = data.total || {}; const total = data.total || {};
const hasTva = total.totalTva > 0; const hasTva = total.totalTva > 0;
const promotion = data.promotion; const promotion = data.promotion;
const formuleName = total.formule;
let promoHtml = ''; let promoHtml = '';
let originalTotalHT = total.totalHT; let originalTotalHT = total.totalHT;
if (formuleName) {
promoHtml += `
<div class="flex justify-between text-xs text-blue-600 font-bold uppercase mb-2">
<span>Formule</span>
<span>${formuleName}</span>
</div>
`;
}
if (promotion && total.discount > 0) { if (promotion && total.discount > 0) {
originalTotalHT = total.totalHT + total.discount; originalTotalHT = total.totalHT + total.discount;
promoHtml = ` promoHtml += `
<div class="flex justify-between text-xs text-[#f39e36] font-bold uppercase"> <div class="flex justify-between text-xs text-[#f39e36] font-bold uppercase">
<span>${promotion.name} (-${promotion.percentage}%)</span> <span>${promotion.name} (-${promotion.percentage}%)</span>
<span>-${this.formatPrice(total.discount)}</span> <span>-${this.formatPrice(total.discount)}</span>
@@ -460,7 +472,8 @@ export class FlowReserve extends HTMLAnchorElement {
ids, ids,
options, options,
start: dates.start, start: dates.start,
end: dates.end end: dates.end,
formule: sessionStorage.getItem('active_formule')
}) })
}); });

View File

@@ -0,0 +1,12 @@
export class SubmitClearStorage extends HTMLFormElement {
constructor() {
super();
}
connectedCallback() {
this.addEventListener('submit', () => {
localStorage.clear();
sessionStorage.clear();
});
}
}

View File

@@ -38,7 +38,7 @@ export class UtmAccount extends HTMLElement {
// 4. Envoi du sessionId à ton backend Symfony // 4. Envoi du sessionId à ton backend Symfony
if (sessionId) { if (sessionId) {
await fetch('/reservation/umami', { await fetch('/umami', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ umami_session: sessionId }) body: JSON.stringify({ umami_session: sessionId })

View File

@@ -46,6 +46,7 @@ nelmio_security:
- "https://challenges.cloudflare.com" - "https://challenges.cloudflare.com"
- "https://tools-security.esy-web.dev" - "https://tools-security.esy-web.dev"
- "https://checkout.stripe.com/" - "https://checkout.stripe.com/"
- "https://unpkg.com/"
frame-src: frame-src:
- "'self'" - "'self'"
- "https://chat.esy-web.dev" - "https://chat.esy-web.dev"

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260209123602 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_session ADD formule_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE order_session ADD CONSTRAINT FK_263E7C9F2A68F4D1 FOREIGN KEY (formule_id) REFERENCES formules (id)');
$this->addSql('CREATE INDEX IDX_263E7C9F2A68F4D1 ON order_session (formule_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_session DROP CONSTRAINT FK_263E7C9F2A68F4D1');
$this->addSql('DROP INDEX IDX_263E7C9F2A68F4D1');
$this->addSql('ALTER TABLE order_session DROP formule_id');
}
}

View File

@@ -139,6 +139,18 @@ class FormulesController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
if($formule->getType() == "free") {
$r = $formule->getFormulesRestriction();
if(!$r) {
$r = new FormulesRestriction();
$r->setNbStructureMax(0);
$r->setNbAlimentaireMax(0);
$r->setNbBarhumsMax(0);
$r->setRestrictionConfig([]);
$this->em->persist($r);
$formule->setFormulesRestriction($r);
}
}
$this->em->persist($formule); $this->em->persist($formule);
$this->em->flush(); $this->em->flush();
@@ -341,6 +353,6 @@ class FormulesController extends AbstractController
if ($restriction && !empty($restriction->getRestrictionConfig())) { if ($restriction && !empty($restriction->getRestrictionConfig())) {
return $restriction->getRestrictionConfig(); return $restriction->getRestrictionConfig();
} }
return [['type' => 'structure', 'product' => '']]; return [['type' => 'structure', 'product' => '','id'=>0]];
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Controller;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerTracking; use App\Entity\CustomerTracking;
use App\Entity\Formules;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\ProductReserve; use App\Entity\ProductReserve;
use App\Entity\Promotion; use App\Entity\Promotion;
@@ -103,6 +104,29 @@ class ReserverController extends AbstractController
$selectedOptionsMap = $sessionData['options'] ?? []; $selectedOptionsMap = $sessionData['options'] ?? [];
$runningTotalHT = 0; $runningTotalHT = 0;
$formule = $session->getFormule();
$formuleConfig = [];
if ($formule) {
$restriction = $formule->getFormulesRestriction();
if ($restriction) {
$formuleConfig = $restriction->getRestrictionConfig() ?? [];
}
$formulaBasePrice = 0;
if ($duration <= 1) $formulaBasePrice = $formule->getPrice1j();
elseif ($duration <= 2) $formulaBasePrice = $formule->getPrice2j();
else $formulaBasePrice = $formule->getPrice5j();
$line = new DevisLine();
$line->setProduct("Formule : " . $formule->getName());
$line->setPriceHt($formulaBasePrice);
$line->setPriceHtSup(0);
$line->setDay(1);
$devis->addDevisLine($line);
$runningTotalHT += $formulaBasePrice;
}
if (!empty($ids)) { if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]); $products = $productRepository->findBy(['id' => $ids]);
$processedProductIds = []; $processedProductIds = [];
@@ -110,15 +134,31 @@ class ReserverController extends AbstractController
foreach ($products as $product) { foreach ($products as $product) {
$processedProductIds[] = $product->getId(); $processedProductIds[] = $product->getId();
$isInFormule = false;
if ($formule) {
foreach ($formuleConfig as $c) {
if (($c['product'] ?? '') === $product->getName()) {
$isInFormule = true;
break;
}
}
}
$line = new DevisLine(); $line = new DevisLine();
$line->setProduct($product->getName()); $line->setProduct($product->getName());
$line->setPriceHt($product->getPriceDay());
$line->setPriceHtSup($product->getPriceSup()); if ($formule && $isInFormule) {
$line->setPriceHt(0);
$line->setPriceHtSup(0);
} else {
$line->setPriceHt($product->getPriceDay());
$line->setPriceHtSup($product->getPriceSup());
$runningTotalHT += $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1));
}
$line->setDay($duration); $line->setDay($duration);
$devis->addDevisLine($line); $devis->addDevisLine($line);
$runningTotalHT += $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1));
if (isset($selectedOptionsMap[$product->getId()])) { if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()]; $optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) { if (!empty($optionIds)) {
@@ -377,7 +417,8 @@ class ReserverController extends AbstractController
ProductRepository $productRepository, ProductRepository $productRepository,
OptionsRepository $optionsRepository, OptionsRepository $optionsRepository,
UploaderHelper $uploaderHelper, UploaderHelper $uploaderHelper,
PromotionRepository $promotionRepository PromotionRepository $promotionRepository,
FormulesRepository $formulesRepository
): Response { ): Response {
$data = json_decode($request->getContent(), true); $data = json_decode($request->getContent(), true);
$ids = $data['ids'] ?? []; $ids = $data['ids'] ?? [];
@@ -398,7 +439,13 @@ class ReserverController extends AbstractController
$promotions = $promotionRepository->findActivePromotions(new \DateTime()); $promotions = $promotionRepository->findActivePromotions(new \DateTime());
$promotion = $promotions[0] ?? null; $promotion = $promotions[0] ?? null;
$cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion); $formuleId = $data['formule'] ?? null;
$formule = null;
if ($formuleId) {
$formule = $formulesRepository->find($formuleId);
}
$cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion, $formule);
return new JsonResponse([ return new JsonResponse([
'start_date' => $startStr, 'start_date' => $startStr,
@@ -407,12 +454,13 @@ class ReserverController extends AbstractController
'options' => $cartData['rootOptions'], 'options' => $cartData['rootOptions'],
'unavailable_products_ids' => $removedIds, 'unavailable_products_ids' => $removedIds,
'total' => $cartData['total'], 'total' => $cartData['total'],
'promotion' => $promotion ? ['name' => $promotion->getName(), 'percentage' => $promotion->getPercentage()] : null 'promotion' => $promotion ? ['name' => $promotion->getName(), 'percentage' => $promotion->getPercentage()] : null,
'formule' => $formule ? ['name' => $formule->getName()] : null
]); ]);
} }
#[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, PromotionRepository $promotionRepository): Response public function createSession(Request $request, EntityManagerInterface $em, OrderSessionRepository $sessionRepository, PromotionRepository $promotionRepository, FormulesRepository $formulesRepository, ProductRepository $productRepository): Response
{ {
$data = json_decode($request->getContent(), true); $data = json_decode($request->getContent(), true);
$existingUuid = $request->getSession()->get('order_session_uuid'); $existingUuid = $request->getSession()->get('order_session_uuid');
@@ -442,6 +490,65 @@ class ReserverController extends AbstractController
$session->setPromotion(null); $session->setPromotion(null);
} }
$formuleId = $data['formule'] ?? null;
if ($formuleId) {
$formule = $formulesRepository->find($formuleId);
if ($formule) {
// --- DURATION CHECK ---
$startStr = $data['start'] ?? null;
$endStr = $data['end'] ?? null;
$duration = $this->calculateDuration($startStr, $endStr);
if ($duration > 5) {
return new JsonResponse(['error' => "Impossible d'ajouter cette formule : la durée de réservation excède 5 jours."], Response::HTTP_BAD_REQUEST);
}
// --- SECURITY CHECK ---
$restriction = $formule->getFormulesRestriction();
if ($restriction) {
$ids = $data['ids'] ?? [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
$config = $restriction->getRestrictionConfig() ?? [];
$counts = ['structure' => 0, 'alimentaire' => 0, 'barhnums' => 0];
foreach ($products as $product) {
$pName = $product->getName();
$type = null;
foreach ($config as $c) {
if (($c['product'] ?? '') === $pName) {
$type = $c['type'] ?? null;
break;
}
}
if ($type === 'structure') {
$counts['structure']++;
} elseif ($type === 'alimentaire') {
$counts['alimentaire']++;
} elseif ($type) {
// Only count if type is defined in config (meaning it's part of the formula)
$counts['barhnums']++;
}
// Products not in the formula config are considered "extras" and allowed.
}
if ($counts['structure'] > $restriction->getNbStructureMax()) {
return new JsonResponse(['error' => "Le nombre maximum de structures est dépassé ({$restriction->getNbStructureMax()})."], Response::HTTP_BAD_REQUEST);
}
if ($counts['alimentaire'] > $restriction->getNbAlimentaireMax()) {
return new JsonResponse(['error' => "Le nombre maximum d'éléments alimentaires est dépassé ({$restriction->getNbAlimentaireMax()})."], Response::HTTP_BAD_REQUEST);
}
if ($counts['barhnums'] > $restriction->getNbBarhumsMax()) {
return new JsonResponse(['error' => "Le nombre maximum de barnums/mobilier est dépassé ({$restriction->getNbBarhumsMax()})."], Response::HTTP_BAD_REQUEST);
}
}
}
$session->setFormule($formule);
}
}
$user = $this->getUser(); $user = $this->getUser();
if ($user instanceof Customer) { if ($user instanceof Customer) {
$session->setCustomer($user); $session->setCustomer($user);
@@ -531,7 +638,9 @@ class ReserverController extends AbstractController
$promotion->setPercentage($promoData['percentage']); $promotion->setPercentage($promoData['percentage']);
} }
$cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion); $formule = $session->getFormule();
$cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion, $formule);
// --- Calcul Frais de Livraison --- // --- Calcul Frais de Livraison ---
$deliveryData = $this->calculateDelivery( $deliveryData = $this->calculateDelivery(
@@ -554,6 +663,7 @@ class ReserverController extends AbstractController
'totalTTC' => $cartData['total']['totalTTC'], 'totalTTC' => $cartData['total']['totalTTC'],
'discount' => $cartData['total']['discount'], 'discount' => $cartData['total']['discount'],
'promotion' => $cartData['total']['promotion'], 'promotion' => $cartData['total']['promotion'],
'formule' => $cartData['total']['formule'],
'tvaEnabled' => $cartData['tvaEnabled'], 'tvaEnabled' => $cartData['tvaEnabled'],
], ],
'delivery' => $deliveryData 'delivery' => $deliveryData
@@ -633,7 +743,9 @@ class ReserverController extends AbstractController
$promotion->setPercentage($promoData['percentage']); $promotion->setPercentage($promoData['percentage']);
} }
$cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion); $formule = $session->getFormule();
$cartData = $this->buildCartData($products, $selectedOptionsMap, $duration, $optionsRepository, $uploaderHelper, $promotion, $formule);
return $this->render('revervation/flow.twig', [ return $this->render('revervation/flow.twig', [
'session' => $session, 'session' => $session,
@@ -650,6 +762,7 @@ class ReserverController extends AbstractController
'totalTTC' => $cartData['total']['totalTTC'], 'totalTTC' => $cartData['total']['totalTTC'],
'discount' => $cartData['total']['discount'], 'discount' => $cartData['total']['discount'],
'promotion' => $cartData['total']['promotion'], 'promotion' => $cartData['total']['promotion'],
'formule' => $cartData['total']['formule'],
'tvaEnabled' => $cartData['tvaEnabled'], 'tvaEnabled' => $cartData['tvaEnabled'],
] ]
]); ]);
@@ -1209,15 +1322,24 @@ class ReserverController extends AbstractController
return $result; return $result;
} }
private function buildCartData(array $products, array $selectedOptionsMap, int $duration, OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper, ?Promotion $promotion = null): array private function buildCartData(array $products, array $selectedOptionsMap, int $duration, OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper, ?Promotion $promotion = null, ?Formules $formule = null): array
{ {
$items = []; $items = [];
$rootOptions = []; $rootOptions = [];
$totalHT = 0; $totalHT = 0;
$formulaExtras = 0;
$tvaEnabled = $this->isTvaEnabled(); $tvaEnabled = $this->isTvaEnabled();
$tvaRate = $tvaEnabled ? 0.20 : 0; $tvaRate = $tvaEnabled ? 0.20 : 0;
$processedProductIds = []; $processedProductIds = [];
$formuleConfig = [];
if ($formule) {
$restriction = $formule->getFormulesRestriction();
if ($restriction) {
$formuleConfig = $restriction->getRestrictionConfig() ?? [];
}
}
foreach ($products as $product) { foreach ($products as $product) {
$processedProductIds[] = $product->getId(); $processedProductIds[] = $product->getId();
$price1Day = $product->getPriceDay(); $price1Day = $product->getPriceDay();
@@ -1247,7 +1369,11 @@ class ReserverController extends AbstractController
$optionsTotalHT += $optPrice; $optionsTotalHT += $optPrice;
} else { } else {
$rootOptions[] = $optData; $rootOptions[] = $optData;
$totalHT += $optPrice; if ($formule) {
$formulaExtras += $optPrice;
} else {
$totalHT += $optPrice;
}
} }
} }
} }
@@ -1256,22 +1382,41 @@ class ReserverController extends AbstractController
$productTotalHT += $optionsTotalHT; $productTotalHT += $optionsTotalHT;
$productTotalTTC = $productTotalHT * (1 + $tvaRate); $productTotalTTC = $productTotalHT * (1 + $tvaRate);
$isInFormule = false;
if ($formule) {
foreach ($formuleConfig as $c) {
if (($c['product'] ?? '') === $product->getName()) {
$isInFormule = true;
break;
}
}
}
if ($formule) {
if ($isInFormule) {
$formulaExtras += $optionsTotalHT;
} else {
$formulaExtras += $productTotalHT;
}
} else {
$totalHT += $productTotalHT;
}
$items[] = [ $items[] = [
'id' => $product->getId(), // Ensure ID is present for basketJson 'id' => $product->getId(),
'name' => $product->getName(), 'name' => $product->getName(),
'product' => $product, // Kept for twig compatibility if needed 'product' => $product,
'image' => $uploaderHelper->asset($product, 'imageFile'), 'image' => $uploaderHelper->asset($product, 'imageFile'),
'priceHt1Day' => $price1Day, // For basketJson 'priceHt1Day' => $price1Day,
'priceHTSupDay' => $priceSup, // For basketJson 'priceHTSupDay' => $priceSup,
'priceTTC1Day' => $price1Day * (1 + $tvaRate), 'priceTTC1Day' => $price1Day * (1 + $tvaRate),
'price1Day' => $price1Day, // For flow template 'price1Day' => $price1Day,
'priceSup' => $priceSup, // For flow template 'priceSup' => $priceSup,
'totalPriceHT' => $productTotalHT, 'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC, 'totalPriceTTC' => $productTotalTTC,
'options' => $productOptions 'options' => $productOptions,
'in_formule' => $isInFormule
]; ];
$totalHT += $productTotalHT;
} }
// Traiter les options orphelines // Traiter les options orphelines
@@ -1286,11 +1431,28 @@ class ReserverController extends AbstractController
'price' => $optPrice, 'price' => $optPrice,
'orphan_product_id' => $prodId 'orphan_product_id' => $prodId
]; ];
$totalHT += $optPrice; if ($formule) {
$formulaExtras += $optPrice;
} else {
$totalHT += $optPrice;
}
} }
} }
} }
if ($formule) {
$formulaBasePrice = 0;
if ($duration <= 1) {
$formulaBasePrice = $formule->getPrice1j();
} elseif ($duration <= 2) {
$formulaBasePrice = $formule->getPrice2j();
} else {
$formulaBasePrice = $formule->getPrice5j();
}
$totalHT = $formulaBasePrice + $formulaExtras;
}
$discountAmount = 0; $discountAmount = 0;
if ($promotion) { if ($promotion) {
$discountAmount = $totalHT * ($promotion->getPercentage() / 100); $discountAmount = $totalHT * ($promotion->getPercentage() / 100);
@@ -1308,7 +1470,8 @@ class ReserverController extends AbstractController
'totalTva' => $totalTva, 'totalTva' => $totalTva,
'totalTTC' => $totalTTC, 'totalTTC' => $totalTTC,
'discount' => $discountAmount, 'discount' => $discountAmount,
'promotion' => $promotion ? $promotion->getName() : null 'promotion' => $promotion ? $promotion->getName() : null,
'formule' => $formule ? $formule->getName() : null
], ],
'tvaEnabled' => $tvaEnabled 'tvaEnabled' => $tvaEnabled
]; ];

View File

@@ -76,6 +76,26 @@ class Webhooks extends AbstractController
if ($pl->getType() === 'accompte' && $contrat->isSigned()) { if ($pl->getType() === 'accompte' && $contrat->isSigned()) {
$contrat->setReservationState('ready'); $contrat->setReservationState('ready');
$entityManager->persist($contrat); $entityManager->persist($contrat);
// Send email to Prestataire
$prestataire = $contrat->getPrestataire();
if ($prestataire) {
$dateStart = $contrat->getDateAt()->format('d/m/Y');
$dateEnd = $contrat->getEndAt()->format('d/m/Y');
$mailer->send(
$prestataire->getEmail(),
$prestataire->getSurname() . ' ' . $prestataire->getName(),
"Nouveau contrat attribué - " . $contrat->getNumReservation(),
"mails/prestataire/new_contrat.twig",
[
'contrat' => $contrat,
'prestataire' => $prestataire,
'dateStart' => $dateStart,
'dateEnd' => $dateEnd
]
);
}
} }
$entityManager->flush(); $entityManager->flush();

View File

@@ -134,9 +134,11 @@ class Formules
/** /**
* @param \DateTimeImmutable|null $updatedAt * @param \DateTimeImmutable|null $updatedAt
*/ */
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{ {
$this->updatedAt = $updatedAt; $this->updatedAt = $updatedAt;
return $this;
} }
/** /**

View File

@@ -93,6 +93,9 @@ class OrderSession
#[ORM\Column(type: Types::JSON, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $promotion = null; private ?array $promotion = null;
#[ORM\ManyToOne(targetEntity: Formules::class)]
private ?Formules $formule = null;
#[ORM\OneToOne(mappedBy: 'orderSession', cascade: ['persist', 'remove'])] #[ORM\OneToOne(mappedBy: 'orderSession', cascade: ['persist', 'remove'])]
private ?Devis $devis = null; private ?Devis $devis = null;
@@ -469,4 +472,16 @@ class OrderSession
return $this; return $this;
} }
public function getFormule(): ?Formules
{
return $this->formule;
}
public function setFormule(?Formules $formule): static
{
$this->formule = $formule;
return $this;
}
} }

View File

@@ -173,10 +173,10 @@ class DevisPdfService extends Fpdf
$this->SetXY(80, $currentY); $this->SetXY(80, $currentY);
$this->SetFont('Arial', '', 8); $this->SetFont('Arial', '', 8);
$this->Cell(15, 10, $nbDays, 'LRB', 0, 'C'); $this->Cell(15, 10, $nbDays, 'LRB', 0, 'C');
$this->Cell(25, 10, number_format($price1Day, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R'); $this->Cell(25, 10, ($price1Day == 0 ? "Inclus" : number_format($price1Day, 2, ',', ' ') . $this->euro()), 'RB', 0, 'R');
$this->Cell(25, 10, number_format($priceSupHT, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R'); $this->Cell(25, 10, ($price1Day == 0 ? "-" : number_format($priceSupHT, 2, ',', ' ') . $this->euro()), 'RB', 0, 'R');
$this->Cell(15, 10, $tvaLabel, 'RB', 0, 'C'); $this->Cell(15, 10, $tvaLabel, 'RB', 0, 'C');
$this->Cell(40, 10, number_format($lineTotalHT, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R'); $this->Cell(40, 10, ($lineTotalHT == 0 && $price1Day == 0 ? "Inclus" : number_format($lineTotalHT, 2, ',', ' ') . $this->euro()), 'RB', 1, 'R');
$this->Line(10, $currentY, 10, $currentY + 10); $this->Line(10, $currentY, 10, $currentY + 10);
$this->Line(10, $currentY + 10, 80, $currentY + 10); $this->Line(10, $currentY + 10, 80, $currentY + 10);

View File

@@ -48,6 +48,7 @@ class StripeExtension extends AbstractExtension
$p = $this->em->getRepository(Product::class)->findOneBy(['name' => $name]); $p = $this->em->getRepository(Product::class)->findOneBy(['name' => $name]);
return [ return [
'id' => $p->getId(),
'name' => $p->getName(), 'name' => $p->getName(),
'image' => $this->uploaderHelper->asset($p,'imageFile'), 'image' => $this->uploaderHelper->asset($p,'imageFile'),
]; ];
@@ -276,7 +277,7 @@ class StripeExtension extends AbstractExtension
$sessionData = $session->getProducts(); $sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? []; $ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? []; $selectedOptionsMap = $sessionData['options'] ?? [];
$startStr = $sessionData['start'] ?? null; $startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null; $endStr = $sessionData['end'] ?? null;
@@ -294,15 +295,39 @@ class StripeExtension extends AbstractExtension
} }
$totalHT = 0; $totalHT = 0;
$formulaExtras = 0;
$productRepo = $this->em->getRepository(Product::class); $productRepo = $this->em->getRepository(Product::class);
$optionsRepo = $this->em->getRepository(Options::class); $optionsRepo = $this->em->getRepository(Options::class);
$formule = $session->getFormule();
$config = [];
if ($formule && ($restrict = $formule->getFormulesRestriction())) {
$config = $restrict->getRestrictionConfig() ?? [];
}
// Products // Products
foreach ($ids as $id) { foreach ($ids as $id) {
$product = $productRepo->find($id); $product = $productRepo->find($id);
if ($product) { if ($product) {
$price = $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1)); $price = $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1));
$totalHT += $price;
$isInFormule = false;
if ($formule) {
foreach ($config as $c) {
if (($c['product'] ?? '') === $product->getName()) {
$isInFormule = true;
break;
}
}
}
if ($formule) {
if (!$isInFormule) {
$formulaExtras += $price;
}
} else {
$totalHT += $price;
}
} }
} }
@@ -311,15 +336,38 @@ class StripeExtension extends AbstractExtension
foreach ($optIds as $optId) { foreach ($optIds as $optId) {
$option = $optionsRepo->find($optId); $option = $optionsRepo->find($optId);
if ($option) { if ($option) {
$totalHT += $option->getPriceHt(); if ($formule) {
$formulaExtras += $option->getPriceHt();
} else {
$totalHT += $option->getPriceHt();
}
} }
} }
} }
if ($formule) {
$formulaBasePrice = 0;
if ($duration <= 1) $formulaBasePrice = $formule->getPrice1j();
elseif ($duration <= 2) $formulaBasePrice = $formule->getPrice2j();
else $formulaBasePrice = $formule->getPrice5j();
$totalHT = $formulaBasePrice + $formulaExtras;
}
$originalHT = $totalHT;
$discountAmount = 0;
if ($promo = $session->getPromotion()) {
$discountAmount = $totalHT * ($promo['percentage'] / 100);
$totalHT -= $discountAmount;
}
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true"; $tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
return [ return [
'ht' => $totalHT, 'ht' => $totalHT,
'originalHT' => $originalHT,
'discount' => $discountAmount,
'duration' => $duration, 'duration' => $duration,
'tvaEnabled' => $tvaEnabled 'tvaEnabled' => $tvaEnabled
]; ];

View File

@@ -9,7 +9,7 @@
crossorigin=""/> crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script> crossorigin="" nonce="{{ csp_nonce('script') }}"></script>
<div class="space-y-8 pb-20"> <div class="space-y-8 pb-20">
@@ -55,12 +55,12 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Distance (km)</label> <label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Distance (km)</label>
<input type="number" step="0.1" name="deliveryDistance" value="{{ session.deliveryDistance }}" <input type="number" step="0.1" name="deliveryDistance" value="{{ session.deliveryDistance }}"
class="w-full bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all"> class="w-full bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all">
</div> </div>
<div> <div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Prix Livraison (€)</label> <label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Prix Livraison (€)</label>
<input type="number" step="0.01" name="deliveryPrice" value="{{ session.deliveryPrice }}" <input type="number" step="0.01" name="deliveryPrice" value="{{ session.deliveryPrice }}"
class="w-full bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all"> class="w-full bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all">
</div> </div>
<div> <div>
@@ -167,7 +167,7 @@
{% if session.deliveryDistance is not null %} {% if session.deliveryDistance is not null %}
<div class="mt-6 border-t border-slate-700/50 pt-6"> <div class="mt-6 border-t border-slate-700/50 pt-6">
<h4 class="text-white font-bold text-sm mb-4">Détails Livraison</h4> <h4 class="text-white font-bold text-sm mb-4">Détails Livraison</h4>
<div class="bg-slate-900/50 rounded-xl border border-slate-700/50 overflow-hidden mb-4"> <div class="bg-slate-900/50 rounded-xl border border-slate-700/50 overflow-hidden mb-4">
{# Map #} {# Map #}
{% if session.deliveryGeometry %} {% if session.deliveryGeometry %}
@@ -219,6 +219,18 @@
Contenu de la demande Contenu de la demande
</h3> </h3>
{% if session.formule %}
<div class="mb-6 p-4 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-center gap-3">
<div class="p-2 bg-blue-500/20 rounded-lg text-blue-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div>
<div>
<p class="text-[10px] font-bold text-blue-400 uppercase tracking-widest">Formule appliquée</p>
<p class="text-white font-bold">{{ session.formule.name }}</p>
</div>
</div>
{% endif %}
{% if session.products['start'] is defined and session.products['end'] is defined %} {% if session.products['start'] is defined and session.products['end'] is defined %}
<div class="flex items-center gap-4 mb-8 p-4 bg-slate-900/50 rounded-xl border border-slate-700/50 w-fit"> <div class="flex items-center gap-4 mb-8 p-4 bg-slate-900/50 rounded-xl border border-slate-700/50 w-fit">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -329,6 +341,16 @@
{% set totalData = totalSession(session) %} {% set totalData = totalSession(session) %}
<div class="mt-8 pt-6 border-t border-slate-700"> <div class="mt-8 pt-6 border-t border-slate-700">
<div class="flex flex-col gap-2 items-end"> <div class="flex flex-col gap-2 items-end">
{% if session.promotion %}
<div class="flex items-center gap-2 text-sm text-amber-400 font-bold uppercase mb-1">
<span>Promotion : {{ session.promotion.name }}</span>
<span class="bg-amber-500/10 px-2 py-0.5 rounded text-amber-500">-{{ session.promotion.percentage }}%</span>
</div>
<div class="text-xs text-slate-500 font-bold line-through">
{{ totalData.originalHT|number_format(2, ',', ' ') }} € HT
</div>
{% endif %}
<div class="text-sm text-slate-400"> <div class="text-sm text-slate-400">
Durée : <span class="text-white font-bold">{{ totalData.duration }} jour{{ totalData.duration > 1 ? 's' : '' }}</span> Durée : <span class="text-white font-bold">{{ totalData.duration }} jour{{ totalData.duration > 1 ? 's' : '' }}</span>
</div> </div>

View File

@@ -63,7 +63,7 @@
<li class="form-repeater__row group animate-in slide-in-from-right-5 duration-300"> <li class="form-repeater__row group animate-in slide-in-from-right-5 duration-300">
<fieldset class="backdrop-blur-md bg-white/5 border border-white/10 rounded-[2.5rem] p-6 hover:border-blue-500/30 transition-all shadow-xl"> <fieldset class="backdrop-blur-md bg-white/5 border border-white/10 rounded-[2.5rem] p-6 hover:border-blue-500/30 transition-all shadow-xl">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-5 items-end"> <div class="grid grid-cols-1 lg:grid-cols-12 gap-5 items-end">
<input type="hidden" name="rest[{{ key }}][id]" value="{{ line.id }}">
{# 1. PRODUIT #} {# 1. PRODUIT #}
<div class="lg:col-span-7"> <div class="lg:col-span-7">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Produit / Prestation</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Produit / Prestation</label>

View File

@@ -24,8 +24,8 @@
<div class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.8)] animate-pulse"></div> <div class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.8)] animate-pulse"></div>
<span class="text-[10px] font-black text-emerald-400 uppercase tracking-widest">En ligne</span> <span class="text-[10px] font-black text-emerald-400 uppercase tracking-widest">En ligne</span>
</div> </div>
<a data-turbo="false" href="{{ path('app_crm_formules_view', {id: formule.id, act: 'togglePublish', status: 'false'}) }}" <a href="{{ path('app_crm_formules_view', {id: formule.id, act: 'togglePublish', status: 'false'}) }}"
onclick="return confirm('Voulez-vous vraiment masquer cette formule ?')" data-turbo-confirm="Voulez-vous vraiment masquer cette formule ?"
class="px-6 py-3 bg-white/5 hover:bg-rose-600 text-slate-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-white/10"> class="px-6 py-3 bg-white/5 hover:bg-rose-600 text-slate-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all active:scale-95 border-l border-white/10">
Désactiver Désactiver
</a> </a>

View File

@@ -0,0 +1,28 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-bottom="0px">
<mj-column width="100%">
<mj-text font-size="20px" font-weight="900" color="#0f172a" text-transform="uppercase" font-style="italic" align="center">
Nouveau Contrat Attribué
</mj-text>
<mj-divider border-width="1px" border-color="#f1f5f9" padding-top="20px" padding-bottom="20px" />
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding-top="0px">
<mj-column width="100%">
<mj-text font-size="16px" color="#1e293b" padding-bottom="15px">
Bonjour {{ datas.prestataire.surname }} {{ datas.prestataire.name }},
</mj-text>
<mj-text font-size="16px" color="#0f172a" line-height="24px">
Un nouveaux contrat à vous été attribuer pour les date du <b>{{ datas.dateStart }}</b> au <b>{{ datas.dateEnd }}</b>.
</mj-text>
<mj-text padding-top="20px" font-size="12px" color="#64748b">
Référence : #{{ datas.contrat.numReservation }}
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -82,7 +82,12 @@
<div class="lg:col-span-3 space-y-6"> <div class="lg:col-span-3 space-y-6">
<div class="bg-white rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/40 overflow-hidden"> <div class="bg-white rounded-[2.5rem] border border-slate-100 shadow-xl shadow-slate-200/40 overflow-hidden">
<div class="p-8 border-b border-slate-50 flex justify-between items-center bg-slate-50/30"> <div class="p-8 border-b border-slate-50 flex justify-between items-center bg-slate-50/30">
<h2 class="text-xs font-black uppercase tracking-widest text-slate-900">Détail des prestations & Options</h2> <div>
<h2 class="text-xs font-black uppercase tracking-widest text-slate-900">Détail des prestations & Options</h2>
{% if contrat.devis and contrat.devis.orderSession and contrat.devis.orderSession.formule %}
<p class="text-[10px] font-bold text-blue-600 uppercase tracking-widest mt-1">Formule : {{ contrat.devis.orderSession.formule.name }}</p>
{% endif %}
</div>
<span class="bg-blue-600 text-white text-[10px] font-black px-4 py-1.5 rounded-full uppercase">{{ days }} Jours</span> <span class="bg-blue-600 text-white text-[10px] font-black px-4 py-1.5 rounded-full uppercase">{{ days }} Jours</span>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View File

@@ -70,6 +70,18 @@
</div> </div>
</div> </div>
{% if cart.formule %}
<div class="mb-6 bg-blue-50 border border-blue-100 rounded-xl p-4 flex items-center gap-3">
<div class="bg-white p-2 rounded-lg text-blue-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div>
<div>
<p class="text-[10px] font-black text-blue-400 uppercase tracking-widest">Formule appliquée</p>
<p class="text-sm font-bold text-slate-900">{{ cart.formule }}</p>
</div>
</div>
{% endif %}
<div class="space-y-4"> <div class="space-y-4">
{% for item in cart.items %} {% for item in cart.items %}
<div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm"> <div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
@@ -83,6 +95,9 @@
</div> </div>
{% endif %} {% endif %}
<div class="flex-1"> <div class="flex-1">
{% if item.in_formule is defined and item.in_formule %}
<span class="inline-block px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-widest bg-blue-100 text-blue-600 mb-1">Inclus formule</span>
{% endif %}
<h3 class="font-bold text-slate-800">{{ item.product.name }}</h4> <h3 class="font-bold text-slate-800">{{ item.product.name }}</h4>
<div class="text-xs text-slate-600 bg-slate-50 p-2 rounded-lg border border-slate-100 inline-block mt-2"> <div class="text-xs text-slate-600 bg-slate-50 p-2 rounded-lg border border-slate-100 inline-block mt-2">

View File

@@ -32,6 +32,18 @@
</div> </div>
</div> </div>
{% if cart.formule %}
<div class="mb-6 bg-blue-50 border border-blue-100 rounded-xl p-4 flex items-center gap-3">
<div class="bg-white p-2 rounded-lg text-blue-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
</div>
<div>
<p class="text-[10px] font-black text-blue-400 uppercase tracking-widest">Formule appliquée</p>
<p class="text-sm font-bold text-slate-900">{{ cart.formule }}</p>
</div>
</div>
{% endif %}
<div class="space-y-4"> <div class="space-y-4">
{% for item in cart.items %} {% for item in cart.items %}
<div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm"> <div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
@@ -45,6 +57,9 @@
</div> </div>
{% endif %} {% endif %}
<div class="flex-1"> <div class="flex-1">
{% if item.in_formule is defined and item.in_formule %}
<span class="inline-block px-2 py-0.5 rounded text-[8px] font-black uppercase tracking-widest bg-blue-100 text-blue-600 mb-1">Inclus formule</span>
{% endif %}
<h4 class="font-bold text-slate-800">{{ item.product.name }}</h4> <h4 class="font-bold text-slate-800">{{ item.product.name }}</h4>
<div class="text-xs text-slate-500 mb-2 prose prose-sm max-w-none"> <div class="text-xs text-slate-500 mb-2 prose prose-sm max-w-none">
{{ item.product.description|raw }} {{ item.product.description|raw }}
@@ -361,7 +376,7 @@
Télécharger le devis Télécharger le devis
</a> </a>
<form data-turbo="false" method="post" action="{{ path('reservation_flow_confirmed', {sessionId: session.uuid}) }}" onsubmit="localStorage.clear();" class="w-full md:w-auto"> <form is="submit-clear-storage" data-turbo="false" method="post" action="{{ path('reservation_flow_confirmed', {sessionId: session.uuid}) }}" class="w-full md:w-auto">
<button type="submit" class="w-full 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"> <button type="submit" class="w-full 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 Je confirme la commande
</button> </button>

View File

@@ -92,58 +92,92 @@
<div class="max-w-7xl mx-auto px-4 py-20"> <div class="max-w-7xl mx-auto px-4 py-20">
{% if formule.type == "free" %} {% if formule.type == "free" %}
{# --- DESIGN BENTO POUR FORMULE FREE --- #} <flow-formule-configurator
<div class="flex items-center space-x-4 mb-12"> data-limits='{"structure": {{ formule.formulesRestriction.nbStructureMax|default(0) }}, "alimentaire": {{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}, "barnum": {{ formule.formulesRestriction.nbBarhumsMax|default(0) }}}'
<h2 class="text-4xl font-black text-slate-900 uppercase italic tracking-tighter">Composez <span class="text-[#f39e36]">votre pack</span></h2> data-prices='{"p1": {{ formule.price1j|default(0) }}, "p2": {{ formule.price2j|default(0) }}, "p5": {{ formule.price5j|default(0) }}}'
<div class="h-1 flex-grow bg-slate-100 rounded-full"></div> data-formule-id="{{ formule.id }}">
</div>
{# --- DESIGN BENTO POUR FORMULE FREE --- #}
{# Grille Bento des Quotas #} <div class="flex items-center space-x-4 mb-12">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16"> <h2 class="text-4xl font-black text-slate-900 uppercase italic tracking-tighter">Composez <span class="text-[#f39e36]">votre pack</span></h2>
<div class="bg-blue-50 border-4 border-slate-900 rounded-[2.5rem] p-8 flex flex-col justify-between h-30 group hover:-translate-y-1 transition-transform"> <div class="h-1 flex-grow bg-slate-100 rounded-full"></div>
<div>
<p class="text-4xl font-black text-slate-900 leading-none">{{ formule.formulesRestriction.nbStructureMax|default(0) }}</p>
<p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Structures au choix</p>
</div>
</div> </div>
<div class="bg-orange-50 border-4 border-slate-900 rounded-[2.5rem] p-8 flex flex-col justify-between h-30 group hover:-translate-y-1 transition-transform"> {# Grille Bento des Quotas #}
<div> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
<p class="text-4xl font-black text-slate-900 leading-none">{{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}</p> <div class="bg-blue-50 border-4 border-slate-900 rounded-[2.5rem] p-8 flex flex-col justify-between h-30 group hover:-translate-y-1 transition-transform">
<p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Animations Alimentaires</p> <div>
</div> <p class="text-4xl font-black text-slate-900 leading-none">
</div> <span id="count-structure" class="text-[#f39e36]">0</span><span class="text-2xl text-slate-400">/{{ formule.formulesRestriction.nbStructureMax|default(0) }}</span>
</p>
<div class="bg-purple-50 border-4 border-slate-900 rounded-[2.5rem] p-8 flex flex-col justify-between h-30 group hover:-translate-y-1 transition-transform"> <p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Structures au choix</p>
<div>
<p class="text-4xl font-black text-slate-900 leading-none">{{ formule.formulesRestriction.nbBarhumsMax|default(0) }}</p>
<p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Barnums & Mobilier</p>
</div>
</div>
</div>
{# Catalogue des produits éligibles #}
<h3 class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] mb-8 text-center italic">— Catalogue éligible à cette formule —</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
{% for item in formule.formulesRestriction.restrictionConfig %}
{% set product = loadProductByName(item.product) %}
<div class="group border-2 border-slate-900 rounded-[2rem] p-2 bg-white hover:bg-slate-50 transition-all shadow-[8px_8px_0px_0px_rgba(15,23,42,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1">
<div class="aspect-square rounded-[1.5rem] overflow-hidden bg-slate-100 mb-3 border border-slate-900/10">
{% if product.image %}
<img src="{{ product.image|imagine_filter('webp') }}" alt="{{ product.name }}" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500">
{% else %}
<div class="w-full h-full flex items-center justify-center italic text-[8px] font-black text-slate-300 uppercase">Image non disp.</div>
{% endif %}
</div>
<div class="px-2 pb-2">
<span class="text-[8px] font-black uppercase px-2 py-0.5 rounded-full border border-slate-900 {% if item.type == 'structure' %}bg-blue-100{% elseif item.type == 'alimentaire' %}bg-orange-100{% else %}bg-purple-100{% endif %}">
{{ item.type }}
</span>
<h4 class="text-[11px] font-black text-slate-900 uppercase italic leading-tight mt-2 line-clamp-2">{{ product.name|default(item.product) }}</h4>
</div> </div>
</div> </div>
{% endfor %}
</div> <div class="bg-orange-50 border-4 border-slate-900 rounded-[2.5rem] p-8 flex flex-col justify-between h-30 group hover:-translate-y-1 transition-transform">
<div>
<p class="text-4xl font-black text-slate-900 leading-none">
<span id="count-alimentaire" class="text-[#f39e36]">0</span><span class="text-2xl text-slate-400">/{{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}</span>
</p>
<p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Animations Alimentaires</p>
</div>
</div>
<div class="bg-purple-50 border-4 border-slate-900 rounded-[2.5rem] p-8 flex flex-col justify-between h-30 group hover:-translate-y-1 transition-transform">
<div>
<p class="text-4xl font-black text-slate-900 leading-none">
<span id="count-barnum" class="text-[#f39e36]">0</span><span class="text-2xl text-slate-400">/{{ formule.formulesRestriction.nbBarhumsMax|default(0) }}</span>
</p>
<p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Barnums & Mobilier</p>
</div>
</div>
</div>
{# Catalogue des produits éligibles #}
<h3 class="text-[10px] font-black text-slate-400 uppercase tracking-[0.4em] mb-8 text-center italic">— Catalogue éligible à cette formule —</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6" id="pack-grid">
{% for item in formule.formulesRestriction.restrictionConfig %}
{% set product = loadProductByName(item.product) %}
{% set catKey = item.type %}
{% if item.type != 'structure' and item.type != 'alimentaire' %}
{% set catKey = 'barnum' %}
{% endif %}
<div class="product-card group cursor-pointer border-2 border-slate-900 rounded-[2rem] p-2 bg-white hover:bg-slate-50 transition-all shadow-[8px_8px_0px_0px_rgba(15,23,42,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 relative"
data-category="{{ catKey }}"
data-id="{{ product.id }}"
data-selected="false">
{# Checkbox Indicator #}
<div class="absolute top-4 right-4 z-10 w-6 h-6 bg-white border-2 border-slate-900 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity selection-indicator">
<div class="w-3 h-3 bg-[#f39e36] rounded-full hidden"></div>
</div>
<div class="aspect-square rounded-[1.5rem] overflow-hidden bg-slate-100 mb-3 border border-slate-900/10">
{% if product.image %}
<img src="{{ product.image|imagine_filter('webp') }}" alt="{{ product.name }}" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500">
{% else %}
<div class="w-full h-full flex items-center justify-center italic text-[8px] font-black text-slate-300 uppercase">Image non disp.</div>
{% endif %}
</div>
<div class="px-2 pb-2">
<span class="text-[8px] font-black uppercase px-2 py-0.5 rounded-full border border-slate-900 {% if item.type == 'structure' %}bg-blue-100{% elseif item.type == 'alimentaire' %}bg-orange-100{% else %}bg-purple-100{% endif %}">
{{ item.type }}
</span>
<h4 class="text-[11px] font-black text-slate-900 uppercase italic leading-tight mt-2 line-clamp-2">{{ product.name|default(item.product) }}</h4>
</div>
</div>
{% endfor %}
</div>
{# Action Bar #}
<div class="mt-12 text-center sticky bottom-8 z-50">
<a id="btn-validate-pack" href="#" class="inline-block px-12 py-4 bg-slate-900 text-white rounded-full font-black uppercase italic tracking-widest hover:bg-[#fc0e50] transition-all shadow-xl opacity-50 pointer-events-none transform translate-y-4">
Valider mon pack (0)
</a>
</div>
</flow-formule-configurator>
{% endif %} {% endif %}
{% if formule.type == "pack" %} {% if formule.type == "pack" %}