PageSourceSearch

https://equipoadip.com.ar/wp-content/plugins/wp-jobsearch/js/jobsearch-common.js?ver=1782993079

js equipoadip.com.ar collected 2026-10-05 22:05:09 UTC 63,752 bytes, 1,431 lines download raw bytes

1/**
2 * Reusable toast / alert helper. Lives in jobsearch-common.js because that
3 * file is enqueued in BOTH frontend and wp-admin contexts.
4 *
5 *   jobsearchAlert('success', 'Saved.');
6 *   jobsearchAlert('error',   'Something went wrong.');
7 *   jobsearchAlert('warning', 'Heads up.');
8 *   jobsearchAlert('info',    'FYI.', { duration: 8000, title: 'Notice' });
9 *
10 * Styles live in css/plugin.css (jobsearch-alert-* classes). Localized strings
11 * pulled from window.jobsearch_plugin_vars.alert_close_label (set via
12 * wp_localize_script). Empty / undefined messages are silently skipped —
13 * callers should pass only dynamic, already-localized strings; this helper
14 * never invents fallback text.
15 */
16(function () {
17    if (typeof window === 'undefined' || window.jobsearchAlert) { return; }
18
19    window.jobsearchAlert = function (type, message, options) {
20        var $ = window.jQuery;
21        if (!$) { return; }
22        if (message === undefined || message === null || message === '') { return; }
23
24        // De-dupe: suppress an identical toast fired within the last ~1.2s. This
25        // lets the global err_msg safety-net (below) run unconditionally without
26        // double-toasting alongside a handler that already showed the same text.
27        var _ddKey = type + '|' + message;
28        var _ddNow = (new Date()).getTime();
29        if (window.__jobsearch_last_toast
30            && window.__jobsearch_last_toast.key === _ddKey
31            && (_ddNow - window.__jobsearch_last_toast.at) < 1200) {
32            return;
33        }
34        window.__jobsearch_last_toast = { key: _ddKey, at: _ddNow };
35
36        var allowed = { success: 1, error: 1, warning: 1, info: 1 };
37        type = allowed[type] ? type : 'info';
38        options = options || {};
39        var duration = (typeof options.duration === 'number' && options.duration > 0) ? options.duration : 5000;
40        var title = options.title || '';
41
42        var $container = $('#jobsearch-alert-container');
43        if (!$container.length) {
44            $container = $('<div id="jobsearch-alert-container" class="jobsearch-alert-container"></div>').appendTo('body');
45        }
46
47        var iconMap = {
48            success: 'fa-check',
49            error:   'fa-exclamation',
50            warning: 'fa-exclamation',
51            info:    'fa-info'
52        };
53
54        var l10n = (window.jobsearch_plugin_vars || {});
55        var closeLabel = l10n.alert_close_label || 'Close';
56
57        var $toast = $(
58            '<div class="jobsearch-alert jobsearch-alert-' + type + '" role="alert" aria-live="polite">' +
59                '<i class="jobsearch-alert-icon fa ' + iconMap[type] + '"></i>' +
60                '<div class="jobsearch-alert-body">' +
61                    (title ? '<div class="jobsearch-alert-title"></div>' : '') +
62                    '<div class="jobsearch-alert-msg"></div>' +
63                '</div>' +
64                '<span class="jobsearch-alert-close" role="button" tabindex="0">&times;</span>' +
65            '</div>'
66        );
67        $toast.find('.jobsearch-alert-close').attr('aria-label', closeLabel);
68        if (title) { $toast.find('.jobsearch-alert-title').text(title); }
69        // Accept either plain text or HTML; existing AJAX responses sometimes
70        // include markup like <i class="fa fa-times"></i> inside response.msg.
71        $toast.find('.jobsearch-alert-msg').html(message);
72
73        $container.append($toast);
74        void $toast[0].offsetWidth;
75        $toast.addClass('is-visible');
76
77        var dismiss = function () {
78            if ($toast.data('dismissed')) { return; }
79            $toast.data('dismissed', true);
80            $toast.removeClass('is-visible').addClass('is-leaving');
81            window.setTimeout(function () { $toast.remove(); }, 350);
82        };
83
84        $toast.on('click', '.jobsearch-alert-close', dismiss);
85        $toast.on('keydown', '.jobsearch-alert-close', function (e) {
86            if (e.key === 'Enter' || e.key === ' ' || e.keyCode === 13 || e.keyCode === 32) { e.preventDefault(); dismiss(); }
87        });
88        var t = window.setTimeout(dismiss, duration);
89        $toast.on('mouseenter', function () { window.clearTimeout(t); });
90        $toast.on('mouseleave', function () { t = window.setTimeout(dismiss, 1500); });
91
92        return { dismiss: dismiss, element: $toast[0] };
93    };
94
95    /**
96     * Global safety net: surface a server `err_msg` as an error toast for ANY
97     * jobsearch_* AJAX response whose own done() handler doesn't display it.
98     * Many dashboard remove/save handlers only read `success` and silently
99     * swallow the restriction message (demo user, capability, nonce). Bound
100     * once; jobsearchAlert's de-dupe (above) prevents double-toasting with the
101     * handlers that already show the message themselves.
102     *
103     * Only catches requests whose serialized data is a query string (the plain
104     * data:{} / data:'...' handlers). FormData uploads (processData:false) are
105     * skipped here and handled by their own per-handler toasts.
106     */
107    (function bindJobsearchErrMsgNet() {
108        var $ = window.jQuery;
109        if (!$ || $.__jobsearch_errmsg_net) { return; }
110        $.__jobsearch_errmsg_net = true;
111        $(document).ajaxComplete(function (event, jqxhr, settings) {
112            try {
113                var data = (settings && typeof settings.data === 'string') ? settings.data : '';
114                if (data.indexOf('action=jobsearch_') === -1) { return; }
115                var resp = jqxhr && jqxhr.responseJSON;
116                if (!resp && jqxhr && jqxhr.responseText) {
117                    try { resp = JSON.parse(jqxhr.responseText); } catch (e) { return; }
118                }
119                if (!resp || typeof resp !== 'object' || !resp.err_msg) { return; }
120                if (typeof window.jobsearchAlert === 'function') {
121                    window.jobsearchAlert('error', resp.err_msg);
122                }
123            } catch (e) {}
124        });
125    })();
126
127    /**
128     * Reusable confirmation modal — accepts an HTML body and custom button
129     * labels, returns a Promise that resolves true on confirm, false on
130     * cancel / dismiss.
131     *
132     *   jobsearchConfirm('Delete this item?').then(function (ok) { ... });
133     *   jobsearchConfirm('<p>Detach <b>foo</b>?</p>', {
134     *       confirmText: 'Yes, detach',
135     *       cancelText:  'Keep',
136     *       title:       'Confirm'
137     *   }).then(function (ok) { ... });
138     *
139     * Default labels are pulled from window.jobsearch_plugin_vars
140     * (confirm_yes / confirm_no / alert_close_label) and fall back to plain
141     * English if not localized.
142     */
143    window.jobsearchConfirm = function (message, options) {
144        var $ = window.jQuery;
145        options = options || {};
146
147        if (!$ || message === undefined || message === null || message === '') {
148            return { then: function (cb) { cb(false); return this; } };
149        }
150
151        var l10n = (window.jobsearch_plugin_vars || {});
152        var confirmText = options.confirmText || l10n.confirm_yes || 'Yes';
153        var cancelText  = options.cancelText  || l10n.confirm_no  || 'No';
154        var closeLabel  = l10n.alert_close_label || 'Close';
155        var title       = options.title || '';
156        var type        = options.type || 'danger';
157        var iconClass   = options.icon || (type === 'danger' ? 'fa fa-tras
157h-o' : 'fa fa-exclamation');
158
159        var $backdrop = $('<div class="jobsearch-confirm-backdrop" role="dialog" aria-modal="true"></div>');
160        var $box = $(
161            '<div class="jobsearch-confirm-box">' +
162                '<div class="jobsearch-confirm-icon"><i></i></div>' +
163                (title ? '<div class="jobsearch-confirm-title"></div>' : '') +
164                '<div class="jobsearch-confirm-body"></div>' +
165                '<div class="jobsearch-confirm-actions">' +
166                    '<button type="button" class="jobsearch-confirm-cancel"></button>' +
167                    '<button type="button" class="jobsearch-confirm-ok"></button>' +
168                '</div>' +
169                '<span class="jobsearch-confirm-close" role="button" tabindex="0">&times;</span>' +
170            '</div>'
171        );
172        $box.attr('data-type', type);
173        $box.find('.jobsearch-confirm-icon i').attr('class', iconClass);
174        if (options.icon === '' || options.icon === false) { $box.find('.jobsearch-confirm-icon').remove(); }
175        if (title) { $box.find('.jobsearch-confirm-title').text(title); }
176        $box.find('.jobsearch-confirm-body').html(message);
177        $box.find('.jobsearch-confirm-ok').text(confirmText);
178        $box.find('.jobsearch-confirm-cancel').text(cancelText);
179        $box.find('.jobsearch-confirm-close').attr('aria-label', closeLabel);
180
181        $backdrop.append($box).appendTo('body');
182        // force reflow so the transition runs
183        void $box[0].offsetWidth;
184        $backdrop.addClass('is-visible');
185
186        var settled = false;
187        var resolveFn;
188        var promise = (typeof Promise !== 'undefined')
189            ? new Promise(function (r) { resolveFn = r; })
190            : (function () {
191                var cbs = [];
192                resolveFn = function (v) { while (cbs.length) { cbs.shift()(v); } };
193                return { then: function (cb) { cbs.push(cb); return this; } };
194            })();
195
196        var done = function (result) {
197            if (settled) { return; }
198            settled = true;
199            $backdrop.removeClass('is-visible').addClass('is-leaving');
200            $(document).off('keydown.jobsearchConfirm');
201            window.setTimeout(function () { $backdrop.remove(); }, 200);
202            resolveFn(!!result);
203        };
204
205        $box.on('click', '.jobsearch-confirm-ok',     function () { done(true); });
206        $box.on('click', '.jobsearch-confirm-cancel', function () { done(false); });
207        $box.on('click', '.jobsearch-confirm-close',  function () { done(false); });
208        $backdrop.on('click', function (e) { if (e.target === $backdrop[0]) { done(false); } });
209        $(document).on('keydown.jobsearchConfirm', function (e) {
210            if (e.key === 'Escape' || e.keyCode === 27) { done(false); }
211            if (e.key === 'Enter'  || e.keyCode === 13) { done(true); }
212        });
213        $box.find('.jobsearch-confirm-ok').trigger('focus');
214
215        return promise;
216    };
217})();
218
219var jobsearch_common_getJSON = function (url, callback) {
220    var xhr = new XMLHttpRequest();
221    xhr.open('GET', url, true);
222    xhr.responseType = 'json';
223    xhr.onload = function () {
224        var status = xhr.status;
225        if (status === 200) {
226            callback(null, xhr.response);
227        } else {
228            callback(status, xhr.response);
229        }
230    };
231    xhr.send();
232};
233
234
235
236jQuery(document).on('click', '.load_users_field', function (e) {
237    e.preventDefault();
238    var this_id = jQuery(this).data('randid'),
239            loaded = jQuery(this).data('loaded'),
240            custom_field = jQuery('#load_users_field_' + this_id),
241            ajax_url = jobsearch_plugin_vars.ajax_url,
242            force_std = jQuery(this).data('forcestd');
243    if (loaded != true) {
244        jQuery('.load_users_loader_' + this_id).html('<i class="fa fa-refresh fa-spin"></i>');
245        var request = jQuery.ajax({
246            url: ajax_url,
247            method: "POST",
248            data: {
249                force_std: force_std,
250                _nonce: jobsearch_comon_script_vars.nonce,
251                action: 'jobsearch_load_all_users_list_opts',
252            },
253            dataType: "json"
254        });
255
256        request.done(function (response) {
257            if ('undefined' !== typeof response.html) {
258                custom_field.html(response.html);
259                jQuery('.load_users_loader_' + this_id).html('');
260                custom_field.data('loaded', true);
261            }
262        });
263
264        request.fail(function (jqXHR, textStatus) {
265        });
266    }
267    return false;
268
269});
270
271function jobsearch_multicap_all_functions() {
272    if (typeof grecaptcha === 'undefined' || typeof grecaptcha.render !== 'function') {
273        return;
274    }
275    var all_elements = jQuery(".g-recaptcha");
276    for (var i = 0; i < all_elements.length; i++) {
277        var el = all_elements[i];
278        var id = el.getAttribute('id');
279        var site_key = el.getAttribute('data-sitekey');
280        if (!id || !site_key) {
281            continue;
282        }
283        if (el.getAttribute('data-jobsearch-captcha-rendered') === '1' || el.childElementCount > 0) {
284            continue;
285        }
286        try {
287            grecaptcha.render(id, { 'sitekey': site_key });
288            el.setAttribute('data-jobsearch-captcha-rendered', '1');
289        } catch (e) {
290            el.setAttribute('data-jobsearch-captcha-rendered', '1');
291        }
292    }
293}
294
295function jobsearch_captcha_reload(admin_url, captcha_id) {
296    "use strict";
297    var dataString = '&action=jobsearch_captcha_reload&captcha_id=' + captcha_id;
298    jQuery.ajax({
299        type: "POST",
300        url: admin_url,
301        data: dataString,
302        dataType: 'html',
303        success: function (data) {
304            jQuery("#" + captcha_id + "_div").html(data);
305//            jQuery('.g-recaptcha').each(function () {
306//                jQuery(this).find('iframe:first')
307//                        .removeAttr('width')
308//                        .addClass('img-responsive')
309//                        .parent().parent()
310//                        .css({'width': 'auto'});
311//            });
312        }
313    });
314}
315
316window.djangoReCaptcha = {
317    list: [],
318    setup: function () {
319        jQuery('.g-recaptcha').each(function () {
320            var $container = jQuery(this);
321            var config = $container.data();
322
323            jobsearchAlert('info', $container.attr('class'));
324
325            djangoReCaptcha.init($container, config);
326        });
327
328        jQuery(window).on('resize orientationchange', function () {
329            jQuery(djangoReCaptcha.list).each(function (idx, el) {
330                djangoReCaptcha.resize.apply(null, el);
331            });
332        });
333    },
334    init: function ($container, config) {
335        grecaptcha.render($container.get(0), config);
336        var captchaSize, scaleFactor;
337        var $iframe = $container.find('iframe').eq(0);
338
339        $iframe.on('load', function () {
340            $container.addClass('g-recaptcha-initted');
341            captchaSize = captchaSize || {w: $iframe.width() - 2, h: $iframe.height()};
342            djangoReCaptcha.resize($container, captchaSize);
343            djangoReCaptcha.list.push([$container, captchaSize]);
344        });
345    },
346};
347
348window.djangoReCaptchaSetup = window.djangoReCaptcha.setup;
349
350jQuery(document).on('click', '.load-more-team', function () {
351
352    var _this = jQuery(this),
353            total_pages = _this.attr('data-pages'),
354            cur_page = _this.attr('data-page'),
355            this_rand = _this.attr('data-rand'),
356            this_view = _this.attr('data-view'),
357            employer_id = _this.attr('data-id'),
358            class_pref = _this.attr('data-pref'),
359            ajax_url = jobsearch_plugin_vars.ajax_url;
360
361    var team_view = 'default';
362    if ('undefined' !== typeof this_view && this_view != '') {
363        team_view = this_view;
364    }
365
366
367
368    var members_holder = jQuery('#members-holder-' + this_rand);
369    var this_html = _this.html();
370
371    if (!_this.hasClass('jobsearch-loading')) {
372        _this.addClass('jobsearch-loading');
373        _this.html('<i class="fa fa-refresh fa-spin"></i> ' + jobsearch_plugin_vars.loading);
374        var request = jQuery.ajax({
375            url: ajax_url,
376            method: "POST",
377            data: {
378                total_pages: total_pages,
379                cur_page: cur_page,
380                employer_id: employer_id,
381                class_pref: class_pref,
382                team_style: team_view,
383                _nonce: jobsearch_comon_script_vars.nonce,
384                action: 'jobsearch_load_employer_team_next_page',
385            },
386            dataType: "json"
387        });
388
389        request.done(function (response) {
390            if ('undefined' !== typeof response.html && response.html != '') {
391                members_holder.append(response.html);
392                members_holder.find('.new-entries').slideDown().removeClass('new-entries');
393                var current_page = parseInt(cur_page) + 1;
394                _this.attr('data-page', current_page);
395                if (current_page == total_pages) {
396                    _this.hide();
397                }
398            }
399            _this.html(this_html);
400            _this.removeClass('jobsearch-loading');
401        });
402
403        request.fail(function (jqXHR, textStatus) {
404            _this.html(this_html);
405            _this.removeClass('jobsearch-loading');
406        });
407    }
408    return false;
409
410});
411
412jQuery(document).on('click', ".jobsearch-click-btn", function () {
413    var t_tihs = jQuery(this);
414    var filtr_cname = t_tihs.attr('data-cname');
415    var filtr_cval = t_tihs.attr('data-cval');
416    if (filtr_cval == 'close') {
417        filtr_cval = 'open';
418    } else {
419        filtr_cval = 'close';
420    }
421
422
423    t_tihs.parents('.jobsearch-search-filter-toggle').find('.jobsearch-checkbox-toggle').slideToggle("slow", function () {
424        var c_date = new Date();
425        c_date.setTime(c_date.getTime() + (60 * 60 * 1000));
426        var c_expires = "; c_expires=" + c_date.toGMTString();
427        console.info(filtr_cname + "=" + filtr_cval + c_expires + "; path=/");
428        document.cookie = filtr_cname + "=" + filtr_cval + c_expires + "; path=/";
429        //console.info(document.cookie);
430    });
431
432    t_tihs.parents('.jobsearch-search-filter-toggle').toggleClass("jobsearch-remove-padding");
433    return false;
434});
435
436if (jQuery('.jobsearch-mobile-btn').length > 0) {
437    jQuery(document).on('click', '.jobsearch-mobile-btn', function () {
438        jQuery('.jobsearch-mobile-section').slideToggle(1000);
439        jQuery(this).toggleClass("open");
440    });
441}
442
443jQuery(document).on('change', '.jobsearch-depndfield-srchange', function() {
444    var _this = jQuery(this);
445    var field_parent = _this.parents('.jobsearch-depndetfield-con');
446    var field_mid = field_parent.attr('data-mid');
447    var field_type = field_parent.attr('data-ftype');
448    var field_place = field_parent.attr('data-plc');
449    var field_thid = field_parent.attr('data-thid');
450    var is_multi_on = field_parent.attr('data-mism');
451    // Read depend / optid from the source <option> element directly. The
452    // previous code looked these up in the Selectize-rendered DOM
453    // (`.selectize-control .selectize-dropdown-content div[data-value=…]`),
454    // which no longer exists since the Selectize→Select2 migration. The
455    // shim flattens Selectize-era `data-data` JSON into real data-<key>
456    // attributes on each <option>, so they're readable here.
457    var slectd_optionval = _this.val();
458    var slectd_optionval_esc = String(slectd_optionval == null ? '' : slectd_optionval).replace(/"/g, '\\"');
459    var selctd_option = _this.find('option[value="' + slectd_optionval_esc + '"]').first();
460    var has_depnd = selctd_option.attr('data-depend');
461    var opt_id = selctd_option.attr('data-optid');
462    var all_nextdep_fields = field_parent.nextAll('.jobsearch-deparent-field.deparent-' + field_thid);
463    if (all_nextdep_fields.length > 0) {
464        all_nextdep_fields.each(function() {
465            jQuery(this).remove();
466        });
467    }
468    if (has_depnd == 'true') {
469        field_parent.addClass('disabled-field');
470        field_parent.append('<span class="jobsearch-depfield-loder"><i class="fa fa-refresh fa-spin"></i></span>');
471        var loc_counts_request = jQuery.ajax({
472            url: jobsearch_plugin_vars.ajax_url,
473            method: "POST",
474            data: {
475                opt_id: opt_id,
476                field_mid: field_mid,
477                field_type: field_type,
478                field_plc: field_place,
479                is_multi_on: is_multi_on,
480                _nonce: jobsearch_comon_script_vars.nonce,
481                action: 'jobsearch_get_depend_fields_infront',
482            },
483            dataType: "json"
484        });
485        loc_counts_request.done(function (response) {
486            if (typeof response.html !== undefined) {
487                field_parent.after(response.html);
488            }
489        });
490        loc_counts_request.complete(function () {
491            field_parent.removeClass('disabled-field');
492            field_parent.find('.jobsearch-depfield-loder').remove();
493        });
494    }
495});
496
497jQuery(document).on('change', '.jobsearch-depndfield-mulchange', function() {
498    var _this = jQuery(this);
499    var field_parent = _this.parents('.jobsearch-depndetfield-con');
500    var field_mid = field_parent.attr('data-mid');
501    var field_type = field_parent.attr('data-ftype');
502    var field_place = field_parent.attr('data-plc');
503    var field_thid = field_parent.attr('data-thid');
504    var selectize_drpdown = field_parent.find('.hiden-multiselc-opts');
505    var is_multi_on = field_parent.attr('data-mism');
506    
507    var slectd_optionval = _this.val();
508    
509    var opt_ids = '';
510    if (slectd_optionval.length > 0) {
511        var opt_ids_arr = [];
512        jQuery(slectd_optionval).each(function(index, _this_val) {
513            var selctize_val_div = selectize_drpdown.find('div[data-value="' + _this_val + '"]');
514            if (selctize_val_div.attr('data-depend') == 'true') {
515                opt_ids_arr.push(selctize_val_div.attr('data-optid'));
516            }
517        });
518        if (opt_ids_arr.length > 0) {
519            opt_ids = opt_ids_arr.join(',');
520        }
521    }
522    
523    //
524    var all_nextdep_fields = field_parent.nextAll('.jobsearch-deparent-field.deparent-' + field_thid);
525    if (all_nextdep_fields.length > 0) {
526        all_nextdep_fields.each(function() {
527            jQuery(this).remove();
528        });
529    }
530    
531    if (opt_ids != '') {
532        field_parent.addClass('disabled-field');
533        field_parent.append('<span class="jobsearch-depfield-loder"><i class="fa fa-refresh fa-spin"></i></span>');
534        var loc_counts_request = jQuery.ajax({
535            url: jobsearch_plugin_vars.ajax_url,
536            method: "POST",
537            data: {
538                opt_id: opt_ids,
539                field_mid: field_mid,
540                field_type: field_type,
541                field_plc: field_place,
542                is_multi_on: is_multi_on,
543                _nonce: jobsearch_comon_script_vars.nonce,
544                action: 'jobsearch_get_depend_fields_infront',
545            },
546            dataType: "json"
547        });
548        loc_counts_request.done(function (response) {
549            if (typeof response.html !== undefined) {
550                field_parent.after(response.html);
551            }
552        });
553        loc_counts_request.complete(function () {
554            field_parent.removeClass('disabled-field');
555            field_parent.find('.jobsearch-depfield-loder').remove();
556        });
557    }
558});
559
560jQuery(document).on('change', '.jobsearch-depndfield-rchchange', function() {
561    var _this = jQuery(this);
562    var field_parent = _this.parents('.jobsearch-depndetfield-con');
563    var field_mid = field_parent.attr('data-mid');
564    var field_type = field_parent.attr('data-ftype');
565    var field_place = field_parent.attr('data-plc');
566    var field_thid = field_parent.attr('data-thid');
567    var is_multi_on = field_parent.attr('data-mism');
568    
569    var opt_ids = '';
570    var opt_ids_arr = [];
571    field_parent.find('.jobsearch-depndfield-rchchange').each(function() {
572        if (jQuery(this).is(':checked') && jQuery(this).attr('data-depend') == 'true') {
573            opt_ids_arr.push(jQuery(this).attr('data-optid'));
574        }
575    });
576    if (opt_ids_arr.length > 0) {
577        opt_ids = opt_ids_arr.join(',');
578    }
579    
580    //
581    var all_nextdep_fields = field_parent.nextAll('.jobsearch-deparent-field.deparent-' + field_thid);
582    if (all_nextdep_fields.length > 0) {
583        all_nextdep_fields.each(function() {
584            jQuery(this).remove();
585        });
586    }
587    
588    if (opt_ids != '') {
589        field_parent.addClass('disabled-field');
590        field_parent.append('<span class="jobsearch-depfield-loder"><i class="fa fa-refresh fa-spin"></i></span>');
591        var loc_counts_request = jQuery.ajax({
592            url: jobsearch_plugin_vars.ajax_url,
593            method: "POST",
594            data: {
595                opt_id: opt_ids,
596                field_mid: field_mid,
597                field_type: field_type,
598                field_plc: field_place,
599                is_multi_on: is_multi_on,
600                _nonce: jobsearch_comon_script_vars.nonce,
601                action: 'jobsearch_get_depend_fields_infront',
602            },
603            dataType: "json"
604        });
605        loc_counts_request.done(function (response) {
606            if (typeof response.html !== undefined) {
607                field_parent.after(response.html);
608            }
609        });
610        loc_counts_request.complete(function () {
611            field_parent.removeClass('disabled-field');
612            field_parent.find('.jobsearch-depfield-loder').remove();
613        });
614    }
615});
616
617jQuery(document).on('click', '.show-applyjob-questsbtn', function () {
618    var _this_id = jQuery(this).attr('data-id');
619    var data_html = jQuery('#cand-ansdata-' + _this_id).html();
620    jQuery('#JobSearchModalApplyJobQuests').find('.jobsearch-applyjobans-con').html(data_html);
621    jobsearch_modal_popup_open('JobSearchModalApplyJobQuests');
622});
623
624/**
625 * Unified front-end form-required validator.
626 *
627 *   var ok = window.jobsearchValidateRequired($form);
628 *   if (!ok) { return false; }   // toast + red borders already shown
629 *
630 * Covers every check the legacy inline blocks did, in one place:
631 *   - .phone-input-error (intl-tel-input error state)
632 *   - API location selects (jobsearch_field_location_location1/2/3)
633 *     when jobsearch_plugin_vars.locations_type === 'api' &&
634 *     required_api_locs === 'yes'
635 *   - select[name^="user_sector"].profile-req-field
636 *   - .jobsearch-reqtext-editor (TinyMCE-backed textareas)
637 *   - .cusfield-checkbox-required groups (at least one checkbox required)
638 *   - input[type=file].jobsearch-cusfieldatt-req
639 *   - Generic [required], .profile-req-field, .multiselect-req
640 *     — widget-agnostic so it works for Select2, Selectize, and plain <select>.
641 *
642 * Fires one jobsearchAlert('error', 'Required fields: …') on failure listing
643 * the missing field labels, applies the existing 1px red border styling,
644 * scrolls to the first missing field, and returns false.
645 *
646 * opts.skip — array of check IDs to skip, e.g. ['phone', 'api_locations']
647 * opts.scroll — false to skip the scroll-to-first-error
648 * opts.toast — false to suppress the toast (caller will show its own)
649 */
650(function ($) {
651    if (!$) { return; }
652
653    function l10nVar(key, fallback) {
654        var bags = [
655            window.jobsearch_plugin_vars,
656            window.jobsearch_dashboard_vars,
657            window.jobsearch_posting_vars,
658            window.jobsearch_admin_vars
659        ];
660        for (var i = 0; i < bags.length; i++) {
661            if (bags[i] && bags[i][key]) { return bags[i][key]; }
662        }
663        return fallback;
664    }
665
666    function humanize(s) {
667        if (!s) { return ''; }
668        return String(s)
669            .replace(/^(jobsearch[_-])?/, '')
670            .replace(/_field$|^u_/, '')
671            .replace(/[_\-]+/g, ' ')
672            .replace(/\b\w/g, function (c) { return c.toUpperCase(); });
673    }
674
675    function cleanLabelText($label) {
676        if (!$label || !$label.length) { return ''; }
677        return $.trim(
678            $label.clone()
679                  .children('.help-icon-con, .jobsearch-tooltipcon, .chk-loder')
680                  .remove()
681                  .end()
682                  .text()
683                  .replace(/\*+\s*$/, '')
684                  .replace(/\s+/g, ' ')
685        );
686    }
687
688    // Extract a *real* human-readable label for the field. Lookup order:
689    //   1. <label for="<this field's id>"> anywhere on the page
690    //      — works even when the label is positioned outside the field's
691    //      container (sidebar, sticky help panel, etc.).
692    //   2. First <label> inside the field's closest container
693    //      (<li>, .form-row, .form-group, .jobsearch-form-row,
694    //      .jobsearch-depndetfield-con).
695    //   3. data-label attribute on the field itself.
696    // Returns '' if none of those yielded a label. The caller treats ''
697    // as the signal to fall back to the generic "Please fill the
698    // required fields." toast — we deliberately don't fabricate labels
699    // from placeholder text or humanized name attrs because that
700    // produced unreadable noise like "Sector Multi" for many fields.
701    function labelFor($el) {
702        var id = $el.attr('id');
703        if (id) {
704            var $byFor = $('label[for="' + id.replace(/"/g, '\\"') + '"]').first();
705            var byForText = cleanLabelText($byFor);
706            if (byForText) { return byForText; }
707        }
708        var $container = $el.closest('li, .form-row, .form-group, .jobsearch-form-row, .jobsearch-depndetfield-con');
709        if (!$container.length) { $container = $el.parent(); }
710        var containerText = cleanLabelText($container.find('label').first());
711        if (containerText) { return containerText; }
712        return $.trim($el.attr('data-label') || '');
713    }
714
715
716    // Apply the legacy red-border treatment so existing CSS targeting these
717    // exact rules keeps working. Selects get their .jobsearch-profile-select
718    // parent flagged (covers Select2 visible container); plain inputs get the
719    // border directly.
720    function markInvalid($el) {
721        var $parentSelect = $el.closest('.jobsearch-profile-select');
722        if ($parentSelect.length) {
723            $parentSelect.css({ 'border': '1px solid #ff0000' });
724        } else {
725            $el.css({ 'border': '1px solid #ff0000' });
726        }
727    }
728
729    function clearInvalid($el) {
730        var $parentSelect = $el.closest('.jobsearch-profile-select');
731        if ($parentSelect.length) {
732            $parentSelect.removeAttr('style');
733        } else {
734            $el.css({ 'border': '' });
735        }
736    }
737
738    function isVisible($el) {
739        // jQuery :visible is too eager — required fields inside hidden tabs
740        // or collapsed accordions still need to validate. Just check for
741        // explicit display:none / hidden type.
742        if (!$el.length) { return false; }
743        if ($el.attr('type') === 'hidden') { return false; }
744        var el = $el[0];
745        if (el && (el.offsetParent === null) && $el.css('position') !== 'fixed') {
746            // hidden via display:none somewhere up the tree
747            return false;
748        }
749        return true;
750    }
751
752    function isEmpty(val) {
753        if (val === undefined || val === null) { return true; }
754        if (Array.isArray(val)) { return val.length === 0; }
755        return $.trim(String(val)) === '';
756    }
757
758    // Surface HTML5 [required] failures as a top-right toast alongside the
759    // native browser tooltip. When the browser rejects a submit because of
760    // a missing [required] field, the form's `submit` event never fires —
761    // so the JS submit handler (and its missing-fields toast call) is
762    // skipped. We attach an `invalid` event listener instead: each
763    // invalid input fires its own `invalid` event during the
764    // pre-submit validity check, and we aggregate them into one toast.
765    //
766    // `invalid` does not bubble, so capture-phase delegation is required.
767    // First invalid of a batch schedules a microtask that builds one
768    // toast for every invalid field seen in this pass. The HTML5
769    // tooltip + the toast both render; nothing about native validation
770    // is suppressed.
771    // Toast message strategy:
772    //   - If labelFor() yielded a real <label>
772 / data-label string for
773    //     EVERY missing field, we render the comma-list:
774    //         "Required fields: Email, Phone"
775    //   - As soon as any one field can't surface a clean label, we drop
776    //     the list and emit the generic message:
777    //         "Please fill the required fields."
778    //     This avoids "Required fields: Email, ?, ?" style noise — when
779    //     even one label is unknown, listing the rest is misleading
780    //     because the user can't reconcile it with what's red on screen.
781    function buildMissingMessage(labels) {
782        if (!labels || !labels.length) { return ''; }
783        var allHaveLabel = true;
784        for (var i = 0; i < labels.length; i++) {
785            if (!labels[i] || labels[i] === '?') { allHaveLabel = false; break; }
786        }
787        if (!allHaveLabel) {
788            return l10nVar('fill_nec_fields', 'Please fill the required fields.');
789        }
790        var cap = 6;
791        var shown = labels.slice(0, cap);
792        var prefix = l10nVar('required_fields_label', 'Required fields: ');
793        var moreTpl = l10nVar('and_n_more', ' and %d more');
794        var msg = prefix + shown.join(', ');
795        if (labels.length > cap) {
796            msg += moreTpl.replace('%d', labels.length - cap);
797        }
798        return msg;
799    }
800
801    function attachInvalidToast(formEl) {
802        if (!formEl || formEl.__jobsearchInvalidBound) { return; }
803        formEl.__jobsearchInvalidBound = true;
804        var pendingTimer = null;
805        var pending = [];
806        formEl.addEventListener('invalid', function (ev) {
807            var el = ev.target;
808            if (!el || pending.indexOf(el) !== -1) { return; }
809            pending.push(el);
810            if (pendingTimer) { return; }
811            pendingTimer = window.setTimeout(function () {
812                pendingTimer = null;
813                var batch = pending.slice();
814                pending = [];
815                var labels = [];
816                batch.forEach(function (it) {
817                    var lbl = labelFor($(it));
818                    // Push '?' for unlabeled fields so buildMissingMessage
819                    // sees the incomplete list and flips to generic. If
820                    // we silently skipped them, the toast would lie about
821                    // how many fields are missing.
822                    if (!lbl) { lbl = '?'; }
823                    if (labels.indexOf(lbl) === -1) {
824                        labels.push(lbl);
825                    }
826                });
827                var msg = buildMissingMessage(labels);
828                if (msg && window.jobsearchAlert) {
829                    window.jobsearchAlert('error', msg);
830                }
831            }, 0);
832        }, true);
833    }
834
835    $(function () {
836        $('form#employer-profilesetings-form,'
837          + 'form#candidate-profilesetings-form,'
838          + 'form#job-posting-form'
839        ).each(function () { attachInvalidToast(this); });
840    });
841
842    // Live border clearing: as soon as the user types something into a
843    // flagged input or picks a value on a flagged select / checkbox, drop
844    // the red border so it doesn't linger after they've fixed the field.
845    // Delegated against document so it covers dynamically-added fields
846    // (Select2 re-renders, AJAX-loaded location dropdowns, etc.).
847    var liveClearSelector =
848        'form#employer-profilesetings-form,'
849      + 'form#candidate-profilesetings-form,'
850      + 'form#job-posting-form,'
851      // Resume-add modals (education / experience / skill / language /
852      // award / portfolio variants) — their <input>s aren't inside a
853      // <form>, the wrapper is a popup div, so we scope the live-clear
854      // behavior to it explicitly.
855      + '.jobsearch-add-resume-item-popup';
856
857    $(document).on('input change', liveClearSelector + ' input, '
858                                 + liveClearSelector + ' select, '
859                                 + liveClearSelector + ' textarea', function () {
860        var $el = $(this);
861        var type = ($el.attr('type') || '').toLowerCase();
862        // Checkbox/radio handled by the group block below.
863        if (type === 'checkbox' || type === 'radio') { return; }
864        var val = $el.val();
865        if (val === null || val === undefined || $.trim(String(val)) === '') { return; }
866        clearInvalid($el);
867        // Editor and file-upload wrappers carry the border on a parent;
868        // those wrappers are also cleared so the visual state matches the
869        // field state.
870        $el.closest('.wp-editor-container, .jobsearch-fileUpload').removeAttr('style');
871    });
872
873    // Checkbox groups: one checked box clears the group's red border.
874    $(document).on('change', liveClearSelector + ' .cusfield-checkbox-required input[type=checkbox]', function () {
875        var $group = $(this).closest('.cusfield-checkbox-required');
876        if ($group.find('input[type=checkbox]:checked').length > 0) {
877            $group.removeAttr('style');
878        }
879    });
880
881    // TinyMCE Visual-mode live border clear. The underlying <textarea>
882    // is display:none while Visual mode is active and only syncs on
883    // submit, so the input/change handlers above never fire while the
884    // user types in the iframe. Hooking TinyMCE's own editor events
885    // gives us per-keystroke updates from inside the iframe document.
886    function bindTinyMCEEditorBorderClear(editor) {
887        if (!editor || editor.__jobsearchBorderBound) { return; }
888        editor.__jobsearchBorderBound = true;
889        var handler = function () {
890            // getContent({format:'text'}) returns the visible text only
891            // (no <p>, no &nbsp;), so an "empty" editor with stray
892            // paragraph tags doesn't keep the border in place.
893            var txt = '';
894            try { txt = editor.getContent({ format: 'text' }); } catch (e) { return; }
895            if (!$.trim(String(txt))) { return; }
896            var $ta = $('#' + editor.id);
897            var $container = $ta.closest('.wp-editor-container');
898            if ($container.length) {
899                $container.removeAttr('style');
900            }
901        };
902        editor.on('input change keyup NodeChange', handler);
903    }
904
905    function attachTinyMCEBorderClear() {
906        if (typeof window.tinymce === 'undefined' || !window.tinymce) {
907            // TinyMCE script not yet loaded — try again shortly.
908            // Bounded retry to avoid an infinite loop on pages with no
909            // editor (in which case tinymce never loads at all).
910            attachTinyMCEBorderClear.retries = (attachTinyMCEBorderClear.retries || 0) + 1;
911            if (attachTinyMCEBorderClear.retries < 20) {
912                window.setTimeout(attachTinyMCEBorderClear, 500);
913            }
914            return;
915        }
916        if (window.tinymce.editors && window.tinymce.editors.length) {
917            for (var i = 0; i < window.tinymce.editors.length; i++) {
918                bindTinyMCEEditorBorderClear(window.tinymce.editors[i]);
919            }
920        }
921        // Late-initialized editors (e.g. an editor added by an AJAX
922        // dialog after page load) — bind once they fire AddEditor.
923        if (typeof window.tinymce.on === 'function' && !window.tinymce.__jobsearchAddListener) {
924            window.tinymce.__jobsearchAddListener = true;
925            window.tinymce.on('AddEditor', function (e) {
926                bindTinyMCEEditorBorderClear(e && e.editor);
927            });
928        }
929    }
930
931    $(function () { attachTinyMCEBorderClear(); });
932
933    window.jobsearchValidateRequired = function ($form, opts) {
934        opts = opts || {};
935        var skip = {};
936        ($.isArray(opts.skip) ? opts.skip : []).forEach(function (k) { skip[k] = 1; });
937        var missing = [];           // [{ $el, label }]
938        var addMissing = function ($el, label) {
939            if (!label) { label = labelFor($el); }
940            if (!label) { label = '?'; }
941            // Dedupe by label so a sector field doesn't appear twice if
942            // both .profile-req-field and an underlying [required] caught it.
943            for (var i = 0; i < missing.length; i++) {
944                if (missing[i].label === label) { return; }
945            }
946            missing.push({ $el: $el, label: label });
947            markInvalid($el);
948        };
949
950        // 1. Intl-tel-input phone error state — no per-field label here, the
951        //    error class itself is the signal. We surface a generic "Phone".
952        if (!skip.phone) {
953            var $phone = $form.find('input.phone-input-error').filter(':visible');
954            if ($phone.length) {
955                addMissing($phone.first(), labelFor($phone.first(), 'phone'));
956            }
957        }
958
959        // 2. API location selects gated by the same flags the legacy block used.
960        if (!skip.api_locations) {
961            var pv = window.jobsearch_plugin_vars || {};
962            var postv = window.jobsearch_posting_vars || {};
963            var loc_type = pv.locations_type || postv.locations_type;
964            var req_api = pv.required_api_locs || postv.required_api_locs;
965            var switch_loc = postv.switch_location_fields || pv.switch_location_fields;
966            // Settings forms: api + required_api_locs=yes.
967            // Job posting:   api + required_api_locs=yes (OR manual) + switch_location_fields=on.
968            var settings_gate = (loc_type === 'api' && req_api === 'yes');
969            var posting_gate  = ((loc_type === 'api' && req_api === 'yes') || loc_type === 'manual') && switch_loc === 'on';
970            if (settings_gate || posting_gate) {
971                ['jobsearch_field_location_location1',
972                 'jobsearch_field_location_location2',
973                 'jobsearch_field_location_location3'].forEach(function (name) {
974                    var $sel = $form.find('select[name="' + name + '"]');
975                    if ($sel.length && isEmpty($sel.val())) {
976                        addMissing($sel);
977                    } else if ($sel.length) {
978                        clearInvalid($sel);
979                    }
980                });
981            }
982        }
983
984        // 3. Sector multi-select.
985        if (!skip.sector) {
986            var $sector = $form.find('select[name^="user_sector"]').filter('.profile-req-field');
987            if ($sector.length && isEmpty($sector.val())) {
988                addMissing($sector);
989            } else if ($sector.length) {
990                clearInvalid($sector);
991            }
992        }
993
994        // 4. TinyMCE-backed required text editors.
995        if (!skip.editor) {
996            $form.find('.jobsearch-reqtext-editor').each(function () {
997                var $ed = $(this);
998                var $container = $ed.closest('.wp-editor-container');
999                if (isEmpty($ed.val())) {
1000                    if ($container.length) { $container.css({ 'border': '1px solid #ff0000' }); }
1001                    addMissing($ed, labelFor($ed));
1002                } else if ($container.length) {
1003                    $container.removeAttr('style');
1004                }
1005            });
1006        }
1007
1008        // 5. Required-checkbox groups — at least one checkbox in the group
1009        //    must be checked. The group container itself gets the label.
1010        if (!skip.checkbox_group) {
1011            $form.find('.cusfield-checkbox-required').each(function () {
1012                var $group = $(this);
1013                var $boxes = $group.find('input[type=checkbox]');
1014                if (!$boxes.length) { return; }
1015                var anyChecked = false;
1016                $boxes.each(function () { if ($(this).is(':checked')) { anyChecked = true; } });
1017                if (!anyChecked) {
1018                    $group.css({ 'border': '1px solid #ff0000' });
1019                    addMissing($group, labelFor($group));
1020                } else {
1021                    $group.removeAttr('style');
1022                }
1023            });
1024        }
1025
1026        // 6. Required file attachments.
1027        if (!skip.file_attach) {
1028            $form.find('input[type=file].jobsearch-cusfieldatt-req').each(function () {
1029                var $f = $(this);
1030                var $wrap = $f.parent('.jobsearch-fileUpload');
1031                if (isEmpty($f.val())) {
1032                    if ($wrap.length) { $wrap.css({ 'border': '1px solid #ff0000' }); }
1033                    addMissing($f, labelFor($f));
1034                } else if ($wrap.length) {
1035                    $wrap.css({ 'border': 'none' });
1036                }
1037            });
1038        }
1039
1040        // 7. Generic markers: HTML5 [required], .profile-req-field,
1041        //    .multiselect-req. Reading .val() is widget-agnostic so this
1042        //    catches Select2, Selectize, and native <select> alike.
1043        if (!skip.generic) {
1044            var $generic = $form.find('[required], .profile-req-field, .multiselect-req');
1045            $generic.each(function () {
1046                var $el = $(this);
1047                if (!isVisible($el)) { return; }
1048                // Skip checkboxes/radios with [required] — they're handled
1049                // by the group check (5) above when relevant.
1050                var type = ($el.attr('type') || '').toLowerCase();
1051                if (type === 'checkbox' || type === 'radio') { return; }
1052                if (isEmpty($el.val())) {
1053                    addMissing($el);
1054                } else {
1055                    clearInvalid($el);
1056                }
1057            });
1058        }
1059
1060        if (missing.length === 0) { return true; }
1061
1062        // Toast with the missing labels (cap at 6 to keep the message readable).
1063        if (opts.toast !== false && window.jobsearchAlert) {
1064            var msg = buildMissingMessage(missing.map(function (m) { return m.label; }));
1065            if (msg) { window.jobsearchAlert('error', msg); }
1066        }
1067
1068        if (opts.scroll !== false) {
1069            var $first = missing[0].$el;
1070            var $scrollTo = $first.closest('.jobsearch-profile-select, .wp-editor-container, .jobsearch-fileUpload, .cusfield-checkbox-required');
1071            if (!$scrollTo.length) { $scrollTo = $first; }
1072            if ($scrollTo.length && $scrollTo.offset()) {
1073                $('html, body').animate({ scrollTop: $scrollTo.offset().top - 110 }, 600);
1074            }
1075        }
1076
1077        return false;
1078    };
1079
1080    // Public exports so other plugin scripts (legacy job-posting validator
1081    // in particular) can reuse the same label extraction + message
1082    // building logic. Keeps the toast format consistent across both the
1083    // new validator and the older field-by-field validators that pre-date
1084    // jobsearchValidateRequired().
1085    window.jobsearchFieldLabel = labelFor;
1086    window.jobsearchBuildMissingMessage = buildMissingMessage;
1087})(window.jQuery);
1088
1089/**
1090 * Chunked applicants Excel export — popup, state machine and chunk loop.
1091 * Drives all six contexts via the clicked button's data-export-context
1092 * attribute. Flow: status -> (resume | configure) -> chunk loop ->
1093 * finalize -> download.
1094 */
1095(function ($) {
1096    if (!$ || window.jobsearchExportPopup) { return; }
1097
1098    function i18n(key, fallback) {
1099        var src = window.jobsearch_export_i18n || {};
1100        return (typeof src[key] === 'string' && src[key] !== '') ? src[key] : fallback;
1101    }
1102
1103    function ajaxUrl() {
1104        if (window.jobsearch_plugin_vars && window.jobsearch_plugin_vars.ajax_url) {
1105            return window.jobsearch_plugin_vars.ajax_url;
1106        }
1107        if (typeof window.ajaxurl !== 'undefined' && window.ajaxurl) {
1108            return window.ajaxurl;
1109        }
1110        return '/wp-admin/admin-ajax.php';
1111    }
1112
1113    function ajaxNonce() {
1114        if (window.jobsearch_comon_script_vars && window.jobsearch_comon_script_vars.nonce) {
1115            return window.jobsearch_comon_script_vars.nonce;
1116        }
1117        return '';
1118    }
1119
1120    function collectFilterArgs($btn) {
1121        // Read the LIVE filter controls so the export honours the current
1122        // job / employer / date selections without requiring a Submit/reload
1123        // first. The selects are identified by data-posttype (consistent
1124        // across all contexts); employer select is absent on the dashboard.
1125        var args = {};
1126        var $scope = $btn.closest('.filter-allreps-mainholdr');
1127        if (!$scope.length) { $scope = $(document); }
1128
1129        var $job = $scope.find('select[data-posttype="job"]').first();
1130        if ($job.length && $job.val()) { args.job_id = $job.val(); }
1131
1132        var $emp = $scope.find('select[data-posttype="employer"]').first();
1133        if ($emp.length && $emp.val()) { args.employer_id = $emp.val(); }
1134
1135        var dfrom = $scope.find('input[name="alljobs_wafrom_date"]').val();
1136        if (dfrom) { args.date_from = dfrom; }
1137
1138        var dto = $scope.find('input[name="alljobs_wato_date"]').val();
1139        if (dto) { args.date_to = dto; }
1140
1141        return args;
1142    }
1143
1144    function formatInt(n) {
1145        n = parseInt(n, 10) || 0;
1146        return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
1147    }
1148
1149    function ExportSession(context) {
1150        this.context = context;
1151        this.prefix = 'jobsearch_export_' + context;
1152        this.token = '';
1153        this.total = 0;
1154        this.offset = 0;
1155        this.chunkSize = 30000;
1156        this.aborted = false;
1157        this.filterArgs = {};
1158    }
1159
1160    ExportSession.prototype.action = function (suffix) {
1161        return this.prefix + '_' + suffix;
1162    };
1163
1164    ExportSession.prototype.post = function (suffix, extra) {
1165        var data = $.extend({
1166            action: this.action(suffix),
1167            _nonce: ajaxNonce()
1168        }, extra || {});
1169        return $.ajax({ url: ajaxUrl(), type: 'POST', dataType: 'json', data: data });
1170    };
1171
1172    function buildModal() {
1173        var $existing = $('#jobsearch-export-modal');
1174        if ($existing.length) { $existing.remove(); }
1175
1176        var sheetIcon = '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline>
1176<line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line></svg>';
1177
1178        var html = ''
1179            + '<div class="jobsearch-export-modal" id="jobsearch-export-modal">'
1180            + '  <div class="jobsearch-export-overlay"></div>'
1181            + '  <div class="jobsearch-export-box" role="dialog" aria-modal="true">'
1182            + '    <button type="button" class="jobsearch-export-close" aria-label="' + i18n('close', 'Close') + '">&times;</button>'
1183
1184            + '    <div class="export-state state-loading">'
1185            + '      <div class="jsx-spinner"></div>'
1186            + '      <p class="jsx-muted export-loading-text">' + i18n('loading', 'Loading…') + '</p>'
1187            + '    </div>'
1188
1189            + '    <div class="export-state state-resume">'
1190            + '      <div class="jsx-head"><span class="jsx-badge jsx-badge-amber">' + sheetIcon + '</span><h3>' + i18n('in_progress_title', 'Export in progress') + '</h3></div>'
1191            + '      <p class="jsx-muted export-resume-text"></p>'
1192            + '      <div class="export-actions">'
1193            + '        <button type="button" class="jsx-btn jsx-btn-primary export-resume-btn">' + i18n('resume', 'Resume') + '</button>'
1194            + '        <button type="button" class="jsx-btn jsx-btn-ghost export-startfresh-btn">' + i18n('start_fresh', 'Start Fresh') + '</button>'
1195            + '      </div>'
1196            + '    </div>'
1197
1198            + '    <div class="export-state state-configure">'
1199            + '      <div class="jsx-head"><span class="jsx-badge jsx-badge-green">' + sheetIcon + '</span><h3>' + i18n('export_title', 'Export to Excel') + '</h3></div>'
1200            + '      <div class="jsx-stat-card">'
1201            + '        <span class="jsx-stat-label">' + i18n('total_applicants', 'Total Applicants') + '</span>'
1202            + '        <span class="jsx-stat-value export-total-num">0</span>'
1203            + '      </div>'
1204            + '      <div class="jsx-field">'
1205            + '        <label class="jsx-field-label">' + i18n('chunk_size', 'Chunk size') + '</label>'
1206            + '        <input type="number" class="jsx-input export-chunk-input" value="30000" min="100" step="100">'
1207            + '      </div>'
1208            + '      <div class="export-actions">'
1209            + '        <button type="button" class="jsx-btn jsx-btn-primary export-start-btn">' + i18n('start_export', 'Start Export') + '</button>'
1210            + '        <button type="button" class="jsx-btn jsx-btn-ghost export-cancel-btn">' + i18n('cancel', 'Cancel') + '</button>'
1211            + '      </div>'
1212            + '    </div>'
1213
1214            + '    <div class="export-state state-progress">'
1215            + '      <div class="jsx-head"><h3>' + i18n('exporting', 'Exporting…') + '</h3></div>'
1216            + '      <div class="jsx-percent"><span class="export-percent-num">0</span><span class="jsx-percent-sign">%</span></div>'
1217            + '      <div class="jobsearch-export-progress-bar"><div class="jobsearch-export-progress-fill"></div></div>'
1218            + '      <p class="jsx-muted export-progress-text">0 / 0</p>'
1219            + '      <div class="export-actions">'
1220            + '        <button type="button" class="jsx-btn jsx-btn-ghost export-cancel-btn">' + i18n('cancel', 'Cancel') + '</button>'
1221            + '      </div>'
1222            + '    </div>'
1223
1224            + '    <div class="export-state state-done">'
1225            + '      <div class="jsx-badge jsx-badge-green jsx-badge-lg jsx-pop"><svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>'
1226            + '      <h3>' + i18n('complete', 'Export complete') + '</h3>'
1227            + '      <p class="jsx-muted">' + i18n('downloading', 'Downloading…') + '</p>'
1228            + '    </div>'
1229
1230            + '    <div class="export-state state-error">'
1231            + '      <div class="jsx-badge jsx-badge-red jsx-badge-lg"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="8" x2="12" y2="13"></line>
1231<line x1="12" y1="17" x2="12.01" y2="17"></line><circle cx="12" cy="12" r="10"></circle></svg></div>'
1232            + '      <p class="export-error-text"></p>'
1233            + '      <div class="export-actions">'
1234            + '        <button type="button" class="jsx-btn jsx-btn-ghost export-close-btn">' + i18n('close', 'Close') + '</button>'
1235            + '      </div>'
1236            + '    </div>'
1237            + '  </div>'
1238            + '</div>';
1239
1240        var $modal = $(html);
1241        $('body').append($modal);
1242        return $modal;
1243    }
1244
1245    function setState($modal, state) {
1246        $modal.removeClass(function (i, cls) {
1247            return (cls.match(/(^|\s)state-active-\S+/g) || []).join(' ');
1248        });
1249        $modal.attr('data-state', state);
1250        $modal.find('.export-state').hide();
1251        $modal.find('.state-' + state).show();
1252    }
1253
1254    var ctrl = {};
1255
1256    ctrl.open = function (btn) {
1257        var $btn = $(btn);
1258        var context = $btn.data('export-context');
1259        if (!context) { return; }
1260
1261        var session = new ExportSession(context);
1262        session.filterArgs = collectFilterArgs($btn);
1263
1264        var $modal = buildModal();
1265        bindModal($modal, session);
1266        $modal.addClass('open');
1267        setState($modal, 'loading');
1268
1269        // Status check first — offers resume if an export is in flight.
1270        session.post('status').done(function (resp) {
1271            if (resp && resp.success && resp.data && resp.data.in_progress) {
1272                var d = resp.data;
1273                session.token = d.token || '';
1274                session.total = parseInt(d.total, 10) || 0;
1275                session.offset = parseInt(d.offset, 10) || 0;
1276                session.chunkSize = parseInt(d.chunk_size, 10) || 30000;
1277                // Cap both: offset advances by chunk_size and can exceed total.
1278                var done = Math.min(session.offset, session.total);
1279                var pct = session.total > 0 ? Math.min(100, Math.round((done / session.total) * 100)) : 0;
1280                var tpl = i18n('resume_text', 'You have an export in progress (%s%% complete).');
1281                $modal.find('.export-resume-text').text(tpl.replace('%s', pct).replace(/%%/g, '%'));
1282                setState($modal, 'resume');
1283            } else {
1284                showConfigure($modal, session);
1285            }
1286        }).fail(function () {
1287            showConfigure($modal, session);
1288        });
1289    };
1290
1291    function showConfigure($modal, session) {
1292        setState($modal, 'loading');
1293        session.post('init', session.filterArgs).done(function (resp) {
1294            if (resp && resp.success && resp.data) {
1295                session.token = resp.data.token;
1296                session.total = parseInt(resp.data.total, 10) || 0;
1297                session.offset = 0;
1298                $modal.find('.export-total-num').text(formatInt(session.total));
1299                $modal.find('.export-chunk-input').val(session.chunkSize);
1300                setState($modal, 'configure');
1301            } else {
1302                showError($modal, (resp && resp.data && resp.data.msg) ? resp.data.msg
1303                    : i18n('no_applicants', 'No applicants match the selected filters.'));
1304            }
1305        }).fail(function () {
1306            showError($modal, i18n('generic_error', 'Something went wrong. Please try again.'));
1307        });
1308    }
1309
1310    function showError($modal, msg) {
1311        $modal.find('.export-error-text').text(msg || i18n('generic_error', 'Something went wrong. Please try again.'));
1312        setState($modal, 'error');
1313    }
1314
1315    function runChunkLoop($modal, session) {
1316        if (session.aborted) { return; }
1317        setState($modal, 'progress');
1318        // is-working animates the bar's stripes while the chunk is in flight.
1319        $modal.find('.jobsearch-export-progress-bar').addClass('is-working');
1320
1321        session.post('chunk', {
1322            token: session.token,
1323            offset: session.offset,
1324            chunk_size: session.chunkSize
1325        }).done(function (resp) {
1326            if (session.aborted) { return; }
1327            if (!resp || !resp.success || !resp.data) {
1328                $modal.find('.jobsearch-export-progress-bar').removeClass('is-working');
1329                showError($modal, (resp && resp.data && resp.data.msg) ? resp.data.msg
1330                    : i18n('generic_error', 'Something went wrong. Please try again.'));
1331                return;
1332            }
1333            var d = resp.data;
1334            session.offset = parseInt(d.offset, 10) || session.offset;
1335            var pct = parseFloat(d.percent) || 0;
1336            updateProgress($modal, session, pct);
1337
1338            if (d.done) {
1339                $modal.find('.jobsearch-export-progress-bar').removeClass('is-working');
1340                finalizeExport($modal, session);
1341            } else {
1342                runChunkLoop($modal, session);
1343            }
1344        }).fail(function () {
1345            if (session.aborted) { return; }
1346            $modal.find('.jobsearch-export-progress-bar').removeClass('is-working');
1347            showError($modal, i18n('generic_error', 'Something went wrong. Please try again.'));
1348        });
1349    }
1350
1351    function updateProgress($modal, session, pct) {
1352        pct = Math.min(100, Math.max(0, pct));
1353        var shown = Math.min(session.offset, session.total);
1354        // Floor so it never reads 100% before finalize flips to the done state.
1355        var pctShown = (pct >= 100) ? 100 : Math.floor(pct);
1356        $modal.find('.jobsearch-export-progress-fill').css('width', pct + '%');
1357        $modal.find('.export-percent-num').text(pctShown);
1358        $modal.find('.export-progress-text').text(formatInt(shown) + ' / ' + formatInt(session.total));
1359    }
1360
1361    function finalizeExport($modal, session) {
1362        session.post('finalize', { token: session.token }).done(function (resp) {
1363            if (resp && resp.success && resp.data && resp.data.download_url) {
1364                setState($modal, 'done');
1365                window.location.href = resp.data.download_url;
1366                setTimeout(function () { closeModal($modal); }, 1500);
1367            } else {
1368                showError($modal, (resp && resp.data && resp.data.msg) ? resp.data.msg
1369                    : i18n('generic_error', 'Something went wrong. Please try again.'));
1370            }
1371        }).fail(function () {
1372            showError($modal, i18n('generic_error', 'Something went wrong. Please try again.'));
1373        });
1374    }
1375
1376    function abortExport(session) {
1377        session.aborted = true;
1378        if (session.token) {
1379            session.post('abort', { token: session.token });
1380        }
1381    }
1382
1383    function closeModal($modal) {
1384        $modal.removeClass('open');
1385        setTimeout(function () { $modal.remove(); }, 200);
1386    }
1387
1388    function bindModal($modal, session) {
1389        $modal.on('click', '.export-start-btn', function () {
1390            var size = parseInt($modal.find('.export-chunk-input').val(), 10);
1391            if (!size || size < 1) { size = 30000; }
1392            session.chunkSize = size;
1393            session.offset = 0;
1394            runChunkLoop($modal, session);
1395        });
1396
1397        $modal.on('click', '.export-resume-btn', function () {
1398            runChunkLoop($modal, session);
1399        });
1400
1401        $modal.on('click', '.export-startfresh-btn', function () {
1402            if (session.token) {
1403                session.post('abort', { token: session.token });
1404            }
1405            session.token = '';
1406            session.offset = 0;
1407            showConfigure($modal, session);
1408        });
1409
1410        $modal.on('click', '.export-cancel-btn', function () {
1411            abortExport(session);
1412            closeModal($modal);
1413        });
1414
1415        $modal.on('click', '.export-close-btn, .jobsearch-export-close, .jobsearch-export-overlay', function () {
1416            var state = $modal.attr('data-state');
1417            if (state === 'progress') {
1418                abortExport(session);
1419            }
1420            closeModal($modal);
1421        });
1422    }
1423
1424    // Delegated so it survives AJAX re-renders of the applicants lists.
1425    $(document).on('click', '.allplics-export-excel[data-export-context]', function (e) {
1426        e.preventDefault();
1427        ctrl.open(this);
1428    });
1429
1430    window.jobsearchExportPopup = ctrl;
1431})(window.jQuery);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.