feat: add previous configuration handling for user selections in quotes

- Implemented session storage management for the last configuration choices made by the user.
- Added functionality to prompt users when changing models to retain their previous selections.
- Introduced methods to apply previous choices to the newly loaded model, including finishes, handles, glass types, and options.
- Enhanced user experience by ensuring selections are automatically applied where applicable.
This commit is contained in:
2026-10-19 19:49:44 +02:00
parent 863e6ca920
commit 06617a985a
197 changed files with 4892 additions and 400 deletions
+12 -9
View File
@@ -1,5 +1,7 @@
import { gsap } from 'gsap';
const impostato = (valore) => valore !== null && valore !== undefined && valore !== '';
var APP = {
Player: function (canvas_container) {
@@ -442,8 +444,9 @@ var APP = {
let texture = null;
let bump = null;
texture = data.texture_map ? await new THREE.TextureLoader().loadAsync(`./assets/data/immagini/${window.sistema}/${data.folder}/${data.texture_folder}/${data.id}/${data.texture_map}`) : null;
bump = data.texture_bump ? await new THREE.TextureLoader().loadAsync(`./assets/data/immagini/${window.sistema}/${data.folder}/${data.texture_folder}/${data.id}/${data.texture_bump}`) : null;
// URL assoluti delle immagini servite dal backoffice (con CORS, richiesto da WebGL).
texture = data.texture_map ? await new THREE.TextureLoader().loadAsync(data.texture_map) : null;
bump = data.texture_bump ? await new THREE.TextureLoader().loadAsync(data.texture_bump) : null;
// A newer selection for this same group may have been made while the texture above was loading.
// Discard this stale result so the last user/init action always wins, regardless of load order.
@@ -466,12 +469,12 @@ var APP = {
}
object.material.needsUpdate = true;
if (data.roughness_valore != "") object.material.roughness = data.roughness_valore;
if (data.metalness_valore != "") object.material.metalness = data.metalness_valore;
if (data.opacity_valore != "") object.material.opacity = data.opacity_valore;
if (data.bump_valore != "") object.material.bumpScale = data.bump_valore;
//if(data.color_valore != "") object.material.color.setHex(parseInt(data.color_valore.replace("#", "0x")));
if (data.color_valore != "") object.material.color = new THREE.Color(data.color_valore);
// Valori assenti (null) o vuoti lasciano quelli del materiale.
if (impostato(data.roughness_valore)) object.material.roughness = data.roughness_valore;
if (impostato(data.metalness_valore)) object.material.metalness = data.metalness_valore;
if (impostato(data.opacity_valore)) object.material.opacity = data.opacity_valore;
if (impostato(data.bump_valore)) object.material.bumpScale = data.bump_valore;
if (impostato(data.color_valore)) object.material.color = new THREE.Color(data.color_valore);
object.material.needsUpdate = true;
}
@@ -479,7 +482,7 @@ var APP = {
});
},
this.changeColor = async (data) => {
if (data.color_valore_corpo_base != "") {
if (impostato(data.color_valore_corpo_base)) {
scene.traverse((object) => {
if (object.isMesh) {
if (data.categories.includes(object.userData.categoria)) {
+62 -137
View File
@@ -1,4 +1,5 @@
let shared_data = null;
let timerPrezzo = null;
const Configurator = {
init: (priceCalculator, config_data) => {
@@ -6,181 +7,105 @@ const Configurator = {
shared_data = config_data;
},
enable: () => {
$(("#larghezza, #altezza, select, input[name=vetro], input[type=radio]")).on('input', (e) => {
Configurator.checkTextures()
$(("#larghezza, #altezza, select, input[name=vetro], input[type=radio], input[type=checkbox]")).on('input', (e) => {
let res = Configurator.generatePrice();
if(res == null) return;
res = Configurator.generateConfigData(res);
$(".prezzo").html(`€ ${res.prezzo.toFixed(2)}<span>IVA esclusa</span>`);
window.configurazione = res;
// Un solo calcolo per raffica di modifiche (es. digitazione delle misure).
clearTimeout(timerPrezzo);
timerPrezzo = setTimeout(Configurator.aggiornaPrezzo, 150);
if($(e.target).data('model')){
caricaModello($(e.target).data('model'));
}
});
},
generatePrice: () => {
/** Scelte correnti (solo codici): il backoffice ne calcola il prezzo e la descrizione. */
raccogliScelte: () => {
const scelte = {
larghezza: parseInt($('#larghezza').val()),
altezza: parseInt($('#altezza').val()),
opzioni: {},
};
const config_data = {};
config_data.height = $('#altezza').val();
config_data.width = $('#larghezza').val();
config_data.finitura = $('#prezzi_finiture').val();
config_data.percentuale_accessori = 0;
$('input.percentuale:checked').each((i, el) => {
let perc = $(el).data('percentuale');
perc = perc == undefined ? 0 : parseFloat(perc);
perc = isNaN(perc) ? 0 : perc;
config_data.percentuale_accessori += (perc / 100);
if($(el).hasClass('check_finitura')){
config_data.override_supplemento_finitura = true;
}
['finitura_interna', 'finitura_esterna', 'maniglia', 'vetro'].forEach((nome) => {
const valore = $(`input[name=${nome}]:checked`).val();
if (valore != null) scelte[nome] = valore;
});
//config_data.finitura_interna = $('input[name=finitura_interna]:checked').val();
//config_data.finitura_esterna = $('input[name=finitura_esterna]:checked').val();
config_data.maniglia = $('input[name=maniglia]:checked').val();
//config_data.finitura_cassonetto = $('input[name=cassonetto]:checked').val();
config_data.finitura_avvolgibile = $('input[name=avvolgibile]:checked').val();
for (const gruppo in shared_data.form.radios) {
const valore = $(`input[name=${gruppo}]:checked`).val();
if (valore != null) scelte.opzioni[gruppo] = valore;
}
config_data.vetro = $('input[name=vetro]:checked').val();
config_data.cerniere_scomparsa = $('.cerniere_a_scomparsa').val();
config_data.altezza_maniglia = $('.altezza_maniglia').val();
config_data.azionamento = $('.azionamento').val();
config_data.tipo_ispezione = $('.tipo_ispezione').val();
config_data.apertura_portoncino = $('.apertura_portoncino option:selected').text();
config_data.tipo_ispezione = $('.tipo_ispezione option:selected').text();
// Scelta multipla: lista dei codici selezionati (anche vuota).
for (const gruppo in shared_data.form.multiple ?? {}) {
scelte.opzioni[gruppo] = $(`input.opzione-multipla[name=${gruppo}]:checked`).map((i, el) => el.value).get();
}
let price = Configurator.priceCalculator.getGridPrice(config_data.width, config_data.height);
for (const gruppo in shared_data.form.select) {
const valore = $(`select.${gruppo}`).val();
if (valore) scelte.opzioni[gruppo] = valore;
}
if(price < 0){
return scelte;
},
aggiornaPrezzo: async () => {
const res = await Configurator.priceCalculator.calcola(shared_data.sistema, shared_data.modello.id, Configurator.raccogliScelte());
if (res.annullata) return;
if (res.errore) {
window.configurazione = null;
$(".errore-misure-cnt").removeClass('d-none');
$(".prezzo").addClass('invisible');
$(".cart-button").addClass('disabled');
}else{
$(".errore-misure-cnt").addClass('d-none');
$(".prezzo").removeClass('invisible');
$(".cart-button").removeClass('disabled');
return;
}
config_data.prezzo_base = price;
$(".errore-misure-cnt").addClass('d-none');
$(".prezzo").removeClass('invisible');
$(".cart-button").removeClass('disabled');
$(".prezzo").html(`€ ${res.prezzo.toFixed(2)}<span>IVA esclusa</span>`);
const config = Configurator.priceCalculator.getConfigurationPrice(config_data);
config.configurazione.height = config_data.height;
config.configurazione.width = config_data.width;
config.configurazione.nome = shared_data.nome_esteso;
if(config_data.apertura_portoncino != "")
config.configurazione.apertura_portoncino = config_data.apertura_portoncino;
if(config_data.tipo_ispezione != "")
config.configurazione.tipo_ispezione = config_data.tipo_ispezione;
return config;
window.configurazione = {
prezzo: res.prezzo,
scelte: res.scelte,
configurazione: { ...Configurator.vociDescrittive(res.configurazione.height), ...res.configurazione },
};
},
generateConfigData: (config) =>{
let input_finiture = $(`.finitura input[type=radio]:checked`);
//console.log(input_finiture);
input_finiture.each((i, el) => {
let id_finitura = $(el).val();
let nome_finitura = $(el).attr('name');
let finitura = shared_data.data_finiture.find(f => f.id == id_finitura);
config.configurazione[nome_finitura] = finitura.descrizione;
});
/** Voci senza effetto sul prezzo, ricavate dal menu e salvate così nel preventivo. */
vociDescrittive: (altezzaSerramento) => {
const voci = {};
//se è un cassonetto
if(shared_data.data_avvolgibile != null && shared_data.data_avvolgibile.length > 0){
let id_avvolgibile = $(`input[name=avvolgibile]:checked`).val();
let avvolgibile = shared_data.data_avvolgibile.find(a => a.id == id_avvolgibile);
config.configurazione.avvolgibile = avvolgibile.descrizione;
let id_cassonetto = $(`input[name=avvolgibile]:checked`).val();
let cassonetto = shared_data.data_finiture.find(a => a.id == id_cassonetto);
config.configurazione.cassonetto = cassonetto.descrizione;
if (avvolgibile) voci.avvolgibile = avvolgibile.descrizione;
let cassonetto = shared_data.data_finiture.find(a => a.id == id_avvolgibile);
if (cassonetto) voci.cassonetto = cassonetto.descrizione;
}
let val_maniglia = $(`input[name=maniglia]:checked`).val();
let finitura_maniglia = shared_data.data_maniglie.find(m => m.id == val_maniglia);
// Prima fascia della tabella con altezza (mm) >= a quella del serramento.
let altezza = parseFloat(config.configurazione.height);
let altezza = parseFloat(altezzaSerramento);
let altezza_maniglia = [...shared_data.altezza_maniglia]
.sort((a, b) => a.altezza - b.altezza)
.find((m) => m.altezza >= altezza);
.find((m) => m.altezza >= altezza) ?? shared_data.altezza_maniglia[0];
if(altezza_maniglia != null){
config.configurazione.altezza_maniglia = altezza_maniglia.altezza_maniglia;
voci.altezza_maniglia = altezza_maniglia.altezza_maniglia;
}
if(altezza_maniglia == null && shared_data.altezza_maniglia.length > 0){
config.configurazione.altezza_maniglia = shared_data.altezza_maniglia[0].altezza_maniglia;
}
if(finitura_maniglia != null)
config.configurazione.maniglia = finitura_maniglia.descrizione;
$('input.auto-radio:checked').each((i, el) => {
let nome = $(el).attr('name');
if($(`.${nome}`).hasClass('d-none')) return;
config.configurazione[nome] = $(el).val();
});
return config;
},
checkTextures: () =>{
let $interna = $("input[name=finitura_interna]:checked");
let $esterna = $("input[name=finitura_esterna]:checked");
let interna = $interna.val();
// modelli senza rivestimento esterno separato (tipo 'uniforme'): segue l'interno
let esterna = $esterna.length ? $esterna.val() : interna;
let internaPredefinita = $interna.data('predefinito') === true;
let tipo = $interna.data('tipo');
let percentuale = $interna.data('percentuale');
// bianco: stesso colore predefinito su interno ed esterno -> nessun sovrapprezzo
// uniforme: stesso colore (non predefinito) su interno ed esterno -> sovrapprezzo "uniforme"
// bicolore: colori interno ed esterno diversi tra loro -> sovrapprezzo "bicolore"
if(interna === esterna){
$("#prezzi_finiture").val(internaPredefinita ? 'bianco' : 'uniforme');
}else{
$("#prezzi_finiture").val('bicolore');
}
if(percentuale != undefined && percentuale.length > 0){
$("#prezzi_finiture").val('');
}
if(tipo == 'uniforme' && internaPredefinita){
$("#prezzi_finiture").val('bianco');
return;
}
if(tipo == 'uniforme' && !internaPredefinita){
$("#prezzi_finiture").val('uniforme');
return;
}
if(shared_data.data_avvolgibile != null && shared_data.data_avvolgibile.length > 0){
$("#prezzi_finiture").val('bicolore');
}
const apertura_portoncino = $('.apertura_portoncino option:selected').text();
if (apertura_portoncino != "") voci.apertura_portoncino = apertura_portoncino;
const tipo_ispezione = $('.tipo_ispezione option:selected').text();
if (tipo_ispezione != "") voci.tipo_ispezione = tipo_ispezione;
return voci;
},
priceCalculator: null
}
export default Configurator;
export default Configurator;
+118
View File
@@ -0,0 +1,118 @@
import $ from 'jquery';
import ParamsParser from './params_parser.js';
// Scelte dell'ultimo infisso aggiunto al preventivo: al cambio modello si chiede se riportarle
// sul nuovo (finiture, maniglia, vetro, opzioni). Il cambio modello è una navigazione di pagina,
// quindi le scelte da applicare passano per sessionStorage.
const KEY_ULTIMA = 'kreios_ultima_configurazione';
const KEY_DA_APPLICARE = 'kreios_configurazione_da_applicare';
const leggi = (storage, key) => {
try {
const raw = storage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
};
const scrivi = (storage, key, value) => {
try {
if (value == null) storage.removeItem(key);
else storage.setItem(key, JSON.stringify(value));
} catch (e) {
// Storage non disponibile (es. iframe con cookie di terze parti bloccati): si va avanti senza.
}
};
const radio = (nome, valore) =>
document.querySelector(`input[type=radio][name="${CSS.escape(nome)}"][value="${CSS.escape(String(valore))}"]`);
// Click nativo: genera click/input/change come una scelta dell'utente (texture, prezzo, apertura...).
const seleziona = (el) => {
if (el && !el.checked) el.click();
};
const chiediConferma = () => new Promise((resolve) => {
const $modal = $('#modal-configurazione-precedente');
const chiudi = (risposta) => {
$modal.addClass('d-none').off('click.cp');
resolve(risposta);
};
$modal.removeClass('d-none')
.on('click.cp', '[data-risposta]', function () { chiudi($(this).data('risposta')); })
.on('click.cp', function (e) { if (e.target === this) chiudi('annulla'); });
});
const ConfigurazionePrecedente = {
/** Da chiamare dopo un'aggiunta al preventivo andata a buon fine. */
salva: (sistema, type, scelte) => {
scrivi(sessionStorage, KEY_ULTIMA, { sistema, type, scelte });
},
/** Intercetta i link verso un altro modello/sistema per chiedere se mantenere le scelte. */
intercettaCambioModello: () => {
$(document).on('click', '.lista-modelli a, .menu-header-sistema a', async function (e) {
const ultima = leggi(sessionStorage, KEY_ULTIMA);
if (!ultima || e.metaKey || e.ctrlKey || e.shiftKey) return;
const destinazione = new URL(this.href, window.location.href);
const params = ParamsParser.getQueryParams();
if (destinazione.searchParams.get('type') === params.type
&& destinazione.searchParams.get('sistema') === params.sistema) return;
e.preventDefault();
const risposta = await chiediConferma();
if (risposta === 'annulla') return;
scrivi(sessionStorage, KEY_DA_APPLICARE, risposta === 'si' ? ultima.scelte : null);
// Si chiede una volta sola per ogni infisso aggiunto al preventivo.
scrivi(sessionStorage, KEY_ULTIMA, null);
window.location.href = destinazione.href;
});
},
/**
* Riporta sul modello appena caricato le scelte del precedente, dove esistono (stesso codice).
* Le misure no: dipendono dal listino del modello.
*/
applica: () => {
const scelte = leggi(sessionStorage, KEY_DA_APPLICARE);
scrivi(sessionStorage, KEY_DA_APPLICARE, null);
if (!scelte) return;
window.rotate = false;
// Prima le finiture: se un'opzione cambia variante 3D (caricaModello) riapplica quelle selezionate.
['finitura_interna', 'finitura_esterna', 'maniglia', 'vetro'].forEach((nome) => {
if (scelte[nome] != null) seleziona(radio(nome, scelte[nome]));
});
for (const [gruppo, valore] of Object.entries(scelte.opzioni ?? {})) {
if (Array.isArray(valore)) {
document.querySelectorAll(`input.opzione-multipla[name="${CSS.escape(gruppo)}"]`).forEach((el) => {
if (el.checked !== valore.includes(el.value)) el.click();
});
continue;
}
const select = document.querySelector(`select.${CSS.escape(gruppo)}`);
if (select) {
if (select.value === String(valore) || ![...select.options].some(o => o.value === String(valore))) continue;
select.value = valore;
select.dispatchEvent(new Event('change', { bubbles: true }));
select.dispatchEvent(new Event('input', { bubbles: true }));
continue;
}
seleziona(radio(gruppo, valore));
}
setTimeout(() => {
window.rotate = true;
}, 1000);
},
};
export default ConfigurazionePrecedente;
+66 -56
View File
@@ -19,19 +19,10 @@ const MenuBuilder = {
data_vetri = data.data_vetri;
listino = data.listino;
const price_data = {
misure: listino,
finiture: params.prezzi_finiture,
cerniere: params.select.cerniere_a_scomparsa,
//altezza_maniglia: params.select.altezza_maniglia,
azionamento: params.select.azionamento,
vetri: data_vetri
}
window.addEventListener('orientationchange', () => {
location.reload();
});
PriceCalculator.init(price_data);
PriceCalculator.init({ misure: listino });
let template_file = params.template != null ? params.template : 'menu.html';
let template = await fetch(`./assets/_${template_file}?rnd=${Math.random()}`);
@@ -123,6 +114,11 @@ const MenuBuilder = {
// Gruppi senza markup fisso nel template: riga creata nella sezione scelta dal backoffice.
for (const key in params.gruppi ?? {}) {
assicuraContenitore(key, params.gruppi[key]);
}
for (const key in params.radios) {
let options = params.radios[key];
@@ -143,6 +139,25 @@ const MenuBuilder = {
}
// Gruppi a scelta multipla (es. profili extra): pulsanti attivabili insieme, anche nessuno.
for (const key in params.multiple ?? {}) {
let options = params.multiple[key];
if (!options.length) continue;
container.querySelector(`.menu-options.${key}`).innerHTML = buildMultiple(options, key);
$(`.cnt-${key}`).removeClass('d-none');
}
// Le opzioni con la stessa "alternativa" si escludono a vicenda (es. coprifili piatti / scatolati).
$(container).on('change', 'input.opzione-multipla', function () {
const alternativa = $(this).data('alternativa');
if (this.checked && alternativa) {
$(container).find(`input.opzione-multipla[name="${this.name}"]`)
.filter((i, el) => el !== this && $(el).data('alternativa') === alternativa)
.prop('checked', false);
}
});
if (isMobile) {
$(".sistema-menu, .finiture-menu, .accessori-menu,.vetro-menu, .misure-menu").addClass('d-none');
@@ -152,7 +167,7 @@ const MenuBuilder = {
$('.apertura input').on('change', () => {
let val = $('.apertura input:checked').val();
let p = params.radios.apertura.find(r => r.label == val);
let p = params.radios.apertura.find(r => r.valore == val);
cambiaApertura('infisso', p.scale_x);
@@ -160,9 +175,11 @@ const MenuBuilder = {
/* scorro i tipi di finitura nella form e se è uniforme nascondo i tasti non necessari */
// Senza finiture esterne (modello uniforme o nessuna finitura attiva sull'esterno) il menu è unico.
const senzaEsterne = !params.finiture.some(f => f.nome == 'finitura_esterna');
params.finiture.forEach((f) => {
buildTextureRadio(model, f.nome, f.cartella_anteprime, f.cartella, f.categorie, data[f.data], f.tipo);
if(f.nome == 'finitura_interna' && f.tipo == 'uniforme'){
buildTextureRadio(f.nome, f.categorie, data[f.data], f.tipo);
if(f.nome == 'finitura_interna' && (f.tipo == 'uniforme' || senzaEsterne)){
//buildTextureRadio(model, 'finitura_esterna', f.cartella_anteprime, f.cartella, f.categorie, data[f.data]);
$('.finiture-esterne-menu').addClass('d-none');
$('.btn-opzioni.finiture-esterne').addClass('d-none');
@@ -242,33 +259,6 @@ const buildListaModelli = (modelli, modello, categoria, sistema) => {
$('.lista-modelli').html(html);
}
const buildSelectFiniture = (options) => {
let select = container.querySelector(`#prezzi_finiture`);
select.innerHTML = ``;
options.forEach(option => {
select.innerHTML += `<option value="${option.finitura}">${option.label}</option>`;
});
select.addEventListener('change', (e) => {
let val = e.target.value;
if (val != 'bicolore' && val != 'uniforme') {
$(container).find(`.menu-container.finitura`).each((i, el) => {
if (!$(el).hasClass(val) && val != 'uniforme') {
$(el).find('.menu-options input').first().trigger('click');
}
});
}
$('.menu-container.finitura').addClass('d-none');
document.querySelectorAll(`.finitura.${val}`).forEach(el => {
el.classList.remove('d-none');
});
});
}
const buildSelect = (options, parent) => {
let select = container.querySelector(`.${parent}`);
@@ -281,7 +271,6 @@ const buildSelect = (options, parent) => {
select.addEventListener('change', (e) => {
let val = e.target.value;
const el = options.find(o => o.valore == val);
const prezzo = el.prezzo ?? 0;
if (el.func != null) {
window[el.func](el.categoria);
@@ -295,7 +284,7 @@ const buildSelect = (options, parent) => {
$(`.cnt-${parent}`).removeClass('d-none');
}
const buildTextureRadio = async (model, parent, icon_folder, texture_folder, categories, data, tipo = '') => {
const buildTextureRadio = async (parent, categories, data, tipo = '') => {
let html = '';
@@ -306,16 +295,16 @@ const buildTextureRadio = async (model, parent, icon_folder, texture_folder, cat
let popup_link = option.pop_up != null ? option.pop_up : '';
if (popup_link != '') {
popup_link = `<a href="javascript:;" class="info-popup" data-image-url="./assets/data/immagini/${window.sistema}/${model}/pop_up_vetri/${popup_link}" ><img width="20" src="./assets/img/menu/icona_info.png" /></a>`;
popup_link = `<a href="javascript:;" class="info-popup" data-image-url="${popup_link}" ><img width="20" src="./assets/img/menu/icona_info.png" /></a>`;
}
let checked = i == defaultIndex && parent != 'maniglia' ? 'checked' : '';
html =
`<div class="d-inline-block texture-radio text-center">
<label class="menu-option">
<input type="radio" ${checked} class="texture-button ${option.percentuale != null && option.percentuale !== '' ? 'percentuale' : ''} check_finitura" data-percentuale="${option.percentuale ?? ''}" data-tipo="${tipo}" data-predefinito="${i == defaultIndex}" name="${parent}" value="${option.id}">
<input type="radio" ${checked} class="texture-button" data-tipo="${tipo}" data-predefinito="${i == defaultIndex}" name="${parent}" value="${option.id}">
<div class="option-content">
<img src="./assets/data/immagini/${window.sistema}/${model}/${icon_folder}/${option.anteprima_img}" alt="${option.descrizione}">
<img src="${option.anteprima ?? ''}" alt="${option.descrizione}">
<span class="checkmark"></span>
</div>
</label>
@@ -334,8 +323,6 @@ const buildTextureRadio = async (model, parent, icon_folder, texture_folder, cat
const isStale = () => textureRequestVersion[parent] !== requestVersion;
option.categories = categories;
option.folder = model;
option.texture_folder = texture_folder;
option.isStale = isStale;
$(".mobile-popup-backdrop, .mobile-popup").addClass("d-none");
@@ -357,15 +344,43 @@ const buildTextureRadio = async (model, parent, icon_folder, texture_folder, cat
//container.querySelector(`.${parent}`).innerHTML = html;
};
const assicuraContenitore = (key, gruppo) => {
if (container.querySelector(`.${key}`)) return;
const controllo = gruppo.ui === 'select'
? `<div class="menu-options"><select class="form-control form-select ${key}"></select></div>`
: `<div class="menu-options ${key}"></div>`;
$(container).find(`.opzioni-${gruppo.sezione}`).append(`<div class="row cnt-${key} d-none">
<div class="col-sm-12">
<div class="menu-container${gruppo.ui === 'select' ? ' mb-3' : ''}">
<h6>${gruppo.etichetta}</h6>
${controllo}
</div>
</div>
</div>`);
if (gruppo.sezione === 'sistema') $('.sistema-menu').removeClass('d-none');
};
const buildMultiple = (options, key) => options.map(option => `<div class="d-inline-block radio-button text-center">
<label class="menu-option">
<input type="checkbox" class="opzione-multipla" name="${key}" value="${option.valore}" data-alternativa="${option.alternativa ?? ''}">
<div class="option-content">
<img src="${option.icon ?? ''}" alt="${option.label}">
<span class="checkmark"></span>
</div>
</label>
<div class="option-label">${option.label}</div>
</div>`).join('');
const buildRadios = (options, key, model) => {
let html = '';
let has_sub_options = false;
options.forEach((option, i) => {
let percentuale = '';
let modello = '';
let class_percentuale = '';
let has_options = false;
let sub_option_container = null;
let html_func = '';
@@ -391,11 +406,6 @@ const buildRadios = (options, key, model) => {
modello = `data-model="${option.model}"`;
}
if (option.percentuale != null) {
percentuale = `data-percentuale="${option.percentuale}"`;
class_percentuale = 'percentuale';
}
if (has_options) {
has_options_class = 'has-options';
html_func = `onclick="toggleSubOption('${sub_option_container.id}',event)"`;
@@ -407,9 +417,9 @@ const buildRadios = (options, key, model) => {
html +=
`<div class="d-inline-block radio-button text-center">
<label class="menu-option">
<input type="radio" ${checked} ${percentuale} ${modello} class="${has_options_class} ${class_percentuale} auto-radio" ${container_id} name="${key}" value="${option.label}">
<input type="radio" ${checked} ${modello} class="${has_options_class} auto-radio" ${container_id} name="${key}" value="${option.valore ?? option.label}">
<div class="option-content">
<img src="./assets/data/immagini/${window.sistema}/${model}/${option.icon}" alt="${option.label}">
<img src="${option.icon ?? ''}" alt="${option.label}">
<span class="checkmark"></span>
</div>
+31 -116
View File
@@ -1,126 +1,41 @@
import { API_BASE } from './api_base.js';
let listini = null;
const toPercentuale = (valore) => {
const perc = parseFloat(valore);
return isNaN(perc) ? 0 : perc / 100;
};
const toPrezzo = (valore) => {
const prezzo = parseFloat(valore);
return isNaN(prezzo) ? 0 : prezzo;
};
// Il prezzo è calcolato dal backoffice (POST .../prezzo) con le regole configurate lì:
// qui resta solo la richiesta, più le misure del listino per i limiti dei campi.
let misure = [];
let richiestaCorrente = null;
const PriceCalculator = {
init: async (data) => {
listini = data;
init: (data) => {
misure = data.misure ?? [];
},
getMinSize: () => {
getMinSize: () => misure.find(item => item.prezzo !== 0) ?? misure[0],
/**
* Prezzo della configurazione. Una richiesta più recente annulla la precedente, così
* vince sempre l'ultima scelta dell'utente. Risolve con { annullata: true } se superata.
*/
calcola: async (sistema, modello, scelte) => {
richiestaCorrente?.abort();
const controller = new AbortController();
richiestaCorrente = controller;
const firstSize = listini.misure.find(item => item.prezzo !== 0);
return firstSize;
},
// Misure in mm: entro min/max del listino si usa la prima misura >= a quella
// richiesta (le griglie non sono sempre a passo regolare, es. traslanti da 1450 mm).
getGridPrice: (width, height) => {
const larghezza = PriceCalculator.misuraListino('larghezza', width);
const altezza = PriceCalculator.misuraListino('altezza', height);
if (larghezza == null || altezza == null) return -1;
const misure = listini.misure.find(el => el.altezza == altezza && el.larghezza == larghezza);
if(misure == null) return -1;
return misure.prezzo;
},
misuraListino: (campo, valore) => {
const v = parseFloat(valore);
if (isNaN(v)) return null;
const misure = [...new Set(listini.misure.map(el => el[campo]))].sort((a, b) => a - b);
// Min/max del listino vincolanti: fuori range la misura non è disponibile.
if (!misure.length || v < misure[0]) return null;
return misure.find(m => m >= v) ?? null;
},
getConfigurationPrice: (data) => {
let prezzo_finale = data.prezzo_base;
let percentuale_finiture = 0;
let prezzo_accessori = 0;
let percentuale_vetro = 0;
let percentuale_accessori = 0;
const config = {};
if(data.finitura && data.override_supplemento_finitura !== true){
let supplemento = (listini.finiture ?? []).find((el) => el.finitura === data.finitura);
config.tipo_finitura = supplemento;
percentuale_finiture = toPercentuale(supplemento?.percentuale);
}
if(data.cerniere_scomparsa != null){
let supplemento = listini.cerniere.find((el) => {
if(el.valore == data.cerniere_scomparsa)
return el;
try {
const res = await fetch(`${API_BASE}/sistemi/${encodeURIComponent(sistema)}/modelli/${encodeURIComponent(modello)}/prezzo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(scelte),
signal: controller.signal,
});
const data = await res.json();
config.cerniere_scomparsa = supplemento;
prezzo_accessori += toPrezzo(supplemento?.prezzo);
}
if(data.altezza_maniglia != null){
let supplemento = listini.altezza_maniglia.find((el) => {
if(el.valore == data.altezza_maniglia)
return el;
});
config.altezza_maniglia = supplemento;
prezzo_accessori += toPrezzo(supplemento?.prezzo);
}
if(data.azionamento != null){
let supplemento = listini.azionamento.find((el) => {
if(el.valore == data.azionamento)
return el;
});
config.azionamento = supplemento;
prezzo_accessori += toPrezzo(supplemento?.prezzo);
}
if(data.vetro != null){
let vetro = listini.vetri.find((el) => {
if(el.id == data.vetro)
return el;
});
config.vetro = vetro;
percentuale_vetro = toPercentuale(vetro?.percentuale);
}
prezzo_finale = (prezzo_finale * (1 + percentuale_finiture+percentuale_vetro+data.percentuale_accessori)) + prezzo_accessori;
// IVA disabilitata: i prezzi sono IVA esclusa
// prezzo_finale += prezzo_finale * 22 / 100;
return {
prezzo: prezzo_finale,
configurazione: config
return res.ok ? data : { errore: data.errors ?? { prezzo: [data.message] } };
} catch (e) {
if (e.name === 'AbortError') return { annullata: true };
return { errore: { prezzo: ['Prezzo non disponibile'] } };
} finally {
if (richiestaCorrente === controller) richiestaCorrente = null;
}
}
};
}
export default PriceCalculator;
export default PriceCalculator;
+52 -16
View File
@@ -13,6 +13,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import Configurator from './configurator.js';
import AuthGate from './auth_gate.js';
import ConfigurazionePrecedente from './configurazione_precedente.js';
import { API_BASE as PREVENTIVI_API_BASE } from './api_base.js';
@@ -46,6 +47,17 @@ const configReady = (async () => {
let invioPreventivoInCorso = false;
// Conferma non bloccante (markup fisso #toast in index.html).
let timerToast = null;
const mostraToast = (messaggio) => {
const toast = document.getElementById('toast');
if (!toast) return;
toast.querySelector('.toast-messaggio').textContent = messaggio;
toast.classList.add('visibile');
clearTimeout(timerToast);
timerToast = setTimeout(() => toast.classList.remove('visibile'), 3000);
};
// Mostra/aggiorna il link "Vai al preventivo" (markup fisso in index.html, sopra la cart-container).
window.mostraLinkPreventivo = (token, count) => {
const link = document.getElementById('vai-al-preventivo');
@@ -88,6 +100,8 @@ ripristinaLinkPreventivo();
$(() => {
ConfigurazionePrecedente.intercettaCambioModello();
$('.cart-button').on('click', async () => {
if ($('.cart-button').hasClass('disabled') || invioPreventivoInCorso)
@@ -97,18 +111,16 @@ $(() => {
try {
const params = ParamsParser.getQueryParams();
const img = await window.generaImagine();
const conf = window.configurazione || {};
const inner = conf.configurazione || {};
const inner = window.configurazione?.configurazione || {};
// Prezzo e descrizione delle scelte li ricalcola il backoffice: si inviano solo i codici
// (letti ora dal menu) e le voci descrittive che non incidono sul prezzo.
const body = {
token: localStorage.getItem(CART_TOKEN_KEY) || undefined,
sistema: params.sistema,
type: params.type,
nome: inner.nome,
larghezza: parseInt(inner.width) || null,
altezza: parseInt(inner.height) || null,
qty: parseInt($('.quantity-input').val()) || 1,
prezzo: conf.prezzo,
scelte: Configurator.raccogliScelte(),
payload: inner,
img: img,
};
@@ -124,6 +136,8 @@ $(() => {
const data = await res.json();
localStorage.setItem(CART_TOKEN_KEY, data.token);
window.mostraLinkPreventivo(data.token, data.carrello.items.length);
ConfigurazionePrecedente.salva(params.sistema, params.type, body.scelte);
mostraToast('Prodotto aggiunto al preventivo');
} catch (e) {
console.error(e);
alert('Si è verificato un errore nell\'aggiunta al preventivo. Riprova.');
@@ -136,7 +150,7 @@ $(() => {
const start = async () => {
const params = ParamsParser.getQueryParams();
// Sistemi e modelli (con icone) sono serviti dal backoffice, già filtrati sui soli attivi
// Sistemi e modelli (con icone) sono serviti dal backoffice, già filtrati sui soli attivi con listino
// e nell'ordine del menu: il primo sistema è quello di default.
const res = await fetch(`${PREVENTIVI_API_BASE}/sistemi`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`Catalogo non disponibile (HTTP ${res.status})`);
@@ -168,23 +182,29 @@ const start = async () => {
window.history.replaceState(null, '', url);
}
const data = await (await fetch(`${modello_folder}/${modello.cartella}/form.json?rnd=${Math.random()}`)).json();
const data_finiture = modello.moduli.includes('modello') ? (await (await fetch(`${modello_folder}/${modello.cartella}/modello.json?rnd=${Math.random()}`)).json()).filter(el => el.id != "") : [];
const data_maniglie = modello.moduli.includes('maniglia') ? (await (await fetch(`${modello_folder}/${modello.cartella}/maniglia.json?rnd=${Math.random()}`)).json()).filter(el => el.id != "") : [];
// Menu, finiture, maniglie, vetri, opzioni (con le immagini) e misure del listino dal backoffice.
const resConfigurazione = await fetch(`${PREVENTIVI_API_BASE}/sistemi/${encodeURIComponent(params.sistema)}/modelli/${encodeURIComponent(modello.id)}/configurazione`, { headers: { Accept: 'application/json' } });
if (!resConfigurazione.ok) throw new Error(`Configurazione non disponibile per il modello ${modello.id} (HTTP ${resConfigurazione.status})`);
const configurazione = await resConfigurazione.json();
const data = configurazione.form;
const data_finiture = configurazione.finiture;
const data_maniglie = configurazione.maniglie;
const data_vetri = configurazione.vetri;
const listino = configurazione.misure;
const data_avvolgibile = modello.moduli.includes('avvolgibile') ? (await (await fetch(`${modello_folder}/${modello.cartella}/avvolgibile.json?rnd=${Math.random()}`)).json()).filter(el => el.id != "") : [];
const data_vetri = modello.moduli.includes('vetro') ? (await (await fetch(`${modello_folder}/${modello.cartella}/vetro.json?rnd=${Math.random()}`)).json()).filter(el => el.id != "") : [];
const listino = modello.moduli.includes('listino')
? await (await fetch(`${PREVENTIVI_API_BASE}/sistemi/${encodeURIComponent(params.sistema)}/modelli/${encodeURIComponent(modello.id)}/listino`, { headers: { Accept: 'application/json' } })).json()
: [];
if (modello.moduli.includes('listino') && !listino.length)
throw new Error(`Nessun listino caricato per il modello ${modello.id}`);
const altezza_maniglia = modello.moduli.includes('altezza_maniglia') ? await (await fetch(`${data_folder}/altezza_maniglia_${modello.categoria}.json?rnd=${Math.random()}`)).json() : [];
let priceCalculator = await MenuBuilder.build(data, 'left-container', modello.cartella_texture || modello.cartella, {
let priceCalculator = await MenuBuilder.build(data, 'left-container', modello.id, {
modelli: modelli,
categoria: modello.categoria,
sistema: params.sistema,
data_finiture: data_finiture,
// Finiture attive nel modello per lato (menu finiture interne / esterne).
data_finiture_interne: configurazione.finiture_interne,
data_finiture_esterne: configurazione.finiture_esterne,
data_maniglie: data_maniglie,
data_avvolgibile: data_avvolgibile,
data_vetri: data_vetri,
@@ -194,7 +214,8 @@ const start = async () => {
Configurator.init(priceCalculator, {
modello: modello,
nome_esteso: data.nome,
sistema: params.sistema,
form: data,
data_finiture: data_finiture,
data_maniglie: data_maniglie,
data_avvolgibile: data_avvolgibile,
@@ -249,6 +270,9 @@ const start = async () => {
$("#larghezza").first().trigger("change");
// Scelte del modello precedente, se l'utente ha chiesto di mantenerle.
ConfigurazionePrecedente.applica();
//player.addMarker("Cylinder028", 'left', 'https://www.youtube.com/embed/l_zqI0v7JcU?si=Vq2k8nWHYk13esMQ');
//player.addMarker("marker_1", 'left', 'https://www.youtube.com/embed/l_zqI0v7JcU?si=Vq2k8nWHYk13esMQ');
//player.addMarker("marker_2", 'right', 'https://www.youtube.com/embed/LCi9jdF_4MQ?si=b8cyxSOI2sKEmmcm');
@@ -368,6 +392,18 @@ const start = async () => {
render();
}
// Mostra / nasconde le mesh di una categoria (es. maniglia col motore): usate dalle opzioni
// dei menu a tendina tramite i parametri func e categoria impostati nel backoffice.
window.mostraCategoria = (categoria) => {
player.showObject(categoria);
render();
}
window.nascondiCategoria = (categoria) => {
player.hideObject(categoria);
render();
}
window.nascondiCerniere = (categoria) => {
player.hideObject(categoria);
render();