✨ 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:
@@ -130,7 +130,9 @@ export class SearchOptionsFormule extends HTMLButtonElement {
|
||||
const row = this.closest('.form-repeater__row');
|
||||
if (row) {
|
||||
const nameInput = row.querySelector('input[name*="[product]"]');
|
||||
const nameId = row.querySelector('input[name*="[id]"]');
|
||||
if(nameInput) nameInput.value = option.name;
|
||||
if(nameId) nameId.value = option.id;
|
||||
|
||||
const fieldset = row.querySelector('fieldset');
|
||||
if (fieldset) {
|
||||
@@ -275,8 +277,10 @@ export class SearchProductFormule extends HTMLButtonElement {
|
||||
fillFormLine(product) {
|
||||
const row = this.closest('.form-repeater__row');
|
||||
if (row) {
|
||||
const nameId = row.querySelector('input[name*="[id]"]');
|
||||
const nameInput = row.querySelector('input[name*="[product]"]');
|
||||
if(nameInput) nameInput.value = product.name;
|
||||
if(nameId) nameId.value = product.id;
|
||||
|
||||
const fieldset = row.querySelector('fieldset');
|
||||
if (fieldset) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { CookieBanner } from "./tools/CookieBanner.js";
|
||||
import { FlowReserve } from "./tools/FlowReserve.js";
|
||||
import { FlowDatePicker } from "./tools/FlowDatePicker.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 { LocalStorageClear } from "./tools/LocalStorageClear.js";
|
||||
import * as Turbo from "@hotwired/turbo";
|
||||
@@ -265,6 +267,12 @@ const registerComponents = () => {
|
||||
|
||||
if(!customElements.get('flow-add-to-cart'))
|
||||
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', () => {
|
||||
|
||||
190
assets/tools/FlowFormuleConfigurator.js
Normal file
190
assets/tools/FlowFormuleConfigurator.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,8 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
ids,
|
||||
options,
|
||||
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 class="flex-1 flex flex-col justify-between">
|
||||
<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>
|
||||
<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>
|
||||
@@ -395,13 +397,23 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
const total = data.total || {};
|
||||
const hasTva = total.totalTva > 0;
|
||||
const promotion = data.promotion;
|
||||
const formuleName = total.formule;
|
||||
|
||||
let promoHtml = '';
|
||||
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) {
|
||||
originalTotalHT = total.totalHT + total.discount;
|
||||
promoHtml = `
|
||||
promoHtml += `
|
||||
<div class="flex justify-between text-xs text-[#f39e36] font-bold uppercase">
|
||||
<span>${promotion.name} (-${promotion.percentage}%)</span>
|
||||
<span>-${this.formatPrice(total.discount)}</span>
|
||||
@@ -460,7 +472,8 @@ export class FlowReserve extends HTMLAnchorElement {
|
||||
ids,
|
||||
options,
|
||||
start: dates.start,
|
||||
end: dates.end
|
||||
end: dates.end,
|
||||
formule: sessionStorage.getItem('active_formule')
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
12
assets/tools/SubmitClearStorage.js
Normal file
12
assets/tools/SubmitClearStorage.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export class SubmitClearStorage extends HTMLFormElement {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('submit', () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export class UtmAccount extends HTMLElement {
|
||||
|
||||
// 4. Envoi du sessionId à ton backend Symfony
|
||||
if (sessionId) {
|
||||
await fetch('/reservation/umami', {
|
||||
await fetch('/umami', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ umami_session: sessionId })
|
||||
|
||||
@@ -46,6 +46,7 @@ nelmio_security:
|
||||
- "https://challenges.cloudflare.com"
|
||||
- "https://tools-security.esy-web.dev"
|
||||
- "https://checkout.stripe.com/"
|
||||
- "https://unpkg.com/"
|
||||
frame-src:
|
||||
- "'self'"
|
||||
- "https://chat.esy-web.dev"
|
||||
|
||||
35
migrations/Version20260209123602.php
Normal file
35
migrations/Version20260209123602.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,18 @@ class FormulesController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
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->flush();
|
||||
|
||||
@@ -341,6 +353,6 @@ class FormulesController extends AbstractController
|
||||
if ($restriction && !empty($restriction->getRestrictionConfig())) {
|
||||
return $restriction->getRestrictionConfig();
|
||||
}
|
||||
return [['type' => 'structure', 'product' => '']];
|
||||
return [['type' => 'structure', 'product' => '','id'=>0]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerTracking;
|
||||
use App\Entity\Formules;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\ProductReserve;
|
||||
use App\Entity\Promotion;
|
||||
@@ -103,6 +104,29 @@ class ReserverController extends AbstractController
|
||||
$selectedOptionsMap = $sessionData['options'] ?? [];
|
||||
$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)) {
|
||||
$products = $productRepository->findBy(['id' => $ids]);
|
||||
$processedProductIds = [];
|
||||
@@ -110,15 +134,31 @@ class ReserverController extends AbstractController
|
||||
foreach ($products as $product) {
|
||||
$processedProductIds[] = $product->getId();
|
||||
|
||||
$isInFormule = false;
|
||||
if ($formule) {
|
||||
foreach ($formuleConfig as $c) {
|
||||
if (($c['product'] ?? '') === $product->getName()) {
|
||||
$isInFormule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$line = new DevisLine();
|
||||
$line->setProduct($product->getName());
|
||||
|
||||
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);
|
||||
$devis->addDevisLine($line);
|
||||
|
||||
$runningTotalHT += $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1));
|
||||
|
||||
if (isset($selectedOptionsMap[$product->getId()])) {
|
||||
$optionIds = $selectedOptionsMap[$product->getId()];
|
||||
if (!empty($optionIds)) {
|
||||
@@ -377,7 +417,8 @@ class ReserverController extends AbstractController
|
||||
ProductRepository $productRepository,
|
||||
OptionsRepository $optionsRepository,
|
||||
UploaderHelper $uploaderHelper,
|
||||
PromotionRepository $promotionRepository
|
||||
PromotionRepository $promotionRepository,
|
||||
FormulesRepository $formulesRepository
|
||||
): Response {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$ids = $data['ids'] ?? [];
|
||||
@@ -398,7 +439,13 @@ class ReserverController extends AbstractController
|
||||
$promotions = $promotionRepository->findActivePromotions(new \DateTime());
|
||||
$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([
|
||||
'start_date' => $startStr,
|
||||
@@ -407,12 +454,13 @@ class ReserverController extends AbstractController
|
||||
'options' => $cartData['rootOptions'],
|
||||
'unavailable_products_ids' => $removedIds,
|
||||
'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'])]
|
||||
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);
|
||||
$existingUuid = $request->getSession()->get('order_session_uuid');
|
||||
@@ -442,6 +490,65 @@ class ReserverController extends AbstractController
|
||||
$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();
|
||||
if ($user instanceof Customer) {
|
||||
$session->setCustomer($user);
|
||||
@@ -531,7 +638,9 @@ class ReserverController extends AbstractController
|
||||
$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 ---
|
||||
$deliveryData = $this->calculateDelivery(
|
||||
@@ -554,6 +663,7 @@ class ReserverController extends AbstractController
|
||||
'totalTTC' => $cartData['total']['totalTTC'],
|
||||
'discount' => $cartData['total']['discount'],
|
||||
'promotion' => $cartData['total']['promotion'],
|
||||
'formule' => $cartData['total']['formule'],
|
||||
'tvaEnabled' => $cartData['tvaEnabled'],
|
||||
],
|
||||
'delivery' => $deliveryData
|
||||
@@ -633,7 +743,9 @@ class ReserverController extends AbstractController
|
||||
$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', [
|
||||
'session' => $session,
|
||||
@@ -650,6 +762,7 @@ class ReserverController extends AbstractController
|
||||
'totalTTC' => $cartData['total']['totalTTC'],
|
||||
'discount' => $cartData['total']['discount'],
|
||||
'promotion' => $cartData['total']['promotion'],
|
||||
'formule' => $cartData['total']['formule'],
|
||||
'tvaEnabled' => $cartData['tvaEnabled'],
|
||||
]
|
||||
]);
|
||||
@@ -1209,15 +1322,24 @@ class ReserverController extends AbstractController
|
||||
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 = [];
|
||||
$rootOptions = [];
|
||||
$totalHT = 0;
|
||||
$formulaExtras = 0;
|
||||
$tvaEnabled = $this->isTvaEnabled();
|
||||
$tvaRate = $tvaEnabled ? 0.20 : 0;
|
||||
$processedProductIds = [];
|
||||
|
||||
$formuleConfig = [];
|
||||
if ($formule) {
|
||||
$restriction = $formule->getFormulesRestriction();
|
||||
if ($restriction) {
|
||||
$formuleConfig = $restriction->getRestrictionConfig() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($products as $product) {
|
||||
$processedProductIds[] = $product->getId();
|
||||
$price1Day = $product->getPriceDay();
|
||||
@@ -1247,31 +1369,54 @@ class ReserverController extends AbstractController
|
||||
$optionsTotalHT += $optPrice;
|
||||
} else {
|
||||
$rootOptions[] = $optData;
|
||||
if ($formule) {
|
||||
$formulaExtras += $optPrice;
|
||||
} else {
|
||||
$totalHT += $optPrice;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$productTotalHT += $optionsTotalHT;
|
||||
$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[] = [
|
||||
'id' => $product->getId(), // Ensure ID is present for basketJson
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'product' => $product, // Kept for twig compatibility if needed
|
||||
'product' => $product,
|
||||
'image' => $uploaderHelper->asset($product, 'imageFile'),
|
||||
'priceHt1Day' => $price1Day, // For basketJson
|
||||
'priceHTSupDay' => $priceSup, // For basketJson
|
||||
'priceHt1Day' => $price1Day,
|
||||
'priceHTSupDay' => $priceSup,
|
||||
'priceTTC1Day' => $price1Day * (1 + $tvaRate),
|
||||
'price1Day' => $price1Day, // For flow template
|
||||
'priceSup' => $priceSup, // For flow template
|
||||
'price1Day' => $price1Day,
|
||||
'priceSup' => $priceSup,
|
||||
'totalPriceHT' => $productTotalHT,
|
||||
'totalPriceTTC' => $productTotalTTC,
|
||||
'options' => $productOptions
|
||||
'options' => $productOptions,
|
||||
'in_formule' => $isInFormule
|
||||
];
|
||||
|
||||
$totalHT += $productTotalHT;
|
||||
}
|
||||
|
||||
// Traiter les options orphelines
|
||||
@@ -1286,10 +1431,27 @@ class ReserverController extends AbstractController
|
||||
'price' => $optPrice,
|
||||
'orphan_product_id' => $prodId
|
||||
];
|
||||
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;
|
||||
if ($promotion) {
|
||||
@@ -1308,7 +1470,8 @@ class ReserverController extends AbstractController
|
||||
'totalTva' => $totalTva,
|
||||
'totalTTC' => $totalTTC,
|
||||
'discount' => $discountAmount,
|
||||
'promotion' => $promotion ? $promotion->getName() : null
|
||||
'promotion' => $promotion ? $promotion->getName() : null,
|
||||
'formule' => $formule ? $formule->getName() : null
|
||||
],
|
||||
'tvaEnabled' => $tvaEnabled
|
||||
];
|
||||
|
||||
@@ -76,6 +76,26 @@ class Webhooks extends AbstractController
|
||||
if ($pl->getType() === 'accompte' && $contrat->isSigned()) {
|
||||
$contrat->setReservationState('ready');
|
||||
$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();
|
||||
|
||||
@@ -134,9 +134,11 @@ class Formules
|
||||
/**
|
||||
* @param \DateTimeImmutable|null $updatedAt
|
||||
*/
|
||||
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
|
||||
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,6 +93,9 @@ class OrderSession
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $promotion = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Formules::class)]
|
||||
private ?Formules $formule = null;
|
||||
|
||||
#[ORM\OneToOne(mappedBy: 'orderSession', cascade: ['persist', 'remove'])]
|
||||
private ?Devis $devis = null;
|
||||
|
||||
@@ -469,4 +472,16 @@ class OrderSession
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFormule(): ?Formules
|
||||
{
|
||||
return $this->formule;
|
||||
}
|
||||
|
||||
public function setFormule(?Formules $formule): static
|
||||
{
|
||||
$this->formule = $formule;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,10 +173,10 @@ class DevisPdfService extends Fpdf
|
||||
$this->SetXY(80, $currentY);
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$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, number_format($priceSupHT, 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, ($price1Day == 0 ? "-" : number_format($priceSupHT, 2, ',', ' ') . $this->euro()), 'RB', 0, 'R');
|
||||
$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, 80, $currentY + 10);
|
||||
|
||||
@@ -48,6 +48,7 @@ class StripeExtension extends AbstractExtension
|
||||
$p = $this->em->getRepository(Product::class)->findOneBy(['name' => $name]);
|
||||
|
||||
return [
|
||||
'id' => $p->getId(),
|
||||
'name' => $p->getName(),
|
||||
'image' => $this->uploaderHelper->asset($p,'imageFile'),
|
||||
];
|
||||
@@ -294,32 +295,79 @@ class StripeExtension extends AbstractExtension
|
||||
}
|
||||
|
||||
$totalHT = 0;
|
||||
$formulaExtras = 0;
|
||||
$productRepo = $this->em->getRepository(Product::class);
|
||||
$optionsRepo = $this->em->getRepository(Options::class);
|
||||
|
||||
$formule = $session->getFormule();
|
||||
$config = [];
|
||||
if ($formule && ($restrict = $formule->getFormulesRestriction())) {
|
||||
$config = $restrict->getRestrictionConfig() ?? [];
|
||||
}
|
||||
|
||||
// Products
|
||||
foreach ($ids as $id) {
|
||||
$product = $productRepo->find($id);
|
||||
if ($product) {
|
||||
$price = $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1));
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Options
|
||||
foreach ($selectedOptionsMap as $prodId => $optIds) {
|
||||
foreach ($optIds as $optId) {
|
||||
$option = $optionsRepo->find($optId);
|
||||
if ($option) {
|
||||
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";
|
||||
|
||||
return [
|
||||
'ht' => $totalHT,
|
||||
'originalHT' => $originalHT,
|
||||
'discount' => $discountAmount,
|
||||
'duration' => $duration,
|
||||
'tvaEnabled' => $tvaEnabled
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
crossorigin=""/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
crossorigin="" nonce="{{ csp_nonce('script') }}"></script>
|
||||
|
||||
<div class="space-y-8 pb-20">
|
||||
|
||||
@@ -219,6 +219,18 @@
|
||||
Contenu de la demande
|
||||
</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 %}
|
||||
<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">
|
||||
@@ -329,6 +341,16 @@
|
||||
{% set totalData = totalSession(session) %}
|
||||
<div class="mt-8 pt-6 border-t border-slate-700">
|
||||
<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">
|
||||
Durée : <span class="text-white font-bold">{{ totalData.duration }} jour{{ totalData.duration > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<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">
|
||||
<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 #}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<span class="text-[10px] font-black text-emerald-400 uppercase tracking-widest">En ligne</span>
|
||||
</div>
|
||||
<a data-turbo="false" href="{{ path('app_crm_formules_view', {id: formule.id, act: 'togglePublish', status: 'false'}) }}"
|
||||
onclick="return confirm('Voulez-vous vraiment masquer cette formule ?')"
|
||||
<a href="{{ path('app_crm_formules_view', {id: formule.id, act: 'togglePublish', status: 'false'}) }}"
|
||||
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">
|
||||
Désactiver
|
||||
</a>
|
||||
|
||||
28
templates/mails/prestataire/new_contrat.twig
Normal file
28
templates/mails/prestataire/new_contrat.twig
Normal 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 %}
|
||||
@@ -82,7 +82,12 @@
|
||||
<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="p-8 border-b border-slate-50 flex justify-between items-center bg-slate-50/30">
|
||||
<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>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
</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">
|
||||
{% for item in cart.items %}
|
||||
<div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||||
@@ -83,6 +95,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
<div class="text-xs text-slate-600 bg-slate-50 p-2 rounded-lg border border-slate-100 inline-block mt-2">
|
||||
|
||||
@@ -32,6 +32,18 @@
|
||||
</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">
|
||||
{% for item in cart.items %}
|
||||
<div class="flex items-center bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
|
||||
@@ -45,6 +57,9 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
<div class="text-xs text-slate-500 mb-2 prose prose-sm max-w-none">
|
||||
{{ item.product.description|raw }}
|
||||
@@ -361,7 +376,7 @@
|
||||
Télécharger le devis
|
||||
</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">
|
||||
Je confirme la commande
|
||||
</button>
|
||||
|
||||
@@ -92,6 +92,11 @@
|
||||
<div class="max-w-7xl mx-auto px-4 py-20">
|
||||
|
||||
{% if formule.type == "free" %}
|
||||
<flow-formule-configurator
|
||||
data-limits='{"structure": {{ formule.formulesRestriction.nbStructureMax|default(0) }}, "alimentaire": {{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}, "barnum": {{ formule.formulesRestriction.nbBarhumsMax|default(0) }}}'
|
||||
data-prices='{"p1": {{ formule.price1j|default(0) }}, "p2": {{ formule.price2j|default(0) }}, "p5": {{ formule.price5j|default(0) }}}'
|
||||
data-formule-id="{{ formule.id }}">
|
||||
|
||||
{# --- DESIGN BENTO POUR FORMULE FREE --- #}
|
||||
<div class="flex items-center space-x-4 mb-12">
|
||||
<h2 class="text-4xl font-black text-slate-900 uppercase italic tracking-tighter">Composez <span class="text-[#f39e36]">votre pack</span></h2>
|
||||
@@ -102,21 +107,27 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-16">
|
||||
<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>
|
||||
<p class="text-4xl font-black text-slate-900 leading-none">{{ formule.formulesRestriction.nbStructureMax|default(0) }}</p>
|
||||
<p class="text-4xl font-black text-slate-900 leading-none">
|
||||
<span id="count-structure" class="text-[#f39e36]">0</span><span class="text-2xl text-slate-400">/{{ formule.formulesRestriction.nbStructureMax|default(0) }}</span>
|
||||
</p>
|
||||
<p class="text-xs font-black text-slate-400 uppercase italic mt-2 tracking-widest">Structures au choix</p>
|
||||
</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">
|
||||
<div>
|
||||
<p class="text-4xl font-black text-slate-900 leading-none">{{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}</p>
|
||||
<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">{{ formule.formulesRestriction.nbBarhumsMax|default(0) }}</p>
|
||||
<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>
|
||||
@@ -124,10 +135,25 @@
|
||||
|
||||
{# 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">
|
||||
|
||||
<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) %}
|
||||
<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">
|
||||
{% 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">
|
||||
@@ -144,6 +170,14 @@
|
||||
</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 %}
|
||||
|
||||
{% if formule.type == "pack" %}
|
||||
|
||||
Reference in New Issue
Block a user