{* Client area header — Proxmox-style UI toolkit + DataTables loader +
   form-validation helpers + transition toasts. Single guarded init. *}

{* i18n bootstrap — PUQ_T is the English-base + current-language map injected by
   puqWebHosting_ClientArea(); puqT(key) returns the translation (or the key as
   fallback). Templates use {$L.key} in markup and puqT('key') in JS. *}
<script>
window.PUQ_T = {$L_json|default:'{}' nofilter};
window.puqT = function(k){ return (window.PUQ_T && window.PUQ_T[k] != null) ? window.PUQ_T[k] : k; };
if (!window.T) window.T = window.puqT;
window.PUQ_CFG = {$PUQ_CFG_json|default:'{}' nofilter};
</script>

{literal}
<style>
:root{--puq-primary:#5865F2;--puq-success:#43b581;--puq-danger:#f04747;--puq-warning:#faa61a;--puq-muted:#72767d;--puq-bg:#f6f6f7;--puq-border:#e3e5e8;--puq-radius:8px;}

/* Cards */
.puq-card{background:#fff;border:1px solid var(--puq-border);border-radius:var(--puq-radius);margin-bottom:12px;overflow:hidden;}
.puq-card-header{background:var(--puq-bg);padding:10px 16px;font-weight:600;font-size:13px;border-bottom:1px solid var(--puq-border);display:flex;align-items:center;gap:8px;}
.puq-card-header .puq-header-spacer{flex:1;}
.puq-card-body{padding:12px 16px;}

/* Grid */
.puq-row{display:flex;flex-wrap:wrap;gap:12px;}
.puq-col{flex:1;min-width:280px;}

/* Stat rows (label + value + optional progress bar) */
.puq-stat{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid #f0f0f0;font-size:13px;}
.puq-stat:last-child{border-bottom:none;}
.puq-stat-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;flex-shrink:0;}
.puq-stat-body{flex:1;min-width:0;}
.puq-stat-label{color:var(--puq-muted);font-size:11px;text-transform:uppercase;letter-spacing:.3px;}
.puq-stat-value{font-weight:600;font-size:14px;}
.puq-stat-value .puq-stat-sub{color:var(--puq-muted);font-weight:400;font-size:12px;margin-left:4px;}

/* Progress bars */
.puq-bar{height:6px;background:#e9ecef;border-radius:3px;overflow:hidden;margin-top:4px;width:100%;}
.puq-bar-fill{height:100%;border-radius:3px;transition:width .4s ease;}

/* Badges */
.puq-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:#fff;}
.puq-badge-success{background:var(--puq-success);}
.puq-badge-danger{background:var(--puq-danger);}
.puq-badge-warning{background:var(--puq-warning);}
.puq-badge-muted{background:var(--puq-muted);}
.puq-badge-primary{background:var(--puq-primary);}

/* Buttons */
.puq-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:var(--puq-radius);font-size:13px;font-weight:600;border:none;cursor:pointer;transition:all .2s;text-decoration:none;color:#fff;line-height:1.2;}
.puq-btn:hover{opacity:.85;text-decoration:none;color:#fff;}
.puq-btn-success{background:var(--puq-success);}
.puq-btn-danger{background:var(--puq-danger);}
.puq-btn-primary{background:var(--puq-primary);}
.puq-btn-warning{background:var(--puq-warning);}
.puq-btn-muted{background:var(--puq-muted);}
.puq-btn-outline{background:transparent;border:1px solid var(--puq-border);color:#333;}
.puq-btn-outline:hover{background:var(--puq-bg);color:#333;}
.puq-btn-sm{padding:5px 10px;font-size:12px;}
.puq-btn[disabled]{opacity:.5;cursor:not-allowed;pointer-events:none;}

.puq-actions{display:flex;flex-wrap:wrap;gap:8px;}

/* Spinner + skeleton — works both as block (div) AND inside a <td colspan>.
   !important on text-align defeats Bootstrap's `.table td { text-align: left }`
   reset; without it the spinner sticks to the left edge of the first column
   even though the cell spans the full table width. */
.puq-loader{text-align:center !important;padding:32px 16px;color:var(--puq-muted);font-size:13px;}
td.puq-loader{padding:36px 16px;vertical-align:middle;}
.puq-loader .fa-spin,.puq-loader .fa-circle-notch{font-size:22px;color:var(--puq-primary);display:block;margin:0 auto 10px;}
.puq-loader-caption{display:block;color:var(--puq-muted);font-size:12px;letter-spacing:.3px;}
.puq-shimmer{background:linear-gradient(90deg,#f0f0f0 25%,#e0e0e0 50%,#f0f0f0 75%);background-size:200% 100%;animation:puqShimmer 1.5s infinite;border-radius:4px;height:16px;display:inline-block;}
@keyframes puqShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}

/* Messages */
.puq-msg{padding:10px 14px;border-radius:var(--puq-radius);margin-bottom:12px;font-size:13px;}
.puq-msg-info{background:#e8f4fd;color:#31708f;border:1px solid #bce8f1;}
.puq-msg-error{background:#fdf2f2;color:#a94442;border:1px solid #ebccd1;}
.puq-msg-success{background:#f0faf0;color:#3c763d;border:1px solid #d6e9c6;}
.puq-msg-warning{background:#fcf8e3;color:#8a6d3b;border:1px solid #faebcc;}

/* Form validation (existing) */
.puq-field-error{border-color:#d9534f !important;box-shadow:0 0 0 2px rgba(217,83,79,.15) !important;}
.puq-validation-err{margin-top:3px;font-size:11px;}

/* Toast container */
#puq-toast-container{position:fixed;top:20px;right:20px;z-index:10000;display:flex;flex-direction:column;gap:8px;max-width:380px;width:100%;pointer-events:none;}
.puq-toast{pointer-events:auto;display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border-radius:var(--puq-radius);color:#fff;font-size:13px;font-weight:500;box-shadow:0 4px 20px rgba(0,0,0,.2);transform:translateX(120%);transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .3s ease;opacity:0;line-height:1.4;position:relative;}
.puq-toast.puq-toast-show{transform:translateX(0);opacity:1;}
.puq-toast.puq-toast-hide{transform:translateX(120%);opacity:0;}
.puq-toast-success{background:var(--puq-success);}
.puq-toast-error{background:var(--puq-danger);}
.puq-toast-info{background:var(--puq-primary);}
.puq-toast-warning{background:var(--puq-warning);color:#333;}
.puq-toast-icon{font-size:16px;flex-shrink:0;margin-top:1px;}
.puq-toast-body{flex:1;word-break:break-word;}
.puq-toast-close{background:none;border:none;color:inherit;font-size:16px;cursor:pointer;opacity:.7;padding:0;flex-shrink:0;}
.puq-toast-close:hover{opacity:1;}

/* Fullscreen loading overlay */
.puq-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.15);z-index:9999;display:flex;justify-content:center;align-items:center;color:#fff;font-size:32px;}

/* Confirm modal */
.puq-confirm-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:10001;display:flex;justify-content:center;align-items:center;opacity:0;transition:opacity .2s ease;}
.puq-confirm-overlay.puq-confirm-show{opacity:1;}
.puq-confirm-box{background:#fff;border-radius:12px;padding:0;max-width:420px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.25);transform:scale(.9);transition:transform .2s cubic-bezier(.4,0,.2,1);overflow:hidden;}
.puq-confirm-overlay.puq-confirm-show .puq-confirm-box{transform:scale(1);}
.puq-confirm-icon{text-align:center;padding:24px 24px 8px;font-size:40px;}
.puq-confirm-title{text-align:center;font-size:17px;font-weight:700;color:#333;padding:0 24px 4px;}
.puq-confirm-text{text-align:center;font-size:13px;color:#666;padding:0 24px 20px;line-height:1.5;}
.puq-confirm-btns{display:flex;border-top:1px solid #eee;}
.puq-confirm-btns button{flex:1;padding:14px;border:none;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;}
.puq-confirm-btn-cancel{background:#f8f9fa;color:#666;border-radius:0 0 0 12px;}
.puq-confirm-btn-cancel:hover{background:#eee;}
.puq-confirm-btn-ok{color:#fff;border-radius:0 0 12px 0;}
.puq-confirm-btn-ok:hover{opacity:.85;}

/* Restore-lock banner (shown on every page when a role this page touches is being restored). */
#puq-restore-lock-banner { display:none; margin-bottom:12px; padding:10px 14px; border-radius:var(--puq-radius);
    background:#fff8e1; border:1px solid #f5c97e; color:#7a4f00; font-size:13px; }
#puq-restore-lock-banner .puq-rlb-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
#puq-restore-lock-banner i.fa-spin { color:var(--puq-warning); }
#puq-restore-lock-banner strong { color:#5a3b00; }
#puq-restore-lock-banner .puq-rlb-meta { color:var(--puq-muted); font-size:11px; }

/* Greyed-out (uneditable) zones during restore. Adds a semi-opaque white
   overlay + 'not-allowed' cursor + pointer-events:none so even keyboard
   shortcuts can't trigger the underlying mutate UI. */
.puq-locked-zone { position:relative; pointer-events:none; user-select:none; }
.puq-locked-zone::after {
    content:""; position:absolute; inset:0; background:rgba(255,255,255,.55);
    border-radius:var(--puq-radius); z-index:5;
}
.puq-locked-zone, .puq-locked-zone * { cursor:not-allowed !important; }
/* Mutate buttons / inputs get the disabled treatment regardless of zone. */
[data-puq-lock-role][data-puq-locked="1"] {
    opacity:.55; cursor:not-allowed !important; pointer-events:none;
}
</style>
{/literal}

<div id="puq-toast-container"></div>
<div id="puq-restore-lock-banner" role="status" aria-live="polite"></div>
{literal}
<script>
(function(){
    if (window.puqClientDT) return; // already initialised on this page

    // ----- Proxmox-style UI primitives ---------------------------------
    var _toastId = 0;
    var _toastIcons = {success:'fa-check-circle',error:'fa-times-circle',info:'fa-info-circle',warning:'fa-exclamation-triangle'};
    window.puqToast = function(type, text, duration){
        // Admin-set display time (settings_general → notification_display_duration_ms)
        // when configured; else the historical error-longer split.
        duration = duration || (window.PUQ_CFG && window.PUQ_CFG.toast_ms) || (type === 'error' ? 6000 : 4000);
        var container = document.getElementById('puq-toast-container');
        if (!container) return;
        var id = 'puq-toast-' + (++_toastId);
        var iconCls = _toastIcons[type] || _toastIcons.info;
        var toast = document.createElement('div');
        toast.className = 'puq-toast puq-toast-' + type;
        toast.id = id;
        toast.innerHTML = '<span class="puq-toast-icon"><i class="fas ' + iconCls + '"></i></span>' +
                          '<span class="puq-toast-body">' + text + '</span>' +
                          '<button class="puq-toast-close" onclick="puqToastClose(\'' + id + '\')">&times;</button>';
        container.appendChild(toast);
        requestAnimationFrame(function(){ requestAnimationFrame(function(){ toast.classList.add('puq-toast-show'); }); });
        setTimeout(function(){ puqToastClose(id); }, duration);
    };
    window.puqToastClose = function(id){
        var el = document.getElementById(id);
        if (!el) return;
        el.classList.remove('puq-toast-show');
        el.classList.add('puq-toast-hide');
        setTimeout(function(){ if (el.parentNode) el.parentNode.removeChild(el); }, 300);
    };

    window.puqLoading = function(){
        if (document.getElementById('puq-loading-overlay')) return;
        var o = document.createElement('div');
        o.className = 'puq-overlay'; o.id = 'puq-loading-overlay';
        o.innerHTML = '<i class="fas fa-circle-notch fa-spin fa-2x"></i>';
        document.body.appendChild(o);
    };
    window.puqLoadingHide = function(){
        var o = document.getElementById('puq-loading-overlay');
        if (o) o.remove();
    };

    /** Replace a button's content with a spinner while running, restore on cb. */
    window.puqBtnSpin = function(btn, label){
        var $b = jQuery(btn);
        if ($b.data('puq-old') === undefined) $b.data('puq-old', $b.html());
        $b.html('<i class="fas fa-circle-notch fa-spin"></i> ' + (label || $b.data('puq-old')));
        $b.prop('disabled', true);
    };
    window.puqBtnRestore = function(btn){
        var $b = jQuery(btn);
        var old = $b.data('puq-old');
        if (old !== undefined) { $b.html(old); $b.removeData('puq-old'); }
        $b.prop('disabled', false);
    };

    /** Standard client-area AJAX. POSTs to clientarea.php?ajax=...
     *  Robust JSON parse — pollution-tolerant. PHP can leak Notices, BOM,
     *  stack traces with `{closure}` etc. before our JSON body. We try the
     *  raw text first, then a series of escalating heuristics. */
    function _puqRobustJsonParse(text){
        function tryP(s){ try { return JSON.parse(s); } catch(e){ return null; } }
        if (typeof text !== 'string') return null;
        // 1. Raw parse.
        var parsed = tryP(text);
        if (parsed !== null) return parsed;
        // 2. Strip BOM + leading whitespace.
        var s = text.replace(/^[﻿\s]+/, '');
        parsed = tryP(s);
        if (parsed !== null) return parsed;
        // 3. Find LAST occurrence of `{"` (a real JSON object start — open
        //    brace + a quote = the first key). This skips PHP-trace noise
        //    like `{closure}()` that contains `{` but not `{"`.
        var idx = s.lastIndexOf('{"');
        while (idx >= 0) {
            parsed = tryP(s.substring(idx));
            if (parsed !== null) return parsed;
            idx = s.lastIndexOf('{"', idx - 1);
        }
        // 4. Try just `{` followed by whitespace + quote (JSON_PRETTY_PRINT).
        var m = s.match(/\{\s*"/);
        if (m) {
            parsed = tryP(s.substring(m.index));
            if (parsed !== null) return parsed;
        }
        return null;
    }

    window.puqClientAjax = function(action, data, cb, method){
        var sid = window.puqServiceId || 0;
        // Robust fallback: some sub-pages forget to set window.puqServiceId.
        // Without a real id the POST goes to id=0 → WHMCS 302-redirects to the
        // products list → the JSON parser sees the full HTML page ("Non-JSON
        // response (… bytes)"). Recover the id from the page URL (?id=N), the
        // same way the resource templates resolve their local sid.
        if (!sid) {
            var _m = window.location.search.match(/[?&]id=(\d+)/);
            if (_m) { sid = parseInt(_m[1], 10); window.puqServiceId = sid; }
        }
        // 4th arg picks the verb. Default GET. POST sends the payload as
        // a JSON body — that's required because plain form-urlencoded
        // POSTs to clientarea.php are intercepted by the host CSRF layer.
        var verb = (typeof method === 'string' && method.toUpperCase() === 'POST') ? 'POST' : 'GET';
        var opts = {
            url: 'clientarea.php?action=productdetails&id=' + sid + '&ajax=' + encodeURIComponent(action),
            method: verb, dataType: 'text',
            success: function(text){
                var parsed = _puqRobustJsonParse(text);
                if (parsed !== null) { cb(parsed); return; }
                var len = (text || '').length;
                var preview = (text || '').replace(/\s+/g,' ').substring(0, 160);
                if (window.console && window.console.error) {
                    console.error('puqClientAjax: could not parse response. action=' + action + ', len=' + len + ', body[0..400]:', (text || '').substring(0, 400));
                }
                cb({
                    status: 'error',
                    msg: puqT('hdr_non_json_response').replace('%d', len) + ' "' + preview + (len > 160 ? '…' : '') + '"',
                    _raw: text
                });
            },
            error: function(x){ cb({status:'error', msg: puqT('hdr_network_error') + ' ' + x.status}); }
        };
        if (verb === 'POST') {
            // Normalise the payload into a plain object so we can ship JSON.
            // Form templates pass `$form.serialize()` (a urlencoded string);
            // action buttons pass an object literal. URLSearchParams handles
            // the urlencoded case, including repeated keys (collapses to last).
            var obj = {};
            if (typeof data === 'string') {
                try {
                    new URLSearchParams(data).forEach(function(v, k){ obj[k] = v; });
                } catch(e) { obj = {}; }
            } else if (data && typeof data === 'object') {
                obj = data;
            }
            opts.contentType = 'application/json; charset=utf-8';
            opts.data = JSON.stringify(obj);
            opts.processData = false;
        } else {
            opts.data = data || {};
        }
        jQuery.ajax(opts);
    };

    /** Deploy-status pill renderer used by every resource page.
     *  Pretty labels — the raw enum names ("pending_delete", "deploying")
     *  read like internal jargon, which scares non-technical customers.
     *  3rd arg `op` ('create' | 'update' | 'delete' | null) disambiguates
     *  the generic queued/deploying statuses: a row whose remote_id is
     *  empty is creating; a row with remote_id but a re-queued worker is
     *  updating. Caller decides the op (see ftp.tpl). */
    window.puqDeployBadge = function(s, err, op){
        var map = {active:'success', error:'danger', queued:'warning',
                   deploying:'primary', pending_delete:'warning', deleting:'warning',
                   pending_terminate:'warning'};
        var queuedLabel = puqT('hdr_queued');
        var goingLabel  = puqT('hdr_deploying');
        if (op === 'update') { queuedLabel = puqT('hdr_queued_update'); goingLabel = puqT('hdr_updating'); }
        else if (op === 'create') { queuedLabel = puqT('hdr_queued_create'); goingLabel = puqT('hdr_creating'); }
        var labels = {active:puqT('hdr_active'), error:puqT('hdr_error'),
                      queued: queuedLabel, deploying: goingLabel,
                      pending_delete:puqT('hdr_queued_for_delete'), deleting:puqT('hdr_deleting'),
                      pending_terminate:puqT('hdr_pending_terminate')};
        var cls = 'puq-badge-' + (map[s] || 'muted');
        var label = labels[s] || s;
        // For transient states add a small spinner so the row reads as
        // "something is happening" at a glance, no need to study the colour.
        var spinner = (s === 'deploying' || s === 'deleting')
            ? '<i class="fas fa-circle-notch fa-spin" style="margin-right:4px;"></i>' : '';
        var html = '<span class="puq-badge ' + cls + '">' + spinner + label + '</span>';
        // The third arg `err` is the worker's raw error message — never
        // surfaced verbatim to the client. We only flag THAT an error
        // occurred via a generic tooltip; full details live in the admin
        // log.
        if (err) html += ' <i class="fas fa-exclamation-triangle" style="color:var(--puq-danger);cursor:help;" title="' + puqT('hdr_prev_change_failed') + '"></i>';
        return html;
    };
    window.puqIsEditable = function(s){ return s === 'active' || s === 'error'; };

    /** Toggle a header-spinner element while a refresh AJAX is in flight. */
    window.puqSetSpinner = function(selector, on){ jQuery(selector).toggle(!!on); };

    /** First-load placeholder row inside an empty <tbody>. Skipped on
     *  subsequent reloads — the header spinner is enough signal then. */
    window.puqInitialTableLoader = function(tbodySelector, rowDataAttr, colspan, caption){
        var $tb = jQuery(tbodySelector);
        if ($tb.children('tr[' + rowDataAttr + ']').length === 0) {
            $tb.html('<tr><td colspan="' + (colspan || 5) + '" class="puq-loader">' +
                '<i class="fas fa-circle-notch fa-spin"></i>' +
                '<span class="puq-loader-caption">' + (caption || puqT('hdr_loading')) + '</span></td></tr>');
        }
    };

    /** Human-readable byte size (1024-base, units B/KB/MB/GB/TB). */
    window.puqFormatBytes = function(bytes, precision){
        var n = Number(bytes); if (!isFinite(n) || n === 0) return '0 B';
        var u = ['B','KB','MB','GB','TB'];
        var i = Math.min(u.length - 1, Math.floor(Math.log(Math.abs(n)) / Math.log(1024)));
        var v = n / Math.pow(1024, i);
        var p = (precision != null) ? precision : (v < 10 ? 2 : (v < 100 ? 1 : 0));
        return v.toFixed(p) + ' ' + u[i];
    };

    /** "5 min ago" relative time, given a server timestamp in 'YYYY-MM-DD
     *  HH:MM:SS' (server's local timezone — MySQL DATETIME with no tz info).
     *  Parses as LOCAL time. If browser and server timezones differ, the
     *  math is off by the tz delta — for that case the backend can also
     *  compute the relative string itself and pass it through; this helper
     *  is just the JS-only fallback. */
    window.puqTimeAgo = function(ts){
        if (!ts) return '';
        // 'YYYY-MM-DD HH:MM:SS' → 'YYYY-MM-DDTHH:MM:SS' (no 'Z' suffix, so
        // browser treats as LOCAL time — matches MySQL DATETIME semantics).
        var t = Date.parse(String(ts).replace(' ', 'T'));
        if (isNaN(t)) return '';
        var d = Math.floor((Date.now() - t) / 1000);
        if (d < 0)     return puqT('hdr_just_now');   // clock skew → don't show negative
        if (d < 10)    return puqT('hdr_just_now');
        if (d < 60)    return d + ' ' + puqT('hdr_s_ago');
        if (d < 3600)  return Math.floor(d / 60) + ' ' + puqT('hdr_min_ago');
        if (d < 86400) return Math.floor(d / 3600) + ' ' + puqT('hdr_h_ago');
        return Math.floor(d / 86400) + ' ' + puqT('hdr_d_ago');
    };

    /** Render a relative-time block from a server-computed seconds-ago value.
     *  Use this when backend can pass `{seconds_ago: N}` — bypasses any
     *  client-side tz drift. Formats: 'just now' / '5 s' / '3 min' / '2 h' / '4 d' ago. */
    window.puqRelativeTime = function(seconds){
        seconds = parseInt(seconds, 10);
        if (!isFinite(seconds) || seconds < 0) return puqT('hdr_just_now');
        if (seconds < 10)    return puqT('hdr_just_now');
        if (seconds < 60)    return seconds + ' ' + puqT('hdr_s_ago');
        if (seconds < 3600)  return Math.floor(seconds / 60) + ' ' + puqT('hdr_min_ago');
        if (seconds < 86400) return Math.floor(seconds / 3600) + ' ' + puqT('hdr_h_ago');
        return Math.floor(seconds / 86400) + ' ' + puqT('hdr_d_ago');
    };

    /**
     * Render a per-resource "Refresh now" toolbar. Hosts the manual sync
     * button + a "Last synced N ago" pill. Used on every per-resource page
     * (FTP, Databases, Mailboxes, Cron, Custom SSL, Web settings, Backups).
     *
     * Usage in a template: drop a <span class="puq-sync-toolbar" data-role="web">
     * element anywhere — auto-init runs on DOM-ready and the shared poll loop
     * keeps the timestamp fresh. No per-template JS needed.
     *
     * The toolbar polls resyncStatus every 8 s; on Refresh click it POSTs
     * refreshRole and updates the pill. Multiple toolbars on one page is
     * fine — they share the same poll.
     */
    window.puqResourceSyncToolbar = function(selector){
        var $hosts = jQuery(selector);
        if (!$hosts.length) return;
        $hosts.each(function(){
            var $h = jQuery(this);
            if ($h.data('puq-sync-init')) return;
            $h.data('puq-sync-init', 1);
            var role = String($h.data('role') || 'web');
            $h.html(
                '<div class="puq-sync-toolbar-inner" style="display:inline-flex;align-items:center;gap:8px;font-size:13px;">' +
                  '<span class="puq-sync-pill text-muted" style="white-space:nowrap;"><i class="fa fa-clock-o"></i> <span class="puq-sync-when">' + puqT('hdr_last_synced_dash') + '</span></span>' +
                  '<button type="button" class="puq-btn puq-btn-outline puq-btn-sm puq-sync-btn"><i class="fa fa-refresh"></i> ' + puqT('hdr_refresh') + '</button>' +
                '</div>'
            );
            $h.find('.puq-sync-btn').on('click', function(){
                var $btn = jQuery(this);
                puqBtnSpin($btn, puqT('hdr_refreshing'));
                puqClientAjax('refreshRole', {role: role}, function(res){
                    if (!res || res.status !== 'success') {
                        puqToast('error', (res && res.msg) || puqT('hdr_refresh_failed'));
                        puqBtnRestore($btn);
                        return;
                    }
                    var d = res.data || {};
                    if (d.reason === 'already_in_progress') {
                        puqToast('info', puqT('hdr_refresh_in_progress'));
                    } else {
                        puqToast('success', puqT('hdr_refresh_queued'));
                    }
                    // Force an immediate status update so the spinner shows.
                    _puqSyncPoll(true);
                }, 'POST');
            });
        });
        // First pull + start the shared poll loop (idempotent).
        _puqSyncPoll(true);
    };

    var _puqSyncTimer = null;
    var _puqSyncInflight = false;
    function _puqSyncPoll(force){
        if (_puqSyncInflight) return;
        if (!jQuery('.puq-sync-toolbar').length) return;
        _puqSyncInflight = true;
        puqClientAjax('resyncStatus', {}, function(res){
            _puqSyncInflight = false;
            if (!res || res.status !== 'success' || !res.data) return;
            jQuery('.puq-sync-toolbar').each(function(){
                var $h = jQuery(this);
                var role = String($h.data('role') || 'web');
                var st = res.data[role];
                if (!st) return;
                var $when = $h.find('.puq-sync-when');
                var $btn  = $h.find('.puq-sync-btn');
                if (!st.available) {
                    $when.text(puqT('hdr_no_role_server').replace('%s', role));
                    $btn.prop('disabled', true).addClass('puq-btn-muted');
                    return;
                }
                if (st.in_progress) {
                    $when.html('<i class="fa fa-spinner fa-spin"></i> ' + puqT('hdr_syncing'));
                    $btn.prop('disabled', true);
                    return;
                }
                $btn.prop('disabled', false).removeClass('puq-btn-muted');
                puqBtnRestore($btn);
                if (st.seconds_ago == null) {
                    $when.text(puqT('hdr_never_synced'));
                } else {
                    $when.text(puqT('hdr_last_synced') + ' ' + puqRelativeTime(st.seconds_ago));
                }
            });
        }, 'POST');
    }
    // Start one shared 8 s poll once toolbars appear on the page + auto-init
    // any `.puq-sync-toolbar` element present at DOM-ready (so per-page
    // templates only need to drop the markup, no per-template JS).
    jQuery(function(){
        if (jQuery('.puq-sync-toolbar').length) {
            puqResourceSyncToolbar('.puq-sync-toolbar');
        }
        if (_puqSyncTimer) clearInterval(_puqSyncTimer);
        _puqSyncTimer = setInterval(function(){ _puqSyncPoll(false); }, 8000);
    });

    /** Recolour a "X / Y" usage badge and its companion add-button as the
     *  resource fills up:
     *      success (lots of room) → warning (one left) → danger (at limit).
     *  Limit 0 = unlimited. */
    window.puqUpdateQuotaBadge = function(badgeSelector, addBtnSelector, used, lim, addLabel){
        var $b = jQuery(badgeSelector), $btn = jQuery(addBtnSelector);
        addLabel = addLabel || puqT('hdr_add');
        $b.text(used + ' / ' + (lim > 0 ? lim : '∞'));
        if (lim > 0 && used >= lim) {
            $b.removeClass().addClass('puq-badge puq-badge-danger').attr('title', puqT('hdr_limit_reached'));
            $btn.removeClass('puq-btn-primary').addClass('puq-btn-muted').attr('title', puqT('hdr_limit_reached')).html('<i class="fa fa-ban"></i> ' + puqT('hdr_limit_reached'));
        } else if (lim > 0 && used >= Math.max(1, lim - 1)) {
            $b.removeClass().addClass('puq-badge puq-badge-warning').attr('title', puqT('hdr_almost_at_limit'));
            $btn.removeClass('puq-btn-muted').addClass('puq-btn-primary').attr('title', '').html('<i class="fa fa-plus"></i> ' + addLabel);
        } else {
            $b.removeClass().addClass('puq-badge puq-badge-success').attr('title', puqT('hdr_used_limit'));
            $btn.removeClass('puq-btn-muted').addClass('puq-btn-primary').attr('title', '').html('<i class="fa fa-plus"></i> ' + addLabel);
        }
    };

    /** Auto-refresh handle. Usage:
     *      var ar = puqAutoRefresh(load);          // 5s default
     *      // inside load() after rendering:
     *      ar.schedule(anyPending);                // (re)arm only while transient
     *      ar.stop();                              // cancel pending timer
     */
    window.puqAutoRefresh = function(fn, interval){
        var ms = interval || 5000, t = null, startedAt = 0;
        // Hard cap on total polling time (settings_general →
        // client_area_poll_max_duration_ms) so a stuck "pending" never polls
        // forever in a left-open tab. 0/absent = no cap (legacy behaviour).
        var maxMs = (window.PUQ_CFG && window.PUQ_CFG.poll_max_ms) || 0;
        return {
            schedule: function(anyPending){
                if (t) { clearTimeout(t); t = null; }
                if (!anyPending) { startedAt = 0; return; }
                if (!startedAt) startedAt = Date.now();
                if (maxMs > 0 && (Date.now() - startedAt) >= maxMs) return; // cap reached — stop polling
                t = setTimeout(fn, ms);
            },
            stop: function(){ if (t) { clearTimeout(t); t = null; } startedAt = 0; }
        };
    };

    /** Generic modal — reuses the .puq-confirm-overlay shell.
     *  opts: { title, body (HTML string), type ('info'|'warning'|'danger'|'success'),
     *          icon, ok, cancel, onOpen(boxEl), onConfirm: function(boxEl) → Promise }
     *  Modal stays open with spinner while onConfirm's Promise is pending;
     *  closes on resolve, shows inline error on reject. */
    window.puqModal = function(opts){
        opts = opts || {};
        var colors = { warning:'#faa61a', danger:'#f04747', info:'#5865F2', success:'#43b581' };
        var icons  = { warning:'fa-exclamation-triangle', danger:'fa-trash-alt', info:'fa-question-circle', success:'fa-check-circle' };
        var color  = colors[opts.type || 'info'] || colors.info;
        var icon   = opts.icon || icons[opts.type || 'info'] || icons.info;
        var ov = document.createElement('div');
        ov.className = 'puq-confirm-overlay';
        ov.innerHTML =
            '<div class="puq-confirm-box" style="max-width:680px;width:90%;">' +
              '<div class="puq-confirm-icon" style="color:' + color + ';"><i class="fas ' + icon + '"></i></div>' +
              (opts.title ? '<div class="puq-confirm-title">' + opts.title + '</div>' : '') +
              '<div class="puq-confirm-text" style="text-align:left;">' + (opts.body || '') + '</div>' +
              '<div class="puq-confirm-err text-danger" style="text-align:left;font-size:12px;padding:0 24px 6px;min-height:0;"></div>' +
              '<div class="puq-confirm-btns">' +
                '<button type="button" class="puq-confirm-btn-cancel">' + (opts.cancel || puqT('hdr_cancel')) + '</button>' +
                '<button type="button" class="puq-confirm-btn-ok" style="background:' + color + ';">' + (opts.ok || puqT('hdr_ok')) + '</button>' +
              '</div>' +
            '</div>';
        document.body.appendChild(ov);
        var $ok = jQuery(ov).find('.puq-confirm-btn-ok');
        var $cn = jQuery(ov).find('.puq-confirm-btn-cancel');
        var $err= jQuery(ov).find('.puq-confirm-err');
        var $box= jQuery(ov).find('.puq-confirm-box');
        var pending = false;
        function close(){
            if (pending) return;
            ov.classList.remove('puq-confirm-show');
            document.removeEventListener('keydown', onKey);
            setTimeout(function(){ if (ov.parentNode) ov.remove(); }, 200);
        }
        function onKey(e){ if (e.key === 'Escape') close(); }
        $cn.on('click', close);
        ov.addEventListener('click', function(e){ if (e.target === ov) close(); });
        document.addEventListener('keydown', onKey);
        $ok.on('click', function(){
            if (pending) return;
            if (!opts.onConfirm) { close(); return; }
            pending = true;
            $err.text('').css('padding','0 24px 6px');
            var okHtml = $ok.html();
            $ok.html('<i class="fas fa-circle-notch fa-spin"></i> ' + (opts.ok || puqT('hdr_ok'))).prop('disabled', true);
            $cn.prop('disabled', true);
            Promise.resolve(opts.onConfirm($box[0])).then(function(){
                pending = false;
                $ok.html(okHtml).prop('disabled', false);
                $cn.prop('disabled', false);
                close();
            }).catch(function(err){
                pending = false;
                $ok.html(okHtml).prop('disabled', false);
                $cn.prop('disabled', false);
                var msg = (err && err.message) ? err.message : (typeof err === 'string' ? err : puqT('hdr_failed'));
                $err.text(msg).css('padding','6px 24px 10px');
                if (window.puqToast) puqToast('error', msg);
            });
        });
        requestAnimationFrame(function(){ requestAnimationFrame(function(){ ov.classList.add('puq-confirm-show'); }); });
        if (typeof opts.onOpen === 'function') opts.onOpen($box[0]);
        return { close: close, box: $box[0] };
    };

    /** Simple HTML escape for use in TEXT content (between tags).
     *  Does NOT escape double/single quotes — those don't need escaping in
     *  text nodes. Use puqEscAttr for attribute values. */
    window.puqEsc = function(s){ return jQuery('<div>').text(s == null ? '' : s).html(); };

    /** HTML escape for use in ATTRIBUTE values (inside `attr="..."` or
     *  `attr='...'`). Adds quote escaping on top of puqEsc — otherwise a
     *  value containing `"` breaks attribute parsing. Without this, a CAA
     *  record value like `0 issue "letsencrypt.org"` put into a
     *  `data-value="..."` attribute closes the attribute early at the
     *  first inner `"`, and the rest of the value silently disappears. */
    window.puqEscAttr = function(s){
        return puqEsc(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    };

    /** Build the fully-prefixed Hestia user name for FTP / database. The
     *  per-page `window.puqWebUsername` is set inline by templates that
     *  need it. Returns "" if the username hasn't been seeded yet (caller
     *  should treat that as not-ready and either retry or fail gracefully). */
    window.puqFullName = function(s){
        var u = window.puqWebUsername || '';
        return u ? (u + '_' + s) : '';
    };

    /** Confirm modal. Two flavours:
     *  1. boolean Promise (legacy): puqConfirm({title:'...'}).then(ok => ...)
     *  2. async-action mode: pass opts.onConfirm = function(){ return Promise; }.
     *     Modal stays OPEN while the promise is pending — OK button shows
     *     a spinner. On promise resolve modal closes; on reject the modal
     *     stays open and shows the error text. Use this for delete buttons
     *     where the AJAX may take a second or two. */
    var _puqConfirmResolve = null;
    window.puqConfirm = function(opts){
        if (typeof opts === 'string') opts = { text: opts };
        var type      = opts.type      || 'warning';
        var title     = opts.title     || '';
        var text      = opts.text      || '';
        var okTxt     = opts.ok        || puqT('hdr_ok');
        var cancel    = opts.cancel    || puqT('hdr_cancel');
        var onConfirm = opts.onConfirm || null;
        var colors = { warning:'#faa61a', danger:'#f04747', info:'#5865F2', success:'#43b581' };
        var icons  = { warning:'fa-exclamation-triangle', danger:'fa-trash-alt', info:'fa-question-circle', success:'fa-check-circle' };
        var color  = colors[type] || colors.warning;
        var icon   = opts.icon || icons[type] || icons.warning;

        var old = document.getElementById('puq-confirm-el');
        if (old) { if (_puqConfirmResolve) _puqConfirmResolve(false); old.remove(); }

        var overlay = document.createElement('div');
        overlay.className = 'puq-confirm-overlay';
        overlay.id = 'puq-confirm-el';
        overlay.innerHTML =
            '<div class="puq-confirm-box">' +
                '<div class="puq-confirm-icon" style="color:' + color + ';"><i class="fas ' + icon + '"></i></div>' +
                (title ? '<div class="puq-confirm-title">' + title + '</div>' : '') +
                '<div class="puq-confirm-text">' + text + '</div>' +
                '<div class="puq-confirm-err text-danger" style="text-align:center;font-size:12px;padding:0 24px 6px;min-height:0;"></div>' +
                '<div class="puq-confirm-btns">' +
                    '<button type="button" class="puq-confirm-btn-cancel">' + cancel + '</button>' +
                    '<button type="button" class="puq-confirm-btn-ok" style="background:' + color + ';">' + okTxt + '</button>' +
                '</div>' +
            '</div>';
        document.body.appendChild(overlay);
        var $okBtn  = jQuery(overlay).find('.puq-confirm-btn-ok');
        var $cnBtn  = jQuery(overlay).find('.puq-confirm-btn-cancel');
        var $errBox = jQuery(overlay).find('.puq-confirm-err');
        var okOrig  = $okBtn.html();

        requestAnimationFrame(function(){ requestAnimationFrame(function(){ overlay.classList.add('puq-confirm-show'); }); });

        return new Promise(function(resolve){
            _puqConfirmResolve = resolve;
            var pending = false;
            function close(result){
                if (pending) return; // ignore close while async in flight
                document.removeEventListener('keydown', onKey);
                overlay.classList.remove('puq-confirm-show');
                setTimeout(function(){ if (overlay.parentNode) overlay.remove(); }, 200);
                _puqConfirmResolve = null;
                resolve(result);
            }
            function onKey(e){
                if (e.key === 'Escape') close(false);
                if (e.key === 'Enter')  onOk();
            }
            function onOk(){
                if (pending) return;
                if (!onConfirm) { close(true); return; }
                pending = true;
                $errBox.text('').css('padding','0 24px 6px');
                $okBtn.html('<i class="fas fa-circle-notch fa-spin"></i> ' + okTxt).prop('disabled', true);
                $cnBtn.prop('disabled', true);
                Promise.resolve(onConfirm()).then(function(res){
                    pending = false;
                    $okBtn.html(okOrig).prop('disabled', false);
                    $cnBtn.prop('disabled', false);
                    close(true);
                    resolve(true);
                }).catch(function(err){
                    pending = false;
                    $okBtn.html(okOrig).prop('disabled', false);
                    $cnBtn.prop('disabled', false);
                    var msg = (err && err.message) ? err.message : (typeof err === 'string' ? err : puqT('hdr_failed'));
                    $errBox.text(msg).css('padding','6px 24px 10px');
                    // Surface as a red toast too — visible even if the user
                    // scrolled past the modal or it's offscreen.
                    if (window.puqToast) puqToast('error', msg);
                });
            }
            $cnBtn.on('click', function(){ close(false); });
            $okBtn.on('click', onOk);
            overlay.addEventListener('click', function(e){ if (e.target === overlay) close(false); });
            document.addEventListener('keydown', onKey);
        });
    };

    /** Prompt modal. Like puqConfirm but with an input field.
     *  Two modes:
     *    1. Legacy: resolves with the entered string (or null on cancel).
     *    2. Async-action: opts.onSubmit = function(value){ return Promise; }.
     *       Modal stays open with spinner until promise resolves; on reject
     *       the error message is shown inside the modal and modal stays open. */
    window.puqPrompt = function(opts){
        opts = opts || {};
        var title       = opts.title       || '';
        var text        = opts.text        || '';
        var placeholder = opts.placeholder || '';
        var inputType   = opts.inputType   || 'text';
        var inputValue  = opts.value       || '';
        var validator   = opts.validator   || null;
        var validatorMsg= opts.validatorMsg|| puqT('hdr_invalid_value');
        var onSubmit    = opts.onSubmit    || null;
        var okTxt       = opts.ok          || puqT('hdr_ok');
        var cancel      = opts.cancel      || puqT('hdr_cancel');
        var type        = opts.type        || 'info';
        var colors = { warning:'#faa61a', danger:'#f04747', info:'#5865F2', success:'#43b581' };
        var color  = colors[type] || colors.info;
        var icon   = opts.icon || 'fa-keyboard';

        var old = document.getElementById('puq-confirm-el');
        if (old) old.remove();

        var overlay = document.createElement('div');
        overlay.className = 'puq-confirm-overlay';
        overlay.id = 'puq-confirm-el';
        overlay.innerHTML =
            '<div class="puq-confirm-box">' +
                '<div class="puq-confirm-icon" style="color:' + color + ';"><i class="fas ' + icon + '"></i></div>' +
                (title ? '<div class="puq-confirm-title">' + title + '</div>' : '') +
                (text  ? '<div class="puq-confirm-text">' + text + '</div>'  : '') +
                '<div style="padding:0 24px 8px;">' +
                    '<input type="' + inputType + '" class="form-control puq-prompt-input" placeholder="' + placeholder + '" value="' + inputValue + '" autocomplete="new-password">' +
                    '<div class="puq-prompt-err text-danger" style="font-size:11px;margin-top:4px;min-height:14px;"></div>' +
                '</div>' +
                '<div class="puq-confirm-btns">' +
                    '<button type="button" class="puq-confirm-btn-cancel">' + cancel + '</button>' +
                    '<button type="button" class="puq-confirm-btn-ok" style="background:' + color + ';">' + okTxt + '</button>' +
                '</div>' +
            '</div>';
        document.body.appendChild(overlay);
        var $in     = jQuery(overlay).find('.puq-prompt-input');
        var $err    = jQuery(overlay).find('.puq-prompt-err');
        var $okBtn  = jQuery(overlay).find('.puq-confirm-btn-ok');
        var $cnBtn  = jQuery(overlay).find('.puq-confirm-btn-cancel');
        var okOrig  = $okBtn.html();
        requestAnimationFrame(function(){ requestAnimationFrame(function(){ overlay.classList.add('puq-confirm-show'); $in.focus(); }); });

        return new Promise(function(resolve){
            var pending = false;
            function close(value){
                if (pending) return;
                document.removeEventListener('keydown', onKey);
                overlay.classList.remove('puq-confirm-show');
                setTimeout(function(){ if (overlay.parentNode) overlay.remove(); }, 200);
                resolve(value);
            }
            function trySubmit(){
                if (pending) return;
                var v = $in.val();
                if (validator && !validator(v)) {
                    $err.text(validatorMsg); $in.focus(); return;
                }
                $err.text('');
                if (!onSubmit) { close(v); return; }
                pending = true;
                $okBtn.html('<i class="fas fa-circle-notch fa-spin"></i> ' + okTxt).prop('disabled', true);
                $cnBtn.prop('disabled', true);
                $in.prop('disabled', true);
                Promise.resolve(onSubmit(v)).then(function(){
                    pending = false;
                    $okBtn.html(okOrig).prop('disabled', false);
                    $cnBtn.prop('disabled', false);
                    $in.prop('disabled', false);
                    close(v);
                }).catch(function(e){
                    pending = false;
                    $okBtn.html(okOrig).prop('disabled', false);
                    $cnBtn.prop('disabled', false);
                    $in.prop('disabled', false);
                    var msg = (e && e.message) ? e.message : (typeof e === 'string' ? e : puqT('hdr_failed'));
                    $err.text(msg); $in.focus();
                    if (window.puqToast) puqToast('error', msg);
                });
            }
            function onKey(e){
                if (e.key === 'Escape') close(null);
                if (e.key === 'Enter')  { e.preventDefault(); trySubmit(); }
            }
            $cnBtn.on('click', function(){ close(null); });
            $okBtn.on('click', trySubmit);
            overlay.addEventListener('click', function(e){ if (e.target === overlay) close(null); });
            document.addEventListener('keydown', onKey);
        });
    };

    // --- Validation helpers ------------------------------------------------
    window.puqValidate = {
        email: function(v){ return /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(String(v||'').trim()); },
        // RFC1035-ish DNS label / hostname token. Allows '@' for zone apex.
        // Trailing dot is OPTIONAL — DNS convention treats "foo.example.com"
        // and "foo.example.com." as equivalent ("absolute" form with the dot,
        // implicit-absolute without). PowerDNS / Hestia / our own templates
        // store with the trailing dot; the customer sees and may type either.
        dnsName: function(v){ v=String(v||'').trim(); if (v==='@') return true;
            return /^(\*\.)?([a-z0-9_]([a-z0-9_\-]{0,61}[a-z0-9_])?\.)*[a-z0-9_]([a-z0-9_\-]{0,61}[a-z0-9_])?\.?$/i.test(v); },
        // Hostname target for CNAME/NS/MX/SRV. Accepts EITHER:
        //  - a short label like "www" or "mail" — DNS resolves it inside
        //    the current zone (becomes www.<zone>)
        //  - a full FQDN like "target.example.com" or "target.example.com."
        //    — absolute, points anywhere
        // Identical to dnsName because the zone-scope vs absolute distinction
        // is just "does it have dots". Both forms are valid DNS targets and
        // the user shouldn't have to type the zone domain twice.
        dnsTarget: function(v){
            v = String(v||'').trim();
            if (v === '' || v === '@') return false; // @ only valid in Name, not target
            return this.dnsName(v);
        },
        ipv4: function(v){
            v = String(v||'').trim();
            var m = v.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
            if (!m) return false;
            for (var i = 1; i <= 4; i++) {
                if (parseInt(m[i], 10) > 255) return false;
                if (m[i].length > 1 && m[i][0] === '0') return false; // no leading zeros
            }
            return true;
        },
        ipv6: function(v){
            v = String(v||'').trim();
            // Browser-built-in URL parser for IPv6 — most lenient & correct.
            // Fallback to a regex for older browsers.
            try { return new URL('http://[' + v + ']').hostname === '[' + v.toLowerCase() + ']' || /^[0-9a-fA-F:]+$/.test(v); }
            catch (e) { return /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4})*::?([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{0,4})$/.test(v); }
        },
        // DNS record value, given the type. Returns true/false. Same checks
        // are applied server-side before the change is accepted.
        dnsValue: function(type, value, priority){
            type = String(type || '').toUpperCase();
            value = String(value || '').trim();
            if (value === '' || value.length > 65000) return false;
            if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(value)) return false;
            switch (type) {
                case 'A':    return this.ipv4(value);
                case 'AAAA': return this.ipv6(value);
                case 'CNAME':
                case 'NS':
                case 'MX':
                case 'PTR':  return this.dnsTarget(value);
                case 'TXT':  return true; // free-form
                case 'SRV': {
                    var parts = value.split(/\s+/);
                    if (parts.length !== 3) return false;
                    var w = parseInt(parts[0], 10), p = parseInt(parts[1], 10);
                    if (isNaN(w) || w < 0 || w > 65535) return false;
                    if (isNaN(p) || p < 1 || p > 65535) return false;
                    return this.dnsTarget(parts[2]);
                }
                case 'CAA': {
                    var m = value.match(/^(\d{1,3})\s+(issue|issuewild|iodef)\s+"([^"]*)"\s*$/i);
                    if (!m) return false;
                    var f = parseInt(m[1], 10);
                    return f >= 0 && f <= 255;
                }
                case 'DNSKEY': {
                    var p = value.split(/\s+/);
                    if (p.length !== 4) return false;
                    var fl = parseInt(p[0],10), pr = parseInt(p[1],10), al = parseInt(p[2],10);
                    if (isNaN(fl) || fl < 0 || fl > 65535) return false;
                    if (isNaN(pr) || pr < 0 || pr > 255)   return false;
                    if (isNaN(al) || al < 0 || al > 255)   return false;
                    return /^[A-Za-z0-9+\/=\s]+$/.test(p[3]);
                }
                case 'DS': {
                    var p = value.split(/\s+/);
                    if (p.length !== 4) return false;
                    var kt = parseInt(p[0],10), al = parseInt(p[1],10), dt = parseInt(p[2],10);
                    if (isNaN(kt) || kt < 0 || kt > 65535) return false;
                    if (isNaN(al) || al < 0 || al > 255)   return false;
                    if (isNaN(dt) || dt < 0 || dt > 255)   return false;
                    return /^[A-Fa-f0-9\s]+$/.test(p[3]);
                }
                case 'NAPTR': {
                    var m = value.match(/^(\d+)\s+(\d+)\s+"([^"]*)"\s+"([^"]*)"\s+"([^"]*)"\s+(\S+)\s*$/);
                    if (!m) return false;
                    var ord = parseInt(m[1],10), pref = parseInt(m[2],10);
                    if (isNaN(ord) || ord < 0 || ord > 65535) return false;
                    if (isNaN(pref) || pref < 0 || pref > 65535) return false;
                    return m[6] === '.' || this.dnsTarget(m[6]);
                }
                case 'TLSA': {
                    var p = value.split(/\s+/);
                    if (p.length !== 4) return false;
                    var u = parseInt(p[0],10), s = parseInt(p[1],10), mt = parseInt(p[2],10);
                    if (isNaN(u)  || u  < 0 || u  > 3) return false;
                    if (isNaN(s)  || s  < 0 || s  > 1) return false;
                    if (isNaN(mt) || mt < 0 || mt > 2) return false;
                    return /^[A-Fa-f0-9\s]+$/.test(p[3]);
                }
            }
            return false;
        },
        dnsValueMsg: function(type){
            var msgs = {
                A:      puqT('hdr_dns_msg_a'),
                AAAA:   puqT('hdr_dns_msg_aaaa'),
                CNAME:  puqT('hdr_dns_msg_cname'),
                NS:     puqT('hdr_dns_msg_ns'),
                MX:     puqT('hdr_dns_msg_mx'),
                PTR:    puqT('hdr_dns_msg_ptr'),
                TXT:    '',
                SRV:    puqT('hdr_dns_msg_srv'),
                CAA:    puqT('hdr_dns_msg_caa'),
                DNSKEY: puqT('hdr_dns_msg_dnskey'),
                DS:     puqT('hdr_dns_msg_ds'),
                NAPTR:  puqT('hdr_dns_msg_naptr'),
                TLSA:   puqT('hdr_dns_msg_tlsa')
            };
            return msgs[String(type || '').toUpperCase()] || puqT('hdr_invalid_value_dot');
        },
        dbName: function(v){ return /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/.test(String(v||'').trim()); },
        password: function(v, min){ return (v||'').length >= (min||8); },
        // Strong-password policy: ≥ 8 chars, with at least one uppercase,
        // one lowercase letter, and one digit. The same rule is enforced
        // server-side before any password change is accepted.
        passwordStrong: function(v){
            if (typeof v !== 'string' || v.length < 8) return false;
            if (!/[a-z]/.test(v)) return false;
            if (!/[A-Z]/.test(v)) return false;
            if (!/\d/.test(v))    return false;
            return true;
        },
        get passwordStrongMsg(){ return puqT('hdr_password_strong_msg'); },
        cronSchedule: function(v){
            v = String(v||'').trim();
            if (!v) return false;
            // 5 fields. Each field is a comma-separated list of items;
            // each item is one of: *, N, N-M, */K, N-M/K (mirrors what
            // Hestia v-add-cron-job accepts). Char class includes `*`
            // so steps like `*/5` validate correctly.
            return /^([\d,\-\/*]+)\s+([\d,\-\/*]+)\s+([\d,\-\/*]+)\s+([\d,\-\/*]+)\s+([\d,\-\/*]+)$/.test(v);
        },
        integer: function(v, min, max){
            var n = parseInt(v, 10); if (isNaN(n)) return false;
            if (min!=null && n<min) return false;
            if (max!=null && n>max) return false;
            return true;
        },
        nonEmpty: function(v){ return (v!=null) && String(v).trim() !== ''; }
    };

    window.puqShowInputError = function(input, msg){
        var $input = jQuery(input);
        $input.addClass('puq-field-error');
        var $help = $input.parent().find('> .puq-validation-err').first();
        if (!$help.length) {
            $help = jQuery('<div class="puq-validation-err text-danger"></div>');
            $input.after($help);
        }
        $help.text(msg).show();
    };

    window.puqClearInputErrors = function(scope){
        jQuery(scope).find('.puq-field-error').removeClass('puq-field-error');
        jQuery(scope).find('.puq-validation-err').remove();
    };

    // -- Transition toasts ------------------------------------------------
    // Client templates call this once per AJAX list refresh:
    //   puqClientNotifyTransitions('puq-mailboxes', rows, 'id', 'deploy_status', 'mailbox', 'email')
    // First call captures the baseline silently; subsequent calls compare and
    // pop a toast when a row's status flips queued/deploying -> active or error.
    // The 6th arg (nameKey, optional) picks a human-readable field — when set
    // the toast says "Mailbox foo@bar is now active" instead of leaking the
    // internal numeric id (which the client has no reason to see).
    var _puqPrev = {};
    var _puqPrevNames = {};
    function _puqToast(msg, type){
        // WHMCS client area exposes a global showNotification or window.alert.
        if (typeof window.showNotification === 'function') {
            try { window.showNotification(msg, type); return; } catch(e){}
        }
        // Fallback toast — minimal, top-right, auto-dismiss after 5s.
        var bg = type === 'success' ? '#5cb85c' : (type === 'error' ? '#d9534f' : '#5bc0de');
        var $t = jQuery('<div></div>')
            .css({
                position:'fixed', top:'70px', right:'20px', zIndex:99999,
                padding:'10px 16px', background:bg, color:'#fff', borderRadius:'4px',
                boxShadow:'0 2px 8px rgba(0,0,0,.2)', maxWidth:'380px', fontSize:'13px'
            })
            .text(msg)
            .appendTo('body');
        setTimeout(function(){ $t.fadeOut(400, function(){ $t.remove(); }); }, 5000);
    }
    window.puqClientNotifyTransitions = function(key, rows, idKey, statusKey, label, nameKey){
        idKey = idKey || 'id';
        statusKey = statusKey || 'deploy_status';
        label = label || 'item';
        var prev = _puqPrev[key];
        var prevNames = _puqPrevNames[key] || {};
        var curr = {};
        var currNames = {};
        (rows || []).forEach(function(r){
            curr[r[idKey]] = r[statusKey];
            // Resolve a human display value: prefer nameKey; fall back to the
            // first non-empty common name field. NEVER leak the numeric id.
            var n = '';
            if (nameKey && r[nameKey] != null && r[nameKey] !== '') n = String(r[nameKey]);
            else if (r.name)      n = String(r.name);
            else if (r.username)  n = String(r.username);
            else if (r.email)     n = String(r.email);
            else if (r.domain)    n = String(r.domain);
            currNames[r[idKey]] = n;
        });
        function disp(id) {
            // Fall back to previous-cycle name on a row that just disappeared.
            return currNames[id] || prevNames[id] || '';
        }
        function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
        function pretty(id) {
            var n = disp(id);
            return cap(label) + (n ? ' ' + n : '');
        }
        if (prev) {
            Object.keys(curr).forEach(function(id){
                var was = prev[id], is = curr[id];
                if (!was || was === is) return;
                var transient = ['queued','deploying','pending_delete','deleting'];
                if (transient.indexOf(was) >= 0 && is === 'active') {
                    _puqToast(pretty(id) + ' ' + puqT('hdr_is_now_active'), 'success');
                } else if (transient.indexOf(was) >= 0 && is === 'error') {
                    _puqToast(pretty(id) + ' ' + puqT('hdr_deploy_failed'), 'error');
                }
            });
            // Detect deletions (was active/deleting, now gone)
            Object.keys(prev).forEach(function(id){
                if (curr[id]) return;
                if (prev[id] === 'deleting' || prev[id] === 'pending_delete') {
                    _puqToast(pretty(id) + ' ' + puqT('hdr_deleted'), 'success');
                }
            });
        }
        _puqPrev[key] = curr;
        _puqPrevNames[key] = currNames;
    };
    var DT_JS  = 'https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js';
    var DT_CSS = 'https://cdn.datatables.net/1.13.7/css/jquery.dataTables.min.css';

    function ensureCss(href){
        var existing = document.querySelector('link[href="' + href + '"]');
        if (existing) return;
        var l = document.createElement('link');
        l.rel = 'stylesheet'; l.href = href;
        document.head.appendChild(l);
    }
    function ensureJs(src, cb){
        if (window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable) { cb(); return; }
        var existing = document.querySelector('script[src="' + src + '"]');
        if (existing) { existing.addEventListener('load', cb); if (existing.dataset.loaded === '1') cb(); return; }
        var s = document.createElement('script');
        s.src = src; s.async = true;
        s.onload = function(){ s.dataset.loaded = '1'; cb(); };
        document.head.appendChild(s);
    }

    ensureCss(DT_CSS);

    // Apply DataTables to a selector once jQuery DataTables is loaded.
    // opts: { order, pageLength, searching, ... } — passed verbatim.
    window.puqClientDT = function(selector, opts){
        opts = opts || {};
        ensureJs(DT_JS, function(){
            try {
                if (window.jQuery.fn.DataTable.isDataTable(selector)) {
                    window.jQuery(selector).DataTable().destroy();
                }
                window.jQuery(selector).DataTable(Object.assign({
                    pageLength: 25,
                    autoWidth: false,
                    order: [],
                    language: { search: puqT('hdr_dt_search'), lengthMenu: puqT('hdr_dt_length_menu'), info: puqT('hdr_dt_info') }
                }, opts));
            } catch (e) {
                // DataTables is a UX nicety; don't break the page on failure.
                if (window.console) console.warn('puqClientDT:', e);
            }
        });
    };

    /** Click-to-copy on any element with class="puq-copy". Reads `data-copy`
     *  attribute (preferred) or the element's text content. One delegated
     *  listener on document — works for dynamically inserted nodes too. */
    jQuery(document).on('click', '.puq-copy', function(){
        var $el = jQuery(this);
        var txt = $el.attr('data-copy') != null ? $el.attr('data-copy') : $el.text();
        if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(txt).then(function(){
                if (window.puqToast) puqToast('success', puqT('hdr_copied_colon') + ' ' + (txt.length > 60 ? txt.substring(0,60)+'…' : txt));
            }).catch(function(){ if (window.puqToast) puqToast('error', puqT('hdr_clipboard_blocked')); });
        } else {
            var $ta = jQuery('<textarea>').val(txt).css({position:'fixed',top:0,left:0,opacity:0}).appendTo('body').select();
            try { document.execCommand('copy'); if (window.puqToast) puqToast('success', puqT('hdr_copied')); }
            catch (e) { if (window.puqToast) puqToast('error', puqT('hdr_copy_failed')); }
            $ta.remove();
        }
    });
    jQuery(document).on('mouseenter', '.puq-copy', function(){ jQuery(this).css({'cursor':'copy'}); });

    // =====================================================================
    // Restore-lock banner + UI greying. Each page declares
    //   window.puqPageRoles = ['web']   // or ['mail'] or ['web','mail']
    // PRIOR to header.tpl execution (Smarty {if} block sets it). When any
    // declared role is locked server-side, we:
    //   1. Show a banner at the top of the page
    //   2. Disable every element with data-puq-lock-role that matches a
    //      locked role (mutate buttons)
    //   3. Add .puq-locked-zone to every container marked the same way
    //      (read-only overlay over forms / panels)
    //
    // Poll cadence: 10 s. The endpoint is a pure DB read so the cost is
    // negligible, and 10 s gives the user a quick "restore finished →
    // controls re-enabled" feedback once the worker clears the lock.
    // Pages with no puqPageRoles (default = no roles) get only banner.
    // =====================================================================
    var puqLockPoll = null;
    var puqLockLastSig = '';
    function puqLockedRolesFromData(data) {
        var roles = [];
        if (data && data.web  && data.web.locked)  roles.push('web');
        if (data && data.mail && data.mail.locked) roles.push('mail');
        return roles;
    }
    function puqShouldShowBanner(lockedRoles) {
        var page = window.puqPageRoles || [];
        // No page roles declared → show banner for any lock (covers shared
        // pages like the dashboard / backups that span both roles).
        if (!page.length) return lockedRoles.length > 0;
        for (var i = 0; i < lockedRoles.length; i++) {
            if (page.indexOf(lockedRoles[i]) >= 0) return true;
        }
        return false;
    }
    function puqRenderLockBanner(data, lockedRoles) {
        var $b = jQuery('#puq-restore-lock-banner');
        if (!puqShouldShowBanner(lockedRoles)) { $b.hide().empty(); return; }
        var bits = [];
        lockedRoles.forEach(function(role) {
            var rec = data[role] || {};
            var rolLabel = role === 'mail' ? puqT('hdr_role_mail') : puqT('hdr_role_website');
            var since = rec.started_at ? ' <span class="puq-rlb-meta">(' + puqT('hdr_since') + ' ' + window.puqEsc(rec.started_at.substring(0,16)) + ')</span>' : '';
            bits.push('<strong>' + rolLabel + '</strong>' + since);
        });
        $b.html(
            '<div class="puq-rlb-row">'
          +   '<i class="fas fa-circle-notch fa-spin"></i> '
          +   '<span>' + bits.join(' &middot; ') + ' ' + puqT('hdr_restore_banner_text') + '</span>'
          + '</div>'
        ).show();
    }
    function puqApplyLockToUi(lockedRoles) {
        // Buttons / inputs marked with data-puq-lock-role
        jQuery('[data-puq-lock-role]').each(function() {
            var $el = jQuery(this);
            var marker = String($el.attr('data-puq-lock-role') || '').toLowerCase();
            var elRoles = marker === 'both' || marker === 'all'
                ? ['web', 'mail']
                : marker.split(/[\s,]+/).filter(Boolean);
            var locked = false;
            for (var i = 0; i < elRoles.length; i++) {
                if (lockedRoles.indexOf(elRoles[i]) >= 0) { locked = true; break; }
            }
            if (locked) {
                $el.attr('data-puq-locked', '1');
                // Native disabled for form controls — keyboard + a11y friendly.
                if (this.tagName === 'BUTTON' || this.tagName === 'INPUT' || this.tagName === 'SELECT' || this.tagName === 'TEXTAREA') {
                    if ($el.attr('data-puq-prev-disabled') == null) {
                        $el.attr('data-puq-prev-disabled', $el.prop('disabled') ? '1' : '0');
                    }
                    $el.prop('disabled', true);
                }
            } else {
                $el.removeAttr('data-puq-locked');
                if (this.tagName === 'BUTTON' || this.tagName === 'INPUT' || this.tagName === 'SELECT' || this.tagName === 'TEXTAREA') {
                    var prev = $el.attr('data-puq-prev-disabled');
                    if (prev != null) {
                        $el.prop('disabled', prev === '1');
                        $el.removeAttr('data-puq-prev-disabled');
                    }
                }
            }
        });
        // Container zones marked with data-puq-lock-zone (entire card overlay)
        jQuery('[data-puq-lock-zone]').each(function() {
            var $el = jQuery(this);
            var marker = String($el.attr('data-puq-lock-zone') || '').toLowerCase();
            var elRoles = marker === 'both' || marker === 'all'
                ? ['web', 'mail']
                : marker.split(/[\s,]+/).filter(Boolean);
            var locked = false;
            for (var i = 0; i < elRoles.length; i++) {
                if (lockedRoles.indexOf(elRoles[i]) >= 0) { locked = true; break; }
            }
            $el.toggleClass('puq-locked-zone', locked);
        });
    }
    function puqLockTick() {
        if (!window.puqServiceId) return; // not on a service page
        jQuery.ajax({
            url: 'clientarea.php?action=productdetails&id=' + window.puqServiceId + '&ajax=restoreLockState',
            dataType: 'json', method: 'GET', cache: false,
            success: function(r) {
                if (!r || r.status !== 'success' || !r.data) return;
                var d = r.data;
                var locked = puqLockedRolesFromData(d);
                // Cheap change detection — avoid DOM thrash when nothing changed.
                var sig = locked.join(',') + '|' + (d.web && d.web.started_at) + '|' + (d.mail && d.mail.started_at);
                if (sig === puqLockLastSig) return;
                puqLockLastSig = sig;
                puqRenderLockBanner(d, locked);
                puqApplyLockToUi(locked);
            }
        });
    }
    // Run once on page load (so server-side state shows up without a 10 s
    // delay) and then poll every 10 s.
    jQuery(function() {
        puqLockTick();
        if (puqLockPoll) clearInterval(puqLockPoll);
        puqLockPoll = setInterval(puqLockTick, 10000);
    });
})();
</script>
{/literal}
