First commit
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Gestion du formulaire de contact en modal avec envoi AJAX.
|
||||
* Protections : CSRF, honeypot, limitation de débit côté session.
|
||||
*/
|
||||
class ContactForm {
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {HTMLElement} modal
|
||||
* @param {string} url - endpoint POST
|
||||
* @param {string} csrfToken
|
||||
*/
|
||||
constructor(form, modal, url, csrfToken) {
|
||||
this.form = form;
|
||||
this.modal = modal;
|
||||
this.url = url;
|
||||
this.csrfToken = csrfToken;
|
||||
|
||||
this.overlay = modal.querySelector('#contact-overlay');
|
||||
this.btnClose = modal.querySelector('#contact-modal-close');
|
||||
this.btnOpen = document.querySelector('#open-contact-modal');
|
||||
this.submit = form.querySelector('#contact-submit');
|
||||
this.success = modal.querySelector('#contact-success');
|
||||
this.globalErr = modal.querySelector('#contact-global-error');
|
||||
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
// ── Initialisation ─────────────────────────────────────────────────────────
|
||||
|
||||
_bindEvents() {
|
||||
// Ouvrir
|
||||
if (this.btnOpen) {
|
||||
this.btnOpen.addEventListener('click', () => {
|
||||
this._setPropertyId(this.btnOpen.dataset.property);
|
||||
this.open();
|
||||
});
|
||||
}
|
||||
|
||||
// Fermer
|
||||
if (this.btnClose) this.btnClose.addEventListener('click', () => this.close());
|
||||
if (this.overlay) this.overlay.addEventListener('click', () => this.close());
|
||||
|
||||
// Clavier
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !this.modal.hidden) this.close();
|
||||
});
|
||||
|
||||
// Soumission
|
||||
this.form.addEventListener('submit', (e) => this._handleSubmit(e));
|
||||
|
||||
// Nettoyage des erreurs à la saisie
|
||||
this.form.querySelectorAll('input, textarea').forEach((el) => {
|
||||
el.addEventListener('input', () => this._clearFieldError(el.name));
|
||||
});
|
||||
}
|
||||
|
||||
// ── API publique ───────────────────────────────────────────────────────────
|
||||
|
||||
open() {
|
||||
this._resetForm();
|
||||
this.modal.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
this.form.querySelector('#id_name')?.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// ── Soumission ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!this._validateLocal()) return;
|
||||
|
||||
this._setLoading(true);
|
||||
this._hideGlobalError();
|
||||
|
||||
const formData = new FormData(this.form);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': this.csrfToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this._showSuccess();
|
||||
} else if (data.errors) {
|
||||
this._showFieldErrors(data.errors);
|
||||
} else if (data.error) {
|
||||
this._showGlobalError(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
this._showGlobalError(
|
||||
(window.I18N?.networkError) || 'Une erreur est survenue. Veuillez réessayer.'
|
||||
);
|
||||
} finally {
|
||||
this._setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation locale légère ───────────────────────────────────────────────
|
||||
|
||||
_validateLocal() {
|
||||
let valid = true;
|
||||
const name = this.form.querySelector('#id_name');
|
||||
const email = this.form.querySelector('#id_email');
|
||||
const msg = this.form.querySelector('#id_message');
|
||||
|
||||
if (!name.value.trim()) {
|
||||
this._setFieldError('name', name.required ? 'Ce champ est requis.' : '');
|
||||
valid = false;
|
||||
}
|
||||
if (!email.value.trim() || !email.value.includes('@')) {
|
||||
this._setFieldError('email', 'Adresse email invalide.');
|
||||
valid = false;
|
||||
}
|
||||
if (!msg.value.trim() || msg.value.trim().length < 10) {
|
||||
this._setFieldError('message', 'Votre message est trop court.');
|
||||
valid = false;
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
|
||||
// ── Utilitaires ────────────────────────────────────────────────────────────
|
||||
|
||||
_setPropertyId(id) {
|
||||
const input = this.form.querySelector('#id_property_id');
|
||||
if (input) input.value = id || '';
|
||||
}
|
||||
|
||||
_setLoading(loading) {
|
||||
if (!this.submit) return;
|
||||
const i18n = window.I18N || {};
|
||||
this.submit.disabled = loading;
|
||||
this.submit.textContent = loading
|
||||
? (i18n.sending || 'Envoi en cours…')
|
||||
: (i18n.send || 'Envoyer');
|
||||
}
|
||||
|
||||
_showSuccess() {
|
||||
this.form.hidden = true;
|
||||
this.success.hidden = false;
|
||||
}
|
||||
|
||||
_resetForm() {
|
||||
this.form.reset();
|
||||
this.form.hidden = false;
|
||||
this.success.hidden = true;
|
||||
this._hideGlobalError();
|
||||
this.form.querySelectorAll('.field-error').forEach(el => { el.textContent = ''; });
|
||||
}
|
||||
|
||||
_setFieldError(name, msg) {
|
||||
const el = this.form.querySelector(`.field-error[data-field="${name}"]`);
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
_clearFieldError(name) {
|
||||
this._setFieldError(name, '');
|
||||
}
|
||||
|
||||
_showFieldErrors(errors) {
|
||||
for (const [field, msgs] of Object.entries(errors)) {
|
||||
const list = Array.isArray(msgs) ? msgs : [msgs];
|
||||
this._setFieldError(field, list.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
_showGlobalError(msg) {
|
||||
if (!this.globalErr) return;
|
||||
this.globalErr.textContent = msg;
|
||||
this.globalErr.hidden = false;
|
||||
}
|
||||
|
||||
_hideGlobalError() {
|
||||
if (this.globalErr) this.globalErr.hidden = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Galerie photos avec modal plein écran.
|
||||
* Gère : onglets par pièce, navigation clavier, swipe mobile.
|
||||
*/
|
||||
class Gallery {
|
||||
/**
|
||||
* @param {Object} data - { 'gallery-key': [{src, caption}, …], … }
|
||||
* @param {HTMLElement} modal - élément .gallery-modal
|
||||
*/
|
||||
constructor(data, modal) {
|
||||
this.data = data; // toutes les galeries indexées par clé
|
||||
this.currentKey = null; // clé de la galerie active
|
||||
this.currentIndex = 0; // index dans la galerie active
|
||||
|
||||
this.modal = modal;
|
||||
this.img = modal.querySelector('#gallery-modal-img');
|
||||
this.caption = modal.querySelector('#gallery-modal-caption');
|
||||
this.counter = modal.querySelector('#gallery-counter');
|
||||
this.overlay = modal.querySelector('#gallery-overlay');
|
||||
this.btnClose = modal.querySelector('#gallery-close');
|
||||
this.btnPrev = modal.querySelector('#gallery-prev');
|
||||
this.btnNext = modal.querySelector('#gallery-next');
|
||||
|
||||
this._touchStartX = 0;
|
||||
|
||||
this._bindEvents();
|
||||
this._initTabs();
|
||||
}
|
||||
|
||||
// ── Initialisation ─────────────────────────────────────────────────────────
|
||||
|
||||
_bindEvents() {
|
||||
// Boutons de navigation
|
||||
this.btnClose.addEventListener('click', () => this.close());
|
||||
this.overlay.addEventListener('click', () => this.close());
|
||||
this.btnPrev.addEventListener('click', () => this.prev());
|
||||
this.btnNext.addEventListener('click', () => this.next());
|
||||
|
||||
// Clavier
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (this.modal.hidden) return;
|
||||
if (e.key === 'Escape') this.close();
|
||||
if (e.key === 'ArrowLeft') this.prev();
|
||||
if (e.key === 'ArrowRight') this.next();
|
||||
});
|
||||
|
||||
// Swipe mobile
|
||||
this.modal.addEventListener('touchstart', (e) => {
|
||||
this._touchStartX = e.touches[0].clientX;
|
||||
}, { passive: true });
|
||||
this.modal.addEventListener('touchend', (e) => {
|
||||
const dx = e.changedTouches[0].clientX - this._touchStartX;
|
||||
if (Math.abs(dx) > 50) {
|
||||
dx < 0 ? this.next() : this.prev();
|
||||
}
|
||||
});
|
||||
|
||||
// Délégation : clic sur les vignettes
|
||||
document.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.gallery-item[data-gallery]');
|
||||
if (!item) return;
|
||||
this.open(item.dataset.gallery, parseInt(item.dataset.index, 10));
|
||||
});
|
||||
}
|
||||
|
||||
_initTabs() {
|
||||
document.querySelectorAll('.gallery-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
// Mise à jour des onglets actifs
|
||||
document.querySelectorAll('.gallery-tab').forEach(t => {
|
||||
t.classList.remove('active');
|
||||
t.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
tab.classList.add('active');
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
|
||||
// Affichage du panneau correspondant
|
||||
const roomId = tab.dataset.room;
|
||||
const panelId = roomId === 'general' ? 'room-general' : `room-${roomId}`;
|
||||
document.querySelectorAll('.gallery-grid').forEach(g => g.classList.remove('active'));
|
||||
const panel = document.getElementById(panelId);
|
||||
if (panel) panel.classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── API publique ───────────────────────────────────────────────────────────
|
||||
|
||||
open(key, index) {
|
||||
this.currentKey = key;
|
||||
this.currentIndex = index;
|
||||
this._render();
|
||||
this.modal.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
this.btnClose.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
prev() {
|
||||
const photos = this.data[this.currentKey] || [];
|
||||
this.currentIndex = (this.currentIndex - 1 + photos.length) % photos.length;
|
||||
this._render();
|
||||
}
|
||||
|
||||
next() {
|
||||
const photos = this.data[this.currentKey] || [];
|
||||
this.currentIndex = (this.currentIndex + 1) % photos.length;
|
||||
this._render();
|
||||
}
|
||||
|
||||
// ── Rendu ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_render() {
|
||||
const photos = this.data[this.currentKey] || [];
|
||||
if (!photos.length) return;
|
||||
|
||||
const photo = photos[this.currentIndex];
|
||||
this.img.src = photo.src;
|
||||
this.img.alt = photo.caption || '';
|
||||
this.caption.textContent = photo.caption || '';
|
||||
|
||||
const total = photos.length;
|
||||
const of = (window.I18N && I18N.of) ? I18N.of : '/';
|
||||
this.counter.textContent = `${this.currentIndex + 1} ${of} ${total}`;
|
||||
|
||||
// Désactiver les flèches si une seule photo
|
||||
this.btnPrev.disabled = total <= 1;
|
||||
this.btnNext.disabled = total <= 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Point d'entrée principal — instancie Gallery et ContactForm
|
||||
* après le chargement du DOM.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// ── Galerie photos ─────────────────────────────────────────────────────────
|
||||
const galleryModal = document.getElementById('gallery-modal');
|
||||
if (galleryModal && typeof GALLERY_DATA !== 'undefined') {
|
||||
window._gallery = new Gallery(GALLERY_DATA, galleryModal);
|
||||
}
|
||||
|
||||
// ── Formulaire de contact ──────────────────────────────────────────────────
|
||||
const contactModal = document.getElementById('contact-modal');
|
||||
const contactForm = document.getElementById('contact-form');
|
||||
if (contactModal && contactForm && typeof CONTACT_URL !== 'undefined') {
|
||||
window._contactForm = new ContactForm(
|
||||
contactForm,
|
||||
contactModal,
|
||||
CONTACT_URL,
|
||||
typeof CSRF_TOKEN !== 'undefined' ? CSRF_TOKEN : '',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Disparition auto des messages flash Django ─────────────────────────────
|
||||
document.querySelectorAll('.alert-auto-hide').forEach((alert) => {
|
||||
setTimeout(() => {
|
||||
alert.style.opacity = '0';
|
||||
alert.style.transition = 'opacity .5s';
|
||||
setTimeout(() => alert.remove(), 500);
|
||||
}, 4000);
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user