Changeset 3483265
- Timestamp:
- 03/15/2026 07:32:45 PM (3 weeks ago)
- Location:
- statusdot
- Files:
-
- 3 added
- 9 edited
-
assets/screenshot-1.png (modified) (previous)
-
assets/screenshot-2.png (modified) (previous)
-
assets/screenshot-3.png (added)
-
assets/screenshot-4.png (added)
-
assets/screenshot-5.png (added)
-
trunk/assets/css/statusdot-admin.css (modified) (3 diffs)
-
trunk/assets/css/statusdot-status.css (modified) (1 diff)
-
trunk/assets/js/statusdot-admin.js (modified) (5 diffs)
-
trunk/assets/js/statusdot-frontend.js (modified) (6 diffs)
-
trunk/includes/class-statusdot-opening-hours.php (modified) (42 diffs)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/statusdot.php (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
statusdot/trunk/assets/css/statusdot-admin.css
r3476743 r3483265 312 312 .statusdot-live-preview{ 313 313 position:sticky; 314 /* Keep the preview aligned with the top of the main card columns (avoid floating above cards). */ 315 top:96px; 314 top:118px; 316 315 align-self:flex-start; 317 316 background:#fff; … … 322 321 width:520px; 323 322 max-width:100%; 323 max-height:calc(100vh - 140px); 324 overflow:auto; 325 } 326 .statusdot-display-split > .statusdot-live-preview{ 327 top:118px; 328 margin-top:14px; 329 } 330 .statusdot-profeatures-wrap > .statusdot-live-preview{ 331 top:134px; 324 332 } 325 333 … … 334 342 /* Pro schedule: Status Mode title should match Free styling */ 335 343 .statusdot-statusmode-title{margin:0 0 8px;font-size:16px;line-height:1.3;} 344 345 346 .statusdot-weekly-table input.statusdot-field-error, 347 .statusdot-pro-table input.statusdot-field-error{ 348 border-color:#d63638 !important; 349 box-shadow:0 0 0 1px #d63638 !important; 350 } 351 352 .statusdot-break-error-msg{ 353 display:none; 354 margin-top:6px; 355 font-size:12px; 356 line-height:1.4; 357 color:#d63638; 358 } 359 360 .statusdot-break-slots{ 361 display:flex; 362 flex-direction:column; 363 gap:8px; 364 } 365 .statusdot-break-slot{ 366 display:flex; 367 align-items:center; 368 gap:8px; 369 flex-wrap:wrap; 370 } 371 .statusdot-break-slot__toggle{ 372 min-width:80px; 373 display:flex; 374 align-items:center; 375 gap:6px; 376 font-size:12px; 377 } 378 .statusdot-break-slot__dash{ 379 color:#646970; 380 } 381 382 .statusdot-break-slot-actions{ 383 display:flex; 384 align-items:center; 385 gap:8px; 386 } 387 .statusdot-break-remove{ 388 margin-left:auto; 389 } 390 .statusdot-break-slot .button-link-delete{ 391 text-decoration:none; 392 } 393 #statusdot-tab-analytics .statusdot-status-icon{ 394 vertical-align:middle; 395 } 396 397 .statusdot-schedule-tabs{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:14px 0;} 398 .statusdot-schedule-tab.is-active{background:#2271b1;border-color:#2271b1;color:#fff;} 399 .statusdot-schedule-panel{margin-bottom:18px;} 400 .statusdot-schedule-panel[style*="display:none"]{margin-bottom:0;} 401 402 403 .statusdot-schedule-tab{display:inline-flex;align-items:center;gap:0;} 404 .statusdot-schedule-tab-label{display:inline-block;} 405 .statusdot-schedule-tab-state{display:inline-block;opacity:.8;} 406 .statusdot-schedule-tab.is-active .statusdot-schedule-tab-state{opacity:.95;} 407 408 409 .statusdot-analytics-hours-details{ 410 text-align:left; 411 } 412 .statusdot-analytics-hours-summary{ 413 cursor:pointer; 414 font-weight:600; 415 list-style:none; 416 } 417 .statusdot-analytics-hours-summary::-webkit-details-marker{ 418 display:none; 419 } 420 .statusdot-analytics-hours-summary::before{ 421 content:'▸'; 422 display:inline-block; 423 margin-right:6px; 424 color:#646970; 425 } 426 .statusdot-analytics-hours-details[open] .statusdot-analytics-hours-summary::before{ 427 content:'▾'; 428 } 429 .statusdot-analytics-hours-details-body{ 430 margin-top:8px; 431 } 432 .statusdot-analytics-hours-line + .statusdot-analytics-hours-line{ 433 margin-top:4px; 434 } 435 .statusdot-activity-log-settings-form, 436 .statusdot-activity-log-filter-form{ 437 display:flex; 438 gap:12px; 439 align-items:flex-end; 440 flex-wrap:wrap; 441 margin-bottom:12px; 442 } 443 .statusdot-field-label{ 444 display:block; 445 font-weight:600; 446 margin-bottom:4px; 447 } 448 .statusdot-activity-log-settings-note{ 449 color:#646970; 450 margin-bottom:4px; 451 } 452 .statusdot-activity-log-settings-actions{ 453 display:flex; 454 gap:8px; 455 flex-wrap:wrap; 456 } 457 .statusdot-activity-log-footer{ 458 display:flex; 459 justify-content:space-between; 460 align-items:center; 461 gap:12px; 462 flex-wrap:wrap; 463 margin-top:12px; 464 } 465 .statusdot-activity-log-count{ 466 color:#646970; 467 } 468 .statusdot-activity-log-pagination{ 469 display:flex; 470 gap:6px; 471 flex-wrap:wrap; 472 } 473 .statusdot-mail-grid{ 474 display:grid; 475 gap:12px; 476 max-width:980px; 477 } 478 .statusdot-mail-grid--two{ 479 grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); 480 } 481 .statusdot-mail-grid--smtp{ 482 grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); 483 } 484 .statusdot-mail-grid label{ 485 display:block; 486 } 487 .statusdot-mail-grid input[type="text"], 488 .statusdot-mail-grid input[type="email"], 489 .statusdot-mail-grid input[type="password"], 490 .statusdot-mail-grid input[type="number"], 491 .statusdot-mail-grid select{ 492 width:100%; 493 max-width:100%; 494 } 495 .statusdot-mail-smtp-box{ 496 max-width:980px; 497 padding:14px; 498 border:1px solid #dcdcde; 499 border-radius:12px; 500 background:#fbfbfc; 501 } 502 .statusdot-mail-smtp-toggle{ 503 display:inline-flex; 504 align-items:center; 505 gap:8px; 506 margin-bottom:12px; 507 font-weight:600; 508 } 509 @media (max-width: 782px){ 510 .statusdot-activity-log-footer{ 511 align-items:flex-start; 512 } 513 } -
statusdot/trunk/assets/css/statusdot-status.css
r3476743 r3483265 86 86 text-transform: var(--statusdot-text-transform, inherit); 87 87 color: var(--statusdot-text-color, inherit); 88 } 89 .statusdot-wrap.statusdot-wrap--light-text .statusdot-status-text, 90 .statusdot-wrap.statusdot-wrap--light-text .statusdot-status-countdown, 91 .statusdot-wrap.statusdot-wrap--light-text .statusdot-sep{ 92 color:#ffffff !important; 88 93 } 89 94 .statusdot-wrap .statusdot-label { margin-left: 0; font-size: 13px; line-height: 1; } -
statusdot/trunk/assets/js/statusdot-admin.js
r3474528 r3483265 10 10 } 11 11 12 function qsa(sel, root) { 13 return Array.prototype.slice.call((root || document).querySelectorAll(sel)); 14 } 15 16 function toMinutes(value) { 17 if (!value || !/^\d{2}:\d{2}$/.test(value)) return null; 18 var parts = value.split(':'); 19 return (parseInt(parts[0], 10) * 60) + parseInt(parts[1], 10); 20 } 21 22 function normalizeWindowMinutes(openM, closeM, startM, endM) { 23 if (openM === null || closeM === null || startM === null || endM === null) return null; 24 var overnight = closeM <= openM; 25 if (overnight) { 26 closeM += 1440; 27 if (startM < openM) startM += 1440; 28 if (endM < openM) endM += 1440; 29 } 30 return { openM: openM, closeM: closeM, startM: startM, endM: endM }; 31 } 32 33 function setRowMessage(row, message) { 34 var msgEl = row.querySelector('.statusdot-break-error-msg'); 35 if (!msgEl) return; 36 msgEl.textContent = message || ''; 37 msgEl.style.display = message ? 'block' : 'none'; 38 } 39 40 function setSlotError(slot, message) { 41 qsa('.statusdot-break-time', slot).forEach(function (input) { 42 input.classList.toggle('statusdot-field-error', !!message); 43 if (typeof input.setCustomValidity === 'function') { 44 input.setCustomValidity(message || ''); 45 } 46 }); 47 } 48 49 function getBreakList(row) { 50 return row.querySelector('.statusdot-break-slot-list') || row.querySelector('.statusdot-break-slots'); 51 } 52 53 function getMaxSlots(row) { 54 var wrap = row.querySelector('.statusdot-break-slots'); 55 var raw = wrap ? parseInt(wrap.getAttribute('data-break-max') || '8', 10) : 8; 56 return Math.max(1, isNaN(raw) ? 8 : raw); 57 } 58 59 function nextBreakIndex(row) { 60 var max = -1; 61 qsa('.statusdot-break-slot', row).forEach(function (slot) { 62 var idx = parseInt(slot.getAttribute('data-break-slot') || '0', 10); 63 if (!isNaN(idx) && idx > max) max = idx; 64 }); 65 return max + 1; 66 } 67 68 function renumberBreakSlots(row) { 69 qsa('.statusdot-break-slot', row).forEach(function (slot, i) { 70 var label = slot.querySelector('.statusdot-break-slot-label'); 71 if (label) label.textContent = 'Break ' + (i + 1); 72 }); 73 } 74 75 function addBreakSlot(row) { 76 var wrap = row.querySelector('.statusdot-break-slots'); 77 var list = getBreakList(row); 78 var tpl = wrap ? wrap.querySelector('.statusdot-break-slot-template') : null; 79 if (!wrap || !list || !tpl) return; 80 if (qsa('.statusdot-break-slot', row).length >= getMaxSlots(row)) return; 81 var idx = nextBreakIndex(row); 82 var number = qsa('.statusdot-break-slot', row).length + 1; 83 var html = (tpl.innerHTML || '') 84 .replace(/__INDEX__/g, String(idx)) 85 .replace(/__NUMBER__/g, String(number)); 86 var tmp = document.createElement('div'); 87 tmp.innerHTML = html.trim(); 88 if (!tmp.firstElementChild) return; 89 list.appendChild(tmp.firstElementChild); 90 renumberBreakSlots(row); 91 updateBreakFields(); 92 } 93 94 function clearBreakSlot(slot) { 95 var checkbox = slot.querySelector('.statusdot-break-enabled'); 96 if (checkbox) checkbox.checked = false; 97 qsa('.statusdot-break-time', slot).forEach(function (input) { input.value = ''; }); 98 } 99 100 function removeBreakSlot(slot) { 101 var row = slot.closest('tr'); 102 if (!row) return; 103 var slots = qsa('.statusdot-break-slot', row); 104 if (slots.length <= 1) { 105 clearBreakSlot(slot); 106 } else { 107 slot.remove(); 108 } 109 renumberBreakSlots(row); 110 updateBreakFields(); 111 } 112 113 function validateBreakRows() { 114 var checkedMode = document.querySelector('input[name="status_mode"]:checked'); 115 var currentMode = checkedMode ? checkedMode.value : 'normal'; 116 var breakModeSupported = currentMode === 'normal' || currentMode === 'open_247'; 117 var breaksToggle = document.getElementById('statusdot-breaks-enabled'); 118 var breaksEnabled = !!(breaksToggle && breaksToggle.checked); 119 120 qsa('#statusdot-free-weekly-table tbody tr').forEach(function (row) { 121 var openInput = row.querySelector('input[name^="open_hour"]'); 122 var closeInput = row.querySelector('input[name^="close_hour"]'); 123 var closedInput = row.querySelector('input[name^="day_closed"]'); 124 var openM = toMinutes(openInput ? openInput.value : ''); 125 var closeM = toMinutes(closeInput ? closeInput.value : ''); 126 var rowMessage = ''; 127 var activeRanges = []; 128 129 qsa('.statusdot-break-slot', row).forEach(function (slot) { 130 var rowToggle = slot.querySelector('.statusdot-break-enabled'); 131 var startInput = slot.querySelector('input[name^="break_start"]'); 132 var endInput = slot.querySelector('input[name^="break_end"]'); 133 var rowEnabled = !!(rowToggle && rowToggle.checked); 134 var slotMessage = ''; 135 var startM = toMinutes(startInput ? startInput.value : ''); 136 var endM = toMinutes(endInput ? endInput.value : ''); 137 138 if (breakModeSupported && breaksEnabled && rowEnabled) { 139 if ((startInput && startInput.value) || (endInput && endInput.value)) { 140 if (startM === null || endM === null) { 141 slotMessage = 'Enter both break times in HH:MM.'; 142 } else if (currentMode === 'open_247') { 143 if (startM >= endM) { 144 slotMessage = 'Break time must stay within that day and end after it starts.'; 145 } else { 146 activeRanges.push({ slot: slot, start: startM, end: endM }); 147 } 148 } else if (closedInput && closedInput.checked) { 149 slotMessage = 'Breaks are not allowed on a closed day.'; 150 } else { 151 var normalized = normalizeWindowMinutes(openM, closeM, startM, endM); 152 if (!normalized) { 153 slotMessage = 'Break time must stay within the opening hours for this day.'; 154 } else if (normalized.startM >= normalized.endM || normalized.startM < normalized.openM || normalized.endM > normalized.closeM) { 155 slotMessage = 'Break time must stay within the opening hours for this day.'; 156 } else { 157 activeRanges.push({ slot: slot, start: normalized.startM, end: normalized.endM }); 158 } 159 } 160 } 161 } 162 163 setSlotError(slot, slotMessage); 164 if (!rowMessage && slotMessage) rowMessage = slotMessage; 165 }); 166 167 activeRanges.sort(function (a, b) { return a.start - b.start; }); 168 for (var i = 1; i < activeRanges.length; i++) { 169 if (activeRanges[i].start < activeRanges[i - 1].end) { 170 var overlapMessage = 'Break windows cannot overlap.'; 171 setSlotError(activeRanges[i - 1].slot, overlapMessage); 172 setSlotError(activeRanges[i].slot, overlapMessage); 173 if (!rowMessage) rowMessage = overlapMessage; 174 } 175 } 176 177 setRowMessage(row, rowMessage); 178 }); 179 } 180 181 function toggleBreakWarningFields() { 182 var breakWarningToggle = document.getElementById('statusdot-break-warning-enabled'); 183 var breakWarningMinutes = document.getElementById('statusdot-break-warning-minutes'); 184 var breakWarningContent = document.getElementById('statusdot-break-warning-content'); 185 var breakWarningDisplay = document.getElementById('statusdot-break-warning-display'); 186 var breakWarningRotate = document.getElementById('statusdot-break-warning-rotate'); 187 var checkedMode = document.querySelector('input[name="status_mode"]:checked'); 188 var breakModeSupported = !!(checkedMode && (checkedMode.value === 'normal' || checkedMode.value === 'open_247')); 189 var breaksToggle = document.getElementById('statusdot-breaks-enabled'); 190 var breaksEnabled = !!(breaksToggle && breaksToggle.checked); 191 var warningEnabled = !!(breakWarningToggle && breakWarningToggle.checked && breakModeSupported && breaksEnabled); 192 193 [breakWarningMinutes, breakWarningContent, breakWarningDisplay].forEach(function (el) { 194 if (el) el.disabled = !warningEnabled; 195 }); 196 if (breakWarningRotate) { 197 breakWarningRotate.disabled = !(warningEnabled && breakWarningDisplay && breakWarningDisplay.value === 'rotate'); 198 } 199 } 200 201 function updateBreakFields() { 202 var checkedMode = document.querySelector('input[name="status_mode"]:checked'); 203 var breakModeSupported = !!(checkedMode && (checkedMode.value === 'normal' || checkedMode.value === 'open_247')); 204 var breaksToggle = document.getElementById('statusdot-breaks-enabled'); 205 var breaksEnabled = !!(breaksToggle && breaksToggle.checked); 206 207 qsa('#statusdot-free-weekly-table tbody tr').forEach(function (row) { 208 qsa('.statusdot-break-slot', row).forEach(function (slot) { 209 var rowToggle = slot.querySelector('.statusdot-break-enabled'); 210 var rowEnabled = !!(rowToggle && rowToggle.checked); 211 if (rowToggle) rowToggle.disabled = !breakModeSupported || !breaksEnabled; 212 qsa('.statusdot-break-time', slot).forEach(function (input) { 213 input.disabled = !(breakModeSupported && breaksEnabled && rowEnabled); 214 }); 215 }); 216 var addBtn = row.querySelector('.statusdot-break-add'); 217 if (addBtn) { 218 addBtn.disabled = !breakModeSupported || !breaksEnabled || qsa('.statusdot-break-slot', row).length >= getMaxSlots(row); 219 } 220 }); 221 222 validateBreakRows(); 223 toggleBreakWarningFields(); 224 } 225 12 226 domReady(function () { 13 // Free: collapse only the Free weekly rows.14 var freeRows = document.querySelectorAll('#statusdot-free-weekly-table tbody tr');15 var modeRadios = document.querySelectorAll('input[name="status_mode"]');227 var freeRows = qsa('#statusdot-free-weekly-table tbody tr'); 228 var modeRadios = qsa('input[name="status_mode"]'); 229 var form = document.querySelector('form'); 16 230 17 231 function toggleFreeRows() { … … 19 233 var checked = document.querySelector('input[name="status_mode"]:checked'); 20 234 var val = checked ? checked.value : 'normal'; 21 var show = (val === 'normal');235 var show = (val !== 'closed'); 22 236 freeRows.forEach(function (row) { row.style.display = show ? '' : 'none'; }); 237 updateBreakFields(); 23 238 } 24 239 25 240 modeRadios.forEach(function (r) { r.addEventListener('change', toggleFreeRows); }); 241 document.addEventListener('change', function (e) { 242 if (e.target.matches('#statusdot-breaks-enabled, #statusdot-break-warning-enabled, #statusdot-break-warning-display, .statusdot-break-enabled, #statusdot-free-weekly-table input[type="time"], #statusdot-free-weekly-table input[type="checkbox"]')) { 243 updateBreakFields(); 244 } 245 }); 246 document.addEventListener('input', function (e) { 247 if (e.target.matches('#statusdot-free-weekly-table input[type="time"]')) { 248 validateBreakRows(); 249 } 250 }); 251 document.addEventListener('click', function (e) { 252 var addBtn = e.target.closest('.statusdot-break-add'); 253 if (addBtn && addBtn.closest('#statusdot-free-weekly-table')) { 254 e.preventDefault(); 255 var row = addBtn.closest('tr'); 256 if (row) addBreakSlot(row); 257 return; 258 } 259 var removeBtn = e.target.closest('.statusdot-break-remove'); 260 if (removeBtn && removeBtn.closest('#statusdot-free-weekly-table')) { 261 e.preventDefault(); 262 var slot = removeBtn.closest('.statusdot-break-slot'); 263 if (slot) removeBreakSlot(slot); 264 } 265 }); 266 267 if (form) { 268 form.addEventListener('submit', function (e) { 269 validateBreakRows(); 270 var invalid = form.querySelector('.statusdot-field-error'); 271 if (invalid && typeof invalid.reportValidity === 'function') { 272 e.preventDefault(); 273 invalid.reportValidity(); 274 invalid.focus(); 275 return; 276 } 277 qsa('.statusdot-break-enabled, .statusdot-break-time, #statusdot-break-warning-minutes, #statusdot-break-warning-content, #statusdot-break-warning-display, #statusdot-break-warning-rotate', form).forEach(function (field) { 278 if (field && field.disabled) field.disabled = false; 279 }); 280 }); 281 } 282 283 freeRows.forEach(renumberBreakSlots); 26 284 toggleFreeRows(); 27 285 toggleBreakWarningFields(); 28 286 }); 29 287 })(); … … 44 302 var url = btn.getAttribute('data-statusdot-dismiss-url'); 45 303 if (!url) return; 46 // Fire-and-forget request; the server sets the dismissed flag and redirects back.47 304 try { 48 305 fetch(url, { credentials: 'same-origin' }).then(function(){ … … 50 307 if (notice) notice.style.display = 'none'; 51 308 }).catch(function(){ 52 // Fallback: navigate to URL if fetch fails.53 309 window.location.href = url; 54 310 }); … … 59 315 }); 60 316 })(); 317 318 319 // Live status badges on the Free settings page. 320 (function(){ 321 'use strict'; 322 function onReady(fn){ 323 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); 324 else fn(); 325 } 326 function qs(sel, root){ return (root || document).querySelector(sel); } 327 function modeStateHtml(payload){ 328 var reason = String((payload && payload.reason) || '').toLowerCase(); 329 if (reason === 'break') return '<span style="color:#dba617;font-weight:600;">(Break)</span>'; 330 if (reason === 'idle' || reason === 'idle_override') return '<span style="color:#dba617;font-weight:600;">(Idle)</span>'; 331 if (reason === 'busy') return '<span style="color:#dba617;font-weight:600;">(Busy)</span>'; 332 if (reason === 'forced_closed' || reason === 'closed') return '<span style="color:#d63638;font-weight:600;">(Closed)</span>'; 333 return '<span style="color:#00a32a;font-weight:600;">(Open)</span>'; 334 } 335 function weeklyStateHtml(payload){ 336 var reason = String((payload && payload.reason) || '').toLowerCase(); 337 var checked = document.querySelector('input[name="status_mode"]:checked'); 338 var mode = checked ? checked.value : 'normal'; 339 if (reason === 'idle' || reason === 'idle_override') return '<span style="color:#dba617;font-weight:600;">(Idle override is active)</span>'; 340 if (reason === 'break') return '<span style="color:#dba617;font-weight:600;">(Break time is active)</span>'; 341 if (reason === 'busy') return '<span style="color:#dba617;font-weight:600;">(Active - Busy Mode Enabled)</span>'; 342 if (mode === 'closed' || reason === 'forced_closed' || reason === 'closed') return '<span style="color:#d63638;font-weight:600;">(Inactive)</span>'; 343 return '<span style="color:#00a32a;font-weight:600;">(Active)</span>'; 344 } 345 function idleHeadingHtml(active){ 346 return active ? '<span style="color:#00a32a;font-weight:600;">(Active)</span>' : '<span style="color:#d63638;font-weight:600;">(Inactive)</span>'; 347 } 348 function idleCanStart(payload){ 349 var reason = String((payload && payload.reason) || '').toLowerCase(); 350 return !(reason === 'forced_closed' || reason === 'closed'); 351 } 352 function updateIdleStartAvailability(payload){ 353 var startButton = qs('.statusdot-idle-start-button'); 354 var help = qs('.statusdot-idle-start-help'); 355 var allowed = idleCanStart(payload); 356 if (startButton) startButton.disabled = !allowed; 357 if (help) help.style.display = allowed ? 'none' : ''; 358 } 359 function refresh(){ 360 if (window.StatusDotAdminPro) return; 361 if (!window.StatusDotAdmin || !window.StatusDotAdmin.ajax_url) return; 362 var fd = new window.FormData(); 363 fd.append('action', 'statusdot_get_status'); 364 fd.append('nonce', window.StatusDotAdmin.nonce || ''); 365 window.fetch(window.StatusDotAdmin.ajax_url, { method:'POST', credentials:'same-origin', body: fd }) 366 .then(function(r){ return r.json(); }) 367 .then(function(res){ 368 if (!res || !res.success || !res.data) return; 369 var payload = res.data; 370 var modeState = qs('.statusdot-live-statusmode-state'); 371 var weeklyState = qs('.statusdot-live-weekly-state'); 372 var idleHeading = qs('.statusdot-idle-live-heading-state'); 373 var idlePill = qs('.statusdot-idle-live-pill'); 374 var reason = String((payload && payload.reason) || '').toLowerCase(); 375 var idleActive = !!(payload && !payload.is_break && (reason === 'idle' || reason === 'idle_override' || payload.is_idle)); 376 if (modeState) modeState.innerHTML = modeStateHtml(payload); 377 if (weeklyState) weeklyState.innerHTML = weeklyStateHtml(payload); 378 if (idleHeading) idleHeading.innerHTML = idleHeadingHtml(idleActive); 379 if (idlePill) { 380 idlePill.textContent = idleActive ? 'Idle is active' : 'Idle is not active'; 381 idlePill.classList.toggle('statusdot-pill--active', idleActive); 382 } 383 updateIdleStartAvailability(payload); 384 }).catch(function(){}); 385 } 386 onReady(function(){ 387 if (window.StatusDotAdminPro) return; 388 refresh(); 389 setInterval(refresh, 5000); 390 }); 391 })(); -
statusdot/trunk/assets/js/statusdot-frontend.js
r3476743 r3483265 12 12 function qsa(sel) { 13 13 return Array.prototype.slice.call(document.querySelectorAll(sel)); 14 } 15 16 function normalizeVariantList(rawVariants) { 17 if (!Array.isArray(rawVariants)) return []; 18 return rawVariants.map(function (variant) { 19 var next = variant || {}; 20 var endMs = parseInt(next.countdown_end_ms || '0', 10); 21 if (!endMs && typeof next.countdown_seconds !== 'undefined' && next.countdown_seconds !== null) { 22 var secs = parseInt(next.countdown_seconds, 10); 23 if (!isNaN(secs) && secs > 0) { 24 endMs = Date.now() + (secs * 1000); 25 } 26 } 27 return { 28 text: next.text || '', 29 countdown: next.countdown || '', 30 countdown_end_ms: (!isNaN(endMs) && endMs > 0) ? endMs : 0 31 }; 32 }); 33 } 34 35 function getActiveWrapVariant(wrap) { 36 var variants = wrap && wrap._statusdotDisplayVariants; 37 if (!variants || !variants.length) return null; 38 if (variants.length === 1) return variants[0]; 39 var rotateSeconds = parseInt(wrap._statusdotRotateSeconds || '0', 10); 40 if (isNaN(rotateSeconds) || rotateSeconds <= 0) rotateSeconds = 4; 41 var idx = Math.floor(Date.now() / 1000 / rotateSeconds) % variants.length; 42 return variants[idx] || variants[0]; 43 } 44 45 function renderWrapVariant(wrap) { 46 if (!wrap) return; 47 var variant = getActiveWrapVariant(wrap); 48 if (!variant) return; 49 var textNode = wrap.querySelector('.statusdot-status-text'); 50 var cdNode = wrap.querySelector('.statusdot-status-countdown'); 51 var sepNode = wrap.querySelector('.statusdot-sep'); 52 if (textNode) { 53 textNode.textContent = variant.text || ''; 54 textNode.style.display = (variant.text && variant.text.trim()) ? '' : 'none'; 55 } 56 if (cdNode) { 57 var diff = 0; 58 if (variant.countdown_end_ms) { 59 diff = Math.max(0, Math.round((variant.countdown_end_ms - Date.now()) / 1000)); 60 } 61 cdNode.textContent = diff > 0 ? fmtHMS(diff) : (variant.countdown || ''); 62 cdNode.style.display = (cdNode.textContent && cdNode.textContent.trim()) ? '' : 'none'; 63 if (variant.countdown_end_ms && diff === 0 && !wrap.getAttribute('data-statusdot-boundary-refresh')) { 64 wrap.setAttribute('data-statusdot-boundary-refresh', '1'); 65 setTimeout(function(){ 66 if (window.StatusDotForceRefresh) { 67 try { window.StatusDotForceRefresh(); } catch (e) {} 68 } 69 }, 150); 70 } 71 if (variant.countdown_end_ms && diff > 0) { 72 wrap.removeAttribute('data-statusdot-boundary-refresh'); 73 } 74 } 75 if (sepNode) { 76 var hasText = textNode && textNode.textContent && textNode.textContent.trim(); 77 var hasCd = cdNode && cdNode.textContent && cdNode.textContent.trim(); 78 sepNode.style.display = (hasText && hasCd) ? '' : 'none'; 79 } 14 80 } 15 81 … … 38 104 var labelEls = qsa('[data-statusdot-label-for="' + schedule + '"]'); 39 105 labelEls.forEach(function (le) { le.textContent = data.label || ''; }); 40 // inline text + countdown ( Pro output)106 // inline text + countdown (Free/Pro output) 41 107 if (wrap) { 42 var textNode = wrap.querySelector('.statusdot-status-text'); 43 if (textNode && typeof data.text !== 'undefined') { 44 textNode.textContent = data.text || ''; 45 if (data.text) { textNode.style.display = ''; } else { textNode.style.display = 'none'; } 46 } 47 var cdNode = wrap.querySelector('.statusdot-status-countdown'); 48 if (cdNode && (typeof data.countdown !== 'undefined' || typeof data.countdown_seconds !== 'undefined')) { 49 // prefer seconds for live ticking 50 if (typeof data.countdown_seconds !== 'undefined' && data.countdown_seconds !== null) { 51 var secs = parseInt(data.countdown_seconds, 10); 52 if (!isNaN(secs)) { 53 wrap.setAttribute('data-statusdot-countdown-end', String(Date.now() + (secs * 1000))); 54 } 55 } 56 if (typeof data.countdown !== 'undefined') { 57 cdNode.textContent = data.countdown || ''; 58 } else { 59 cdNode.textContent = cdNode.textContent || ''; 60 } 61 62 // Hide countdown when empty 63 if (!cdNode.textContent || !cdNode.textContent.trim()) { 64 cdNode.style.display = 'none'; 65 wrap.removeAttribute('data-statusdot-countdown-end'); 66 } else { 67 cdNode.style.display = ''; 68 } 69 70 // Separator visibility (Free/Pro) 71 // NOTE: 72 // The separator BETWEEN status text and the countdown time is always a plain space 73 // rendered by the server as " " inside .statusdot-sep. 74 // The configurable separator ( - / — / | / • ) is part of the status text itself. 75 var sepNode = wrap.querySelector('.statusdot-sep'); 76 if (sepNode) { 77 var tnode = wrap.querySelector('.statusdot-status-text'); 78 var hasText = tnode && tnode.textContent && tnode.textContent.trim(); 79 var hasCd = cdNode && cdNode.textContent && cdNode.textContent.trim(); 80 sepNode.style.display = (hasText && hasCd) ? '' : 'none'; 81 } 82 83 // Fallback: if server only returned a formatted countdown (HH:MM:SS / MM:SS), 84 // parse it and enable local 1s ticking. 85 if (!wrap.getAttribute('data-statusdot-countdown-end')) { 86 var t = (cdNode.textContent || '').trim(); 87 var m = t.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/); 88 if (m) { 89 var hh = parseInt(m[1] || '0', 10); 90 var mm = parseInt(m[2] || '0', 10); 91 var ss = parseInt(m[3] || '0', 10); 92 var total = (hh * 3600) + (mm * 60) + ss; 93 if (!isNaN(total) && total >= 0) { 94 wrap.setAttribute('data-statusdot-countdown-end', String(Date.now() + (total * 1000))); 108 if (Array.isArray(data.display_variants) && data.display_variants.length) { 109 wrap._statusdotDisplayVariants = normalizeVariantList(data.display_variants); 110 wrap._statusdotRotateSeconds = parseInt(data.display_rotation_seconds || '0', 10) || 0; 111 wrap.removeAttribute('data-statusdot-countdown-end'); 112 renderWrapVariant(wrap); 113 } else { 114 wrap._statusdotDisplayVariants = null; 115 wrap._statusdotRotateSeconds = 0; 116 117 var textNode = wrap.querySelector('.statusdot-status-text'); 118 if (textNode && typeof data.text !== 'undefined') { 119 textNode.textContent = data.text || ''; 120 if (data.text) { textNode.style.display = ''; } else { textNode.style.display = 'none'; } 121 } 122 var cdNode = wrap.querySelector('.statusdot-status-countdown'); 123 if (cdNode && (typeof data.countdown !== 'undefined' || typeof data.countdown_seconds !== 'undefined')) { 124 var endMs = parseInt(data.countdown_end_ms || '0', 10); 125 if (!endMs && typeof data.countdown_seconds !== 'undefined' && data.countdown_seconds !== null) { 126 var secs = parseInt(data.countdown_seconds, 10); 127 if (!isNaN(secs) && secs > 0) { 128 endMs = Date.now() + (secs * 1000); 95 129 } 96 130 } 97 } 98 99 if (data.countdown || cdNode.textContent) { cdNode.style.display = ''; } else { cdNode.style.display = 'none'; } 100 } 101 102 // separator visibility handled above 131 if (!isNaN(endMs) && endMs > 0) { 132 wrap.setAttribute('data-statusdot-countdown-end', String(endMs)); 133 var diff = Math.max(0, Math.round((endMs - Date.now()) / 1000)); 134 cdNode.textContent = diff > 0 ? fmtHMS(diff) : ''; 135 } else if (typeof data.countdown !== 'undefined') { 136 cdNode.textContent = data.countdown || ''; 137 } else { 138 cdNode.textContent = cdNode.textContent || ''; 139 } 140 141 // Hide countdown when empty 142 if (!cdNode.textContent || !cdNode.textContent.trim()) { 143 cdNode.style.display = 'none'; 144 wrap.removeAttribute('data-statusdot-countdown-end'); 145 wrap.removeAttribute('data-statusdot-boundary-refresh'); 146 } else { 147 cdNode.style.display = ''; 148 wrap.removeAttribute('data-statusdot-boundary-refresh'); 149 } 150 151 var sepNode = wrap.querySelector('.statusdot-sep'); 152 if (sepNode) { 153 var tnode = wrap.querySelector('.statusdot-status-text'); 154 var hasText = tnode && tnode.textContent && tnode.textContent.trim(); 155 var hasCd = cdNode && cdNode.textContent && cdNode.textContent.trim(); 156 sepNode.style.display = (hasText && hasCd) ? '' : 'none'; 157 } 158 159 if (!wrap.getAttribute('data-statusdot-countdown-end')) { 160 var t = (cdNode.textContent || '').trim(); 161 var m = t.match(/^(?:(\d+):)?(\d{1,2}):(\d{2})$/); 162 if (m) { 163 var hh = parseInt(m[1] || '0', 10); 164 var mm = parseInt(m[2] || '0', 10); 165 var ss = parseInt(m[3] || '0', 10); 166 var total = (hh * 3600) + (mm * 60) + ss; 167 if (!isNaN(total) && total >= 0) { 168 wrap.setAttribute('data-statusdot-countdown-end', String(Date.now() + (total * 1000))); 169 } 170 } 171 } 172 173 if (data.countdown || cdNode.textContent) { cdNode.style.display = ''; } else { cdNode.style.display = 'none'; } 174 } 175 } 103 176 } 104 177 … … 124 197 125 198 function getMinRefreshSeconds(els) { 126 var min = 30;199 var min = 20; 127 200 els.forEach(function (el) { 128 var v = parseInt(el.getAttribute('data-statusdot-refresh') || ' 30', 10);201 var v = parseInt(el.getAttribute('data-statusdot-refresh') || '20', 10); 129 202 if (!isNaN(v) && v > 0) min = Math.min(min, v); 130 203 }); 131 204 // We tick countdown locally in Pro; keep network polling at a sane interval. 132 205 if (window.StatusDotData && StatusDotData.pro_nonce) { 133 return Math.max( 30, min);134 } 135 return Math.max( 5, min);206 return Math.max(15, min); 207 } 208 return Math.max(15, min); 136 209 137 210 } … … 194 267 }; 195 268 269 window.StatusDotForceRefresh = function(){ return tick(); }; 270 window.StatusDotRenderWrapVariant = renderWrapVariant; 196 271 tick(); 197 272 window.setInterval(tick, getMinRefreshSeconds(icons) * 1000); … … 223 298 224 299 function tickCountdowns() { 300 qsa('.statusdot-wrap').forEach(function(w){ 301 if (w && w._statusdotDisplayVariants && w._statusdotDisplayVariants.length) { 302 if (window.StatusDotRenderWrapVariant) { 303 window.StatusDotRenderWrapVariant(w); 304 } 305 } 306 }); 307 225 308 var wraps = qsa('.statusdot-wrap[data-statusdot-countdown-end]'); 226 309 wraps.forEach(function(w){ 310 if (w && w._statusdotDisplayVariants && w._statusdotDisplayVariants.length) return; 227 311 var endMs = parseInt(w.getAttribute('data-statusdot-countdown-end') || '0', 10); 228 312 if (!endMs) return; … … 232 316 if (diff < 0) diff = 0; 233 317 cdNode.textContent = diff > 0 ? fmtHMS(diff) : ''; 234 // Show/hide the spacer between text and countdown 318 if (diff === 0 && !w.getAttribute('data-statusdot-boundary-refresh')) { 319 w.setAttribute('data-statusdot-boundary-refresh', '1'); 320 setTimeout(function(){ 321 if (window.StatusDotForceRefresh) { 322 try { window.StatusDotForceRefresh(); } catch (e) {} 323 } 324 }, 150); 325 } 235 326 var sep = w.querySelector('.statusdot-sep'); 236 327 if (sep) { -
statusdot/trunk/includes/class-statusdot-opening-hours.php
r3476743 r3483265 20 20 const OPTION_SHOW_TIME_CLOSED = 'statusdot_show_time_closed'; 21 21 const OPTION_SEPARATOR_MODE = 'statusdot_separator_mode'; 22 const OPTION_LIGHT_TEXT = 'statusdot_use_light_text'; 22 23 23 24 // Idle override (Back in...) (Free/Pro) … … 26 27 const OPTION_IDLE_AFTER = 'statusdot_idle_after'; // schedule|open_247|closed|busy 27 28 29 // Break times (Free/Pro master; Pro has customizable text) 30 const OPTION_BREAKS_ENABLED = 'statusdot_breaks_enabled'; 31 const OPTION_BREAK_ENABLED_PREFIX = 'statusdot_break_enabled_'; 32 const OPTION_BREAK_START_PREFIX = 'statusdot_break_start_'; 33 const OPTION_BREAK_END_PREFIX = 'statusdot_break_end_'; 34 const OPTION_BREAKS_PREFIX = 'statusdot_breaks_'; 35 const BREAK_SLOTS_PER_DAY = 8; 36 37 // Pre-break warning (Free/Pro shared behavior; Pro adds customizable text only) 38 const OPTION_BREAK_WARNING_ENABLED = 'statusdot_break_warning_enabled'; 39 const OPTION_BREAK_WARNING_MINUTES = 'statusdot_break_warning_minutes'; 40 const OPTION_BREAK_WARNING_CONTENT = 'statusdot_break_warning_content'; // countdown|time|both 41 const OPTION_BREAK_WARNING_DISPLAY = 'statusdot_break_warning_display'; // warning_only|rotate|normal_only 42 const OPTION_BREAK_WARNING_ROTATE = 'statusdot_break_warning_rotate'; // seconds 28 43 29 44 public static function init() { … … 39 54 // Migration helper (reads old MU keys if present) 40 55 add_action('admin_init', [__CLASS__, 'maybe_migrate_old_options']); 56 add_action('admin_init', [__CLASS__, 'maybe_sync_from_saved_pro_default']); 41 57 } 42 58 … … 53 69 public static function days() { 54 70 return ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']; 71 } 72 73 74 public static function break_slot_count() : int { 75 return self::BREAK_SLOTS_PER_DAY; 76 } 77 78 public static function normalize_break_items( $raw_day ) : array { 79 $items = []; 80 81 if ( is_array( $raw_day ) && isset( $raw_day['items'] ) && is_array( $raw_day['items'] ) ) { 82 $items = $raw_day['items']; 83 } elseif ( is_array( $raw_day ) && ( isset( $raw_day['enabled'] ) || isset( $raw_day['start'] ) || isset( $raw_day['end'] ) ) ) { 84 $items = [ $raw_day ]; 85 } elseif ( is_array( $raw_day ) ) { 86 $items = $raw_day; 87 } 88 89 $normalized = []; 90 foreach ( array_values( $items ) as $item ) { 91 if ( ! is_array( $item ) ) { 92 continue; 93 } 94 $normalized[] = [ 95 'enabled' => ! empty( $item['enabled'] ) ? 1 : 0, 96 'start' => self::sanitize_optional_clock_time( $item['start'] ?? '' ), 97 'end' => self::sanitize_optional_clock_time( $item['end'] ?? '' ), 98 ]; 99 } 100 101 $max = max( 1, self::break_slot_count() ); 102 $normalized = array_slice( $normalized, 0, $max ); 103 104 while ( count( $normalized ) > 1 ) { 105 $last = end( $normalized ); 106 if ( ! is_array( $last ) || ! empty( $last['enabled'] ) || ! empty( $last['start'] ) || ! empty( $last['end'] ) ) { 107 break; 108 } 109 array_pop( $normalized ); 110 } 111 112 if ( empty( $normalized ) ) { 113 $normalized[] = [ 'enabled' => 0, 'start' => '', 'end' => '' ]; 114 } 115 116 return [ 'items' => array_values( $normalized ) ]; 117 } 118 119 private static function build_break_validation_range( array $item, bool $is_closed_today, string $open_time, string $close_time, string $mode ) : ?array { 120 $start = self::sanitize_optional_clock_time( $item['start'] ?? '' ); 121 $end = self::sanitize_optional_clock_time( $item['end'] ?? '' ); 122 if ( $start === '' || $end === '' ) { 123 return null; 124 } 125 126 if ( $mode === 'open_247' ) { 127 if ( ! self::full_day_break_window_is_valid( $start, $end ) ) { 128 return null; 129 } 130 return [ 131 'start' => self::time_to_minutes( $start ), 132 'end' => self::time_to_minutes( $end ), 133 ]; 134 } 135 136 if ( ! self::break_window_is_valid( $is_closed_today, $open_time, $close_time, $start, $end ) ) { 137 return null; 138 } 139 140 $open_m = self::time_to_minutes( $open_time ); 141 $close_m = self::time_to_minutes( $close_time ); 142 $start_m = self::time_to_minutes( $start ); 143 $end_m = self::time_to_minutes( $end ); 144 if ( $open_m === null || $close_m === null || $start_m === null || $end_m === null ) { 145 return null; 146 } 147 list( $open_m, $close_m ) = self::normalize_window_clock_minutes( $open_m, $close_m ); 148 $start_m = self::map_time_minutes_into_window( $start_m, $open_m, $close_m ); 149 $end_m = self::map_time_minutes_into_window( $end_m, $open_m, $close_m ); 150 if ( $start_m === null || $end_m === null || $start_m >= $end_m ) { 151 return null; 152 } 153 return [ 'start' => $start_m, 'end' => $end_m ]; 154 } 155 156 public static function validate_break_items_config( array $items, bool $is_closed_today, string $open_time, string $close_time, string $mode = 'normal' ) : array { 157 $errors = []; 158 $ranges = []; 159 160 foreach ( array_values( $items ) as $slot => $item ) { 161 if ( ! is_array( $item ) || empty( $item['enabled'] ) ) { 162 continue; 163 } 164 165 $start = self::sanitize_optional_clock_time( $item['start'] ?? '' ); 166 $end = self::sanitize_optional_clock_time( $item['end'] ?? '' ); 167 if ( $start === '' || $end === '' ) { 168 $errors[ $slot ] = __( 'Enter both break times in HH:MM.', 'statusdot' ); 169 continue; 170 } 171 172 $range = self::build_break_validation_range( [ 'start' => $start, 'end' => $end ], $is_closed_today, $open_time, $close_time, $mode ); 173 if ( ! is_array( $range ) ) { 174 $errors[ $slot ] = ( $mode === 'open_247' ) 175 ? __( 'Break time must stay within that day and end after it starts.', 'statusdot' ) 176 : __( 'Break time must stay within the opening hours for this day.', 'statusdot' ); 177 continue; 178 } 179 180 $ranges[] = [ 181 'slot' => $slot, 182 'start' => (int) $range['start'], 183 'end' => (int) $range['end'], 184 ]; 185 } 186 187 usort( $ranges, static function( $a, $b ) { 188 return (int) $a['start'] <=> (int) $b['start']; 189 } ); 190 191 for ( $i = 1; $i < count( $ranges ); $i++ ) { 192 $prev = $ranges[ $i - 1 ]; 193 $curr = $ranges[ $i ]; 194 if ( (int) $curr['start'] < (int) $prev['end'] ) { 195 $message = __( 'Break windows cannot overlap.', 'statusdot' ); 196 $errors[ (int) $prev['slot'] ] = $message; 197 $errors[ (int) $curr['slot'] ] = $message; 198 } 199 } 200 201 ksort( $errors ); 202 return $errors; 203 } 204 205 public static function get_break_items_for_day( string $day ) : array { 206 $stored = get_option( self::OPTION_BREAKS_PREFIX . $day, null ); 207 if ( is_array( $stored ) ) { 208 return self::normalize_break_items( $stored )['items']; 209 } 210 211 return self::normalize_break_items( [ 212 'enabled' => (int) get_option( self::OPTION_BREAK_ENABLED_PREFIX . $day, 0 ), 213 'start' => get_option( self::OPTION_BREAK_START_PREFIX . $day, '' ), 214 'end' => get_option( self::OPTION_BREAK_END_PREFIX . $day, '' ), 215 ] )['items']; 216 } 217 218 private static function first_enabled_break_item( array $items ) : array { 219 foreach ( $items as $item ) { 220 if ( ! empty( $item['enabled'] ) ) { 221 return [ 222 'enabled' => 1, 223 'start' => self::sanitize_optional_clock_time( $item['start'] ?? '' ), 224 'end' => self::sanitize_optional_clock_time( $item['end'] ?? '' ), 225 ]; 226 } 227 } 228 229 return [ 'enabled' => 0, 'start' => '', 'end' => '' ]; 230 } 231 232 private static function find_active_break_from_items( array $items, int $current_m, int $open_m, int $close_m, DateTimeImmutable $base_dt ) : ?array { 233 foreach ( $items as $item ) { 234 if ( empty( $item['enabled'] ) ) { 235 continue; 236 } 237 $start_m = self::map_time_minutes_into_window( self::time_to_minutes( $item['start'] ?? '' ), $open_m, $close_m ); 238 $end_m = self::map_time_minutes_into_window( self::time_to_minutes( $item['end'] ?? '' ), $open_m, $close_m ); 239 if ( $start_m === null || $end_m === null || $start_m >= $end_m ) { 240 continue; 241 } 242 if ( $current_m >= $start_m && $current_m < $end_m ) { 243 $end_dt = $base_dt->modify( '+' . $end_m . ' minutes' ); 244 return [ 245 'start' => self::sanitize_optional_clock_time( $item['start'] ?? '' ), 246 'end' => self::sanitize_optional_clock_time( $item['end'] ?? '' ), 247 'end_ts' => $end_dt->getTimestamp(), 248 'end_display' => self::format_display_time( $end_dt ), 249 ]; 250 } 251 } 252 253 return null; 254 } 255 256 private static function find_upcoming_break_warning_from_items( array $items, int $warning_minutes, DateTimeImmutable $now, int $open_m, int $close_m, DateTimeImmutable $base_dt ) : ?array { 257 $best = null; 258 foreach ( $items as $item ) { 259 if ( empty( $item['enabled'] ) ) { 260 continue; 261 } 262 $start_m = self::map_time_minutes_into_window( self::time_to_minutes( $item['start'] ?? '' ), $open_m, $close_m ); 263 $end_m = self::map_time_minutes_into_window( self::time_to_minutes( $item['end'] ?? '' ), $open_m, $close_m ); 264 if ( $start_m === null || $end_m === null || $start_m >= $end_m ) { 265 continue; 266 } 267 $start_dt = $base_dt->modify( '+' . $start_m . ' minutes' ); 268 $in_seconds = (int) max( 0, $start_dt->getTimestamp() - $now->getTimestamp() ); 269 if ( $in_seconds <= 0 || $in_seconds > ( $warning_minutes * 60 ) ) { 270 continue; 271 } 272 if ( $best === null || $in_seconds < $best['in_seconds'] ) { 273 $best = [ 274 'start' => self::sanitize_optional_clock_time( $item['start'] ?? '' ), 275 'start_ts' => $start_dt->getTimestamp(), 276 'start_display' => self::format_display_time( $start_dt ), 277 'in_seconds' => $in_seconds, 278 ]; 279 } 280 } 281 282 return $best; 283 } 284 285 286 public static function get_free_schedule_payload() : array { 287 $weekly = []; 288 $breaks = []; 289 foreach ( self::days() as $day ) { 290 $weekly[ $day ] = [ 291 'open' => self::sanitize_clock_time( get_option( self::OPTION_OPEN_PREFIX . $day, '09:00' ), '09:00' ), 292 'close' => self::sanitize_clock_time( get_option( self::OPTION_CLOSE_PREFIX . $day, '17:00' ), '17:00' ), 293 'closed' => (int) get_option( self::OPTION_DAY_CLOSED_PREFIX . $day, 0 ), 294 ]; 295 $breaks[ $day ] = [ 296 'items' => self::get_break_items_for_day( $day ), 297 ]; 298 } 299 300 $mode = get_option( self::OPTION_MODE, 'normal' ); 301 if ( ! in_array( $mode, [ 'normal', 'closed', 'open_247' ], true ) ) { 302 $mode = 'normal'; 303 } 304 305 return [ 306 'mode' => $mode, 307 'busy_manual' => (int) get_option( self::OPTION_BUSY, 0 ), 308 'breaks_enabled' => (int) get_option( self::OPTION_BREAKS_ENABLED, 0 ), 309 'weekly' => $weekly, 310 'breaks' => $breaks, 311 'break_warning' => self::get_break_warning_settings(), 312 ]; 313 } 314 315 public static function save_free_schedule_payload( array $payload, bool $bump_rev = true ) : void { 316 $normalized = self::normalize_free_schedule_payload( $payload ); 317 318 update_option( self::OPTION_MODE, $normalized['mode'] ); 319 update_option( self::OPTION_BUSY, $normalized['busy_manual'] ); 320 update_option( self::OPTION_BREAKS_ENABLED, $normalized['breaks_enabled'] ); 321 update_option( self::OPTION_BREAK_WARNING_ENABLED, $normalized['break_warning']['enabled'] ); 322 update_option( self::OPTION_BREAK_WARNING_MINUTES, $normalized['break_warning']['minutes'] ); 323 update_option( self::OPTION_BREAK_WARNING_CONTENT, $normalized['break_warning']['content'] ); 324 update_option( self::OPTION_BREAK_WARNING_DISPLAY, $normalized['break_warning']['display'] ); 325 update_option( self::OPTION_BREAK_WARNING_ROTATE, $normalized['break_warning']['rotate_seconds'] ); 326 327 foreach ( self::days() as $day ) { 328 $weekly = $normalized['weekly'][ $day ]; 329 $break = $normalized['breaks'][ $day ]; 330 $break_items = self::normalize_break_items( $break )['items']; 331 $first_break = self::first_enabled_break_item( $break_items ); 332 333 update_option( self::OPTION_DAY_CLOSED_PREFIX . $day, $weekly['closed'] ); 334 update_option( self::OPTION_OPEN_PREFIX . $day, $weekly['open'] ); 335 update_option( self::OPTION_CLOSE_PREFIX . $day, $weekly['close'] ); 336 update_option( self::OPTION_BREAKS_PREFIX . $day, [ 'items' => $break_items ] ); 337 update_option( self::OPTION_BREAK_ENABLED_PREFIX . $day, $first_break['enabled'] ); 338 update_option( self::OPTION_BREAK_START_PREFIX . $day, $first_break['start'] ); 339 update_option( self::OPTION_BREAK_END_PREFIX . $day, $first_break['end'] ); 340 } 341 342 self::maybe_sync_saved_pro_default_from_free_payload( $normalized ); 343 update_option( 'statusdot_free_pro_mirror_hash', self::free_schedule_payload_hash( $normalized ) ); 344 345 if ( $bump_rev ) { 346 update_option( 'statusdot_settings_rev', time() ); 347 } 348 } 349 350 public static function free_schedule_payload_hash( ?array $payload = null ) : string { 351 if ( $payload === null ) { 352 $payload = self::get_free_schedule_payload(); 353 } 354 $normalized = self::normalize_free_schedule_payload( $payload ); 355 return md5( wp_json_encode( $normalized ) ); 356 } 357 358 private static function maybe_sync_saved_pro_default_from_free_payload( array $payload ) : void { 359 $normalized = self::normalize_free_schedule_payload( $payload ); 360 $raw = get_option( 'statusdot_pro_schedules', null ); 361 if ( ! is_array( $raw ) ) { 362 return; 363 } 364 365 $default = []; 366 if ( isset( $raw['default'] ) && is_array( $raw['default'] ) ) { 367 $default = $raw['default']; 368 } 369 370 $default['mode'] = $normalized['mode']; 371 $default['busy_manual'] = $normalized['busy_manual']; 372 $default['breaks_enabled'] = $normalized['breaks_enabled']; 373 $default['weekly'] = $normalized['weekly']; 374 $default['breaks'] = $normalized['breaks']; 375 $default['break_warning'] = $normalized['break_warning']; 376 377 $raw['default'] = $default; 378 update_option( 'statusdot_pro_schedules', $raw ); 379 } 380 381 private static function normalize_free_schedule_payload( array $payload ) : array { 382 $weekly = []; 383 $breaks = []; 384 foreach ( self::days() as $day ) { 385 $weekly_raw = isset( $payload['weekly'][ $day ] ) && is_array( $payload['weekly'][ $day ] ) ? $payload['weekly'][ $day ] : []; 386 $break_raw = isset( $payload['breaks'][ $day ] ) && is_array( $payload['breaks'][ $day ] ) ? $payload['breaks'][ $day ] : []; 387 388 $weekly[ $day ] = [ 389 'open' => self::sanitize_clock_time( $weekly_raw['open'] ?? '09:00', '09:00' ), 390 'close' => self::sanitize_clock_time( $weekly_raw['close'] ?? '17:00', '17:00' ), 391 'closed' => ! empty( $weekly_raw['closed'] ) ? 1 : 0, 392 ]; 393 $breaks[ $day ] = self::normalize_break_items( $break_raw ); 394 } 395 396 $mode = isset( $payload['mode'] ) ? (string) $payload['mode'] : 'normal'; 397 if ( ! in_array( $mode, [ 'normal', 'closed', 'open_247' ], true ) ) { 398 $mode = 'normal'; 399 } 400 401 return [ 402 'mode' => $mode, 403 'busy_manual' => ! empty( $payload['busy_manual'] ) ? 1 : 0, 404 'breaks_enabled' => ! empty( $payload['breaks_enabled'] ) ? 1 : 0, 405 'weekly' => $weekly, 406 'breaks' => $breaks, 407 'break_warning' => self::normalize_break_warning_config( isset( $payload['break_warning'] ) && is_array( $payload['break_warning'] ) ? $payload['break_warning'] : [] ), 408 ]; 55 409 } 56 410 … … 129 483 } 130 484 485 /** 486 * Sanitize an optional clock time. Returns an empty string when blank. 487 */ 488 private static function sanitize_optional_clock_time( $value ) : string { 489 if ( $value === null ) { 490 return ''; 491 } 492 $raw = trim( (string) $value ); 493 if ( $raw === '' ) { 494 return ''; 495 } 496 if ( preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)$/' , $raw, $m ) ) { 497 return sprintf( '%02d:%02d', (int) $m[1], (int) $m[2] ); 498 } 499 return ''; 500 } 501 502 private static function time_to_minutes( string $time ) : ?int { 503 if ( ! preg_match( '/^(\d{2}):(\d{2})$/' , $time, $m ) ) { 504 return null; 505 } 506 return ( (int) $m[1] * 60 ) + (int) $m[2]; 507 } 508 509 private static function normalize_window_clock_minutes( int $open_m, int $close_m ) : array { 510 $overnight = ( $close_m <= $open_m ); 511 if ( $overnight ) { 512 $close_m += 1440; 513 } 514 return [ $open_m, $close_m, $overnight ]; 515 } 516 517 private static function map_time_minutes_into_window( ?int $time_m, int $open_m, int $close_m ) : ?int { 518 if ( $time_m === null ) { 519 return null; 520 } 521 if ( $close_m > 1440 && $time_m < $open_m ) { 522 $time_m += 1440; 523 } 524 return $time_m; 525 } 526 527 private static function get_free_day_schedule_for_date( DateTimeImmutable $date ) : array { 528 $site_id = get_current_blog_id(); 529 $day = strtolower( $date->format( 'l' ) ); 530 return [ 531 'day' => $day, 532 'closed' => (int) self::get_opt_with_fallback( self::OPTION_DAY_CLOSED_PREFIX . $day, 'opening_hours_day_closed_' . $day . '_' . $site_id, 0 ), 533 'open' => self::get_time_opt_with_fallback( self::OPTION_OPEN_PREFIX . $day, 'opening_hours_open_' . $day . '_' . $site_id, '09:00' ), 534 'close' => self::get_time_opt_with_fallback( self::OPTION_CLOSE_PREFIX . $day, 'opening_hours_close_' . $day . '_' . $site_id, '17:00' ), 535 ]; 536 } 537 538 private static function build_window_from_day_schedule( DateTimeImmutable $date, array $day_schedule ) : ?array { 539 $open_m_raw = self::time_to_minutes( (string) ( $day_schedule['open'] ?? '' ) ); 540 $close_m_raw = self::time_to_minutes( (string) ( $day_schedule['close'] ?? '' ) ); 541 if ( $open_m_raw === null || $close_m_raw === null ) { 542 return null; 543 } 544 list( $open_m, $close_m, $overnight ) = self::normalize_window_clock_minutes( $open_m_raw, $close_m_raw ); 545 $day_start = $date->setTime( 0, 0, 0 ); 546 return [ 547 'day' => (string) ( $day_schedule['day'] ?? strtolower( $date->format( 'l' ) ) ), 548 'day_start' => $day_start, 549 'open' => (string) ( $day_schedule['open'] ?? '09:00' ), 550 'close' => (string) ( $day_schedule['close'] ?? '17:00' ), 551 'open_m' => $open_m, 552 'close_m' => $close_m, 553 'overnight' => $overnight, 554 'open_dt' => $day_start->modify( '+' . $open_m_raw . ' minutes' ), 555 'close_dt' => $day_start->modify( '+' . $close_m . ' minutes' ), 556 ]; 557 } 558 559 private static function get_active_free_schedule_window( DateTimeImmutable $now, string $mode_option = 'normal' ) : ?array { 560 if ( $mode_option !== 'normal' ) { 561 return null; 562 } 563 foreach ( [ -1, 0 ] as $offset ) { 564 $date = ( $offset === 0 ) ? $now : $now->modify( $offset . ' day' ); 565 if ( ! $date instanceof DateTimeImmutable ) { 566 continue; 567 } 568 $day_schedule = self::get_free_day_schedule_for_date( $date ); 569 if ( ! empty( $day_schedule['closed'] ) ) { 570 continue; 571 } 572 $window = self::build_window_from_day_schedule( $date, $day_schedule ); 573 if ( $window && $now >= $window['open_dt'] && $now < $window['close_dt'] ) { 574 return $window; 575 } 576 } 577 return null; 578 } 579 580 private static function get_seconds_until_next_free_open( DateTimeImmutable $now ) : ?int { 581 $best = null; 582 for ( $i = 0; $i <= 7; $i++ ) { 583 $date = ( $i === 0 ) ? $now : $now->modify( '+' . $i . ' day' ); 584 if ( ! $date instanceof DateTimeImmutable ) { 585 continue; 586 } 587 $day_schedule = self::get_free_day_schedule_for_date( $date ); 588 if ( ! empty( $day_schedule['closed'] ) ) { 589 continue; 590 } 591 $window = self::build_window_from_day_schedule( $date, $day_schedule ); 592 if ( ! $window ) { 593 continue; 594 } 595 $diff = $window['open_dt']->getTimestamp() - $now->getTimestamp(); 596 if ( $diff > 0 && ( $best === null || $diff < $best ) ) { 597 $best = (int) $diff; 598 } 599 } 600 return $best; 601 } 602 603 private static function format_display_time( DateTimeImmutable $dt ) : string { 604 $fmt = get_option( 'time_format' ); 605 if ( ! is_string( $fmt ) || $fmt === '' ) { 606 $fmt = 'H:i'; 607 } 608 return wp_date( $fmt, $dt->getTimestamp(), $dt->getTimezone() ); 609 } 610 611 public static function get_break_warning_settings() : array { 612 return self::normalize_break_warning_config([ 613 'enabled' => (int) get_option( self::OPTION_BREAK_WARNING_ENABLED, 0 ), 614 'minutes' => (int) get_option( self::OPTION_BREAK_WARNING_MINUTES, 15 ), 615 'content' => (string) get_option( self::OPTION_BREAK_WARNING_CONTENT, 'both' ), 616 'display' => (string) get_option( self::OPTION_BREAK_WARNING_DISPLAY, 'rotate' ), 617 'rotate_seconds' => (int) get_option( self::OPTION_BREAK_WARNING_ROTATE, 4 ), 618 ]); 619 } 620 621 public static function normalize_break_warning_config( array $raw ) : array { 622 $content = isset( $raw['content'] ) ? sanitize_key( (string) $raw['content'] ) : 'both'; 623 if ( ! in_array( $content, [ 'countdown', 'time', 'both' ], true ) ) { 624 $content = 'both'; 625 } 626 627 $display = isset( $raw['display'] ) ? sanitize_key( (string) $raw['display'] ) : 'rotate'; 628 if ( ! in_array( $display, [ 'warning_only', 'rotate', 'normal_only' ], true ) ) { 629 $display = 'rotate'; 630 } 631 632 $minutes = isset( $raw['minutes'] ) ? (int) $raw['minutes'] : 15; 633 $minutes = max( 1, min( 1440, $minutes ) ); 634 635 $rotate = isset( $raw['rotate_seconds'] ) ? (int) $raw['rotate_seconds'] : 4; 636 $rotate = max( 1, min( 60, $rotate ) ); 637 638 return [ 639 'enabled' => ! empty( $raw['enabled'] ) ? 1 : 0, 640 'minutes' => $minutes, 641 'content' => $content, 642 'display' => $display, 643 'rotate_seconds' => $rotate, 644 ]; 645 } 646 647 private static function get_break_settings_for_day( string $day ) : array { 648 $items = self::get_break_items_for_day( $day ); 649 $first = self::first_enabled_break_item( $items ); 650 return [ 651 'global_enabled' => (int) get_option( self::OPTION_BREAKS_ENABLED, 0 ), 652 'items' => $items, 653 'enabled' => $first['enabled'], 654 'start' => $first['start'], 655 'end' => $first['end'], 656 ]; 657 } 658 659 private static function is_break_supported_mode( string $mode ) : bool { 660 return in_array( $mode, [ 'normal', 'open_247' ], true ); 661 } 662 663 private static function full_day_break_window_is_valid( string $break_start, string $break_end ) : bool { 664 $start_m = self::time_to_minutes( $break_start ); 665 $end_m = self::time_to_minutes( $break_end ); 666 if ( $start_m === null || $end_m === null ) { 667 return false; 668 } 669 return $start_m < $end_m; 670 } 671 672 private static function get_open_247_break_payload( DateTimeImmutable $now ) : ?array { 673 $day = strtolower( $now->format( 'l' ) ); 674 $break = self::get_break_settings_for_day( $day ); 675 if ( empty( $break['global_enabled'] ) ) { 676 return null; 677 } 678 $day_start = $now->setTime( 0, 0, 0 ); 679 $current_m = (int) floor( ( $now->getTimestamp() - $day_start->getTimestamp() ) / 60 ); 680 return self::find_active_break_from_items( (array) ( $break['items'] ?? [] ), $current_m, 0, 1440, $day_start ); 681 } 682 683 private static function get_open_247_pre_break_warning_payload( DateTimeImmutable $now ) : ?array { 684 $warning = self::get_break_warning_settings(); 685 if ( empty( $warning['enabled'] ) ) { 686 return null; 687 } 688 $day = strtolower( $now->format( 'l' ) ); 689 $break = self::get_break_settings_for_day( $day ); 690 if ( empty( $break['global_enabled'] ) ) { 691 return null; 692 } 693 $day_start = $now->setTime( 0, 0, 0 ); 694 $payload = self::find_upcoming_break_warning_from_items( (array) ( $break['items'] ?? [] ), (int) $warning['minutes'], $now, 0, 1440, $day_start ); 695 if ( ! is_array( $payload ) ) { 696 return null; 697 } 698 $payload['warning_config'] = $warning; 699 return $payload; 700 } 701 702 private static function break_window_is_valid( bool $is_closed_today, string $open_time, string $close_time, string $break_start, string $break_end ) : bool { 703 if ( $is_closed_today || $break_start === '' || $break_end === '' ) { 704 return false; 705 } 706 $open_m = self::time_to_minutes( $open_time ); 707 $close_m = self::time_to_minutes( $close_time ); 708 $start_m = self::time_to_minutes( $break_start ); 709 $end_m = self::time_to_minutes( $break_end ); 710 if ( $open_m === null || $close_m === null || $start_m === null || $end_m === null ) { 711 return false; 712 } 713 list( $open_m, $close_m ) = self::normalize_window_clock_minutes( $open_m, $close_m ); 714 $start_m = self::map_time_minutes_into_window( $start_m, $open_m, $close_m ); 715 $end_m = self::map_time_minutes_into_window( $end_m, $open_m, $close_m ); 716 if ( $start_m === null || $end_m === null || $start_m >= $end_m ) { 717 return false; 718 } 719 return ( $start_m >= $open_m && $end_m <= $close_m ); 720 } 721 722 private static function get_active_break_payload( DateTimeImmutable $now, array $window ) : ?array { 723 $day = (string) ( $window['day'] ?? '' ); 724 if ( $day === '' ) { 725 return null; 726 } 727 $break = self::get_break_settings_for_day( $day ); 728 if ( empty( $break['global_enabled'] ) ) { 729 return null; 730 } 731 $base_dt = $window['day_start']; 732 $current_m = (int) floor( ( $now->getTimestamp() - $base_dt->getTimestamp() ) / 60 ); 733 return self::find_active_break_from_items( (array) ( $break['items'] ?? [] ), $current_m, (int) $window['open_m'], (int) $window['close_m'], $base_dt ); 734 } 735 736 private static function get_pre_break_warning_payload( DateTimeImmutable $now, array $window ) : ?array { 737 $warning = self::get_break_warning_settings(); 738 if ( empty( $warning['enabled'] ) ) { 739 return null; 740 } 741 $day = (string) ( $window['day'] ?? '' ); 742 if ( $day === '' ) { 743 return null; 744 } 745 $break = self::get_break_settings_for_day( $day ); 746 if ( empty( $break['global_enabled'] ) ) { 747 return null; 748 } 749 $payload = self::find_upcoming_break_warning_from_items( (array) ( $break['items'] ?? [] ), (int) $warning['minutes'], $now, (int) $window['open_m'], (int) $window['close_m'], $window['day_start'] ); 750 if ( ! is_array( $payload ) ) { 751 return null; 752 } 753 $payload['warning_config'] = $warning; 754 return $payload; 755 } 756 757 private static function build_display_variant( string $text, ?int $countdown_seconds, string $countdown = '', int $countdown_end_ms = 0 ) : array { 758 $countdown_seconds = is_int( $countdown_seconds ) ? max( 0, $countdown_seconds ) : null; 759 if ( $countdown_seconds !== null && $countdown_seconds > 0 && $countdown_end_ms <= 0 ) { 760 $countdown_end_ms = (int) round( ( microtime( true ) + $countdown_seconds ) * 1000 ); 761 } 762 if ( $countdown_seconds !== null && $countdown_seconds > 0 && $countdown === '' ) { 763 $countdown = self::format_countdown_mmss( $countdown_seconds ); 764 } 765 return [ 766 'text' => $text, 767 'countdown' => $countdown, 768 'countdown_seconds' => ( $countdown_seconds !== null && $countdown_seconds > 0 ) ? $countdown_seconds : null, 769 'countdown_end_ms' => ( $countdown_end_ms > 0 ) ? $countdown_end_ms : 0, 770 ]; 771 } 772 773 private static function get_break_warning_text( string $content_style, string $time_display ) : string { 774 if ( 'countdown' === $content_style ) { 775 return __( 'We are going on break in', 'statusdot' ); 776 } 777 if ( 'time' === $content_style ) { 778 /* translators: %s: break start time */ 779 return sprintf( __( 'We are going on break at %s', 'statusdot' ), $time_display ); 780 } 781 /* translators: %s: break start time */ 782 return sprintf( __( 'We are going on break at %s', 'statusdot' ), $time_display ) . ' - ' . __( 'in', 'statusdot' ); 783 } 784 785 private static function maybe_apply_break_warning_display( array $data, array $warning_payload ) : array { 786 $config = isset( $warning_payload['warning_config'] ) && is_array( $warning_payload['warning_config'] ) ? $warning_payload['warning_config'] : self::get_break_warning_settings(); 787 if ( empty( $config['enabled'] ) || empty( $warning_payload['start_ts'] ) ) { 788 return $data; 789 } 790 791 $warning_variant = self::build_display_variant( 792 self::get_break_warning_text( (string) $config['content'], (string) ( $warning_payload['start_display'] ?? '' ) ), 793 ( 'time' === (string) $config['content'] ) ? null : (int) ( $warning_payload['in_seconds'] ?? 0 ), 794 '', 795 ( 'time' === (string) $config['content'] ) ? 0 : ( (int) $warning_payload['start_ts'] * 1000 ) 796 ); 797 798 if ( 'warning_only' === (string) $config['display'] ) { 799 $data['text'] = $warning_variant['text']; 800 $data['countdown_seconds'] = $warning_variant['countdown_seconds']; 801 $data['countdown'] = $warning_variant['countdown']; 802 $data['countdown_end_ms'] = $warning_variant['countdown_end_ms']; 803 $data['display_variants'] = [ $warning_variant ]; 804 $data['display_rotation_seconds'] = 0; 805 $data['is_break_warning'] = true; 806 return $data; 807 } 808 809 if ( 'rotate' === (string) $config['display'] ) { 810 $normal_variant = self::build_display_variant( 811 (string) ( $data['text'] ?? '' ), 812 isset( $data['countdown_seconds'] ) && is_int( $data['countdown_seconds'] ) ? (int) $data['countdown_seconds'] : null, 813 (string) ( $data['countdown'] ?? '' ), 814 isset( $data['countdown_end_ms'] ) ? (int) $data['countdown_end_ms'] : 0 815 ); 816 $variants = []; 817 if ( $normal_variant['text'] !== '' || ! empty( $normal_variant['countdown_end_ms'] ) || ! empty( $normal_variant['countdown'] ) ) { 818 $variants[] = $normal_variant; 819 } 820 $variants[] = $warning_variant; 821 $data['display_variants'] = $variants; 822 $data['display_rotation_seconds'] = (int) $config['rotate_seconds']; 823 $data['is_break_warning'] = true; 824 } 825 826 return $data; 827 } 828 131 829 public static function maybe_migrate_old_options() { 132 830 if (!current_user_can('manage_options')) return; … … 172 870 173 871 if ($found_old) update_option('statusdot_migrated_from_mu', 1); 872 } 873 874 public static function maybe_sync_from_saved_pro_default() : void { 875 if ( class_exists( 'StatusDot_Pro' ) ) { 876 return; 877 } 878 if ( ! current_user_can( 'manage_options' ) ) { 879 return; 880 } 881 882 $raw = get_option( 'statusdot_pro_schedules', null ); 883 if ( ! is_array( $raw ) || empty( $raw['default'] ) || ! is_array( $raw['default'] ) ) { 884 return; 885 } 886 887 $payload = [ 888 'mode' => $raw['default']['mode'] ?? 'normal', 889 'busy_manual' => ! empty( $raw['default']['busy_manual'] ) ? 1 : 0, 890 'breaks_enabled' => ! empty( $raw['default']['breaks_enabled'] ) ? 1 : 0, 891 'weekly' => isset( $raw['default']['weekly'] ) && is_array( $raw['default']['weekly'] ) ? $raw['default']['weekly'] : [], 892 'breaks' => isset( $raw['default']['breaks'] ) && is_array( $raw['default']['breaks'] ) ? $raw['default']['breaks'] : [], 893 'break_warning' => isset( $raw['default']['break_warning'] ) && is_array( $raw['default']['break_warning'] ) ? $raw['default']['break_warning'] : [], 894 ]; 895 896 $pro_hash = self::free_schedule_payload_hash( $payload ); 897 $free_hash = self::free_schedule_payload_hash(); 898 $mirror = (string) get_option( 'statusdot_free_pro_mirror_hash', '' ); 899 900 if ( $pro_hash !== $free_hash && $mirror === $pro_hash ) { 901 self::save_free_schedule_payload( $payload, false ); 902 update_option( 'statusdot_free_pro_mirror_hash', $pro_hash ); 903 } 904 } 905 906 907 private static function free_idle_can_start_now( ?DateTimeImmutable $now = null ) : bool { 908 $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wp_timezone_string() ); 909 if ( ! ( $now instanceof DateTimeImmutable ) ) { 910 $now = new DateTimeImmutable( 'now', $tz ); 911 } 912 913 $mode_option = (string) get_option( self::OPTION_MODE, 'normal' ); 914 if ( $mode_option === 'closed' ) { 915 return false; 916 } 917 if ( $mode_option === 'open_247' ) { 918 return true; 919 } 920 921 return is_array( self::get_active_free_schedule_window( $now, 'normal' ) ); 174 922 } 175 923 … … 195 943 196 944 if ( isset( $_POST['statusdot_idle_start'] ) ) { 197 $mins = isset( $_POST['idle_minutes'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['idle_minutes'] ) ) : 30; 198 $mins = max( 1, min( 1440, $mins ) ); // 1..1440 199 update_option( self::OPTION_IDLE_MINUTES, $mins ); 200 update_option( self::OPTION_IDLE_UNTIL, time() + ( $mins * 60 ) ); 945 if ( self::free_idle_can_start_now() ) { 946 $mins = isset( $_POST['idle_minutes'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['idle_minutes'] ) ) : 30; 947 $mins = max( 1, min( 1440, $mins ) ); // 1..1440 948 update_option( self::OPTION_IDLE_MINUTES, $mins ); 949 update_option( self::OPTION_IDLE_UNTIL, time() + ( $mins * 60 ) ); 950 } else { 951 $errors = get_transient( 'statusdot_admin_errors' ); 952 $errors = is_array( $errors ) ? $errors : []; 953 $errors[] = __( 'Your store is currently closed. Idle override can only be started during opening hours.', 'statusdot' ); 954 set_transient( 'statusdot_admin_errors', $errors, 30 ); 955 } 201 956 } 202 957 203 958 204 959 $days = self::days(); 205 $posted_day_closed = (array) filter_input( INPUT_POST, 'day_closed', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 206 $posted_open_hours = (array) filter_input( INPUT_POST, 'open_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 207 $posted_close_hours = (array) filter_input( INPUT_POST, 'close_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 208 $posted_day_closed = is_array( $posted_day_closed ) ? wp_unslash( $posted_day_closed ) : []; 209 $posted_open_hours = is_array( $posted_open_hours ) ? wp_unslash( $posted_open_hours ) : []; 210 $posted_close_hours = is_array( $posted_close_hours ) ? wp_unslash( $posted_close_hours ) : []; 960 $posted_day_closed = (array) filter_input( INPUT_POST, 'day_closed', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 961 $posted_open_hours = (array) filter_input( INPUT_POST, 'open_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 962 $posted_close_hours = (array) filter_input( INPUT_POST, 'close_hour', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 963 $posted_break_enable = (array) filter_input( INPUT_POST, 'break_enabled', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 964 $posted_break_start = (array) filter_input( INPUT_POST, 'break_start', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 965 $posted_break_end = (array) filter_input( INPUT_POST, 'break_end', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); 966 $posted_day_closed = is_array( $posted_day_closed ) ? wp_unslash( $posted_day_closed ) : []; 967 $posted_open_hours = is_array( $posted_open_hours ) ? wp_unslash( $posted_open_hours ) : []; 968 $posted_close_hours = is_array( $posted_close_hours ) ? wp_unslash( $posted_close_hours ) : []; 969 $posted_break_enable = is_array( $posted_break_enable ) ? wp_unslash( $posted_break_enable ) : []; 970 $posted_break_start = is_array( $posted_break_start ) ? wp_unslash( $posted_break_start ) : []; 971 $posted_break_end = is_array( $posted_break_end ) ? wp_unslash( $posted_break_end ) : []; 972 $break_errors = []; 973 $posted_status_mode = isset( $_POST['status_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['status_mode'] ) ) : (string) get_option( self::OPTION_MODE, 'normal' ); 974 975 update_option( self::OPTION_BREAKS_ENABLED, isset( $_POST['breaks_enabled'] ) ? 1 : 0 ); 976 update_option( self::OPTION_BREAK_WARNING_ENABLED, isset( $_POST['break_warning_enabled'] ) ? 1 : 0 ); 977 update_option( self::OPTION_BREAK_WARNING_MINUTES, max( 1, min( 1440, isset( $_POST['break_warning_minutes'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['break_warning_minutes'] ) ) : 15 ) ) ); 978 update_option( self::OPTION_BREAK_WARNING_CONTENT, self::normalize_break_warning_config([ 979 'content' => isset( $_POST['break_warning_content'] ) ? sanitize_text_field( wp_unslash( $_POST['break_warning_content'] ) ) : 'both', 980 ])['content'] ); 981 update_option( self::OPTION_BREAK_WARNING_DISPLAY, self::normalize_break_warning_config([ 982 'display' => isset( $_POST['break_warning_display'] ) ? sanitize_text_field( wp_unslash( $_POST['break_warning_display'] ) ) : 'rotate', 983 ])['display'] ); 984 update_option( self::OPTION_BREAK_WARNING_ROTATE, max( 1, min( 60, isset( $_POST['break_warning_rotate'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['break_warning_rotate'] ) ) : 4 ) ) ); 211 985 212 986 foreach ($days as $day) { … … 214 988 update_option(self::OPTION_DAY_CLOSED_PREFIX . $day, $is_closed); 215 989 216 // Sanitize each element explicitly so Plugin Check recognises the input is sanitized. 217 $open_hour_raw = isset( $posted_open_hours[ $day ] ) ? sanitize_text_field( (string) $posted_open_hours[ $day ] ) : '09:00'; 218 $close_hour_raw = isset( $posted_close_hours[ $day ] ) ? sanitize_text_field( (string) $posted_close_hours[ $day ] ) : '17:00'; 219 update_option( self::OPTION_OPEN_PREFIX . $day, self::sanitize_time_field( $open_hour_raw, '09:00' ) ); 220 update_option( self::OPTION_CLOSE_PREFIX . $day, self::sanitize_time_field( $close_hour_raw, '17:00' ) ); 990 $open_hour_raw = isset( $posted_open_hours[ $day ] ) ? sanitize_text_field( (string) $posted_open_hours[ $day ] ) : '09:00'; 991 $close_hour_raw = isset( $posted_close_hours[ $day ] ) ? sanitize_text_field( (string) $posted_close_hours[ $day ] ) : '17:00'; 992 $open_time = self::sanitize_time_field( $open_hour_raw, '09:00' ); 993 $close_time = self::sanitize_time_field( $close_hour_raw, '17:00' ); 994 update_option( self::OPTION_OPEN_PREFIX . $day, $open_time ); 995 update_option( self::OPTION_CLOSE_PREFIX . $day, $close_time ); 996 997 $enabled_map = isset( $posted_break_enable[ $day ] ) && is_array( $posted_break_enable[ $day ] ) ? $posted_break_enable[ $day ] : []; 998 $start_map = isset( $posted_break_start[ $day ] ) && is_array( $posted_break_start[ $day ] ) ? $posted_break_start[ $day ] : []; 999 $end_map = isset( $posted_break_end[ $day ] ) && is_array( $posted_break_end[ $day ] ) ? $posted_break_end[ $day ] : []; 1000 $slot_keys = array_unique( array_merge( array_keys( $enabled_map ), array_keys( $start_map ), array_keys( $end_map ) ) ); 1001 natcasesort( $slot_keys ); 1002 $day_break_items = []; 1003 foreach ( $slot_keys as $slot_key ) { 1004 $enabled = isset( $enabled_map[ $slot_key ] ) ? 1 : 0; 1005 $start_raw = isset( $start_map[ $slot_key ] ) ? sanitize_text_field( (string) $start_map[ $slot_key ] ) : ''; 1006 $end_raw = isset( $end_map[ $slot_key ] ) ? sanitize_text_field( (string) $end_map[ $slot_key ] ) : ''; 1007 $day_break_items[] = [ 1008 'enabled' => $enabled, 1009 'start' => self::sanitize_optional_clock_time( $start_raw ), 1010 'end' => self::sanitize_optional_clock_time( $end_raw ), 1011 ]; 1012 } 1013 if ( empty( $day_break_items ) ) { 1014 $day_break_items[] = [ 'enabled' => 0, 'start' => '', 'end' => '' ]; 1015 } 1016 1017 if ( isset( $_POST['breaks_enabled'] ) ) { 1018 $slot_errors = self::validate_break_items_config( $day_break_items, (bool) $is_closed, $open_time, $close_time, $posted_status_mode === 'open_247' ? 'open_247' : 'normal' ); 1019 foreach ( $slot_errors as $slot => $message ) { 1020 $break_errors[] = sprintf( 1021 /* translators: 1: weekday name, 2: break slot number, 3: validation error */ 1022 __( '%1$s break %2$d: %3$s', 'statusdot' ), 1023 ucfirst( $day ), 1024 (int) $slot + 1, 1025 $message 1026 ); 1027 } 1028 } 1029 1030 $normalized_breaks = self::normalize_break_items( [ 'items' => $day_break_items ] ); 1031 1032 $first_break = self::first_enabled_break_item( $normalized_breaks['items'] ); 1033 update_option( self::OPTION_BREAKS_PREFIX . $day, $normalized_breaks ); 1034 update_option( self::OPTION_BREAK_ENABLED_PREFIX . $day, $first_break['enabled'] ); 1035 update_option( self::OPTION_BREAK_START_PREFIX . $day, $first_break['start'] ); 1036 update_option( self::OPTION_BREAK_END_PREFIX . $day, $first_break['end'] ); 1037 } 1038 1039 if ( ! empty( $break_errors ) ) { 1040 set_transient( 'statusdot_admin_errors', $break_errors, 60 ); 1041 } else { 1042 delete_transient( 'statusdot_admin_errors' ); 221 1043 } 222 1044 … … 255 1077 } 256 1078 update_option( self::OPTION_SEPARATOR_MODE, $separator_mode ); 1079 update_option( self::OPTION_LIGHT_TEXT, isset( $_POST['light_text'] ) ? 1 : 0 ); 1080 1081 // Mark the free payload as the latest intentional state and keep the stored Pro default schedule mirrored. 1082 $free_payload_now = self::get_free_schedule_payload(); 1083 self::maybe_sync_saved_pro_default_from_free_payload( $free_payload_now ); 1084 update_option( 'statusdot_free_pro_mirror_hash', self::free_schedule_payload_hash( $free_payload_now ) ); 257 1085 258 1086 // Bump settings revision so frontend AJAX requests bypass any caches immediately. … … 267 1095 268 1096 // Determine the effective status right now (used for admin labels). 269 $idle_until = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );270 $idle_active = ( $idle_until> time() );1097 $idle_until_raw = (int) get_option( self::OPTION_IDLE_UNTIL, 0 ); 1098 $idle_active_raw = ( $idle_until_raw > time() ); 271 1099 272 1100 $tz = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wp_timezone_string() ); … … 278 1106 $close_time_today = self::sanitize_clock_time( (string) get_option( self::OPTION_CLOSE_PREFIX . $today_key, '17:00' ), '17:00' ); 279 1107 280 $is_open_now = false; 281 if ( $mode === 'open_247' ) { 282 $is_open_now = true; 283 } elseif ( $mode !== 'normal' ) { 284 $is_open_now = false; 285 } elseif ( $is_closed_today ) { 286 $is_open_now = false; 287 } else { 288 try { 289 list( $oh, $om ) = array_map( 'intval', explode( ':', $open_time_today ) ); 290 list( $ch, $cm ) = array_map( 'intval', explode( ':', $close_time_today ) ); 291 $open_dt = ( clone $now_dt )->setTime( $oh, $om, 0 ); 292 $close_dt = ( clone $now_dt )->setTime( $ch, $cm, 0 ); 293 $is_open_now = ( $now_dt >= $open_dt && $now_dt < $close_dt ); 294 } catch ( Exception $e ) { 295 $is_open_now = false; 296 } 297 } 1108 $current_window = ( $mode === 'normal' ) ? self::get_active_free_schedule_window( DateTimeImmutable::createFromMutable( $now_dt ), 'normal' ) : null; 1109 $is_open_now = ( $mode === 'open_247' ) ? true : is_array( $current_window ); 1110 $idle_can_start = ( $mode !== 'closed' ) && ( $mode === 'open_247' || $is_open_now ); 1111 $idle_active = ( $idle_active_raw && $idle_can_start ); 1112 1113 $break_payload = ( $mode === 'normal' && $is_open_now && ! $idle_active && is_array( $current_window ) ) 1114 ? self::get_active_break_payload( DateTimeImmutable::createFromMutable( $now_dt ), $current_window ) 1115 : ( ( $mode === 'open_247' && ! $idle_active ) 1116 ? self::get_open_247_break_payload( DateTimeImmutable::createFromMutable( $now_dt ) ) 1117 : null ); 1118 $break_active = is_array( $break_payload ) && ! empty( $break_payload['end_ts'] ); 298 1119 299 1120 $effective = 'closed'; … … 313 1134 } elseif ( $idle_active ) { 314 1135 $effective = 'idle'; 1136 } elseif ( $break_active ) { 1137 $effective = 'break'; 315 1138 } elseif ( $busy_mode ) { 316 1139 $effective = 'busy'; … … 327 1150 } elseif ( $effective === 'idle' ) { 328 1151 $status_state_html = '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Idle', 'statusdot' ) . ')</span>'; 1152 } elseif ( $effective === 'break' ) { 1153 $status_state_html = '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Break', 'statusdot' ) . ')</span>'; 329 1154 } else { 330 1155 $status_state_html = '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Closed', 'statusdot' ) . ')</span>'; … … 337 1162 <div class="wrap"> 338 1163 <h1><?php echo esc_html__('StatusDot', 'statusdot'); ?></h1> 1164 1165 <?php 1166 $statusdot_admin_errors = get_transient( 'statusdot_admin_errors' ); 1167 if ( is_array( $statusdot_admin_errors ) && ! empty( $statusdot_admin_errors ) ) : 1168 delete_transient( 'statusdot_admin_errors' ); 1169 ?> 1170 <div class="notice notice-error"><p><?php echo esc_html( implode( ' ', $statusdot_admin_errors ) ); ?></p></div> 1171 <?php endif; ?> 339 1172 340 1173 <?php do_action( 'statusdot_admin_notices' ); ?> … … 355 1188 <a href="#statusdot-tab-advanced" class="nav-tab nav-tab-active" data-statusdot-tab="advanced"><?php echo esc_html__( 'StatusDot Pro', 'statusdot' ); ?></a> 356 1189 <a href="#statusdot-tab-pro" class="nav-tab" data-statusdot-tab="pro"><?php echo esc_html__( 'Pro Features', 'statusdot' ); ?></a> 1190 <a href="#statusdot-tab-analytics" class="nav-tab" data-statusdot-tab="analytics"><?php echo esc_html__( 'Analytics', 'statusdot' ); ?></a> 357 1191 </h2> 358 1192 … … 363 1197 <div id="statusdot-tab-pro" class="statusdot-tab-panel" style="display:none;"> 364 1198 <?php do_action( 'statusdot_admin_tab_profeatures' ); ?> 1199 </div> 1200 1201 <div id="statusdot-tab-analytics" class="statusdot-tab-panel" style="display:none;"> 1202 <?php do_action( 'statusdot_admin_tab_analytics' ); ?> 365 1203 </div> 366 1204 … … 396 1234 $opt_time_busy = (int) get_option( self::OPTION_SHOW_TIME_BUSY, 1 ); 397 1235 $opt_time_closed = (int) get_option( self::OPTION_SHOW_TIME_CLOSED, 1 ); 1236 $opt_light_text = (int) get_option( self::OPTION_LIGHT_TEXT, 0 ); 398 1237 $opt_separator = (string) get_option( self::OPTION_SEPARATOR_MODE, '-' ); 399 1238 if ( $opt_separator === 'none' ) { … … 432 1271 </td> 433 1272 </tr> 1273 <tr> 1274 <th scope="row"><?php echo esc_html__( 'Text color', 'statusdot' ); ?></th> 1275 <td> 1276 <label><input type="checkbox" name="light_text" <?php checked( $opt_light_text, 1 ); ?> /> <?php echo esc_html__( 'Use light text (white)', 'statusdot' ); ?></label> 1277 <p class="description"><?php echo esc_html__( 'Makes the shortcode text and countdown white. Helpful on dark backgrounds.', 'statusdot' ); ?></p> 1278 </td> 1279 </tr> 434 1280 </tbody> 435 1281 </table> … … 439 1285 440 1286 441 <h2><?php echo wp_kses_post( esc_html__( 'Status Mode', 'statusdot' ) . ' ' . $status_state_html ); ?></h2>1287 <h2><?php echo esc_html__( 'Status Mode', 'statusdot' ); ?> <span class="statusdot-live-statusmode-state"><?php echo wp_kses_post( $status_state_html ); ?></span></h2> 442 1288 <label> 443 1289 <input type="radio" name="status_mode" value="normal" <?php checked($mode, 'normal'); ?>> … … 465 1311 466 1312 <?php 467 $idle_until = (int) get_option( self::OPTION_IDLE_UNTIL, 0 );468 1313 $idle_mins = (int) get_option( self::OPTION_IDLE_MINUTES, 30 ); 469 $idle_active = ( $idle_until > time() );470 1314 $idle_state = $idle_active 471 1315 ? '<span style="color:#00a32a;font-weight:600;">(' . esc_html__( 'Active', 'statusdot' ) . ')</span>' 472 1316 : '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Inactive', 'statusdot' ) . ')</span>'; 473 $weekly_active = ( $mode === 'normal' ); 474 $weekly_state = ( $weekly_active && $idle_active ) 1317 $weekly_active = self::is_break_supported_mode( (string) $mode ); 1318 $breaks_enabled = (int) get_option( self::OPTION_BREAKS_ENABLED, 0 ); 1319 $weekly_state = ( $weekly_active && $idle_active ) 475 1320 ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Idle override is active', 'statusdot' ) . ')</span>' 476 : ( ( $weekly_active && $busy_mode && $is_open_now ) 477 ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Active - Busy Mode Enabled', 'statusdot' ) . ')</span>' 478 : ( $weekly_active 479 ? '<span style="color:#00a32a;font-weight:600;">(' . esc_html__( 'Active', 'statusdot' ) . ')</span>' 480 : '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Inactive', 'statusdot' ) . ')</span>' 1321 : ( ( $weekly_active && ! empty( $break_active ) ) 1322 ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Break time is active', 'statusdot' ) . ')</span>' 1323 : ( ( $weekly_active && $busy_mode && $is_open_now ) 1324 ? '<span style="color:#dba617;font-weight:600;">(' . esc_html__( 'Active - Busy Mode Enabled', 'statusdot' ) . ')</span>' 1325 : ( $weekly_active 1326 ? '<span style="color:#00a32a;font-weight:600;">(' . esc_html__( 'Active', 'statusdot' ) . ')</span>' 1327 : '<span style="color:#d63638;font-weight:600;">(' . esc_html__( 'Inactive', 'statusdot' ) . ')</span>' 1328 ) 481 1329 ) 482 1330 ); … … 485 1333 <div class="statusdot-card statusdot-idle-card" style="margin-top:14px;"> 486 1334 <div class="statusdot-card-head"> 487 <h2><?php echo wp_kses_post( esc_html__( 'Idle override (Back in...)', 'statusdot' ) . ' ' . $idle_state ); ?></h2>1335 <h2><?php echo esc_html__( 'Idle override (Back in...)', 'statusdot' ); ?> <span class="statusdot-idle-live-heading-state"><?php echo wp_kses_post( $idle_state ); ?></span></h2> 488 1336 </div> 489 1337 <div class="statusdot-card-body"> … … 504 1352 <td> 505 1353 <?php if ( $idle_active ) : ?> 506 <span class="statusdot-pill statusdot-pill--active "><?php echo esc_html__( 'Idle is active', 'statusdot' ); ?></span>1354 <span class="statusdot-pill statusdot-pill--active statusdot-idle-live-pill"><?php echo esc_html__( 'Idle is active', 'statusdot' ); ?></span> 507 1355 <?php else : ?> 508 <span class="statusdot-pill "><?php echo esc_html__( 'Idle is not active', 'statusdot' ); ?></span>1356 <span class="statusdot-pill statusdot-idle-live-pill"><?php echo esc_html__( 'Idle is not active', 'statusdot' ); ?></span> 509 1357 <?php endif; ?> 510 1358 </td> … … 514 1362 515 1363 <p style="margin-top:10px;"> 516 <button type="submit" name="statusdot_idle_start" class="button button-primary "><?php echo esc_html__( 'Start Idle', 'statusdot' ); ?></button>1364 <button type="submit" name="statusdot_idle_start" class="button button-primary statusdot-idle-start-button"<?php disabled( ! $idle_can_start ); ?>><?php echo esc_html__( 'Start Idle', 'statusdot' ); ?></button> 517 1365 <button type="submit" name="statusdot_idle_stop" class="button"><?php echo esc_html__( 'Stop Idle', 'statusdot' ); ?></button> 518 1366 </p> 1367 <p class="description statusdot-idle-start-help"<?php echo $idle_can_start ? ' style="display:none;"' : ''; ?>><?php echo esc_html__( 'Your store is currently closed. Idle override can only be started during opening hours.', 'statusdot' ); ?></p> 519 1368 </div> 520 1369 </div> 521 1370 <div class="statusdot-card statusdot-card--weekly"> 522 1371 <div class="statusdot-card-head"> 523 <h2><?php echo wp_kses_post( esc_html__('Weekly Schedule', 'statusdot') . ' ' . $weekly_state ); ?></h2>1372 <h2><?php echo esc_html__('Weekly Schedule', 'statusdot'); ?> <span class="statusdot-live-weekly-state"><?php echo wp_kses_post( $weekly_state ); ?></span></h2> 524 1373 </div> 525 1374 <div class="statusdot-card-body"> 1375 <p style="margin:0 0 12px;"> 1376 <label> 1377 <input type="checkbox" id="statusdot-breaks-enabled" name="breaks_enabled" value="1" <?php checked( $breaks_enabled, 1 ); ?>> 1378 <?php echo esc_html__( 'Enable breaks', 'statusdot' ); ?> 1379 </label> 1380 <span class="description" style="display:block; margin-top:6px;"><?php echo esc_html__( 'Breaks apply while Weekly Schedule or Open 24/7 is active. In Weekly Schedule they must stay inside that day\'s opening hours; in Open 24/7 they can be set anywhere within that day.', 'statusdot' ); ?></span> 1381 <span class="description" style="display:block; margin-top:4px;"><?php echo esc_html__( 'Tip: if the close time is earlier than the open time, StatusDot treats it as an overnight shift into the next day.', 'statusdot' ); ?></span> 1382 </p> 1383 <?php $break_warning_cfg = self::get_break_warning_settings(); ?> 1384 <div class="statusdot-break-warning-settings" style="margin:0 0 14px; padding:12px; border:1px solid #e5e5e5; border-radius:8px; background:#fff;"> 1385 <p style="margin:0 0 10px;"> 1386 <label> 1387 <input type="checkbox" id="statusdot-break-warning-enabled" name="break_warning_enabled" value="1" <?php checked( ! empty( $break_warning_cfg['enabled'] ), true ); ?>> 1388 <?php echo esc_html__( 'Enable pre-break warning', 'statusdot' ); ?> 1389 </label> 1390 <span class="description" style="display:block; margin-top:6px;"><?php echo esc_html__( 'Only applies when Weekly Schedule or Open 24/7 is active and a valid break is coming up.', 'statusdot' ); ?></span> 1391 </p> 1392 <div class="statusdot-break-warning-grid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px;"> 1393 <p style="margin:0;"> 1394 <label for="statusdot-break-warning-minutes"><strong><?php echo esc_html__( 'Warn before break (minutes)', 'statusdot' ); ?></strong></label><br> 1395 <input type="number" min="1" max="1440" class="small-text" id="statusdot-break-warning-minutes" name="break_warning_minutes" value="<?php echo esc_attr( (int) $break_warning_cfg['minutes'] ); ?>"> 1396 </p> 1397 <p style="margin:0;"> 1398 <label for="statusdot-break-warning-content"><strong><?php echo esc_html__( 'Warning content', 'statusdot' ); ?></strong></label><br> 1399 <select id="statusdot-break-warning-content" name="break_warning_content"> 1400 <option value="countdown" <?php selected( $break_warning_cfg['content'], 'countdown' ); ?>><?php echo esc_html__( 'Countdown only', 'statusdot' ); ?></option> 1401 <option value="time" <?php selected( $break_warning_cfg['content'], 'time' ); ?>><?php echo esc_html__( 'Start time only', 'statusdot' ); ?></option> 1402 <option value="both" <?php selected( $break_warning_cfg['content'], 'both' ); ?>><?php echo esc_html__( 'Both', 'statusdot' ); ?></option> 1403 </select> 1404 </p> 1405 <p style="margin:0;"> 1406 <label for="statusdot-break-warning-display"><strong><?php echo esc_html__( 'Display behavior', 'statusdot' ); ?></strong></label><br> 1407 <select id="statusdot-break-warning-display" name="break_warning_display"> 1408 <option value="warning_only" <?php selected( $break_warning_cfg['display'], 'warning_only' ); ?>><?php echo esc_html__( 'Show warning only', 'statusdot' ); ?></option> 1409 <option value="rotate" <?php selected( $break_warning_cfg['display'], 'rotate' ); ?>><?php echo esc_html__( 'Rotate warning with normal open text', 'statusdot' ); ?></option> 1410 <option value="normal_only" <?php selected( $break_warning_cfg['display'], 'normal_only' ); ?>><?php echo esc_html__( 'Show only normal open text', 'statusdot' ); ?></option> 1411 </select> 1412 </p> 1413 <p style="margin:0;"> 1414 <label for="statusdot-break-warning-rotate"><strong><?php echo esc_html__( 'Rotate every (seconds)', 'statusdot' ); ?></strong></label><br> 1415 <input type="number" min="1" max="60" class="small-text" id="statusdot-break-warning-rotate" name="break_warning_rotate" value="<?php echo esc_attr( (int) $break_warning_cfg['rotate_seconds'] ); ?>"> 1416 </p> 1417 </div> 1418 </div> 526 1419 <div class="statusdot-tablewrap"> 527 1420 <table id="statusdot-free-weekly-table" class="statusdot-weekly-table"> … … 532 1425 <th><?php echo esc_html__('Close Hour', 'statusdot'); ?></th> 533 1426 <th><?php echo esc_html__('Closed?', 'statusdot'); ?></th> 1427 <th><?php echo esc_html__('Break windows', 'statusdot'); ?></th> 534 1428 </tr> 535 1429 </thead> 536 1430 <tbody> 537 1431 <?php foreach ($days as $day): ?> 1432 <?php $break_items_day = self::get_break_items_for_day( $day ); ?> 538 1433 <tr> 539 1434 <td><?php echo esc_html(ucfirst($day)); ?></td> … … 554 1449 <?php checked((int) get_option(self::OPTION_DAY_CLOSED_PREFIX . $day, 0), 1); ?>> 555 1450 </td> 1451 <td> 1452 <div class="statusdot-break-slots" data-break-max="<?php echo esc_attr( self::break_slot_count() ); ?>"> 1453 <div class="statusdot-break-slot-list"> 1454 <?php foreach ( $break_items_day as $slot_index => $break_item ) : ?> 1455 <div class="statusdot-break-slot" data-break-slot="<?php echo esc_attr( $slot_index ); ?>"> 1456 <label class="statusdot-break-slot__toggle"> 1457 <input type="checkbox" class="statusdot-break-enabled" 1458 name="break_enabled[<?php echo esc_attr($day); ?>][<?php echo esc_attr( $slot_index ); ?>]" 1459 value="1" 1460 <?php checked( ! empty( $break_item['enabled'] ), true ); ?>> 1461 <?php /* translators: %d: break slot number. */ ?> 1462 <span class="statusdot-break-slot-label"><?php echo esc_html( sprintf( __( 'Break %d', 'statusdot' ), $slot_index + 1 ) ); ?></span> 1463 </label> 1464 <input type="time" step="60" class="statusdot-time statusdot-break-time" 1465 name="break_start[<?php echo esc_attr($day); ?>][<?php echo esc_attr( $slot_index ); ?>]" 1466 value="<?php echo esc_attr( self::sanitize_optional_clock_time( $break_item['start'] ?? '' ) ); ?>"> 1467 <span class="statusdot-break-slot__dash">–</span> 1468 <input type="time" step="60" class="statusdot-time statusdot-break-time" 1469 name="break_end[<?php echo esc_attr($day); ?>][<?php echo esc_attr( $slot_index ); ?>]" 1470 value="<?php echo esc_attr( self::sanitize_optional_clock_time( $break_item['end'] ?? '' ) ); ?>"> 1471 <button type="button" class="button-link-delete statusdot-break-remove"><?php echo esc_html__( 'Remove', 'statusdot' ); ?></button> 1472 </div> 1473 <?php endforeach; ?> 1474 </div> 1475 <div class="statusdot-break-slot-actions"> 1476 <button type="button" class="button button-secondary statusdot-break-add"><?php echo esc_html__( 'Add break', 'statusdot' ); ?></button> 1477 </div> 1478 <div class="statusdot-break-error-msg" style="display:none;"></div> 1479 <template class="statusdot-break-slot-template"> 1480 <div class="statusdot-break-slot" data-break-slot="__INDEX__"> 1481 <label class="statusdot-break-slot__toggle"> 1482 <input type="checkbox" class="statusdot-break-enabled" name="break_enabled[<?php echo esc_attr($day); ?>][__INDEX__]" value="1"> 1483 <span class="statusdot-break-slot-label"><?php echo esc_html__( 'Break', 'statusdot' ); ?> __NUMBER__</span> 1484 </label> 1485 <input type="time" step="60" class="statusdot-time statusdot-break-time" name="break_start[<?php echo esc_attr($day); ?>][__INDEX__]" value=""> 1486 <span class="statusdot-break-slot__dash">–</span> 1487 <input type="time" step="60" class="statusdot-time statusdot-break-time" name="break_end[<?php echo esc_attr($day); ?>][__INDEX__]" value=""> 1488 <button type="button" class="button-link-delete statusdot-break-remove"><?php echo esc_html__( 'Remove', 'statusdot' ); ?></button> 1489 </div> 1490 </template> 1491 </div> 1492 </td> 556 1493 </tr> 557 1494 <?php endforeach; ?> … … 600 1537 $sta_rows = [ 601 1538 [ 'label' => __( 'Single weekly schedule', 'statusdot' ), 'free' => true, 'pro' => true ], 1539 [ 'label' => __( 'Status text and countdown', 'statusdot' ), 'free' => true, 'pro' => true ], 1540 [ 'label' => __( 'Idle override (Back in...)', 'statusdot' ), 'free' => true, 'pro' => true ], 1541 [ 'label' => __( 'Break time mode', 'statusdot' ), 'free' => true, 'pro' => true ], 1542 [ 'label' => __( 'Warn before break', 'statusdot' ), 'free' => true, 'pro' => true ], 1543 [ 'label' => __( 'Break warning content styles', 'statusdot' ), 'free' => true, 'pro' => true ], 1544 [ 'label' => __( 'Break warning display modes', 'statusdot' ), 'free' => true, 'pro' => true ], 1545 [ 'label' => __( 'Break warning rotation timing', 'statusdot' ), 'free' => true, 'pro' => true ], 1546 [ 'label' => __( 'Separator choice', 'statusdot' ), 'free' => true, 'pro' => true ], 602 1547 [ 'label' => __( 'Multiple schedules and locations', 'statusdot' ), 'free' => false, 'pro' => true ], 603 1548 [ 'label' => __( 'Manual override per schedule', 'statusdot' ), 'free' => false, 'pro' => true ], … … 605 1550 [ 'label' => __( 'Exceptions and one off overrides', 'statusdot' ), 'free' => false, 'pro' => true ], 606 1551 [ 'label' => __( 'Busy windows time ranges', 'statusdot' ), 'free' => false, 'pro' => true ], 607 [ 'label' => __( 'Status text and countdown', 'statusdot' ), 'free' => true, 'pro' => true ], 608 [ 'label' => __( 'Idle override (Back in...)', 'statusdot' ), 'free' => true, 'pro' => true ], 1552 [ 'label' => __( 'Custom break warning text', 'statusdot' ), 'free' => false, 'pro' => true ], 609 1553 [ 'label' => __( 'Customizable status text and countdown', 'statusdot' ), 'free' => false, 'pro' => true ], 1554 [ 'label' => __( 'Conditional display rules', 'statusdot' ), 'free' => false, 'pro' => true ], 1555 [ 'label' => __( 'Schedule analytics dashboard', 'statusdot' ), 'free' => false, 'pro' => true ], 1556 [ 'label' => __( 'Live activity log', 'statusdot' ), 'free' => false, 'pro' => true ], 1557 [ 'label' => __( 'Mail center and report emails', 'statusdot' ), 'free' => false, 'pro' => true ], 610 1558 [ 'label' => __( 'Dot size and gap control', 'statusdot' ), 'free' => false, 'pro' => true ], 611 1559 [ 'label' => __( 'Custom colors and gradients', 'statusdot' ), 'free' => false, 'pro' => true ], 612 [ 'label' => __( 'Separator choice', 'statusdot' ), 'free' => true, 'pro' => true ],613 1560 [ 'label' => __( 'Border control', 'statusdot' ), 'free' => false, 'pro' => true ], 614 1561 [ 'label' => __( 'Pulse animation styles', 'statusdot' ), 'free' => false, 'pro' => true ], … … 691 1638 ); 692 1639 693 $idle_until = (int) get_option( self::OPTION_IDLE_UNTIL, 0 ); 694 $idle_active = ( $idle_until > time() ); 695 696 // 1) Force closed always wins 697 if ($mode_option === 'closed') { 698 $icon = 'red-dot.svg'; 699 $color = 'red-dot'; 700 } else { 701 702 // Determine if we're open right now 703 if ($mode_option === 'open_247') { 704 $is_open_now = true; 705 } elseif ($is_closed_today) { 706 $is_open_now = false; 707 } else { 708 try { 709 $open_dt = $now->setTime( $open_hour, $open_min, 0 ); 710 $close_dt = $now->setTime( $close_hour, $close_min, 0 ); 711 } catch ( Exception $e ) { 712 $open_dt = null; 713 $close_dt = null; 714 } 715 if ( $open_dt && $close_dt ) { 716 $is_open_now = ( $now >= $open_dt && $now < $close_dt ); 717 } else { 718 $is_open_now = false; 719 } 720 } 721 722 // 2) Busy overlaps only when allowed: 723 // - open_247 => busy can override anytime 724 // - normal => busy only when currently open 725 if ($busy_mode && ($mode_option === 'open_247' || $is_open_now)) { 726 $icon = 'orange-dot.svg'; 727 $color = 'orange-dot'; 728 } else { 729 // 3) Base status rules 730 if ($mode_option === 'open_247') { 731 $icon = 'green-dot.svg'; 732 $color = 'green-dot'; 733 } elseif ($is_open_now) { 734 $icon = 'green-dot.svg'; 735 $color = 'green-dot'; 736 } else { 737 $icon = 'red-dot.svg'; 738 $color = 'red-dot'; 739 } 740 } 741 } 742 743 $icon_url = STATUSDOT_PLUGIN_URL . 'assets/icons/' . $icon; 744 745 // Semantic status used by the frontend (open, busy, closed) 1640 $idle_until_raw = (int) get_option( self::OPTION_IDLE_UNTIL, 0 ); 1641 1642 $current_window = ( $mode_option === 'normal' ) ? self::get_active_free_schedule_window( $now, 'normal' ) : null; 1643 $is_open_now = ( $mode_option === 'open_247' ) ? true : is_array( $current_window ); 1644 $idle_active = ( $idle_until_raw > time() ) && ( $mode_option !== 'closed' ) && ( $mode_option === 'open_247' || $is_open_now ); 1645 $idle_until = $idle_active ? $idle_until_raw : 0; 1646 1647 $break_payload = ( $mode_option === 'normal' && $is_open_now && ! $idle_active && is_array( $current_window ) ) 1648 ? self::get_active_break_payload( $now, $current_window ) 1649 : ( ( $mode_option === 'open_247' && ! $idle_active ) 1650 ? self::get_open_247_break_payload( $now ) 1651 : null ); 1652 $break_active = is_array( $break_payload ) && ! empty( $break_payload['end_ts'] ); 1653 $break_warning_payload = ( $mode_option === 'normal' && $is_open_now && ! $idle_active && ! $break_active && is_array( $current_window ) ) 1654 ? self::get_pre_break_warning_payload( $now, $current_window ) 1655 : ( ( $mode_option === 'open_247' && ! $idle_active && ! $break_active ) 1656 ? self::get_open_247_pre_break_warning_payload( $now ) 1657 : null ); 1658 1659 // Semantic status used by the frontend (open, busy, closed, idle/break). 746 1660 if ( $mode_option === 'closed' ) { 747 1661 $mode = 'closed'; 1662 $color = 'red-dot'; 748 1663 } elseif ( ! empty( $idle_active ) ) { 749 $ icon = 'orange-dot.svg';1664 $mode = 'idle'; 750 1665 $color = 'orange-dot'; 751 $mode = 'idle'; 1666 } elseif ( $break_active ) { 1667 $mode = 'idle'; 1668 $color = 'orange-dot'; 752 1669 } elseif ( $busy_mode && ( $mode_option === 'open_247' || ! empty( $is_open_now ) ) ) { 753 1670 $mode = 'busy'; 1671 $color = 'orange-dot'; 754 1672 } elseif ( $mode_option === 'open_247' || ! empty( $is_open_now ) ) { 755 1673 $mode = 'open'; 1674 $color = 'green-dot'; 756 1675 } else { 757 1676 $mode = 'closed'; 758 } 1677 $color = 'red-dot'; 1678 } 1679 1680 $icon = ( $color === 'green-dot' ) ? 'green-dot.svg' : ( $color === 'red-dot' ? 'red-dot.svg' : 'orange-dot.svg' ); 1681 $icon_url = STATUSDOT_PLUGIN_URL . 'assets/icons/' . $icon; 1682 $effective_idle_until = $break_active ? (int) $break_payload['end_ts'] : $idle_until; 1683 $is_break = ( $break_active && ! $idle_active ); 759 1684 760 1685 $data = [ … … 763 1688 'status' => ( $mode === 'idle' ? 'busy' : $mode ), // open|busy|closed 764 1689 // Semantic mode (open|busy|closed|idle) used by the frontend for small display tweaks. 765 'status_semantic' => $mode, 766 'label' => ucfirst( $mode ), 767 768 'is_idle' => ( $mode === 'idle' ), 769 'idle_until' => (int) ( $mode === 'idle' ? get_option( self::OPTION_IDLE_UNTIL, 0 ) : 0 ), 770 ]; 1690 'status_semantic' => ( $is_break ? 'break' : $mode ), 1691 'label' => ( $is_break ? __( 'Break', 'statusdot' ) : ucfirst( $mode ) ), 1692 'is_idle' => ( $mode === 'idle' && ! $is_break ), 1693 'is_break' => $is_break, 1694 'idle_until' => (int) ( $mode === 'idle' ? $effective_idle_until : 0 ), 1695 'reason' => ( $mode_option === 'closed' 1696 ? 'forced_closed' 1697 : ( $is_break 1698 ? 'break' 1699 : ( ( $mode === 'idle' ) 1700 ? 'idle' 1701 : ( ( $mode === 'busy' ) 1702 ? 'busy' 1703 : ( ( $mode_option === 'open_247' ) ? 'open_247' : ( ( $mode === 'open' ) ? 'open' : 'closed' ) ) 1704 ) 1705 ) 1706 ) 1707 ), 1708 ]; 771 1709 772 1710 // Basic status text + countdown (Free). Pro can override via the filter below. … … 782 1720 $show_time_closed = (int) get_option( self::OPTION_SHOW_TIME_CLOSED, 1 ); 783 1721 784 $idle_until = (int) get_option( self::OPTION_IDLE_UNTIL, 0 ); 785 786 $raw_countdown = self::get_basic_countdown_seconds( $mode, $now, $today, $open_hour, $open_min, $close_hour, $close_min, (int) $is_closed_today, $idle_until ); 1722 $raw_countdown = ( $mode_option !== 'normal' ) 1723 ? ( ( $mode === 'idle' ) ? self::get_basic_countdown_seconds( $mode, $now, $today, $open_hour, $open_min, $close_hour, $close_min, (int) $is_closed_today, $effective_idle_until ) : null ) 1724 : self::get_basic_countdown_seconds( $mode, $now, $today, $open_hour, $open_min, $close_hour, $close_min, (int) $is_closed_today, $effective_idle_until ); 1725 $break_time_display = $is_break && ! empty( $break_payload['end_display'] ) ? (string) $break_payload['end_display'] : ''; 787 1726 788 1727 // Text: base status + optional label (even when time is hidden). … … 797 1736 (bool) $show_time_open, 798 1737 (bool) $show_time_idle, 799 (bool) $show_time_closed 1738 (bool) $show_time_closed, 1739 $is_break, 1740 $break_time_display 800 1741 ); 801 1742 … … 805 1746 if ( $mode === 'open' ) { 806 1747 $show_time = (bool) $show_time_open; 1748 } elseif ( $mode === 'busy' ) { 1749 $show_time = (bool) ( $show_time_open || $show_time_idle ); 807 1750 } elseif ( $mode === 'closed' ) { 808 1751 $show_time = (bool) $show_time_closed; … … 814 1757 $data['countdown_seconds'] = ( $show_time && is_int( $raw_countdown ) ) ? $raw_countdown : null; 815 1758 $data['countdown'] = ( $show_time && is_int( $raw_countdown ) ) ? self::format_countdown_mmss( $raw_countdown ) : ''; 1759 $data['countdown_end_ms'] = ( $show_time && is_int( $raw_countdown ) && $raw_countdown > 0 ) ? (int) round( ( microtime( true ) + $raw_countdown ) * 1000 ) : 0; 1760 1761 if ( in_array( $mode, [ 'open', 'busy' ], true ) && is_array( $break_warning_payload ) ) { 1762 $data = self::maybe_apply_break_warning_display( $data, $break_warning_payload ); 1763 } 816 1764 // Pro integration: allow Pro (or others) to extend/override the payload 817 1765 $data = apply_filters('statusdot_status_data', $data, [ … … 865 1813 array( 866 1814 'id' => 'header', 867 'refresh' => ' 30',1815 'refresh' => '20', 868 1816 ), 869 1817 $atts, … … 875 1823 876 1824 // Free build: dot + basic status text + countdown (not customizable). 877 $out = '<span class="statusdot-wrap" data-statusdot-id="' . esc_attr( $id ) . '" data-statusdot-refresh="' . esc_attr( $refresh ) . '">'; 1825 $wrap_classes = 'statusdot-wrap'; 1826 if ( (int) get_option( self::OPTION_LIGHT_TEXT, 0 ) === 1 ) { 1827 $wrap_classes .= ' statusdot-wrap--light-text'; 1828 } 1829 1830 $out = '<span class="' . esc_attr( $wrap_classes ) . '" data-statusdot-id="' . esc_attr( $id ) . '" data-statusdot-refresh="' . esc_attr( $refresh ) . '">'; 878 1831 $out .= '<span class="statusdot-status-icon statusdot-status-icon--unknown" data-statusdot-id="' . esc_attr( $id ) . '" data-statusdot-refresh="' . esc_attr( $refresh ) . '" aria-hidden="true"></span>'; 879 1832 $out .= '<span class="screen-reader-text">' . esc_html__( 'Status indicator', 'statusdot' ) . '</span>'; … … 895 1848 896 1849 /** 897 * Default status text (Free): "Open now — Closes in", "Closed now — Opens in", "Busy now". 1850 * Default status text (Free): "Open now — Closes in", "Closed now — Opens in", and 1851 * when manual Busy is active during weekly hours, "Busy now — Closes in". 898 1852 */ 899 1853 private static function get_default_status_text_with_label( … … 907 1861 bool $show_time_open, 908 1862 bool $show_time_idle, 909 bool $show_time_closed 1863 bool $show_time_closed, 1864 bool $is_break = false, 1865 string $break_time_display = '' 910 1866 ): string { 911 1867 $parts = array(); … … 919 1875 if ( is_int( $raw_countdown ) && $raw_countdown > 0 && $show_time_idle ) { 920 1876 $parts[] = esc_html__( 'Back in', 'statusdot' ); 1877 } elseif ( $is_break && $break_time_display !== '' && $show_text_busy ) { 1878 /* translators: %s: break end time */ 1879 $parts[] = sprintf( esc_html__( 'We are back at %s', 'statusdot' ), $break_time_display ); 921 1880 } 922 1881 return implode( $joiner, array_filter( $parts ) ); … … 924 1883 925 1884 if ( $mode === 'busy' ) { 926 return $show_text_busy ? esc_html__( 'Busy now', 'statusdot' ) : ''; 1885 if ( $show_text_busy ) { 1886 $parts[] = esc_html__( 'Busy now', 'statusdot' ); 1887 } 1888 if ( is_int( $raw_countdown ) && $raw_countdown > 0 && $show_lbl_closes && ( $show_time_open || $show_time_idle ) ) { 1889 $parts[] = esc_html__( 'Closes in', 'statusdot' ); 1890 } 1891 return implode( $joiner, array_filter( $parts ) ); 927 1892 } 928 1893 … … 962 1927 ): ?int { 963 1928 964 // Idle: countdown to idle_until965 1929 if ( $mode === 'idle' ) { 966 1930 $diff = $idle_until - time(); … … 968 1932 } 969 1933 970 // Busy (manual) never shows countdown in Free. 971 if ( $mode === 'busy' ) { 972 return null; 973 } 974 975 // If schedule closed today, no open countdown. 976 if ( $is_closed_today ) { 977 if ( $mode === 'open' ) { 1934 $current_window = self::get_active_free_schedule_window( $now, 'normal' ); 1935 if ( $mode === 'busy' || $mode === 'open' ) { 1936 if ( ! is_array( $current_window ) ) { 978 1937 return null; 979 1938 } 980 // closed: find next open day 981 return self::seconds_until_next_open_day( $now, $today ); 982 } 983 984 try { 985 $open_dt = $now->setTime( $open_hour, $open_min, 0 ); 986 $close_dt = $now->setTime( $close_hour, $close_min, 0 ); 987 } catch ( Exception $e ) { 988 return null; 989 } 990 991 if ( $mode === 'open' ) { 992 $diff = $close_dt->getTimestamp() - $now->getTimestamp(); 993 return ( $diff > 0 ) ? $diff : null; 994 } 995 996 // closed: countdown to open 997 $diff = $open_dt->getTimestamp() - $now->getTimestamp(); 998 if ( $diff > 0 ) { 999 return $diff; 1000 } 1001 1002 // If we're already past today's opening time, go to next open day. 1003 return self::seconds_until_next_open_day( $now, $today ); 1939 $diff = $current_window['close_dt']->getTimestamp() - $now->getTimestamp(); 1940 return ( $diff > 0 ) ? (int) $diff : null; 1941 } 1942 1943 return self::get_seconds_until_next_free_open( $now ); 1004 1944 } 1005 1945 1006 1946 private static function seconds_until_next_open_day( DateTimeImmutable $now, ?string $today = null ): ?int { 1007 $site_id = get_current_blog_id(); 1008 $tz = $now->getTimezone(); 1009 1010 for ( $i = 1; $i <= 7; $i++ ) { 1011 $cand = $now->modify( '+' . $i . ' day' ); 1012 if ( ! $cand instanceof DateTimeImmutable ) { 1013 continue; 1014 } 1015 $day = strtolower( $cand->format( 'l' ) ); 1016 1017 $is_closed = (int) self::get_opt_with_fallback( 1018 self::OPTION_DAY_CLOSED_PREFIX . $day, 1019 'opening_hours_day_closed_' . $day . '_' . $site_id, 1020 0 1021 ); 1022 1023 if ( $is_closed ) { 1024 continue; 1025 } 1026 1027 $open_time = self::get_time_opt_with_fallback( 1028 self::OPTION_OPEN_PREFIX . $day, 1029 'opening_hours_open_' . $day . '_' . $site_id, 1030 '09:00' 1031 ); 1032 list( $open_hour, $open_min ) = array_map( 'intval', explode( ':', $open_time ) ); 1033 1034 try { 1035 $open_dt = ( new DateTimeImmutable( $cand->format('Y-m-d') . ' 00:00:00', $tz ) )->setTime( $open_hour, $open_min, 0 ); 1036 } catch ( Exception $e ) { 1037 continue; 1038 } 1039 1040 $secs = (int) ( $open_dt->getTimestamp() - $now->getTimestamp() ); 1041 return $secs > 0 ? $secs : null; 1042 } 1043 1044 return null; 1947 return self::get_seconds_until_next_free_open( $now ); 1045 1948 } 1046 1949 1047 1950 /** 1048 * Format seconds as H:MM (>=1h) or M:SS (<1h). For Free display.1951 * Format seconds as a fixed-width HH:MM:SS string. 1049 1952 */ 1050 1953 private static function format_countdown_mmss( int $seconds ): string { … … 1053 1956 $m = (int) floor( ( $seconds % 3600 ) / 60 ); 1054 1957 $s = (int) ( $seconds % 60 ); 1055 1056 if ( $h > 0 ) { 1057 return sprintf( '%d:%02d', $h, $m ); 1058 } 1059 return sprintf( '%d:%02d', ( $m ), $s ); 1958 return sprintf( '%02d:%02d:%02d', $h, $m, $s ); 1060 1959 } 1061 1960 -
statusdot/trunk/readme.txt
r3476743 r3483265 2 2 Contributors: designplug, freemius 3 3 Donate link: https://www.paypal.com/paypalme/DesignPlugNL 4 Tags: opening-hours, business-hours, open-closed, countdown, status-indicator4 Tags: opening-hours, business-hours, status-indicator, open-closed, countdown 5 5 Requires at least: 5.8 6 6 Tested up to: 6.9 7 Stable tag: 2. 1.07 Stable tag: 2.2.0 8 8 Requires PHP: 7.4 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html 11 Real-time opening hours with a clean status dot, optional text, and countdown timers.11 Opening hours status dot with weekly schedules, overnight hours, breaks, busy/idle states, and live countdowns. 12 12 13 13 == Description == … … 25 25 == Features == 26 26 * Weekly opening hours (HH:MM, including minutes) 27 * Overnight hours supported (set the close time earlier than the open time) 28 * Optional break windows during Weekly Schedule and Open 24/7 29 * Optional pre-break warnings before scheduled breaks 27 30 * Status modes: 28 31 * Use Opening Hours (Weekly Schedule) … … 35 38 * Toggle countdown label + time per state (Closes in / Opens in / Back in) 36 39 * Separator selection (-, —, |, •) 37 * Live countdown to the next opening/closing moment 40 * Optional light text (white) output for dark backgrounds 41 * Live countdowns to the next opening, closing, break end, or idle return 38 42 * AJAX-based live updates (configurable refresh interval) 39 43 * Unlimited shortcodes per page … … 46 50 47 51 Optional attributes: 48 [statusdot id="header" refresh=" 30"]52 [statusdot id="header" refresh="20"] 49 53 50 54 * `id` – Optional unique identifier (useful for targeting with custom CSS). Default: header 51 * `refresh` – Refresh interval in seconds (default: 30)55 * `refresh` – Refresh interval in seconds (default: 20) 52 56 53 57 == Installation == … … 75 79 76 80 == Screenshots == 77 1. Frontend open / busy / closed status with countdown 78 2. Settings page (schedule + display options) 81 1. Frontend example showing Open / Busy / Closed with live countdown 82 2. Frontend Idle override example with “Back in” countdown 83 3. Frontend break status example with live return countdown 84 4. Main settings page with weekly schedule, breaks, and display options 85 5. Dark-background frontend output with light text enabled 79 86 80 87 == Changelog == 88 89 = 2.2.0 = 90 * New: Added overnight opening hours, so a closing time earlier than the opening time now continues into the next day. 91 * New: Added optional scheduled break windows for Weekly Schedule and Open 24/7, including support for multiple break slots per day. 92 * New: Added optional pre-break warnings with configurable minutes-before, content display, and rotation behavior. 93 * New: Added an optional light text (white) setting for shortcode text and countdown output on dark backgrounds. 94 * Improved: Live status updates now handle opening, closing, break, and idle countdown boundaries more smoothly without a full page refresh. 95 * Improved: The admin live state handling now refreshes Busy / Break / Idle states more reliably, and Idle override can no longer be started while the schedule is closed. 96 * Meta: Refreshed the WordPress.org screenshots/readme visuals for the 2.2.0 release. 97 81 98 = 2.1.0 = 82 * New: Display options (status text + countdown label/time toggles) 83 * New: Separator selection (-, —, |, •) 84 * New: Idle override (Back in...) with start/stop 85 * Improved: Weekly schedule supports HH:MM (minutes) 86 * Improved: Instant refresh after saving settings 87 * UI polish and WordPress.org compliance fixes 88 89 = 2.0.1 = 90 * Add basic status text and countdown to the free version 91 * Improve shortcode copy/paste formatting 92 * Minor readme improvements 99 * New: Added display options for status text and countdown labels/times. 100 * New: Added separator selection (-, —, |, •) for the shortcode output. 101 * New: Added Idle override ("Back in...") with start/stop and countdown support. 102 * Improved: Weekly schedules now support HH:MM values, including minutes. 103 * Improved: Settings changes now refresh more immediately after saving. 104 * Improved: General admin UI polish and WordPress.org compliance updates. 93 105 94 106 = 2.0.0 = 95 * Licensing integration (optional upgrade) 96 * Code quality improvements 97 * WordPress.org compatibility fixes 98 * Performance improvements 99 100 = 1.0.0 = 101 * Initial release 107 * Added Freemius integration for the optional upgrade path. 108 * Improved: Code quality, performance, and WordPress.org compatibility. 102 109 103 110 == Upgrade Notice == 104 = 2.1.0 = 105 Adds Display Options, Separator selection, and an Idle override — plus HH:MM schedule support and instant refresh after saving settings. 106 107 = 2.0.1 = 108 Adds basic status text and countdown display to the free version. 111 = 2.2.0 = 112 Adds overnight hours, scheduled break windows, optional pre-break warnings, light text output, and smoother live state handling for countdowns, breaks, and idle mode. -
statusdot/trunk/statusdot.php
r3476743 r3483265 3 3 /** 4 4 * Plugin Name: StatusDot 5 * Description: Minimal opening hours status dot (open/busy/closed).6 * Version: 2. 1.05 * Description: Opening hours status dot with weekly schedules, overnight hours, breaks, busy/idle states, and live countdowns. 6 * Version: 2.2.0 7 7 * Author: Design Plug 8 8 * Author URI: https://profiles.wordpress.org/designplug/ … … 60 60 // Plugin constants 61 61 // ---------------------------- 62 define( 'STATUSDOT_VERSION', '2. 1.0' );62 define( 'STATUSDOT_VERSION', '2.2.0' ); 63 63 define( 'STATUSDOT_PLUGIN_FILE', __FILE__ ); 64 64 define( 'STATUSDOT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); … … 78 78 public function init_modules() { 79 79 StatusDot_Opening_Hours::init(); 80 // Load Pro module only in the premium (Pro) build. This block is stripped from the Free build by Freemius. 81 if ( function_exists( 'statusdot_fs' ) && statusdot_fs()->is__premium_only() ) { 80 // Load Pro module only when the premium build is installed *and* premium code is allowed. 81 // This lets a premium ZIP gracefully fall back to the Free feature set until a valid 82 // license / trial is active, instead of exposing paid settings just because the Pro ZIP exists. 83 if ( function_exists( 'statusdot_fs' ) && statusdot_fs()->is__premium_only() && statusdot_fs()->can_use_premium_code__premium_only() ) { 82 84 $pro_file = STATUSDOT_PLUGIN_DIR . 'includes/class-statusdot-pro__premium_only.php'; 83 85 if ( file_exists( $pro_file ) ) { … … 146 148 true 147 149 ); 150 wp_localize_script( 'statusdot-admin', 'StatusDotAdmin', [ 151 'ajax_url' => admin_url( 'admin-ajax.php' ), 152 'nonce' => wp_create_nonce( 'statusdot_frontend' ), 153 ] ); 148 154 wp_enqueue_script( 'statusdot-admin' ); 149 155 }
Note: See TracChangeset
for help on using the changeset viewer.