var guest_reload = false;
const getCustomConsent = () => {
let customConsents = [];
$(".custom-consent-chb:checked").each((index, element) => {
customConsents = [...customConsents, $(element).val()]
});
if(customConsents.length == 0) {
return false;
}
return customConsents.join(',');
}
// The checkin submit buttons are always clickable; pressing one runs the form checks and, if something is wrong,
// shows the errors (hidden by .checkin-pending until the first press) and scrolls to the first problem
const validateCheckinOnSubmit = (button) => {
const popup = $(button).closest('.checkinPopup');
popup.removeClass('checkin-pending');
if (validateBasicForm()) {
return true;
}
const scope = popup.length ? popup : $(document);
const firstProblem = scope.find('.is-invalid:visible').first();
const target = firstProblem.length ? firstProblem : scope.find('#checking-error-reporting');
if (target.length) {
target[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return false;
}
// The modern checkin dialog is requested only on pages that load its stylesheet (header_restyle.php)
const modernCheckinEnabled = () => $('link[href*="checkin_modern.css"]').length > 0;
// Last valid discount code of the open checkin form, for the summary's estimate: { code, percent, route, toParams }
let ckmDiscount = null;
// Puts a loaded checkin form (logged-in, guest or register) into its dialog and opens it; the two-panel layout gets its dialog class and setup
const showCheckinForm = (popup, html) => {
ckmDiscount = null;
popup.html(html).addClass('checkin-pending');
const isModernForm = popup.find('.ckm').length > 0;
if (isModernForm) {
popup.dialog('option', 'dialogClass', 'ckm-dialog');
}
popup.dialog('open');
if (isModernForm) {
initModernCheckinForm(popup);
}
};
// The same form shown in the page instead of a dialog (group register page): a card (.ckm-inline) in holder.
// It is put into the page before the HTML, so the form's own scripts find their elements
const showCheckinFormInline = (holder, html) => {
ckmDiscount = null;
const card = $('
');
const popup = $('').appendTo(card);
holder.empty().append(card);
popup.html(html);
if (popup.find('.ckm').length) {
initModernCheckinForm(popup);
} else {
// A message instead of the form (e.g. registrations closed) keeps the page's own look
card.removeClass('ckm-dialog ckm-inline');
}
};
// Styled replacement for the native dropdown of a (checkin forms and the participant chooser).
// The select stays in the page, invisible but still :visible to jQuery, so the existing code keeps reading, setting and
// validating it (.parameter:visible, #select-checkin, .personUncheck…); the button follows its value, disabled, invalid and hidden state
const ckmCustomSelect = (select) => {
const KEY = { tab: 9, enter: 13, escape: 27, space: 32, end: 35, home: 36, up: 38, down: 40 };
const $select = $(select).addClass('ckm-select-native').attr({ tabindex: '-1', 'aria-hidden': 'true' });
const wrap = $('
').insertBefore($select);
// The button comes first, so a wrapping opens the list when clicked
const button = $(' ').appendTo(wrap);
wrap.append($select);
const list = $('').appendTo(wrap);
let items = $();
let activeIndex = -1;
let typed = '';
let typedTimer;
const sync = () => {
const option = select.options[select.selectedIndex];
const text = option ? $.trim($(option).text()) : '';
if (button.text() !== text) button.text(text);
button.prop('disabled', select.disabled).toggleClass('is-invalid', $select.hasClass('is-invalid'));
wrap.toggleClass('is-hidden', select.style.display === 'none');
};
wrap.data('ckmSync', sync);
// Built on every opening: the forms show, hide and wrap options as other fields change
const render = () => {
list.empty();
$.each(select.options, (index, option) => {
if (option.hidden || option.style.display === 'none') return;
$(' ')
.text($.trim($(option).text()))
.attr('aria-selected', option.selected ? 'true' : 'false')
.toggleClass('is-disabled', option.disabled)
.data('option', option)
.appendTo(list);
});
items = list.children();
};
const setActive = (index) => {
if (index < 0 || index >= items.length || items.eq(index).hasClass('is-disabled')) return;
activeIndex = index;
items.removeClass('is-active').eq(index).addClass('is-active');
const item = items[index];
if (item.offsetTop < list[0].scrollTop) {
list[0].scrollTop = item.offsetTop - 6;
} else if (item.offsetTop + item.offsetHeight > list[0].scrollTop + list[0].clientHeight) {
list[0].scrollTop = item.offsetTop + item.offsetHeight - list[0].clientHeight + 6;
}
};
const move = (from, step) => {
for (let index = from; index >= 0 && index < items.length; index += step) {
if (!items.eq(index).hasClass('is-disabled')) {
setActive(index);
return;
}
}
};
const close = (focusButton) => {
wrap.removeClass('is-open');
button.attr('aria-expanded', 'false');
if (focusButton) button.focus();
};
// Fixed position, so the list can reach past the dialog's edges; it opens upwards when there is no room below.
// On phones the dialog is transformed, which makes it the frame (and clip) of fixed elements, so measure against it
const position = () => {
const rect = button[0].getBoundingClientRect();
const dialog = wrap.closest('.ui-dialog');
const frame = dialog.length && dialog.css('transform') !== 'none'
? dialog[0].getBoundingClientRect()
: { top: 0, left: 0, bottom: window.innerHeight };
const spaceBelow = frame.bottom - rect.bottom - 12;
const spaceAbove = rect.top - frame.top - 12;
list.css({ left: rect.left - frame.left, width: rect.width, top: 0, maxHeight: '' });
const opensUp = list.outerHeight() > spaceBelow && spaceAbove > spaceBelow;
const height = Math.min(list.outerHeight(), opensUp ? spaceAbove : spaceBelow);
list.css({ maxHeight: height, top: (opensUp ? rect.top - 6 - height : rect.bottom + 6) - frame.top });
};
const open = () => {
if (select.disabled) return;
closeCkmSelects();
render();
wrap.addClass('is-open');
button.attr('aria-expanded', 'true');
position();
move(Math.max(0, items.index(items.filter('[aria-selected="true"]'))), 1);
list.focus();
};
const choose = (item) => {
const option = item.data('option');
if (!option || option.disabled) return;
if (!option.selected) {
option.selected = true;
$select.trigger('change');
}
sync();
close(true);
};
// Jump to the first entry starting with what was typed (e.g. a country or a birth year)
const typeAhead = (character) => {
clearTimeout(typedTimer);
typed += character.toLowerCase();
typedTimer = setTimeout(() => { typed = ''; }, 700);
items.each((index, item) => {
if (!$(item).hasClass('is-disabled') && $(item).text().toLowerCase().indexOf(typed) === 0) {
setActive(index);
return false;
}
});
};
button.on('click', () => (wrap.hasClass('is-open') ? close(false) : open()));
button.on('keydown', (e) => {
if (e.which === KEY.down || e.which === KEY.up) {
e.preventDefault();
open();
}
});
$select.on('change', sync).on('focus', () => button.focus());
list.on('mouseenter', '.ckm-select-option', function () {
setActive(items.index(this));
});
list.on('click', '.ckm-select-option', function () {
choose($(this));
});
list.on('keydown', (e) => {
switch (e.which) {
case KEY.down: e.preventDefault(); move(activeIndex + 1, 1); break;
case KEY.up: e.preventDefault(); move(activeIndex - 1, -1); break;
case KEY.home: e.preventDefault(); move(0, 1); break;
case KEY.end: e.preventDefault(); move(items.length - 1, -1); break;
case KEY.enter:
case KEY.space: e.preventDefault(); choose(items.eq(activeIndex)); break;
// preventDefault also stops jQuery UI from closing the whole dialog on Escape
case KEY.escape: e.preventDefault(); e.stopPropagation(); close(true); break;
case KEY.tab: close(false); break;
default:
if (e.originalEvent && e.originalEvent.key && e.originalEvent.key.length === 1) typeAhead(e.originalEvent.key);
}
});
sync();
};
// An open styled dropdown closes on a click elsewhere, and on scrolling or resizing (its fixed list would drift away from its button)
const closeCkmSelects = (except) => {
$('.ckm-select.is-open').not(except).removeClass('is-open').find('.ckm-select-button').attr('aria-expanded', 'false');
};
$(document).on('mousedown', (e) => closeCkmSelects($(e.target).closest('.ckm-select')));
$(window).on('resize', () => closeCkmSelects());
window.addEventListener('scroll', (e) => {
if (!$(e.target).closest('.ckm-select-list').length) closeCkmSelects();
}, true);
// Summary of the chosen priced items (data-price from checkin_price_attributes) with an estimated total, the discount
// of a valid code included; the payment page calculates the final amount. Also shows the next price period of the chosen route
const updateCheckinSummary = (root) => {
const summary = root.find('.ckm-summary');
const currency = summary.data('currency') || '€';
const money = (value) => value.toFixed(2).replace('.', ',') + ' ' + currency;
const rows = [];
let routeOption = null;
let routePrice = 0;
let paramsPrice = 0;
// Only what the person can see counts, like on submit (.parameter:visible)
root.find('select.parameter').filter(':visible').each((index, select) => {
const option = select.options[select.selectedIndex];
if (!option || option.getAttribute('data-price') === null) return;
const price = parseFloat(option.getAttribute('data-price')) || 0;
const name = option.getAttribute('data-name');
if ($(select).hasClass('select-ruta')) {
routeOption = option;
routePrice += price;
rows.push({ label: "Startnina" + ' · ' + name, amount: price });
} else if (price > 0) {
const label = $.trim($(select).closest('.popup-line').find('label > span:first-child').first().text()) || select.name;
paramsPrice += price;
rows.push({ label: label.charAt(0).toUpperCase() + label.slice(1) + ' · ' + name, amount: price });
}
});
const dailyLicence = root.find('#buy_daily_licence');
if (dailyLicence.filter(':visible').val() === '1') {
const price = parseFloat(dailyLicence.find('option[value="1"]').attr('data-price')) || 0;
if (price > 0) rows.push({ label: "Dnevna licenca", amount: price });
}
const donation = parseFloat(root.find('input[name=donation]').val()) || 0;
if (donation > 0) rows.push({ label: "Donacija", amount: donation });
// A code limited to a route only counts while that route is chosen; extras are discounted only when the code says so
if (ckmDiscount && (!ckmDiscount.route || (routeOption && +routeOption.value === ckmDiscount.route))) {
const amount = Math.round((routePrice + (ckmDiscount.toParams ? paramsPrice : 0)) * ckmDiscount.percent) / 100;
if (amount > 0) {
rows.push({
label: "Popust" + ' ' + ckmDiscount.code + ' (−' + ckmDiscount.percent + ' %)',
// A code that does not cover the extras says so
note: ckmDiscount.toParams ? '' : "samo za traso",
amount: -amount,
discount: true
});
}
}
const total = rows.reduce((sum, row) => sum + row.amount, 0);
// Redraw only when something changed (this also runs on a timer)
const signature = JSON.stringify(rows) + (routeOption ? routeOption.value : '');
if (summary.data('signature') === signature) return;
summary.data('signature', signature);
const list = summary.find('.ckm-summary-items').empty();
rows.forEach((row) => {
$(' ').toggleClass('is-discount', !!row.discount)
.append($(' ').text(row.label).append(row.note ? $(' ').text(row.note) : null))
.append($(' ').text((row.amount < 0 ? '−' : '') + money(Math.abs(row.amount))))
.appendTo(list);
});
summary.find('.ckm-summary-amount').text(money(Math.max(0, total)));
summary.toggleClass('has-prices', root.find('option[data-price]').length > 0).toggleClass('has-items', rows.length > 0);
const note = root.find('.ckm-price-note');
const until = routeOption && routeOption.getAttribute('data-price-until');
const next = routeOption && routeOption.getAttribute('data-next-price');
if (until && next !== null && parseFloat(next) !== parseFloat(routeOption.getAttribute('data-price'))) {
const date = until.split('-');
note.text("Trenutna cena velja do %s, nato %s.".replace('%s', +date[2] + '. ' + +date[1] + '. ' + date[0]).replace('%s', money(parseFloat(next)))).show();
} else {
note.hide();
}
};
// The forms post their own discount check and daily-licence price request; the summary picks up their answers
$(document).ajaxSuccess((event, xhr, settings) => {
const url = settings.url || '';
let data = null;
try {
data = JSON.parse(xhr.responseText);
} catch (e) {
return;
}
if (url.indexOf('ajax/check_discount') !== -1) {
// Only the code typed into the form counts; the group and club pages also post their group code when the form is submitted
const posted = /(?:^|&)discount_code=([^&]*)/.exec(String(settings.data || ''));
const code = $.trim(posted ? decodeURIComponent(posted[1].replace(/\+/g, ' ')) : '');
if (code !== $.trim($('.discount_code').val())) return;
ckmDiscount = data && data.status == 1 && +data.discount > 0
? { code: code, percent: Math.min(+data.discount, 100), route: +data.ruta || 0, toParams: data.always_applied_to_params == 1 }
: null;
} else if (url.indexOf('ajax/getDailyLicencePrice') !== -1 && data && data.price) {
$('#buy_daily_licence option[value="1"]').attr('data-price', data.price);
} else {
return;
}
$('.ckm').each((index, element) => {
const update = $(element).data('ckmSummary');
if (update) update();
});
});
// Scripts also set values on the hidden selects directly (e.g. element.value = ''), which fires no event; keep the buttons
// and the summary in step
setInterval(() => {
$('.ckm-select').each((index, element) => $(element).data('ckmSync')());
$('.ckm').each((index, element) => {
const update = $(element).data('ckmSummary');
if (update) update();
});
}, 250);
// Pairs related fields, marks required ones and hides empty sections
function initModernCheckinForm(popup) {
const root = popup.find('.ckm');
const main = root.find('.ckm-main');
// Dolžina trase is shown next to Kategorija
const categoryLine = root.find('#check_category').closest('.popup-line');
const routeLine = root.find('.select-ruta').first().closest('.popup-line');
if (categoryLine.length && routeLine.length) {
routeLine.insertAfter(categoryLine);
}
// The licence warning gets its own row under the licence input, so it does not lift the input above its neighbour:
// after the daily licence while the two share a row, right after the licence field once they stack (phones)
const licenceMessage = root.find('#licenceInvalidMessage');
if (licenceMessage.length) {
const licenceLine = licenceMessage.closest('.popup-line');
const dailyLicenceLine = root.find('#buy-daily-licence');
licenceMessage.insertAfter(licenceLine);
if (dailyLicenceLine.length) {
const placeLicenceMessage = () => {
// Measured with the warning after the daily licence, where it does not change the two fields' rows
licenceMessage.insertAfter(dailyLicenceLine);
const stacked = dailyLicenceLine.is(':visible')
&& dailyLicenceLine[0].getBoundingClientRect().top >= licenceLine[0].getBoundingClientRect().bottom;
if (stacked) licenceMessage.insertAfter(licenceLine);
};
placeLicenceMessage();
// Again when the section resizes: window or screen rotation, the daily licence shown or hidden
if (window.ResizeObserver) {
new ResizeObserver(placeLicenceMessage).observe(licenceLine.parent()[0]);
}
}
}
// The guest form positions the licence icons with inline !important offsets; checkin_modern.css places them inside the input instead
root.find('.position-relative-m > .position-absolute-m').removeAttr('style');
// Guest and register forms: Telefon next to Email, Poštna številka next to the street, and the person's own consents under Soglasja.
// The moved consents stay part of their