feat(reservation/flow): Améliore le flux de réservation et ajoute des options.

Cette commit améliore le flux de réservation, ajoute une estimation des
frais de livraison et gère les options de produit et les paiements.
```
This commit is contained in:
Serreau Jovann
2026-02-05 08:18:29 +01:00
parent c837095cc3
commit 1896f83107
28 changed files with 1654 additions and 215 deletions

View File

@@ -28,4 +28,4 @@ jobs:
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
cd /var/www/ludikevent-intranet && sudo update-alternatives --set php /usr/bin/php8.4 && php bin/console app:maintenance on && git pull origin master && sh ./update.sh && php bin/console app:maintenance off && php bin/console app:purge-cloudflare && sudo update-alternatives --set php /usr/bin/php8.3
cd /var/www/ludikevent-intranet && sudo update-alternatives --set php /usr/bin/php8.4 && php bin/console app:maintenance on && git reset --hard HEAD && git pull origin master && sh ./update.sh && php bin/console app:maintenance off && php bin/console app:purge-cloudflare && sudo update-alternatives --set php /usr/bin/php8.3

View File

@@ -14,6 +14,7 @@ import PlaningLogestics from "./libs/PlaningLogestics.js";
import {SortableReorder} from "./libs/SortableReorder.js";
import { StripeCommissionCalculator } from "./libs/StripeCommissionCalculator.js";
import { ProductAddOption } from "./libs/ProductAddOption.js";
import { LeafletMap } from "./tools/LeafletMap.js";
// --- CONFIGURATION SENTRY ---
Sentry.init({
@@ -43,7 +44,8 @@ const registerCustomElements = () => {
{ name: 'search-optionsdevis', class: SearchOptionsDevis, extends: 'button' },
{ name: 'crm-editor', class: CrmEditor, extends: 'textarea' },
{ name: 'stripe-commission-calculator', class: StripeCommissionCalculator, extends: 'div' },
{ name: 'product-add-option', class: ProductAddOption, extends: 'button' }
{ name: 'product-add-option', class: ProductAddOption, extends: 'button' },
{ name: 'leaflet-map', class: LeafletMap }
];
elements.forEach(el => {

View File

@@ -5,6 +5,7 @@ import { FlowReserve } from "./tools/FlowReserve.js";
import { FlowDatePicker } from "./tools/FlowDatePicker.js";
import { FlowAddToCart } from "./tools/FlowAddToCart.js";
import { LeafletMap } from "./tools/LeafletMap.js";
import { LocalStorageClear } from "./tools/LocalStorageClear.js";
import * as Turbo from "@hotwired/turbo";
import { onLCP, onINP, onCLS } from 'web-vitals';
import AOS from 'aos';
@@ -253,7 +254,7 @@ const initRegisterLogic = () => {
// --- INITIALISATION ---
const registerComponents = () => {
const comps = [['utm-event', UtmEvent], ['utm-account', UtmAccount], ['cookie-banner', CookieBanner], ['leaflet-map', LeafletMap]];
const comps = [['utm-event', UtmEvent], ['utm-account', UtmAccount], ['cookie-banner', CookieBanner], ['leaflet-map', LeafletMap], ['local-storage-clear', LocalStorageClear]];
comps.forEach(([name, cl]) => { if (!customElements.get(name)) customElements.define(name, cl); });
if(!customElements.get('flow-reserve'))

View File

@@ -0,0 +1,11 @@
export class LocalStorageClear extends HTMLElement {
connectedCallback() {
const keysAttr = this.getAttribute('keys');
if (keysAttr) {
keysAttr.split(',').forEach(key => {
localStorage.removeItem(key);
});
}
}
}

BIN
cert/email_smime.p12 Normal file

Binary file not shown.

125
cert/email_smime.pem Normal file
View File

@@ -0,0 +1,125 @@
Bag Attributes
localKeyID: DF 79 62 A4 C4 A4 70 75 2D 68 FF C7 FD AF 35 E1 7B BE A0 75
friendlyName: no-reply@esy-web.fr
subject=CN = no-reply@esy-web.fr
issuer=C = IT, ST = Bergamo, L = Ponte San Pietro, O = Actalis S.p.A., CN = Actalis Client Authentication CA G3
-----BEGIN CERTIFICATE-----
MIIF6zCCA9OgAwIBAgIQMdJDXJvY5Eybkx2x+JNOYjANBgkqhkiG9w0BAQsFADCB
gTELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB0JlcmdhbW8xGTAXBgNVBAcMEFBvbnRl
IFNhbiBQaWV0cm8xFzAVBgNVBAoMDkFjdGFsaXMgUy5wLkEuMSwwKgYDVQQDDCNB
Y3RhbGlzIENsaWVudCBBdXRoZW50aWNhdGlvbiBDQSBHMzAeFw0yNjAyMDQwOTMy
MDhaFw0yNzAyMDQwOTMyMDhaMB4xHDAaBgNVBAMME25vLXJlcGx5QGVzeS13ZWIu
ZnIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDQFhbttYZYfQc/FZ9N
qikTdDoA5jvXaZLKtmadF51M4jHypRf/4c8kmMm/zFkGHeu3Syprp1bxEpOvDtyj
1UmNoiOBEax/Gf6J4CSCz2Naf39Ps2wOpxJlwNI50zhTcGZa3r5ns33t2lvZd9Ul
1xKs58YkX99Af83qSCHbzYtyjqX9NKlkK5wHvzS1/Xd56SMxrCd6VkTvYG23QLMx
5LHK2Ei2IOMEZ4XxXKKmoXfvUtbtCfFs0l8n12Xnu7W4k3t1ijwQ0oVZ8DPoNPQX
yMKZ3rPlM2BkxkX2TjuDBJrArhqF3V3oUlYeFhiTUvt1bzPGZCMPy095QcINul3K
6VGHAgMBAAGjggG/MIIBuzAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFL6XqaqE
v4C/EFN9CTL54S4yG893MH4GCCsGAQUFBwEBBHIwcDA7BggrBgEFBQcwAoYvaHR0
cDovL2NhY2VydC5hY3RhbGlzLml0L2NlcnRzL2FjdGFsaXMtYXV0Y2xpZzMwMQYI
KwYBBQUHMAGGJWh0dHA6Ly9vY3NwMDkuYWN0YWxpcy5pdC9WQS9BVVRIQ0wtRzMw
HgYDVR0RBBcwFYETbm8tcmVwbHlAZXN5LXdlYi5mcjBSBgNVHSAESzBJMDwGBiuB
HxABATAyMDAGCCsGAQUFBwIBFiRodHRwczovL3d3dy5hY3RhbGlzLml0L2FyZWEt
ZG93bmxvYWQwCQYHZ4EMAQUBAjAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUH
AwQwSAYDVR0fBEEwPzA9oDugOYY3aHR0cDovL2NybDA5LmFjdGFsaXMuaXQvUmVw
b3NpdG9yeS9BVVRIQ0wtRzMvZ2V0TGFzdENSTDAdBgNVHQ4EFgQU33lipMSkcHUt
aP/H/a814Xu+oHUwDgYDVR0PAQH/BAQDAgWgMA0GCSqGSIb3DQEBCwUAA4ICAQB/
iPZkXh6YIwf7dzjIyyLoO/ZwXVF63jR9Yvdrzt+UIkgbN3wieLOS+9feC5IDgcJ0
3zl3NkBODVqM5r1fMuhOGazZirZ35jmfV7FgAWEGzuCDiVPKc95hegUhoCnvGniE
UKn0NAJvkk0ml6n95Yxk6PwAtE9B4uNCJxGf5gEAWgi2Ln35j3GVqkwrryiYf3qe
TsWTqGOoS+b/RF6Vxl+BuVPuVK9hhNi6b4sE3OltBeL71u4JnLUpwxpSqCbxsvgd
XEfYZnsIrI44rE2DGGJI/pQ4Oc4URI/PbyuSkLgqZ2C2zjw/gJcztiNk3CahtqxE
ZoBc3XhxAjTsCS5JYsgZWl8n9oI6Vb92zNxGd1I/aFEh9qn4YlWp2g9DwMzoH0Yo
XLZVHZz0dgzZNMWPgKlHhrs7hrYg80b5MuH0kUaP/CEknuVqm77BRp6WdUhAt493
FSM6wlMggyDFKJfo/VDJUxpOtLwh/gP44Vkgejq99mueOswhd0tZnBuDdXfEEtqG
HBIOGivnLUaJM1zI8C+j56ZaHq8esLR203sW4jq23HE17VE3yKuGBR++vvEuq1QN
PV36jR65aBsCyL9zVcyudficLeR6gbIry2BCSDh06yAIMF/vOGKU1eeBD96SBwbl
IpXYYRRgxMej5xt2p+cKvndOrmEkA80qQ2DsT6RcJA==
-----END CERTIFICATE-----
Bag Attributes
2.16.840.1.113894.746875.1.1: <Unsupported tag 6>
<Unsupported tag 6>
friendlyName: Actalis Client Authentication CA G3
subject=C = IT, ST = Bergamo, L = Ponte San Pietro, O = Actalis S.p.A., CN = Actalis Client Authentication CA G3
issuer=C = IT, L = Milan, O = Actalis S.p.A./03358520967, CN = Actalis Authentication Root CA
-----BEGIN CERTIFICATE-----
MIIHbTCCBVWgAwIBAgIQFxA+3j2KHLXKBlGT58pDazANBgkqhkiG9w0BAQsFADBr
MQswCQYDVQQGEwJJVDEOMAwGA1UEBwwFTWlsYW4xIzAhBgNVBAoMGkFjdGFsaXMg
Uy5wLkEuLzAzMzU4NTIwOTY3MScwJQYDVQQDDB5BY3RhbGlzIEF1dGhlbnRpY2F0
aW9uIFJvb3QgQ0EwHhcNMjAwNzA2MDg0NTQ3WhcNMzAwOTIyMTEyMjAyWjCBgTEL
MAkGA1UEBhMCSVQxEDAOBgNVBAgMB0JlcmdhbW8xGTAXBgNVBAcMEFBvbnRlIFNh
biBQaWV0cm8xFzAVBgNVBAoMDkFjdGFsaXMgUy5wLkEuMSwwKgYDVQQDDCNBY3Rh
bGlzIENsaWVudCBBdXRoZW50aWNhdGlvbiBDQSBHMzCCAiIwDQYJKoZIhvcNAQEB
BQADggIPADCCAgoCggIBAO3mh5ahwaS27cJCVfc/Dw8iYF8T4KZDiIZJkXkcGy8a
UA/cRgHu9ro6hsxRYe/ED4AIcSlarRh82HqtFSVQs4ZwikQW1V/icCIS91C2IVAG
a1YlKfedqgweqky+bBniUvRevVT0keZOqRTcO5hw007dL6FhYNmlZBt5IaJs1V6I
niRjokOHR++qWgrUGy5LefY6ACs9gZ8Bi0OMK9PZ37pibeQCsdmMRytl4Ej7JVWe
M/BtNIIprHwO1LY0/8InpGOmdG+5LC6xHLzg53B0HvVUqzUQNePUhNwJZFmmTP46
FXovxmH4/SuY5IkXop0eJqjN+dxRHHizngYUk1EaTHUOcLFy4vQ0kxgbjb+GsNg6
M2/6gZZIRk78JPdpotIwHnBNtkp9wPVH61NqdcP7kbPkyLXkNMTtAfydpmNnGqqH
LEvUrK4iBpUPG9C09KOjm9OyhrT2uf5SLzJsee9g79r/rw4hAgcsZtR3YI6fCbRO
JncmD+hgbHCck+9TWcNc1x5xZMgm8UXmoPamkkfceAlVV49QQ5jUTgqneTQHyF1F
2ExXmf47pEIoJMVxloRIXywQuB2uqcIs8/X6tfsMDynFmhfT/0mTrgQ6xt9DIsgm
WuuhvZhLReWS7oeKxnyqscuGeTMXnLs7fjGZq0inyhnlznhA/4rl+WdNjNaO4jEv
AgMBAAGjggH0MIIB8DAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFFLYiDrI
n3hm7YnzezhwlMkCAjbQMEEGCCsGAQUFBwEBBDUwMzAxBggrBgEFBQcwAYYlaHR0
cDovL29jc3AwNS5hY3RhbGlzLml0L1ZBL0FVVEgtUk9PVDBFBgNVHSAEPjA8MDoG
BFUdIAAwMjAwBggrBgEFBQcCARYkaHR0cHM6Ly93d3cuYWN0YWxpcy5pdC9hcmVh
LWRvd25sb2FkMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDCB4wYDVR0f
BIHbMIHYMIGWoIGToIGQhoGNbGRhcDovL2xkYXAwNS5hY3RhbGlzLml0L2NuJTNk
QWN0YWxpcyUyMEF1dGhlbnRpY2F0aW9uJTIwUm9vdCUyMENBLG8lM2RBY3RhbGlz
JTIwUy5wLkEuJTJmMDMzNTg1MjA5NjcsYyUzZElUP2NlcnRpZmljYXRlUmV2b2Nh
dGlvbkxpc3Q7YmluYXJ5MD2gO6A5hjdodHRwOi8vY3JsMDUuYWN0YWxpcy5pdC9S
ZXBvc2l0b3J5L0FVVEgtUk9PVC9nZXRMYXN0Q1JMMB0GA1UdDgQWBBS+l6mqhL+A
vxBTfQky+eEuMhvPdzAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIB
ACab5xtZDXSzEgPp51X3hICFzULDO2EcV8em5hLfSCKxZR9amCnjcODVfMbaKfdU
ZXtevMIIZmHgkz9dBan7ijGbJXjZCPP29zwZGSyCjpfadg5s9hnNCN1r3DGwIHfy
LgbcfffDyV/2wW+XTGbhldnazZsX892q+srRmC8XnX4ygg+eWL/AkHDenvbFuTlJ
vUyd5I7e1nb3dYXMObPu24ZTQ9/K1hSQbs7pqecaptTUjoIDpBUpSp4Us+h1I4MA
WonemKYoPS9f0y65JrRCKcfsKSI+1kwPSanDDMiydKzeo46XrS0hlA5NzQjqUJ7U
suGvPtDvknqc0v03nNXBnUjejYtvwO3sEDXdUW5m9kjNqlQZXzdHumZJVqPUGKTW
cn9Hf3d7qbCmmxPXjQoNUuHg56fLCanZWkEO4SP1GAgIA7SyJu/yffv0ts7sBFrS
TD3L2mCAXM3Y8BfblvvDSf2bvySm/fPe9brmuzrCXsTxUQc1+/z5ydvzV3E3cLnU
oSXP6XfXNyEVO6sPkcUSnISHM798xLkCTB5EkjPCjPE2zs4v9L9JVOkkskvW6RnW
WccdfR3fELNHL/kep8re6IbbYs8Hn5GM0Ohs8CMDPYEox+QX/6/SnOfyaqqSilBo
nMQBstsymBBgdEKO+tTHHCMnJQVvZn7jRQ20wXgxMrvN
-----END CERTIFICATE-----
Bag Attributes
2.16.840.1.113894.746875.1.1: <Unsupported tag 6>
friendlyName: Actalis Authentication Root CA
subject=C = IT, L = Milan, O = Actalis S.p.A./03358520967, CN = Actalis Authentication Root CA
issuer=C = IT, L = Milan, O = Actalis S.p.A./03358520967, CN = Actalis Authentication Root CA
-----BEGIN CERTIFICATE-----
MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UE
BhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8w
MzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290
IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDkyMjExMjIwMlowazELMAkGA1UEBhMC
SVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1
ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENB
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNv
UTufClrJwkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX
4ay8IMKx4INRimlNAJZaby/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9
KK3giq0itFZljoZUj5NDKd45RnijMCO6zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/
gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1fYVEiVRvjRuPjPdA1Yprb
rxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2oxgkg4YQ
51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2F
be8lEfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxe
KF+w6D9Fz8+vm2/7hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4F
v6MGn8i1zeQf1xcGDXqVdFUNaBr8EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbn
fpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5jF66CyCU3nuDuP/jVo23Eek7
jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLYiDrIn3hm7Ynz
ezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt
ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQAL
e3KHwGCmSUyIWOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70
jsNjLiNmsGe+b7bAEzlgqqI0JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDz
WochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKxK3JCaKygvU5a2hi/a5iB0P2avl4V
SM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+Xlff1ANATIGk0k9j
pwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC4yyX
X04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+Ok
fcvHlXHo2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7R
K4X9p2jIugErsWx0Hbhzlefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btU
ZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXemOR/qnuOf0GZvBeyqdn6/axag67XH/JJU
LysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9vwGYT7JZVEc+NHt4bVaT
LnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg==
-----END CERTIFICATE-----

32
cert/email_smime_key.pem Normal file
View File

@@ -0,0 +1,32 @@
Bag Attributes
localKeyID: DF 79 62 A4 C4 A4 70 75 2D 68 FF C7 FD AF 35 E1 7B BE A0 75
friendlyName: no-reply@esy-web.fr
Key Attributes: <No Attributes>
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQFhbttYZYfQc/
FZ9NqikTdDoA5jvXaZLKtmadF51M4jHypRf/4c8kmMm/zFkGHeu3Syprp1bxEpOv
Dtyj1UmNoiOBEax/Gf6J4CSCz2Naf39Ps2wOpxJlwNI50zhTcGZa3r5ns33t2lvZ
d9Ul1xKs58YkX99Af83qSCHbzYtyjqX9NKlkK5wHvzS1/Xd56SMxrCd6VkTvYG23
QLMx5LHK2Ei2IOMEZ4XxXKKmoXfvUtbtCfFs0l8n12Xnu7W4k3t1ijwQ0oVZ8DPo
NPQXyMKZ3rPlM2BkxkX2TjuDBJrArhqF3V3oUlYeFhiTUvt1bzPGZCMPy095QcIN
ul3K6VGHAgMBAAECggEAJiiG19x74Go9E/JxHhrgIXMk19lgi+YORyIfuxsDe3/X
TPm9Zu0PwVRLWuKsSJTgDuK3yroLFXYkouuExn5sWI6tGBQXn88ygDIcP+ir/YJ3
5DOw0xcMJqCvbG8xWwu3mV5TaEzgAOgUA9MXwTKpeA+gpDb4h6loJ1hG7TnKIp11
SfGE85uiFfvQuCKuZpBXfK3cN4DncZOMhkK/m5e6owy4wf6BjoNg5pWDO0DbfIn0
YhInBY1Vt29TtUcdRqG0iolcly8E91OqtQ4+kuQY5R1DGaEdG5HJ3leR/YXdYLaD
cvstkAZ6Wkepz2nZ2HtELXeFXYEe0g456jiMPpndUQKBgQDzbpV6iAhKiU/mmm9T
IoTkSiFdWWZaOIC7zHn0KvUd/Q/JcxsSpkCsS2CR7sw6Upc0HdK4Ol6OKF5xIijZ
Ry51xiOvMU2IE6/3RK3Yc27JK9n6hjkupaY6ncVzw9KZvHPWJrA9ohIof5hevBUt
vSDEqD95osrG0wulmBIW6VbyWwKBgQDa1FhmmljKlD/fjfTYhkgKpterIJ112EDT
v2wXH1GZ9vN8xo2ZN9T67APHxA7dz72IkMqQJ9YzpkhdWUKLCNOYECdNxE38ajyd
arfPkmbCfS6DSgMgDUg7DuLV3iDF9O9+6zK88RY/wHH3NVuvFNvGpps1JBkxoR6N
br7uf6AtRQKBgB5X1WgNlsL7tKrw6xl4vwnZt6j0IM70Jg/CoBzwUddoGNSqdWBw
urT/PE1Ub76BVvmXEhIGrIyZuZPuhxr2RXNGvGH1Ck4A7jkrJWRKly+aOLSCkJQg
78R0sA0LBrBFDkdOUT+NeSf1J5//X8Bwx6nbsvXNdessmVINz4ttkHnrAoGAERzL
8PIfAXCIci4CuK//kD2t3ecGCUIpB6YPiNtdIUIrllVcm1+/WwP747JUS0pEkxpn
jNBgstdND2e8iWzeRyT0PeOdCaExLko7J5NWT91ENuYhym7feCbY3Eqrm29lDzLL
W/UqfT/Kab+VdOKXsTg0KPqysavc3MiNS89VMlUCgYEA1z338zWm1VyNBhzuC7Ui
jHerwf26rUAwDP+DqMPBwTezOltpr+7r89PKylEZJfOgoEiLMD6vBVdwvGCjAOEF
dZ+zUMArk3X8C/Cdvx9p1dUYwHD9usgrfsXqin1JYPgkEXzc7cBuKCt1L+ZE1p+7
ETkTi8pYJ03ZieAFEV3qW9M=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,33 @@
<?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 Version20260204131618 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 delivery_distance DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE order_session ADD delivery_price DOUBLE PRECISION DEFAULT NULL');
}
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 delivery_distance');
$this->addSql('ALTER TABLE order_session DROP delivery_price');
}
}

View File

@@ -0,0 +1,33 @@
<?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 Version20260204141133 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 delivery_geometry JSON DEFAULT NULL');
$this->addSql('ALTER TABLE order_session ADD type_paiement VARCHAR(255) DEFAULT NULL');
}
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 delivery_geometry');
$this->addSql('ALTER TABLE order_session DROP type_paiement');
}
}

View File

@@ -0,0 +1,33 @@
<?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 Version20260204142338 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 customer ADD typ_company VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD raison_social VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer DROP typ_company');
$this->addSql('ALTER TABLE customer DROP raison_social');
}
}

View File

@@ -62,21 +62,6 @@ class ContratController extends AbstractController
EntityManagerInterface $entityManager,
): Response {
$type = $request->query->get('type', 'accompte');
if ($type === "caution") {
$pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'type' => 'caution',
'contrat' => $contrat
]);
// Si le paiement est déjà marqué comme complété par le Webhook
if ($pl && $pl->getState() === "complete") {
return $this->render('reservation/contrat/success.twig', [
'contrat' => $contrat,
'type' => $type
]);
}
}
if ($type === "accompte") {
$pl = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
@@ -273,43 +258,6 @@ class ContratController extends AbstractController
}
}
// --- GESTION PAIEMENT CAUTION (GARANTIE) ---
if ($request->query->get('act') === 'cautionPay') {
// On vérifie si une caution n'est pas déjà validée
$existingCaution = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'caution',
'state' => 'complete'
]);
if (!$existingCaution) {
// Appel au service Stripe pour la caution (montant totalCaution calculé plus haut)
$result = $stripeClient->createPaymentCaution($totalCaution, $contrat);
if ($result['state']) {
$pl = new ContratsPayments();
$pl->setContrat($contrat);
$pl->setType("caution");
$pl->setAmount($totalCaution);
$pl->setPaymentAt(new \DateTimeImmutable('now'));
$pl->setState("created");
$pl->setPaymentId($result['id']);
$entityManager->persist($pl);
$entityManager->flush();
return new RedirectResponse($result['url']);
}
} else {
// SCÉNARIO 2 : RÉCUPÉRATION OU MISE À JOUR (si le montant a changé par exemple)
$result = $stripeClient->linkPaymentCaution($totalCaution, $contrat, $existingCaution);
if ($result['state']) {
return new RedirectResponse($result['url']);
}
}
}
if ($request->query->has('act') && $request->query->get('act') === 'soldePay') {
// 1. Récupération et sécurisation du montant
$amountRequested = (float) $request->query->get('amountToPay', $solde);

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Controller\Dashboard;
use App\Logger\AppLogger;
use App\Repository\OrderSessionRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Route('/crm/flow')]
class FlowController extends AbstractController
{
public function __construct(
private readonly AppLogger $appLogger,
private readonly OrderSessionRepository $orderSessionRepository,
private readonly HttpClientInterface $client
) {
}
#[Route('', name: 'app_crm_flow', methods: ['GET'])]
public function index(Request $request, PaginatorInterface $paginator): Response
{
$this->appLogger->record('VIEW', 'Consultation des réservations en ligne');
$query = $this->orderSessionRepository->findBy(['state' => 'send'], ['id' => 'DESC']);
$pagination = $paginator->paginate(
$query,
$request->query->getInt('page', 1),
20
);
return $this->render('dashboard/flow/index.twig', [
'sessions' => $pagination,
]);
}
#[Route('/{id}', name: 'app_crm_flow_view', methods: ['GET'])]
public function view(\App\Entity\OrderSession $session, \Doctrine\ORM\EntityManagerInterface $em): Response
{
$this->appLogger->record('VIEW', 'Consultation détails réservation en ligne #' . $session->getId());
// Auto-calculation of delivery if missing or geometry missing
if (($session->getDeliveryDistance() === null || $session->getDeliveryPrice() === null || $session->getDeliveryGeometry() === null) && $session->getAdressEvent()) {
$this->calculateDelivery($session);
$em->flush();
}
return $this->render('dashboard/flow/view.twig', [
'session' => $session,
]);
}
#[Route('/update/{id}', name: 'app_crm_flow_update', methods: ['POST'])]
public function update(\App\Entity\OrderSession $session, Request $request, \Doctrine\ORM\EntityManagerInterface $em): Response
{
if ($request->request->has('deliveryDistance')) {
$session->setDeliveryDistance((float)$request->request->get('deliveryDistance'));
}
if ($request->request->has('deliveryPrice')) {
$session->setDeliveryPrice((float)$request->request->get('deliveryPrice'));
}
if ($request->request->has('typePaiement')) {
$session->setTypePaiement($request->request->get('typePaiement'));
}
// Recalculate if address changed or forced update (optional, but good for consistency)
// For now, simple update.
$em->flush();
$this->addFlash('success', 'Informations mises à jour.');
return $this->redirectToRoute('app_crm_flow_view', ['id' => $session->getId()]);
}
#[Route('/allow/{id}', name: 'app_crm_flow_allow', methods: ['GET'])]
public function allow(\App\Entity\OrderSession $session, \Doctrine\ORM\EntityManagerInterface $em): Response
{
$session->setState('allow');
$em->flush();
$this->addFlash('success', 'La réservation a été validée.');
return $this->redirectToRoute('app_crm_flow');
}
#[Route('/delete/{id}', name: 'app_crm_flow_delete', methods: ['GET'])]
public function delete(\App\Entity\OrderSession $session, \Doctrine\ORM\EntityManagerInterface $em): Response
{
$em->remove($session);
$em->flush();
$this->addFlash('success', 'La réservation a été supprimée (refusée).');
return $this->redirectToRoute('app_crm_flow');
}
private function calculateDelivery(\App\Entity\OrderSession $session): void
{
if (!$session->getAdressEvent() || !$session->getZipCodeEvent() || !$session->getTownEvent()) {
return;
}
$query = sprintf('%s %s %s', $session->getAdressEvent(), $session->getZipCodeEvent(), $session->getTownEvent());
try {
$response = $this->client->request('GET', 'https://api-adresse.data.gouv.fr/search/', [
'query' => [
'q' => $query,
'limit' => 1
]
]);
$content = $response->toArray();
if (!empty($content['features'])) {
$coords = $content['features'][0]['geometry']['coordinates'];
$lon = $coords[0];
$lat = $coords[1];
// Point de départ (LudikEvent)
$startLat = 49.849;
$startLon = 3.286;
// Calcul itinéraire via API Geoplateforme
$itineraireResponse = $this->client->request('GET', 'https://data.geopf.fr/navigation/itineraire', [
'query' => [
'resource' => 'bdtopo-osrm',
'start' => $startLon . ',' . $startLat,
'end' => $lon . ',' . $lat,
'profile' => 'car',
'optimization' => 'fastest',
'distanceUnit' => 'kilometer',
'geometryFormat' => 'geojson'
]
]);
$itineraire = $itineraireResponse->toArray();
$distance = $itineraire['distance'];
$geometry = $itineraire['geometry'] ?? null;
$rate = 0.50;
$trips = 4;
$price = 0.0;
if ($distance > 10) {
$chargedDistance = $distance - 10;
$price = ($chargedDistance * $trips) * $rate;
}
$session->setDeliveryDistance($distance);
$session->setDeliveryPrice($price);
$session->setDeliveryGeometry($geometry);
}
} catch (\Exception $e) {
// Log error or silent fail
}
}
}

View File

@@ -50,13 +50,18 @@ class ReserverController extends AbstractController
string $sessionId,
OrderSessionRepository $repository,
ProductRepository $productRepository,
KernelInterface $kernel
KernelInterface $kernel,
\App\Repository\OptionsRepository $optionsRepository
): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->redirectToRoute('reservation');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$startStr = $sessionData['start'] ?? null;
@@ -106,15 +111,50 @@ class ReserverController extends AbstractController
$devis->setStartAt($start);
$devis->setEndAt($end);
$selectedOptionsMap = $sessionData['options'] ?? [];
if (!empty($ids)) {
$products = $productRepository->findBy(['id' => $ids]);
$processedProductIds = [];
foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$line = new DevisLine();
$line->setProduct($product->getName());
$line->setPriceHt($product->getPriceDay());
$line->setPriceHtSup($product->getPriceSup());
$line->setDay($duration);
$devis->addDevisLine($line);
if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) {
$options = $optionsRepository->findBy(['id' => $optionIds]);
foreach ($options as $option) {
$lineOpt = new DevisLine();
$lineOpt->setProduct("Option : " . $option->getName());
$lineOpt->setPriceHt($option->getPriceHt());
$lineOpt->setPriceHtSup(0);
$lineOpt->setDay($duration);
$devis->addDevisLine($lineOpt);
}
}
}
}
foreach ($selectedOptionsMap as $prodId => $optIds) {
if (!in_array($prodId, $processedProductIds) && !empty($optIds)) {
$options = $optionsRepository->findBy(['id' => $optIds]);
foreach ($options as $option) {
$lineOpt = new DevisLine();
$lineOpt->setProduct("Option : " . $option->getName());
$lineOpt->setPriceHt($option->getPriceHt());
$lineOpt->setPriceHtSup(0);
$lineOpt->setDay($duration);
$devis->addDevisLine($lineOpt);
}
}
}
}
@@ -494,13 +534,35 @@ class ReserverController extends AbstractController
UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository,
EntityManagerInterface $em,
\App\Repository\OptionsRepository $optionsRepository
\App\Repository\OptionsRepository $optionsRepository,
HttpClientInterface $client,
Request $request,
Mailer $mailer
): Response {
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->render('revervation/session_lost.twig');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
if ($request->isMethod('POST')) {
$mailer->send(
'contact@ludikevent.fr',
"Ludikevent",
"[Ludikevent] - Nouvelle demande de réservation",
"mails/reserve/confirmation.twig",
['session' => $session]
);
$session->setState('send');
$em->flush();
$request->getSession()->remove('order_session_uuid');
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? [];
@@ -620,6 +682,73 @@ class ReserverController extends AbstractController
$totalTva = $totalHT * $tvaRate;
$totalTTC = $totalHT + $totalTva;
// --- Calcul Frais de Livraison ---
$deliveryEstimation = null;
$deliveryDetails = null;
$deliveryGeometry = null;
if ($session->getAdressEvent() && $session->getZipCodeEvent() && $session->getTownEvent()) {
$query = sprintf('%s %s %s', $session->getAdressEvent(), $session->getZipCodeEvent(), $session->getTownEvent());
try {
$response = $client->request('GET', 'https://api-adresse.data.gouv.fr/search/', [
'query' => [
'q' => $query,
'limit' => 1
]
]);
$content = $response->toArray();
if (!empty($content['features'])) {
$coords = $content['features'][0]['geometry']['coordinates'];
$lon = $coords[0];
$lat = $coords[1];
// Point de départ (LudikEvent)
$startLat = 49.849;
$startLon = 3.286;
// Calcul itinéraire via API Geoplateforme
$itineraireResponse = $client->request('GET', 'https://data.geopf.fr/navigation/itineraire', [
'query' => [
'resource' => 'bdtopo-osrm',
'start' => $startLon . ',' . $startLat,
'end' => $lon . ',' . $lat,
'profile' => 'car',
'optimization' => 'fastest',
'distanceUnit' => 'kilometer',
'geometryFormat' => 'geojson'
]
]);
$itineraire = $itineraireResponse->toArray();
$distance = $itineraire['distance'];
$deliveryGeometry = $itineraire['geometry'] ?? null;
$rate = 0.50;
$trips = 4;
if ($distance <= 10) {
$deliveryEstimation = 0.0;
$chargedDistance = 0.0;
} else {
$chargedDistance = $distance - 10;
$deliveryEstimation = ($chargedDistance * $trips) * $rate;
}
$deliveryDetails = [
'distance' => $distance,
'chargedDistance' => $chargedDistance,
'trips' => $trips,
'rate' => $rate,
'isFree' => ($distance <= 10)
];
}
} catch (\Exception $e) {
// Silent fail for delivery calculation in flow
}
}
return $this->render('revervation/flow_confirmed.twig', [
'session' => $session,
'cart' => [
@@ -632,10 +761,31 @@ class ReserverController extends AbstractController
'totalTva' => $totalTva,
'totalTTC' => $totalTTC,
'tvaEnabled' => $tvaEnabled,
],
'delivery' => [
'estimation' => $deliveryEstimation,
'details' => $deliveryDetails,
'geometry' => $deliveryGeometry
]
]);
}
#[Route('/flow/{sessionId}/success', name: 'reservation_flow_success', methods: ['GET'])]
public function flowSuccess(string $sessionId, OrderSessionRepository $repository): Response
{
$session = $repository->findOneBy(['uuid' => $sessionId]);
if (!$session) {
return $this->redirectToRoute('reservation');
}
if ($session->getState() !== 'send') {
return $this->redirectToRoute('reservation_flow', ['sessionId' => $sessionId]);
}
return $this->render('revervation/success.twig');
}
#[Route('/flow/{sessionId}', name: 'reservation_flow', methods: ['GET', 'POST'])]
public function flowLogin(
string $sessionId,
@@ -643,8 +793,9 @@ class ReserverController extends AbstractController
OrderSessionRepository $repository,
ProductRepository $productRepository,
UploaderHelper $uploaderHelper,
ProductReserveRepository $productReserveRepository, // Added dependency
EntityManagerInterface $em
ProductReserveRepository $productReserveRepository,
EntityManagerInterface $em,
\App\Repository\OptionsRepository $optionsRepository
): Response {
// This is the POST target for the login form, but also the GET page.
// The authenticator handles the POST. For GET, we just render the page.
@@ -653,8 +804,13 @@ class ReserverController extends AbstractController
return $this->render('revervation/session_lost.twig');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? [];
$startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null;
@@ -698,12 +854,45 @@ class ReserverController extends AbstractController
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0;
$rootOptions = [];
$processedProductIds = [];
foreach ($products as $product) {
$processedProductIds[] = $product->getId();
$price1Day = $product->getPriceDay();
$priceSup = $product->getPriceSup() ?? 0.0;
// Calcul du coût total pour ce produit selon la durée
$productTotalHT = $price1Day + ($priceSup * max(0, $duration - 1));
// Traitement des options
$productOptions = [];
$optionsTotalHT = 0;
if (isset($selectedOptionsMap[$product->getId()])) {
$optionIds = $selectedOptionsMap[$product->getId()];
if (!empty($optionIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optionIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$optData = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
if ($product->getOptions()->contains($option)) {
$productOptions[] = $optData;
$optionsTotalHT += $optPrice;
} else {
$rootOptions[] = $optData;
$totalHT += $optPrice;
}
}
}
}
$productTotalHT += $optionsTotalHT;
$productTotalTTC = $productTotalHT * (1 + $tvaRate);
$items[] = [
@@ -713,11 +902,28 @@ class ReserverController extends AbstractController
'priceSup' => $priceSup,
'totalPriceHT' => $productTotalHT,
'totalPriceTTC' => $productTotalTTC,
'options' => $productOptions
];
$totalHT += $productTotalHT;
}
// Traiter les options orphelines
foreach ($selectedOptionsMap as $prodId => $optIds) {
if (!in_array($prodId, $processedProductIds) && !empty($optIds)) {
$optionsEntities = $optionsRepository->findBy(['id' => $optIds]);
foreach ($optionsEntities as $option) {
$optPrice = $option->getPriceHt();
$rootOptions[] = [
'id' => $option->getId(),
'name' => $option->getName(),
'price' => $optPrice
];
$totalHT += $optPrice;
}
}
}
$totalTva = $totalHT * $tvaRate;
$totalTTC = $totalHT + $totalTva;
@@ -727,6 +933,7 @@ class ReserverController extends AbstractController
'error' => $authenticationUtils->getLastAuthenticationError(),
'cart' => [
'items' => $items,
'options' => $rootOptions,
'startDate' => $startStr ? new \DateTimeImmutable($startStr) : null,
'endDate' => $endStr ? new \DateTimeImmutable($endStr) : null,
'duration' => $duration,
@@ -750,6 +957,10 @@ class ReserverController extends AbstractController
return $this->redirectToRoute('reservation');
}
if ($session->getState() === 'send') {
return $this->redirectToRoute('reservation_flow_success', ['sessionId' => $sessionId]);
}
$session->setBillingAddress($request->request->get('billingAddress'));
$session->setBillingZipCode($request->request->get('billingZipCode'));
$session->setBillingTown($request->request->get('billingTown'));
@@ -922,6 +1133,8 @@ class ReserverController extends AbstractController
if ($customer->getType() === 'buisness') {
$customer->setSiret($payload->getString('siret'));
$customer->setRaisonSocial($payload->getString('raisonSocial'));
$customer->setTypCompany($payload->getString('typCompany'));
}
$hashedPassword = $hasher->hashPassword($customer, $payload->getString('password'));

View File

@@ -11,6 +11,7 @@ use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Repository\ContratsRepository;
use App\Repository\DevisRepository;
use App\Repository\ProductRepository;
use App\Service\Mailer\Mailer;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
@@ -38,6 +39,7 @@ class SignatureController extends AbstractController
Client $client,
DevisRepository $devisRepository,
ContratsRepository $contratsRepository,
ProductRepository $productRepository,
EntityManagerInterface $entityManager,
Request $request,
Mailer $mailer,
@@ -83,6 +85,18 @@ class SignatureController extends AbstractController
];
$entityManager->persist($contrats);
foreach ($contrats->getContratsLines() as $line) {
$p = $productRepository->findOneBy(['name' => $line->getName()]);
$pr = new ProductReserve();
$pr->setContrat($contrats);
$pr->setProduct($p);
$pr->setStartAt($contrats->getDateAt());
$pr->setEndAt($contrats->getEndAt());
$pr->setCustomer($contrats->getCustomer());
$pr->setDevis($contrats->getDevis());
$entityManager->persist($pr);
}
$entityManager->flush();
// 5. Envoi du mail de confirmation avec le récapitulatif

View File

@@ -48,38 +48,6 @@ class Webhooks extends AbstractController
$contrat = $pl->getContrat();
if($pl->getType() == "accompte") {
foreach ($contrat->getContratsLines() as $line) {
$p = $productRepository->findOneBy(['name' => $line->getProduct()]);
$pr = new ProductReserve();
$pr->setContrat($contrat);
$pr->setProduct($p);
$pr->setStartAt($contrat->getDateAt());
$pr->setEndAt($contrat->getEndAt());
$pr->setCustomer($contrat->getCustomer());
$pr->setDevis($contrat->getDevis());
$entityManager->persist($pr);
$entityManager->flush();
}
}
$contrat = $pl->getContrat();
if($pl->getType() == "accompte") {
foreach ($contrat->getContratsLines() as $line) {
$r = explode(" - ",$line->getName());
if(isset($r[1])) {
$p = $productRepository->findOneBy(['ref' => $r[1]]);
$pr = new ProductReserve();
$pr->setContrat($contrat);
$pr->setProduct($p);
$pr->setStartAt($contrat->getDateAt());
$pr->setEndAt($contrat->getEndAt());
$pr->setCustomer($contrat->getCustomer());
$entityManager->persist($pr);
}
}
}
$customer = $contrat->getCustomer();
$pdf = new PlPdf($kernel, $pl, $contrat);

View File

@@ -60,6 +60,12 @@ class Customer implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255, nullable: true)]
private ?string $siret = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $typCompany = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $raisonSocial = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customerId = null;
@@ -245,6 +251,28 @@ class Customer implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getTypCompany(): ?string
{
return $this->typCompany;
}
public function setTypCompany(?string $typCompany): static
{
$this->typCompany = $typCompany;
return $this;
}
public function getRaisonSocial(): ?string
{
return $this->raisonSocial;
}
public function setRaisonSocial(?string $raisonSocial): static
{
$this->raisonSocial = $raisonSocial;
return $this;
}
public function getCustomerId(): ?string
{
return $this->customerId;

View File

@@ -78,6 +78,18 @@ class OrderSession
#[ORM\Column(nullable: true)]
private ?float $distancePower = null;
#[ORM\Column(nullable: true)]
private ?float $deliveryDistance = null;
#[ORM\Column(nullable: true)]
private ?float $deliveryPrice = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $deliveryGeometry = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $typePaiement = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
@@ -354,4 +366,52 @@ class OrderSession
return $this;
}
public function getDeliveryDistance(): ?float
{
return $this->deliveryDistance;
}
public function setDeliveryDistance(?float $deliveryDistance): static
{
$this->deliveryDistance = $deliveryDistance;
return $this;
}
public function getDeliveryPrice(): ?float
{
return $this->deliveryPrice;
}
public function setDeliveryPrice(?float $deliveryPrice): static
{
$this->deliveryPrice = $deliveryPrice;
return $this;
}
public function getDeliveryGeometry(): ?array
{
return $this->deliveryGeometry;
}
public function setDeliveryGeometry(?array $deliveryGeometry): static
{
$this->deliveryGeometry = $deliveryGeometry;
return $this;
}
public function getTypePaiement(): ?string
{
return $this->typePaiement;
}
public function setTypePaiement(?string $typePaiement): static
{
$this->typePaiement = $typePaiement;
return $this;
}
}

View File

@@ -8,11 +8,13 @@ use Symfony\Component\HttpKernel\Profiler\Profiler;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Crypto\SMimeSigner;
use Symfony\Component\Mime\Email;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
use Symfony\Component\HttpKernel\KernelInterface;
class Mailer
{
@@ -24,6 +26,7 @@ class Mailer
private readonly UrlGeneratorInterface $urlGenerator,
private readonly ?Profiler $profiler,
private readonly Environment $environment,
private readonly KernelInterface $kernel,
) {
$this->mailer = $mailer;
}
@@ -62,7 +65,8 @@ class Mailer
): void {
$domain = "ludikevent.fr";
$dest = new Address($address, $addressName);
$src = new Address("contact@" . $domain, "Ludikevent");
$src = new Address("no-reply@esy-web.fr", "Ludikevent");
$replyTo = new Address("contact@ludikevent.fr", "Ludikevent");
// 1. Génération du Message-ID (SANS les crochets < >, Symfony les ajoute)
$messageId = sprintf('%s.%s@%s',
@@ -77,7 +81,8 @@ class Mailer
$mail = (new Email())
->subject($subject)
->to($dest)
->from($src);
->from($src)
->replyTo($replyTo);
// 3. Configuration des Headers
$headers = $mail->getHeaders();
@@ -123,10 +128,17 @@ class Mailer
$mail->html($htmlContent);
try {
$this->mailer->send($mail);
} catch (TransportExceptionInterface $e) {
throw $e;
// Signer l'email S/MIME
$certPath = $this->kernel->getProjectDir() . '/cert/email_smime.pem';
$keyPath = $this->kernel->getProjectDir() . '/cert/email_smime_key.pem';
$passphrase = 'vwWRh+1+0u2U9ppMB3';
if (file_exists($certPath) && file_exists($keyPath)) {
$signer = new SMimeSigner($certPath, $keyPath, $passphrase);
$signedEmail = $signer->sign($mail);
$this->mailer->send($signedEmail);
} else {
$this->mailer->send($mail);
}
}
}

View File

@@ -6,7 +6,10 @@ use App\Entity\Contrats;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Entity\DevisOptions;
use App\Entity\Options;
use App\Entity\Product;
use App\Repository\OrderSessionRepository;
use App\Repository\ProductRepository;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
@@ -17,8 +20,14 @@ use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
class StripeExtension extends AbstractExtension
{
public function __construct(private readonly UploaderHelper $uploaderHelper,private readonly \App\Service\Signature\Client $clientSignature,private readonly Client $client,private readonly EntityManagerInterface $em)
{
public function __construct(
private readonly UploaderHelper $uploaderHelper,
private readonly \App\Service\Signature\Client $clientSignature,
private readonly Client $client,
private readonly EntityManagerInterface $em,
private readonly OrderSessionRepository $orderSessionRepository,
) {
}
public function getFilters(): array
@@ -228,10 +237,99 @@ class StripeExtension extends AbstractExtension
new TwigFunction('isBot', [$this, 'isBot']),
new TwigFunction('syncStripe', [$this, 'syncStripe']),
new TwigFunction('contratPaymentPay', [$this, 'contratPaymentPay']),
new TwigFunction('loadProductByName',[$this,'loadProductByName'])
new TwigFunction('loadProductByName',[$this,'loadProductByName']),
new TwigFunction('loadProductById',[$this,'loadProductById']),
new TwigFunction('loadOptionById',[$this,'loadOptionById']),
new TwigFunction('totalSession',[$this,'totalSession']),
new TwigFunction('getPendingOrderSessionCount', [$this, 'getPendingOrderSessionCount']),
];
}
public function loadProductById($id)
{
$p = $this->em->getRepository(Product::class)->find($id);
if (!$p) return null;
return [
'name' => $p->getName(),
'image' => $this->uploaderHelper->asset($p,'imageFile'),
'price1day' => $p->getPriceDay(),
'priceSup' => $p->getPriceSup(),
];
}
public function loadOptionById($id)
{
$o = $this->em->getRepository(Options::class)->find($id);
if (!$o) return null;
return [
'name' => $o->getName(),
'price' => $o->getPriceHt(),
];
}
public function totalSession(\App\Entity\OrderSession $session): array
{
$sessionData = $session->getProducts();
$ids = $sessionData['ids'] ?? [];
$selectedOptionsMap = $sessionData['options'] ?? [];
$startStr = $sessionData['start'] ?? null;
$endStr = $sessionData['end'] ?? null;
$duration = 1;
if ($startStr && $endStr) {
try {
$start = new \DateTimeImmutable($startStr);
$end = new \DateTimeImmutable($endStr);
if ($end >= $start) {
$duration = $start->diff($end)->days + 1;
}
} catch (\Exception $e) {
$duration = 1;
}
}
$totalHT = 0;
$productRepo = $this->em->getRepository(Product::class);
$optionsRepo = $this->em->getRepository(Options::class);
// Products
foreach ($ids as $id) {
$product = $productRepo->find($id);
if ($product) {
$price = $product->getPriceDay() + ($product->getPriceSup() * max(0, $duration - 1));
$totalHT += $price;
}
}
// Options
foreach ($selectedOptionsMap as $prodId => $optIds) {
foreach ($optIds as $optId) {
$option = $optionsRepo->find($optId);
if ($option) {
$totalHT += $option->getPriceHt();
}
}
}
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
return [
'ht' => $totalHT,
'duration' => $duration,
'tvaEnabled' => $tvaEnabled
];
}
public function getPendingOrderSessionCount(): int
{
return $this->orderSessionRepository->count(['state' => 'send']);
}
public function devisSignUrl(Devis $devis): string
{
return $this->clientSignature->getLinkSign($devis->getSignatureId());

View File

@@ -45,6 +45,19 @@
{{ menu.nav_link(path('app_crm_formules'), 'Formules', '<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>', 'app_crm_formules') }}
{{ menu.nav_link(path('app_crm_facture'), 'Facture', '<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>', 'app_crm_facture') }}
{{ menu.nav_link(path('app_crm_customer'), 'Clients', '<path d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>', 'app_clients') }}
{% set pendingCount = getPendingOrderSessionCount() %}
<a data-turbo="false" href="{{ path('app_crm_flow') }}" class="flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-200 group {{ app.current_route == 'app_crm_flow' ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/30' : 'hover:bg-slate-800 text-slate-400' }}">
<div class="flex items-center space-x-3">
<svg class="w-5 h-5 {{ app.current_route == 'app_crm_flow' ? 'text-white' : 'text-slate-400 group-hover:text-blue-500' }}" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span class="font-semibold text-sm">Réservation sur internet</span>
</div>
{% if pendingCount > 0 %}
<span class="bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">{{ pendingCount }}</span>
{% endif %}
</a>
</div>
</div>

View File

@@ -0,0 +1,122 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Réservations en ligne{% endblock %}
{% block title_header %}Réservations en ligne{% endblock %}
{% block body %}
<div class="space-y-8 pb-20">
{# --- FILTRES (Optionnel) --- #}
{# Pour l'instant simple liste #}
<div id="flowList" class="grid gap-6">
{% for session in sessions %}
<div class="contrat-card relative overflow-hidden group">
{# Background Hover Effect #}
<div class="absolute -inset-px bg-gradient-to-r from-transparent via-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-[2rem]"></div>
<div class="relative bg-white/[0.03] border border-white/10 backdrop-blur-md rounded-[2rem] transition-all duration-300 group-hover:bg-white/[0.06] group-hover:translate-y-[-2px] group-hover:shadow-2xl group-hover:shadow-blue-500/10">
<div class="grid grid-cols-1 lg:grid-cols-12 items-center">
{# 1. DATE & STATUS #}
<div class="lg:col-span-2 p-8 border-b lg:border-b-0 lg:border-r border-white/5 text-center lg:text-left">
<span class="text-[10px] font-bold text-blue-400 uppercase tracking-[0.2em] mb-1 block">Créé le</span>
<h3 class="text-white font-bold text-sm tracking-tight mb-3">
{{ session.createdAt|date('d/m/Y H:i') }}
</h3>
{% if session.state == 'send' %}
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[9px] font-bold uppercase tracking-wider">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></span> Envoyée
</div>
{% elseif session.state == 'created' %}
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-slate-500/10 border border-slate-500/20 text-slate-400 text-[9px] font-bold uppercase tracking-wider">
Brouillon
</div>
{% else %}
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/10 border border-white/20 text-slate-300 text-[9px] font-bold uppercase tracking-wider">
{{ session.state|capitalize }}
</div>
{% endif %}
</div>
{# 2. CLIENT #}
<div class="lg:col-span-3 p-8 border-b lg:border-b-0 lg:border-r border-white/5">
{% if session.customer %}
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-tr from-blue-600/20 to-indigo-600/20 rounded-2xl flex items-center justify-center border border-white/10 group-hover:scale-110 transition-transform">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" stroke-width="1.5"></path></svg>
</div>
<div>
<p class="text-white font-bold text-base tracking-tight uppercase">{{ session.customer.surname }} {{ session.customer.name }}</p>
<p class="text-slate-500 text-xs font-medium line-clamp-1">{{ session.customer.email }}</p>
<p class="text-slate-500 text-xs font-medium">{{ session.customer.phone }}</p>
</div>
</div>
{% else %}
<div class="flex items-center gap-4 opacity-50">
<div class="w-12 h-12 bg-slate-800 rounded-2xl flex items-center justify-center border border-white/5">
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" stroke-width="1.5"></path></svg>
</div>
<div>
<p class="text-slate-400 font-bold text-sm tracking-tight italic">Client non identifié</p>
</div>
</div>
{% endif %}
</div>
{# 3. LIEU & TYPE #}
<div class="lg:col-span-3 p-8 border-b lg:border-b-0 lg:border-r border-white/5">
<div class="flex flex-col gap-2">
<div>
<span class="text-[10px] font-bold text-slate-300 uppercase tracking-widest mb-1 block">Événement</span>
<p class="text-slate-200 font-bold text-sm">{{ session.type|default('Non spécifié') }}</p>
</div>
<div>
<p class="text-slate-400 text-xs flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
{{ session.townEvent|default('Ville inconnue') }} <span class="text-slate-600">({{ session.zipCodeEvent }})</span>
</p>
</div>
</div>
</div>
{# 4. DETAILS (Nb produits) #}
<div class="lg:col-span-2 p-8 border-b lg:border-b-0 border-white/5">
<span class="text-[10px] font-bold text-slate-300 uppercase tracking-widest mb-1 block">Contenu</span>
<p class="text-white font-bold text-sm">
{{ session.products['ids']|default([])|length }} produits
</p>
{% if session.products['start'] is defined %}
<p class="text-xs text-slate-500 mt-1">
Du {{ session.products['start']|date('d/m') }} au {{ session.products['end']|date('d/m/Y') }}
</p>
{% endif %}
</div>
{# 5. ACTIONS #}
<div class="lg:col-span-2 p-6 flex items-center justify-end pr-8">
<a data-turbo="false" href="{{ path('app_crm_flow_view', {id: session.id}) }}"
class="group/btn relative px-4 py-2 bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white border border-blue-500/20 rounded-xl transition-all flex items-center gap-2 text-xs font-bold uppercase tracking-wider">
Voir
<svg class="w-4 h-4 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
</a>
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center py-20">
<div class="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path></svg>
</div>
<p class="text-slate-500 font-medium">Aucune demande de réservation trouvée.</p>
</div>
{% endfor %}
</div>
<div class="mt-12 glass-pagination">
{{ knp_pagination_render(sessions) }}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,386 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Détail de la réservation #{{ session.id }}{% endblock %}
{% block title_header %}Réservation #{{ session.id }}{% endblock %}
{% block actions %}
<div class="flex items-center gap-3">
<a href="{{ path('app_crm_flow') }}"
class="group relative flex items-center gap-2 px-6 py-3 bg-slate-700 hover:bg-slate-600 rounded-2xl text-white text-xs font-bold uppercase tracking-wider transition-all shadow-lg">
<svg class="w-4 h-4 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour
</a>
<a href="{{ path('app_crm_flow_delete', {id: session.id}) }}"
onclick="return confirm('Voulez-vous vraiment supprimer cette demande ? Cette action est irréversible.')"
data-turbo="false"
class="group relative flex items-center gap-2 px-6 py-3 bg-red-600 hover:bg-red-500 rounded-2xl text-white text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-red-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Refuser
</a>
<a href="{{ path('app_crm_flow_allow', {id: session.id}) }}"
data-turbo="false"
class="group relative flex items-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-2xl text-white text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-emerald-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Valider la demande
</a>
</div>
{% endblock %}
{% block body %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<div class="space-y-8 pb-20">
{# STATUS CARD #}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="p-6 bg-slate-800/50 backdrop-blur-md border border-slate-700/50 rounded-[2rem] flex flex-col items-center justify-center text-center">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mb-2">Statut</span>
{% if session.state == 'send' %}
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-bold uppercase tracking-wider">
<span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span> Envoyée
</div>
{% elseif session.state == 'created' %}
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-slate-500/10 border border-slate-500/20 text-slate-400 text-xs font-bold uppercase tracking-wider">
Brouillon
</div>
{% else %}
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 border border-white/20 text-slate-300 text-xs font-bold uppercase tracking-wider">
{{ session.state|capitalize }}
</div>
{% endif %}
</div>
<div class="p-6 bg-slate-800/50 backdrop-blur-md border border-slate-700/50 rounded-[2rem] flex flex-col items-center justify-center text-center">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mb-2">Création</span>
<p class="text-white font-bold text-lg">{{ session.createdAt|date('d/m/Y') }}</p>
<p class="text-slate-500 text-xs">{{ session.createdAt|date('H:i') }}</p>
</div>
<div class="p-6 bg-slate-800/50 backdrop-blur-md border border-slate-700/50 rounded-[2rem] flex flex-col items-center justify-center text-center">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mb-2">Dernière mise à jour</span>
{% if session.updatedAt %}
<p class="text-white font-bold text-lg">{{ session.updatedAt|date('d/m/Y') }}</p>
<p class="text-slate-500 text-xs">{{ session.updatedAt|date('H:i') }}</p>
{% else %}
<p class="text-slate-500 text-sm italic">Aucune modification</p>
{% endif %}
</div>
<div class="p-6 bg-slate-800/50 backdrop-blur-md border border-slate-700/50 rounded-[2rem] flex flex-col items-center justify-center text-center">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mb-2">Type d'événement</span>
<p class="text-white font-bold text-lg">{{ session.type|default('Non défini') }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
{# CLIENT INFO #}
<div class="bg-slate-800/40 backdrop-blur-xl border border-slate-700/50 rounded-[2rem] p-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center gap-3">
<div class="p-2 bg-blue-500/10 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
</div>
Informations Client
</h3>
{% if session.customer %}
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="text-slate-400 text-xs uppercase tracking-wider font-bold">Nom complet</span>
<span class="text-white font-bold">{{ session.customer.surname }} {{ session.customer.name }}</span>
</div>
<div class="flex items-center justify-between p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="text-slate-400 text-xs uppercase tracking-wider font-bold">Email</span>
<a href="mailto:{{ session.customer.email }}" class="text-blue-400 hover:text-blue-300 transition-colors font-medium">{{ session.customer.email }}</a>
</div>
<div class="flex items-center justify-between p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="text-slate-400 text-xs uppercase tracking-wider font-bold">Téléphone</span>
<a href="tel:{{ session.customer.phone }}" class="text-white font-medium hover:text-blue-400 transition-colors">{{ session.customer.phone }}</a>
</div>
</div>
{% else %}
<div class="p-8 text-center border-2 border-dashed border-slate-700 rounded-2xl">
<p class="text-slate-500 font-medium">Aucun client associé pour le moment.</p>
</div>
{% endif %}
</div>
{# LOCATION & TECHNICAL INFO #}
<div class="bg-slate-800/40 backdrop-blur-xl border border-slate-700/50 rounded-[2rem] p-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center gap-3">
<div class="p-2 bg-purple-500/10 rounded-lg text-purple-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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
</div>
Lieu & Technique
</h3>
<div class="space-y-4">
<div class="p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="block text-slate-400 text-[10px] uppercase tracking-wider font-bold mb-1">Adresse de l'événement</span>
<p class="text-white font-medium">{{ session.adressEvent|default('Non renseignée') }}</p>
{% if session.adress2Event %}<p class="text-slate-400 text-sm mt-1">{{ session.adress2Event }}</p>{% endif %}
<p class="text-blue-400 font-bold mt-1">{{ session.zipCodeEvent }} {{ session.townEvent }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="block text-slate-400 text-[10px] uppercase tracking-wider font-bold mb-1">Type de sol</span>
<p class="text-white font-medium">{{ session.typeSol|default('-') }}</p>
</div>
<div class="p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="block text-slate-400 text-[10px] uppercase tracking-wider font-bold mb-1">Pente</span>
<p class="text-white font-medium">{{ session.pente|default('-') }}</p>
</div>
</div>
<div class="p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="block text-slate-400 text-[10px] uppercase tracking-wider font-bold mb-1">Accès</span>
<p class="text-slate-300 text-sm leading-relaxed">{{ session.access|default('Aucune information') }}</p>
</div>
<div class="p-4 bg-slate-900/50 rounded-xl border border-slate-700/50 flex justify-between items-center">
<span class="text-slate-400 text-xs uppercase tracking-wider font-bold">Distance Prise élec.</span>
<span class="text-white font-bold">{{ session.distancePower|default(0) }} m</span>
</div>
</div>
{% if session.deliveryDistance is not null %}
<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>
<div class="bg-slate-900/50 rounded-xl border border-slate-700/50 overflow-hidden mb-4">
{# Map #}
{% if session.deliveryGeometry %}
<div class="h-48 w-full relative z-0">
<leaflet-map class="absolute inset-0 w-full h-full"
data-geometry="{{ session.deliveryGeometry|json_encode|e('html_attr') }}">
</leaflet-map>
</div>
{% endif %}
{# Details #}
<div class="p-4 space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-slate-400">Distance réelle (Aller)</span>
<span class="text-white font-bold">{{ session.deliveryDistance|number_format(1, ',', ' ') }} km</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Franchise</span>
<span class="text-emerald-400 font-bold">- 10.0 km</span>
</div>
<div class="flex justify-between border-t border-slate-700 pt-2">
<span class="text-slate-400">Distance facturée</span>
<span class="text-white font-bold">{{ max(0, session.deliveryDistance - 10)|number_format(1, ',', ' ') }} km</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">Trajets</span>
<span class="text-white font-bold">4 (2 A/R)</span>
</div>
<div class="mt-2 p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-lg text-center">
<code class="text-[10px] text-indigo-300 font-mono">
({{ session.deliveryDistance|number_format(1) }} - 10) x 4 x 0.50€ = {{ session.deliveryPrice|number_format(2) }}
</code>
</div>
</div>
</div>
</div>
{% endif %}
<div class="mt-6 pt-6 border-t border-slate-700/50">
<h4 class="text-white font-bold text-sm mb-4">Gestion Livraison</h4>
<form action="{{ path('app_crm_flow_update', {id: session.id}) }}" method="post" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<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 }}"
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>
<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 }}"
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>
<button type="submit" class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold uppercase tracking-widest rounded-lg transition-all">
Mettre à jour
</button>
</form>
</div>
</div>
</div>
{# CART CONTENT #}
<div class="bg-slate-800/40 backdrop-blur-xl border border-slate-700/50 rounded-[2rem] p-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center gap-3">
<div class="p-2 bg-emerald-500/10 rounded-lg text-emerald-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="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path></svg>
</div>
Contenu de la demande
</h3>
{% 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">
<span class="text-slate-400 text-xs font-bold uppercase">Du</span>
<span class="text-white font-bold">{{ session.products['start']|date('d/m/Y') }}</span>
</div>
<span class="text-slate-600">➔</span>
<div class="flex items-center gap-2">
<span class="text-slate-400 text-xs font-bold uppercase">Au</span>
<span class="text-white font-bold">{{ session.products['end']|date('d/m/Y') }}</span>
</div>
</div>
{% endif %}
<div class="space-y-4">
{% if session.products['ids'] is defined %}
{% for productId in session.products['ids'] %}
{% set product = loadProductById(productId) %}
{% if product %}
<div class="flex items-center gap-4 p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
{% if product.image %}
<img src="{{ product.image }}" alt="{{ product.name }}" class="w-12 h-12 rounded-lg object-cover bg-slate-800">
{% else %}
<div class="w-12 h-12 rounded-lg bg-slate-800 flex items-center justify-center text-slate-500">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
</div>
{% endif %}
<div>
<p class="text-white font-bold">{{ product.name }}</p>
<span class="text-xs text-slate-500">Réf: #{{ productId }}</span>
<div class="mt-1 text-xs text-slate-400">
<span class="block">1er jour : {{ product.price1day|number_format(2, ',', ' ') }} €</span>
{% if product.priceSup > 0 %}
<span class="block">Jours supp : {{ product.priceSup|number_format(2, ',', ' ') }} €</span>
{% endif %}
</div>
</div>
</div>
{# LINKED OPTIONS #}
{% if session.products.options[productId] is defined and session.products.options[productId]|length > 0 %}
<div class="mt-2 ml-16 space-y-1">
{% for optionId in session.products.options[productId] %}
{% set option = loadOptionById(optionId) %}
{% if option %}
<div class="flex items-center gap-2 text-xs text-slate-400">
<span class="bg-indigo-500/10 text-indigo-400 px-1.5 py-0.5 rounded text-[10px] font-bold uppercase border border-indigo-500/20">Option</span>
<span>{{ option.name }}</span>
<span class="font-bold text-slate-300">+ {{ option.price|number_format(2, ',', ' ') }} €</span>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="flex items-center justify-between p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<span class="text-slate-400 italic">Produit introuvable (ID #{{ productId }})</span>
</div>
{% endif %}
{% endfor %}
{% else %}
<p class="text-slate-500 italic">Aucun produit.</p>
{% endif %}
{# ORPHAN OPTIONS #}
{% if session.options is defined %}
{% set hasOrphans = false %}
{% for prodId, opts in session.options %}
{% if prodId not in session.products['ids']|default([]) %}
{% set hasOrphans = true %}
{% endif %}
{% endfor %}
{% if hasOrphans %}
<div class="mt-6 pt-6 border-t border-slate-700/50">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mb-3 block">Options supplémentaires</span>
{% for prodId, opts in session.options %}
{% if prodId not in session.products['ids']|default([]) %}
{% for optionId in opts %}
{% set option = loadOptionById(optionId) %}
{% if option %}
<div class="flex items-center gap-3 p-3 bg-slate-900/30 rounded-xl border border-slate-700/30 mb-2">
<div class="h-8 w-8 bg-indigo-500/10 rounded-lg flex items-center justify-center text-indigo-500 border border-indigo-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
</div>
<span class="text-sm font-medium text-slate-300">{{ option.name }}</span>
<span class="ml-auto text-sm font-bold text-white">{{ option.price|number_format(2, ',', ' ') }} € HT</span>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
{% if session.details %}
<div class="mt-8">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em] mb-2 block">Notes client</span>
<div class="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-200 text-sm leading-relaxed">
{{ session.details }}
</div>
</div>
{% endif %}
{# TOTAL #}
{% set totalData = totalSession(session) %}
<div class="mt-8 pt-6 border-t border-slate-700">
<div class="flex flex-col gap-2 items-end">
<div class="text-sm text-slate-400">
Durée : <span class="text-white font-bold">{{ totalData.duration }} jour{{ totalData.duration > 1 ? 's' : '' }}</span>
</div>
<div class="text-xl text-white">
Total HT : <span class="font-black">{{ totalData.ht|number_format(2, ',', ' ') }} €</span>
</div>
{% if totalData.tvaEnabled %}
<div class="text-sm text-slate-500">
Soit <span class="font-bold text-slate-300">{{ (totalData.ht * 1.20)|number_format(2, ',', ' ') }} € TTC</span> (TVA 20%)
</div>
{% endif %}
</div>
</div> </div>
{# BILLING INFO #}
<div class="bg-slate-800/40 backdrop-blur-xl border border-slate-700/50 rounded-[2rem] p-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center gap-3">
<div class="p-2 bg-indigo-500/10 rounded-lg text-indigo-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
</div>
Facturation
</h3>
<div class="p-4 bg-slate-900/50 rounded-xl border border-slate-700/50">
<p class="text-white font-medium">{{ session.billingAddress|default('Même que livraison') }}</p>
<p class="text-blue-400 font-bold mt-1">{{ session.billingZipCode }} {{ session.billingTown }}</p>
</div>
<div class="mt-6 pt-6 border-t border-slate-700/50">
<form action="{{ path('app_crm_flow_update', {id: session.id}) }}" method="post" class="flex items-end gap-4">
<div class="flex-1">
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Mode de paiement</label>
<select name="typePaiement" 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">
<option value="">Sélectionner...</option>
{% for type in ['Paiement Via Chorus', 'Paiement En ligne', 'Paiement Après événement', 'Autre mode de paiement'] %}
<option value="{{ type }}" {% if session.typePaiement == type %}selected{% endif %}>{{ type }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="py-2 px-4 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold uppercase tracking-widest rounded-lg transition-all h-[38px]">
Enregistrer
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-bottom="0px">
<mj-column width="100%">
<mj-text font-size="22px" font-weight="900" color="#0f172a" text-transform="uppercase" font-style="italic" align="center">
Nouvelle <span style="color:#2563eb">Demande de Réservation</span>
</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 padding-bottom="0px" font-size="10px" font-weight="900" color="#94a3b8" text-transform="uppercase" letter-spacing="1px">
Client
</mj-text>
<mj-text font-size="18px" font-weight="700" color="#1e293b" padding-top="5px">
{{ datas.session.customer.surname }} {{ datas.session.customer.name|upper }}
</mj-text>
<mj-text padding-top="15px" padding-bottom="0px" font-size="10px" font-weight="900" color="#94a3b8" text-transform="uppercase" letter-spacing="1px">
Coordonnées
</mj-text>
<mj-text font-size="15px" color="#0f172a" padding-top="5px">
<b>Tél :</b> {{ datas.session.customer.phone }} <br/>
<b>Email :</b> <span style="color:#2563eb; font-weight:700;">{{ datas.session.customer.email }}</span>
</mj-text>
<mj-text padding-top="25px" padding-bottom="0px" font-size="10px" font-weight="900" color="#94a3b8" text-transform="uppercase" letter-spacing="1px">
Détails Événement
</mj-text>
<mj-text font-size="15px" color="#0f172a" padding-top="5px">
<b>Type :</b> {{ datas.session.type }} <br/>
<b>Lieu :</b> {{ datas.session.adressEvent }} {{ datas.session.zipCodeEvent }} {{ datas.session.townEvent }}
</mj-text>
<mj-text padding-top="25px" padding-bottom="0px" font-size="10px" font-weight="900" color="#94a3b8" text-transform="uppercase" letter-spacing="1px">
Lien vers la session
</mj-text>
<mj-text font-size="15px" color="#0f172a" padding-top="5px">
{{ system.path }}/flow/{{ datas.session.uuid }}/confirmed
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-button background-color="#2563eb" color="#ffffff" font-size="14px" font-weight="900" text-transform="uppercase" border-radius="16px" padding-top="30px" inner-padding="18px 30px" href="mailto:{{ datas.session.customer.email }}">
Répondre au client ⚡
</mj-button>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -189,54 +189,8 @@
</div>
{% set acompteOk = contratPaymentPay(contrat, 'accompte') %}
{% set cautionOk = contratPaymentPay(contrat, 'caution') %}
{% if solde > 0 %}
{% if acompteOk and cautionOk %}
<form data-turbo="false" action="{{ path('gestion_contrat_view', {'num': contrat.numReservation, 'act': 'soldePay'}) }}" method="GET" class="space-y-4">
{# On garde les paramètres de la route pour le formulaire en GET #}
<input type="hidden" name="num" value="{{ contrat.numReservation }}">
<input type="hidden" name="act" value="soldePay">
<div class="space-y-2">
<label for="amountToPay" class="text-[10px] font-black uppercase text-slate-400 ml-1">Montant à régler maintenant</label>
<div class="relative">
<input type="number"
name="amountToPay"
id="amountToPay"
step="0.01"
min="1"
max="{{ solde }}"
value="{{ solde }}"
class="w-full bg-white/5 border border-white/10 rounded-2xl px-6 py-4 text-2xl font-black italic text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white/10 transition-all"
>
<span class="absolute right-6 top-1/2 -translate-y-1/2 text-xl font-black italic text-blue-400">€</span>
</div>
<p class="text-[9px] text-slate-500 italic ml-1 italic">Saisissez un montant (Max. {{ solde|number_format(2, ',', ' ') }}€)</p>
</div>
<button type="submit"
class="group flex items-center justify-center gap-3 w-full bg-blue-600 hover:bg-blue-500 text-white py-4 rounded-2xl font-black uppercase italic transition-all shadow-lg shadow-blue-900/20">
<span>Procéder au paiement</span>
<svg class="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
</button>
</form>
{% else %}
<div class="bg-white/5 border border-white/10 rounded-2xl p-6">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
<p class="text-[10px] text-slate-400 font-bold uppercase italic leading-tight">
<span class="text-amber-500">Paiement du solde indisponible</span><br><br>
Vous devez d'abord :<br>
<span class="{{ acompteOk ? 'text-green-500' : 'text-slate-300' }}">1. Régler l'acompte ({{ acompteOk ? 'OK' : 'En attente' }})</span><br>
<span class="{{ cautionOk ? 'text-green-500' : 'text-slate-300' }}">2. Déposer la caution ({{ cautionOk ? 'OK' : 'En attente' }})</span>
</p>
</div>
</div>
{% endif %}
{% else %}
{% if solde <=0 %}
<div class="flex items-center gap-3 bg-green-500/20 text-green-400 p-6 rounded-2xl border border-green-500/30">
<svg class="w-8 h-8 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
@@ -330,54 +284,6 @@
</div>
{% endif %}
{# 3. CAUTION #}
{% if not contratPaymentPay(contrat, 'caution') %}
<div class="bg-white rounded-[2rem] border border-slate-100 shadow-xl shadow-slate-200/20 overflow-hidden">
<div class="bg-slate-800 p-6 text-white flex items-center gap-4">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 9v2m0 4h.01"></path></svg></div>
<p class="text-sm font-black uppercase italic leading-none">Caution</p>
</div>
<div class="p-6 text-center">
<p class="text-3xl font-black text-slate-900 italic mb-4">{{ totalCaution|number_format(2, ',', ' ') }}€</p>
{% set canPayCaution = (date('now') >= contrat.dateAt.modify('-7 days')) %}
{% if canPayCaution and contratPaymentPay(contrat, 'accompte') %}
<a data-turbo="false" href="{{ path('gestion_contrat_view', {'num': contrat.numReservation,'act':'cautionPay'}) }}" class="block w-full bg-slate-900 text-white py-4 rounded-xl font-black uppercase text-xs hover:bg-blue-600 transition-all shadow-md">Déposer l'empreinte</a>
{% else %}
<div class="p-3 bg-slate-50 rounded-xl border border-slate-100">
<p class="text-[9px] text-slate-400 font-black uppercase tracking-tighter">Lien actif le {{ contrat.dateAt.modify('-7 days')|date('d/m/Y') }}</p>
<p class="text-[8px] text-slate-300 italic mt-1">(7j avant prestation)</p>
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="bg-white rounded-[2rem] border border-green-100 shadow-xl shadow-green-100/20 overflow-hidden">
<div class="bg-green-500 p-6 text-white flex items-center gap-4">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg></div>
<p class="text-sm font-black uppercase italic leading-none">Caution Sécurisée</p>
</div>
<div class="p-5">
{% for payment in paymentCaution %}
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-100">
<div class="flex justify-between items-center mb-2">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">Dépôt</p>
<p class="text-lg font-black text-slate-900 italic leading-none">{{ payment.amount|number_format(2, ',', ' ') }}€</p>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black uppercase text-slate-700 italic">{{ payment.card.card.brand|default('Carte') }}</span>
<span class="text-[9px] text-slate-400 font-bold uppercase italic">**** {{ payment.card.card.last4|default('') }}</span>
{% if payment.card.card.funding == "debit" %}<span class="text-[7px] bg-slate-200 text-slate-500 px-1 rounded uppercase">Debit</span>{% endif %}
</div>
<div class="flex items-center gap-1 mt-2">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
<p class="text-[8px] text-green-600 font-black uppercase tracking-tighter">Empreinte bancaire validée</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{# ... (Garder tout le code précédent inchangé jusqu'à la fin de la grille 3 colonnes) ... #}

View File

@@ -84,9 +84,8 @@
{% endif %}
<div class="flex-1">
<h4 class="font-bold text-slate-800">{{ item.product.name }}</h4>
<p class="text-xs text-slate-500 line-clamp-1 mb-2">{{ item.product.description }}</p>
<div class="text-xs text-slate-600 bg-slate-50 p-2 rounded-lg border border-slate-100 inline-block">
<div class="text-xs text-slate-600 bg-slate-50 p-2 rounded-lg border border-slate-100 inline-block mt-2">
<div class="flex flex-wrap gap-x-3 gap-y-1">
<span>1er jour : <strong class="text-slate-800">{{ item.price1Day|number_format(2, ',', ' ') }} €</strong></span>
{% if cart.duration > 1 %}
@@ -95,6 +94,20 @@
{% endif %}
</div>
</div>
{% if item.options is defined and item.options|length > 0 %}
<div class="mt-2 text-xs">
<p class="font-semibold text-slate-500 uppercase tracking-wider mb-1">Options incluses :</p>
<ul class="list-disc list-inside text-slate-600 space-y-0.5">
{% for option in item.options %}
<li>
{{ option.name }}
<span class="font-medium text-slate-800">({{ option.price|number_format(2, ',', ' ') }} €)</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div class="text-right">
<p class="font-bold text-slate-900">{{ item.totalPriceHT|number_format(2, ',', ' ') }} € HT</p>
@@ -106,6 +119,25 @@
{% else %}
<p class="text-center text-slate-500 py-4">Aucun produit sélectionné.</p>
{% endfor %}
{% if cart.options is defined and cart.options|length > 0 %}
<div class="mt-4 border-t border-slate-100 pt-4">
<h4 class="text-sm font-bold text-slate-900 mb-3">Options supplémentaires</h4>
{% for option in cart.options %}
<div class="flex items-center justify-between bg-white p-3 rounded-xl border border-slate-200 shadow-sm mb-2">
<div class="flex items-center gap-3">
<div class="bg-indigo-50 p-2 rounded-lg text-indigo-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<span class="font-medium text-slate-800 text-sm">{{ option.name }}</span>
</div>
<span class="font-bold text-slate-900 text-sm">{{ option.price|number_format(2, ',', ' ') }} € HT</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mt-6 border-t border-slate-200 pt-4 space-y-2">

View File

@@ -3,6 +3,13 @@
{% block title %}Confirmation de votre demande{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
{% endblock %}
{% block body %}
<div class="max-w-7xl mx-auto px-4 py-12">
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
@@ -52,7 +59,7 @@
{% endif %}
</div>
</div>
{# Linked Options #}
{% if item.options is defined and item.options|length > 0 %}
<div class="mt-2 space-y-1">
@@ -76,7 +83,7 @@
{% else %}
<p class="text-center text-slate-500 py-4">Aucun produit sélectionné.</p>
{% endfor %}
{# Orphan Options #}
{% if cart.options is defined and cart.options|length > 0 %}
<div class="border-t border-slate-100 pt-4 mt-4">
@@ -241,10 +248,100 @@
</div>
</div>
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-6" role="alert">
<p class="font-bold">Information importante sur la livraison</p>
<p>Pour la livraison, des frais peuvent s'appliquer selon l'endroit et le type de structure réservée. Une fois que vous avez reconfirmé votre réservation, nos équipes vous retourneront un devis complet avec les frais de livraison si vous prévoyez cette option.</p>
</div>
{# Delivery Estimation #}
{% if delivery is defined and delivery.estimation is not null %}
<div class="bg-white rounded-2xl border border-slate-200 shadow-sm mb-8 overflow-hidden">
<div class="p-6 border-b border-slate-100 flex items-center gap-3 bg-slate-50/50">
<div class="bg-emerald-100 p-2 rounded-lg text-emerald-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-lg font-bold text-slate-900">Estimation des frais de livraison</h3>
</div>
<div class="flex flex-col md:flex-row">
{# Details (60%) #}
<div class="w-full md:w-3/5 p-6 border-b md:border-b-0 md:border-r border-slate-100">
{% if delivery.details.isFree %}
<div class="bg-emerald-50 border border-emerald-100 rounded-xl p-4 mb-6">
<div class="flex items-center gap-3">
<span class="text-2xl font-black text-emerald-600 italic">Offert !</span>
<span class="px-3 py-1 bg-white text-emerald-700 rounded-full text-[10px] font-bold uppercase tracking-wide shadow-sm">Zone gratuite</span>
</div>
<p class="text-sm text-emerald-800 mt-2 font-medium">Votre événement se trouve à moins de 10km de nos locaux.</p>
</div>
{% else %}
<div class="mb-6">
<span class="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Coût estimé</span>
<p class="text-4xl font-black text-slate-900 italic">
{{ delivery.estimation|format_currency('EUR') }}
</p>
</div>
{% endif %}
<div class="space-y-3">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">Détails du calcul</p>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Distance réelle (Aller)</span>
<span class="font-bold text-slate-700">{{ delivery.details.distance|number_format(1, ',', ' ') }} km</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Franchise kilométrique</span>
<span class="font-bold text-emerald-500">- 10.0 km (Offerts)</span>
</div>
<div class="border-t border-slate-100 my-2"></div>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Distance facturée</span>
<span class="font-bold text-slate-700">{{ delivery.details.chargedDistance|number_format(1, ',', ' ') }} km</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Nombre de trajets</span>
<span class="font-bold text-slate-700">{{ delivery.details.trips }} (2 A/R)</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-slate-500">Tarif kilométrique</span>
<span class="font-bold text-slate-700">{{ delivery.details.rate }} € / km</span>
</div>
</div>
{% if not delivery.details.isFree %}
<div class="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-xl text-center">
<p class="text-[10px] font-bold text-indigo-400 uppercase mb-1">Formule appliquée</p>
<code class="text-xs text-indigo-700 font-mono font-bold">
({{ delivery.details.distance|number_format(1) }} - 10) x {{ delivery.details.trips }} x {{ delivery.details.rate }}€ = {{ delivery.estimation|number_format(2) }}
</code>
</div>
{% endif %}
<div class="mt-4 p-3 bg-slate-50 rounded-xl text-center">
<p class="text-[10px] text-slate-400 italic">
Cette estimation est indicative. Le montant définitif figurera sur votre devis.
</p>
</div>
</div>
{# Map (40%) #}
<div class="w-full md:w-2/5 h-64 md:h-auto min-h-[16rem] bg-slate-100 relative">
{% if delivery.geometry %}
<leaflet-map class="absolute inset-0 z-0"
data-geometry="{{ delivery.geometry|json_encode|e('html_attr') }}">
</leaflet-map>
{% else %}
<div class="absolute inset-0 flex items-center justify-center text-slate-400">
<span class="text-xs font-medium">Carte non disponible</span>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="pt-6 border-t border-slate-100 mt-8 flex flex-col md:flex-row justify-center gap-4">
<a data-turbo="false" href="{{ path('reservation_generate_devis', {sessionId: session.uuid}) }}" class="w-full md:w-auto px-8 py-4 bg-gray-200 text-gray-800 font-bold rounded-2xl shadow-sm hover:shadow-md hover:scale-[1.01] transition-all flex items-center justify-center gap-2 text-lg">
@@ -253,13 +350,23 @@
</svg>
Télécharger le devis
</a>
<a href="#" class="w-full md:w-auto px-8 py-4 bg-green-500 text-white font-bold rounded-2xl shadow-lg shadow-green-200 hover:shadow-xl hover:scale-[1.02] transition-all flex items-center justify-center gap-2 text-lg">
Prendre les options de livraison
</a>
<button type="submit" class="w-full md:w-auto 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>
<form data-turbo="false" method="post" action="{{ path('reservation_flow_confirmed', {sessionId: session.uuid}) }}" onsubmit="localStorage.clear();" 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>
</form>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% if delivery is defined and delivery.geometry %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
nonce="{{ csp_nonce('script') }}"></script>
{% endif %}
{% endblock %}

View File

@@ -21,7 +21,7 @@
</label>
<label class="flex-1 text-center py-3 rounded-xl cursor-pointer transition-all font-bold text-sm has-[:checked]:bg-white has-[:checked]:text-blue-600 has-[:checked]:shadow-sm text-gray-500">
<input type="radio" name="type" value="buisness" class="hidden" onchange="toggleSiret(true)">
Professionnel / Asso
Professionnel / Asso / Mairie
</label>
</div>
@@ -58,11 +58,26 @@
class="w-full bg-gray-50 border-none rounded-2xl px-6 py-4 focus:ring-2 focus:ring-blue-500 outline-none">
</div>
{# SIRET (Caché par défaut) #}
<div id="siret-container" class="md:col-span-2 hidden">
<label class="block text-xs font-bold text-gray-400 uppercase mb-2 ml-2">Numéro SIRET</label>
<input type="text" name="siret" placeholder="123 456 789 00012"
class="w-full bg-gray-50 border-none rounded-2xl px-6 py-4 focus:ring-2 focus:ring-blue-500 outline-none">
{# SIRET & Business Fields (Caché par défaut) #}
<div id="siret-container" class="md:col-span-2 hidden grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="md:col-span-2">
<label class="block text-xs font-bold text-gray-400 uppercase mb-2 ml-2">Type d'organisation</label>
<select name="typCompany" class="w-full bg-gray-50 border-none rounded-2xl px-6 py-4 focus:ring-2 focus:ring-blue-500 outline-none appearance-none font-medium">
<option value="Entreprise">Entreprise</option>
<option value="Association">Association</option>
<option value="Service Public">Service Public (Mairie, École...)</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-2 ml-2">Raison Sociale</label>
<input type="text" name="raisonSocial" placeholder="Ex: Ma Société SAS"
class="w-full bg-gray-50 border-none rounded-2xl px-6 py-4 focus:ring-2 focus:ring-blue-500 outline-none">
</div>
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-2 ml-2">Numéro SIRET</label>
<input type="text" name="siret" placeholder="123 456 789 00012"
class="w-full bg-gray-50 border-none rounded-2xl px-6 py-4 focus:ring-2 focus:ring-blue-500 outline-none">
</div>
</div>
{# Mot de passe #}

View File

@@ -0,0 +1,34 @@
{% extends 'revervation/base.twig' %}
{% block title %}Confirmation de commande | Ludik Event{% endblock %}
{% block body %}
<div class="min-h-screen bg-slate-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow-xl shadow-slate-200 sm:rounded-3xl sm:px-10 border border-slate-100 text-center">
<div class="mx-auto flex items-center justify-center h-20 w-20 rounded-full bg-green-100 mb-6 animate-in zoom-in duration-500">
<svg class="h-10 w-10 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-3xl font-black text-slate-900 mb-2 uppercase italic tracking-tight">Merci !</h2>
<p class="text-lg text-slate-600 font-medium mb-8">Votre demande a bien été envoyée.</p>
<div class="space-y-4 text-sm text-slate-500 bg-slate-50 p-6 rounded-2xl border border-slate-100">
<p>Notre équipe va étudier votre demande et vous recevrez un devis complet par email dans les plus brefs délais.</p>
<p>Pensez à vérifier vos courriers indésirables (spam).</p>
</div>
<div class="mt-8">
<a href="{{ path('reservation') }}" class="w-full flex justify-center py-4 px-4 border border-transparent rounded-2xl shadow-lg text-sm font-bold text-white bg-slate-900 hover:bg-[#f39e36] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-900 transition-all uppercase tracking-widest">
Retour à l'accueil
</a>
</div>
</div>
</div>
</div>,flowConfirmed
<local-storage-clear keys="lp_list,lp_options"></local-storage-clear>
{% endblock %}