diff --git a/assets/mobile/phone-template.php b/assets/mobile/phone-template.php
new file mode 100644
index 0000000..8ed0502
--- /dev/null
+++ b/assets/mobile/phone-template.php
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+
+
+Phone -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ extension) : '—'; ?>
+
+
+
+
+
+
+ Today:
+ Total:
+ Avg: s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ready
+
Loading...
+
+
00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No calls yet this session.
+
+
+
+
+
+
+
+
+
+
Loading your queues...
+
+
+
+
+
+
+
+
+
+
+
+
+
Outbound Caller ID
+
+
+
+
+
+
+
+
+
+
+
Appearance
+
+
+
+
+
+
+
+
+
+
Call Reception Mode
+
+
+
+
+
+
+ Current:
+
+
+
+
+
+
+
Keep this page open to receive calls.
+
+
+
Calls forwarded to: Not configured'; ?>
+
+
+
+
+
+
+
Setup Required
+
Update your phone number webhook to:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/mobile/phone.css b/assets/mobile/phone.css
new file mode 100644
index 0000000..ac61566
--- /dev/null
+++ b/assets/mobile/phone.css
@@ -0,0 +1,848 @@
+/* ===================================================================
+ CSS Reset & Base
+ =================================================================== */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg-primary: #f5f6fa;
+ --bg-secondary: #ffffff;
+ --bg-phone: #1a1a2e;
+ --bg-display: #16213e;
+ --text-primary: #2c3e50;
+ --text-secondary: #7f8c8d;
+ --text-light: #ffffff;
+ --accent: #2196F3;
+ --accent-dark: #1976D2;
+ --success: #4CAF50;
+ --warning: #FF9800;
+ --danger: #f44336;
+ --border: #e0e0e0;
+ --shadow: 0 2px 8px rgba(0,0,0,0.1);
+ --radius: 12px;
+ --safe-top: env(safe-area-inset-top, 0px);
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) {
+ --bg-primary: #0f0f23;
+ --bg-secondary: #1a1a2e;
+ --bg-phone: #16213e;
+ --bg-display: #0a0a1a;
+ --text-primary: #ecf0f1;
+ --text-secondary: #95a5a6;
+ --border: #2c3e50;
+ --shadow: 0 2px 8px rgba(0,0,0,0.4);
+ }
+}
+
+/* Manual dark mode override via class on */
+:root.dark-mode {
+ --bg-primary: #0f0f23;
+ --bg-secondary: #1a1a2e;
+ --bg-phone: #16213e;
+ --bg-display: #0a0a1a;
+ --text-primary: #ecf0f1;
+ --text-secondary: #95a5a6;
+ --border: #2c3e50;
+ --shadow: 0 2px 8px rgba(0,0,0,0.4);
+}
+
+html, body {
+ height: 100%;
+ overflow: hidden;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ -webkit-text-size-adjust: 100%;
+ -webkit-tap-highlight-color: transparent;
+}
+
+/* ===================================================================
+ Layout — full-screen flex column
+ =================================================================== */
+.twp-app {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-width: 500px;
+ margin: 0 auto;
+ padding-top: var(--safe-top);
+ padding-bottom: var(--safe-bottom);
+}
+
+/* ===================================================================
+ Agent Status Bar (compact)
+ =================================================================== */
+.agent-status-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.status-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ flex-wrap: wrap;
+}
+.extension-badge {
+ background: var(--accent);
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 600;
+}
+.agent-stats {
+ display: flex;
+ gap: 10px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+#login-toggle-btn, #agent-status-select {
+ font-size: 12px;
+ padding: 3px 10px;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ cursor: pointer;
+}
+#login-toggle-btn.logged-in {
+ background: var(--danger);
+ color: #fff;
+ border-color: var(--danger);
+}
+
+/* ===================================================================
+ Notices
+ =================================================================== */
+.twp-notice {
+ padding: 8px 12px;
+ margin: 6px 10px;
+ border-radius: 6px;
+ font-size: 13px;
+ animation: fadeIn 0.2s ease;
+}
+.twp-notice-success { background: #e8f5e9; color: #2e7d32; }
+.twp-notice-error { background: #ffebee; color: #c62828; }
+.twp-notice-info { background: #e3f2fd; color: #1565c0; }
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
+ :root:not(.light-mode) .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
+ :root:not(.light-mode) .twp-notice-info { background: #0d47a1; color: #90caf9; }
+}
+.dark-mode .twp-notice-success { background: #1b5e20; color: #a5d6a7; }
+.dark-mode .twp-notice-error { background: #b71c1c; color: #ef9a9a; }
+.dark-mode .twp-notice-info { background: #0d47a1; color: #90caf9; }
+@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
+
+/* ===================================================================
+ Tab Navigation
+ =================================================================== */
+.tab-nav {
+ display: flex;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+.tab-btn {
+ flex: 1;
+ padding: 10px 0;
+ text-align: center;
+ font-size: 13px;
+ font-weight: 600;
+ background: none;
+ border: none;
+ border-bottom: 3px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: color 0.2s, border-color 0.2s;
+}
+.tab-btn.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+/* ===================================================================
+ Tab Content
+ =================================================================== */
+.tab-content {
+ flex: 1;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+.tab-pane { display: none; height: 100%; }
+.tab-pane.active { display: flex; flex-direction: column; }
+
+/* ===================================================================
+ Phone Interface
+ =================================================================== */
+.phone-interface {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 12px;
+ gap: 12px;
+}
+
+/* Display */
+.phone-display {
+ background: var(--bg-display);
+ color: var(--text-light);
+ padding: 16px;
+ border-radius: var(--radius);
+ text-align: center;
+}
+#phone-status {
+ font-size: 14px;
+ color: var(--success);
+ margin-bottom: 4px;
+}
+#device-connection-status {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+#phone-number-display {
+ font-size: 20px;
+ min-height: 28px;
+ font-weight: 600;
+ letter-spacing: 1px;
+}
+#call-timer {
+ font-size: 16px;
+ margin-top: 6px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Input */
+#phone-number-input {
+ width: 100%;
+ padding: 12px;
+ font-size: 20px;
+ text-align: center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ outline: none;
+}
+#phone-number-input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(33,150,243,0.2);
+}
+
+/* Dialpad */
+.dialpad-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.dialpad-btn {
+ padding: 14px 0;
+ font-size: 22px;
+ font-weight: 500;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-radius: var(--radius);
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ position: relative;
+ transition: background 0.1s;
+ min-height: 54px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+.dialpad-btn:active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+.dialpad-btn span {
+ display: block;
+ font-size: 9px;
+ color: var(--text-secondary);
+ margin-top: 1px;
+ letter-spacing: 2px;
+}
+.dialpad-btn:active span { color: rgba(255,255,255,0.8); }
+
+/* Phone Controls */
+.phone-controls {
+ display: flex;
+ gap: 8px;
+}
+.phone-controls .btn-phone {
+ flex: 1;
+ height: 50px;
+ font-size: 16px;
+ font-weight: 600;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ transition: opacity 0.2s;
+}
+.btn-call {
+ background: var(--success);
+ color: #fff;
+}
+.btn-hangup {
+ background: var(--danger);
+ color: #fff;
+}
+.btn-answer {
+ background: var(--success);
+ color: #fff;
+ animation: pulse 1.5s infinite;
+}
+@keyframes pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(76,175,80,0.4); }
+ 50% { box-shadow: 0 0 0 10px rgba(76,175,80,0); }
+}
+
+/* Call Controls (hold/transfer/requeue/record) */
+.call-controls-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+}
+.call-controls-grid .btn-ctrl {
+ padding: 10px 8px;
+ font-size: 13px;
+ font-weight: 500;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-radius: 8px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ transition: background 0.1s;
+}
+.btn-ctrl:active { background: #e3f2fd; }
+.btn-ctrl.btn-active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+/* Error display */
+#browser-phone-error {
+ background: #ffebee;
+ color: #c62828;
+ padding: 10px 12px;
+ border-radius: 8px;
+ font-size: 13px;
+ margin: 0 12px;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
+}
+.dark-mode #browser-phone-error { background: #b71c1c; color: #ef9a9a; }
+
+/* ===================================================================
+ Settings Tab
+ =================================================================== */
+.settings-panel {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+}
+.settings-section {
+ background: var(--bg-secondary);
+ padding: 14px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+.settings-section h4 {
+ font-size: 14px;
+ margin-bottom: 10px;
+ color: var(--accent-dark);
+}
+.settings-section label {
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.settings-section select {
+ width: 100%;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 14px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ margin-top: 6px;
+}
+
+/* Call mode radio cards */
+.mode-selection {
+ display: flex;
+ gap: 10px;
+ margin: 10px 0;
+}
+.mode-option {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ border: 2px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.2s;
+ flex: 1;
+ background: var(--bg-primary);
+}
+.mode-option.active {
+ border-color: var(--accent);
+ background: #e3f2fd;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) .mode-option.active { background: #0d47a1; }
+}
+.dark-mode .mode-option.active { background: #0d47a1; }
+.mode-option input[type="radio"] { margin: 0 8px 0 0; }
+.mode-icon { font-size: 20px; margin-right: 8px; }
+.mode-details strong { display: block; font-size: 13px; }
+.mode-details small { font-size: 11px; color: var(--text-secondary); }
+
+.mode-status {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px;
+ background: var(--bg-primary);
+ border-radius: 6px;
+ font-size: 13px;
+}
+.mode-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
+
+#save-mode-btn {
+ padding: 6px 16px;
+ border: none;
+ background: var(--accent);
+ color: #fff;
+ border-radius: 6px;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+/* Setup info box */
+.setup-info {
+ background: #fff3cd;
+ padding: 12px;
+ border-radius: 8px;
+ border-left: 4px solid #ffc107;
+ font-size: 12px;
+}
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) .setup-info { background: #4a3800; color: #ffe082; }
+}
+.dark-mode .setup-info { background: #4a3800; color: #ffe082; }
+.setup-info h4 { margin-bottom: 6px; color: #856404; font-size: 13px; }
+.setup-info code {
+ display: block;
+ padding: 6px 8px;
+ background: rgba(0,0,0,0.05);
+ border-radius: 4px;
+ font-size: 11px;
+ word-break: break-all;
+ margin: 6px 0;
+}
+.btn-copy {
+ font-size: 11px;
+ padding: 2px 8px;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+/* ===================================================================
+ Queue Tab
+ =================================================================== */
+.queue-panel {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+}
+.queue-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.queue-header h4 { font-size: 15px; }
+.user-extension-admin {
+ background: var(--bg-secondary);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 12px;
+ color: var(--accent-dark);
+ border: 1px solid var(--border);
+}
+.queue-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+.queue-item.queue-type-personal { border-left: 4px solid var(--success); }
+.queue-item.queue-type-hold { border-left: 4px solid var(--warning); }
+.queue-item.queue-type-general { border-left: 4px solid var(--accent); }
+.queue-item.has-calls { background: #fff3cd; }
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) .queue-item.has-calls { background: #4a3800; }
+}
+.dark-mode .queue-item.has-calls { background: #4a3800; }
+.queue-info { flex: 1; }
+.queue-name {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 14px;
+}
+.queue-details {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 3px;
+ display: flex;
+ gap: 10px;
+}
+.queue-waiting.has-calls {
+ color: var(--danger);
+ font-weight: bold;
+}
+.queue-loading {
+ text-align: center;
+ color: var(--text-secondary);
+ font-style: italic;
+ padding: 20px;
+}
+.queue-actions { text-align: center; }
+
+.btn-sm {
+ font-size: 12px;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-radius: 6px;
+ cursor: pointer;
+}
+.btn-sm:active { background: #e3f2fd; }
+
+.btn-refresh {
+ padding: 8px 16px;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+/* ===================================================================
+ Recent Tab
+ =================================================================== */
+.recent-panel {
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ height: 100%;
+}
+.recent-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.recent-header h4 { font-size: 15px; }
+.recent-empty {
+ text-align: center;
+ color: var(--text-secondary);
+ font-style: italic;
+ padding: 40px 20px;
+ font-size: 14px;
+}
+.recent-item {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ gap: 10px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+.recent-item:active { background: var(--bg-primary); }
+.recent-direction {
+ font-size: 18px;
+ flex-shrink: 0;
+ width: 28px;
+ text-align: center;
+}
+.recent-info { flex: 1; min-width: 0; }
+.recent-number {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--accent);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.recent-meta {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+ display: flex;
+ gap: 10px;
+}
+.recent-callback {
+ flex-shrink: 0;
+ padding: 6px 12px;
+ background: var(--success);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+/* ===================================================================
+ Dialog / Overlay (transfer, requeue)
+ =================================================================== */
+.twp-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.5);
+ z-index: 9999;
+}
+.twp-dialog {
+ position: fixed;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ background: var(--bg-secondary);
+ padding: 20px;
+ border-radius: var(--radius);
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
+ z-index: 10000;
+ width: 90%;
+ max-width: 420px;
+ max-height: 80vh;
+ overflow-y: auto;
+ color: var(--text-primary);
+}
+.twp-dialog h3 { margin: 0 0 12px 0; font-size: 16px; }
+.twp-dialog input[type="text"],
+.twp-dialog input[type="tel"] {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 14px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ margin: 8px 0;
+}
+.twp-dialog .dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 14px;
+}
+.btn-primary {
+ padding: 8px 18px;
+ border: none;
+ background: var(--accent);
+ color: #fff;
+ border-radius: 6px;
+ font-size: 13px;
+ cursor: pointer;
+}
+.btn-primary:disabled { opacity: 0.5; cursor: default; }
+.btn-secondary {
+ padding: 8px 18px;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-radius: 6px;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.agent-option, .queue-option {
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ background: var(--bg-primary);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.agent-option.selected, .queue-option.selected {
+ background: #e3f2fd;
+ border-color: var(--accent);
+}
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) .agent-option.selected, :root:not(.light-mode) .queue-option.selected { background: #0d47a1; }
+}
+.dark-mode .agent-option.selected, .dark-mode .queue-option.selected { background: #0d47a1; }
+
+/* ===================================================================
+ Z-Index Layering & Overlap Fixes
+ =================================================================== */
+.twp-app { position: relative; z-index: 1; }
+.agent-status-bar { position: relative; z-index: 10; }
+.tab-nav { position: relative; z-index: 10; }
+.tab-content { position: relative; z-index: 1; }
+.phone-interface { position: relative; z-index: 1; overflow-y: auto; }
+.phone-controls { position: relative; z-index: 2; }
+#admin-call-controls-panel { position: relative; z-index: 2; margin-top: 4px; }
+.call-controls-grid { position: relative; z-index: 2; }
+.twp-overlay { z-index: 9999; }
+.twp-dialog { z-index: 10000; }
+
+/* Ensure tab panes scroll properly */
+.tab-pane.active { overflow-y: auto; -webkit-overflow-scrolling: touch; }
+#tab-phone.active { overflow: hidden; }
+.phone-interface { flex: 1; overflow-y: auto; min-height: 0; }
+
+/* Prevent call controls from overlapping dialpad */
+.dialpad-grid { position: relative; z-index: 1; flex-shrink: 0; }
+
+/* Dark mode for btn-ctrl active state */
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) .btn-ctrl:active { background: #0d47a1; color: #fff; }
+ :root:not(.light-mode) .btn-sm:active { background: #0d47a1; color: #fff; }
+}
+.dark-mode .btn-ctrl:active { background: #0d47a1; color: #fff; }
+.dark-mode .btn-sm:active { background: #0d47a1; color: #fff; }
+
+/* ===================================================================
+ Comprehensive Dark Mode Enhancements
+ =================================================================== */
+/* Shared dark mode rules — applied by media query or manual toggle */
+@media (prefers-color-scheme: dark) {
+ :root:not(.light-mode) input[type="tel"],
+ :root:not(.light-mode) input[type="text"],
+ :root:not(.light-mode) select {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-color: var(--border);
+ }
+ :root:not(.light-mode) .settings-section h4 { color: #64b5f6; }
+ :root:not(.light-mode) .setup-info h4 { color: #ffe082; }
+ :root:not(.light-mode) .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
+ :root:not(.light-mode) .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
+ :root:not(.light-mode) .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
+ :root:not(.light-mode) .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
+}
+.dark-mode input[type="tel"],
+.dark-mode input[type="text"],
+.dark-mode select {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-color: var(--border);
+}
+.dark-mode .settings-section h4 { color: #64b5f6; }
+.dark-mode .setup-info h4 { color: #ffe082; }
+.dark-mode .setup-info code { background: rgba(255,255,255,0.08); color: #ffe082; }
+.dark-mode .btn-copy { background: var(--bg-secondary); color: var(--text-primary); border-color: var(--border); }
+.dark-mode .btn-refresh { background: var(--bg-secondary); color: var(--text-primary); }
+.dark-mode .btn-secondary { background: var(--bg-secondary); color: var(--text-primary); }
+
+/* Dark mode toggle switch styling */
+.dark-mode-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 4px 0;
+}
+.dark-mode-toggle .toggle-label {
+ font-size: 13px;
+ color: var(--text-primary);
+}
+.toggle-switch {
+ position: relative;
+ width: 48px;
+ height: 26px;
+ cursor: pointer;
+}
+.toggle-switch input { opacity: 0; width: 0; height: 0; }
+.toggle-slider {
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: var(--border);
+ border-radius: 26px;
+ transition: background 0.2s;
+}
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ left: 3px;
+ bottom: 3px;
+ background: #fff;
+ border-radius: 50%;
+ transition: transform 0.2s;
+}
+.toggle-switch input:checked + .toggle-slider {
+ background: var(--accent);
+}
+.toggle-switch input:checked + .toggle-slider::before {
+ transform: translateX(22px);
+}
+.dark-mode-options {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+}
+.dark-mode-opt {
+ flex: 1;
+ padding: 8px 4px;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 500;
+ border: 1px solid var(--border);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+.dark-mode-opt.active {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: #fff;
+}
diff --git a/assets/mobile/phone.js b/assets/mobile/phone.js
new file mode 100644
index 0000000..1213c44
--- /dev/null
+++ b/assets/mobile/phone.js
@@ -0,0 +1,1065 @@
+// Configuration injected by PHP template via twpConfig global
+var ajaxurl = window.twpConfig.ajaxUrl;
+var twpNonce = window.twpConfig.nonce;
+var twpRingtoneUrl = window.twpConfig.ringtoneUrl;
+var twpPhoneIconUrl = window.twpConfig.phoneIconUrl;
+var twpSwUrl = window.twpConfig.swUrl;
+var twpTwilioEdge = window.twpConfig.twilioEdge;
+
+(function($) {
+ // ============================================================
+ // Flutter WebView Bridge
+ // ============================================================
+ window.TwpMobile = window.TwpMobile || {};
+
+ /** Flutter injects FCM token via this method. */
+ window.TwpMobile.getFcmToken = function() {
+ return window.TwpMobile._fcmToken || null;
+ };
+ window.TwpMobile.setFcmToken = function(token) {
+ window.TwpMobile._fcmToken = token;
+ // Register FCM token with server via WP AJAX (uses cookie auth)
+ $.post(ajaxurl, {
+ action: 'twp_register_fcm_token',
+ nonce: twpNonce,
+ fcm_token: token
+ }).fail(function() { console.warn('TWP: FCM token registration failed'); });
+ };
+
+ /** Flutter calls this when a notification is tapped. */
+ window.TwpMobile.onNotificationTap = function(data) {
+ // Switch to phone tab and focus.
+ switchTab('phone');
+ if (data && data.caller) {
+ $('#phone-number-input').val(data.caller);
+ }
+ };
+
+ /** Notify Flutter that page is ready (via webview_flutter JavaScriptChannel). */
+ function notifyFlutterReady() {
+ try {
+ if (window.TwpMobile && window.TwpMobile.postMessage) {
+ window.TwpMobile.postMessage('onPageReady');
+ }
+ } catch (e) { /* not in WebView */ }
+ }
+
+ /** Notify Flutter that session has expired. */
+ function notifyFlutterSessionExpired() {
+ try {
+ if (window.TwpMobile && window.TwpMobile.postMessage) {
+ window.TwpMobile.postMessage('onSessionExpired');
+ }
+ } catch (e) { /* not in WebView */ }
+ }
+
+ /**
+ * Wrapper around $.post that detects session expiration.
+ */
+ function twpPost(data, successCb, failCb) {
+ return $.post(ajaxurl, data, function(response) {
+ if (successCb) successCb(response);
+ }).fail(function(xhr) {
+ // Detect login redirect / 403
+ if (xhr.status === 403 || (xhr.responseText && xhr.responseText.indexOf('wp-login') !== -1)) {
+ notifyFlutterSessionExpired();
+ }
+ if (failCb) failCb(xhr);
+ });
+ }
+
+ // ============================================================
+ // Tab Navigation
+ // ============================================================
+ function switchTab(name) {
+ $('.tab-btn').removeClass('active');
+ $('.tab-btn[data-tab="' + name + '"]').addClass('active');
+ $('.tab-pane').removeClass('active');
+ $('#tab-' + name).addClass('active');
+ }
+ $('.tab-btn').on('click', function() { switchTab($(this).data('tab')); });
+
+ // ============================================================
+ // Notices
+ // ============================================================
+ function showNotice(message, type) {
+ var cls = 'twp-notice twp-notice-' + (type || 'info');
+ var $el = $('' + message + '
');
+ $('#twp-notices').append($el);
+ setTimeout(function() { $el.fadeOut(300, function() { $el.remove(); }); }, 4000);
+ }
+
+ function showError(message) {
+ $('#browser-phone-error').html('Error: ' + message + '
').show();
+ $('#phone-status').text('Error').css('color', 'var(--danger)');
+ }
+
+ // ============================================================
+ // Core phone state
+ // ============================================================
+ var device = null;
+ var currentCall = null;
+ var callTimer = null;
+ var callStartTime = null;
+ var tokenRefreshTimer = null;
+ var tokenExpiry = null;
+ var audioContext = null;
+ var ringtoneAudio = null;
+ var isPageVisible = true;
+ var deviceConnectionState = 'disconnected';
+ var serviceWorkerRegistration = null;
+ var currentCallDirection = null;
+ var callHistory = [];
+
+ // ============================================================
+ // AudioContext & Ringtone
+ // ============================================================
+ function initializeAudioContext() {
+ try {
+ if (!audioContext) {
+ var AC = window.AudioContext || window.webkitAudioContext;
+ audioContext = new AC();
+ }
+ if (audioContext.state === 'suspended') {
+ audioContext.resume().catch(function() {});
+ }
+ return true;
+ } catch (e) { return false; }
+ }
+
+ function setupRingtone() {
+ if (!ringtoneAudio) {
+ ringtoneAudio = new Audio();
+ ringtoneAudio.loop = true;
+ ringtoneAudio.volume = 0.7;
+ ringtoneAudio.src = twpRingtoneUrl;
+ ringtoneAudio.addEventListener('error', function() {}, { once: true });
+ ringtoneAudio.load();
+ }
+ }
+
+ function playRingtone() {
+ try {
+ initializeAudioContext();
+ if (ringtoneAudio) {
+ var p = ringtoneAudio.play();
+ if (p !== undefined) p.catch(function() { vibrateDevice([300,200,300,200,300]); });
+ }
+ vibrateDevice([300,200,300,200,300]);
+ } catch (e) {}
+ }
+
+ function stopRingtone() {
+ try { if (ringtoneAudio) { ringtoneAudio.pause(); ringtoneAudio.currentTime = 0; } } catch (e) {}
+ }
+
+ function vibrateDevice(pattern) {
+ if ('vibrate' in navigator) navigator.vibrate(pattern);
+ }
+
+ // ============================================================
+ // Service Worker & Notifications
+ // ============================================================
+ function registerServiceWorker() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register(twpSwUrl).then(function(reg) {
+ serviceWorkerRegistration = reg;
+ if ('Notification' in window && Notification.permission === 'default') {
+ Notification.requestPermission();
+ }
+ }).catch(function() {});
+ }
+ }
+
+ function sendIncomingCallNotification(callerNumber) {
+ if ('Notification' in window && Notification.permission === 'granted') {
+ if (serviceWorkerRegistration && serviceWorkerRegistration.active) {
+ serviceWorkerRegistration.active.postMessage({
+ type: 'SHOW_NOTIFICATION',
+ title: 'Incoming Call',
+ body: 'Call from ' + (callerNumber || 'Unknown Number'),
+ icon: twpPhoneIconUrl,
+ tag: 'incoming-call',
+ requireInteraction: true
+ });
+ } else {
+ new Notification('Incoming Call', {
+ body: 'Call from ' + (callerNumber || 'Unknown Number'),
+ icon: twpPhoneIconUrl,
+ tag: 'incoming-call',
+ requireInteraction: true
+ });
+ }
+ }
+ }
+
+ // ============================================================
+ // Page Visibility
+ // ============================================================
+ function setupPageVisibility() {
+ document.addEventListener('visibilitychange', function() {
+ isPageVisible = !document.hidden;
+ if (isPageVisible && audioContext) initializeAudioContext();
+ });
+ }
+
+ // ============================================================
+ // Connection Status
+ // ============================================================
+ function updateConnectionStatus(state) {
+ deviceConnectionState = state;
+ var text = '', color = '';
+ switch (state) {
+ case 'connected': text = 'Connected'; color = 'var(--success)'; break;
+ case 'connecting': text = 'Connecting...'; color = 'var(--warning)'; break;
+ case 'disconnected': text = 'Disconnected'; color = 'var(--danger)'; break;
+ default: text = 'Unknown'; color = 'var(--text-secondary)';
+ }
+ $('#device-connection-status').text(text).css('color', color);
+ }
+
+ // ============================================================
+ // Twilio Device Setup
+ // ============================================================
+ function waitForTwilioSDK(cb) {
+ if (typeof Twilio !== 'undefined' && Twilio.Device) { cb(); }
+ else { setTimeout(function() { waitForTwilioSDK(cb); }, 100); }
+ }
+
+ function initializeBrowserPhone() {
+ $('#phone-status').text('Initializing...');
+ updateConnectionStatus('connecting');
+
+ setupRingtone();
+ registerServiceWorker();
+ setupPageVisibility();
+
+ $(document).one('click touchstart', function() { initializeAudioContext(); });
+
+ waitForTwilioSDK(function() {
+ twpPost({
+ action: 'twp_generate_capability_token',
+ nonce: twpNonce
+ }, function(response) {
+ if (response.success) {
+ $('#browser-phone-error').hide();
+ setupTwilioDevice(response.data.token);
+ tokenExpiry = Date.now() + (response.data.expires_in || 3600) * 1000;
+ scheduleTokenRefresh();
+ } else {
+ var msg = response.data || 'Unknown error';
+ showError('Failed to initialize: ' + msg);
+ updateConnectionStatus('disconnected');
+ }
+ }, function() {
+ showError('Failed to connect to server');
+ updateConnectionStatus('disconnected');
+ });
+ });
+ }
+
+ async function requestMediaPermissions() {
+ try {
+ var stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
+ stream.getTracks().forEach(function(t) { t.stop(); });
+ return true;
+ } catch (error) {
+ var msg = 'Microphone access is required. ';
+ if (error.name === 'NotAllowedError') msg += 'Please allow microphone access.';
+ else if (error.name === 'NotFoundError') msg += 'No microphone found.';
+ else msg += 'Check browser settings.';
+ showError(msg);
+ return false;
+ }
+ }
+
+ async function setupTwilioDevice(token) {
+ try {
+ if (typeof Twilio === 'undefined' || !Twilio.Device) throw new Error('Twilio Voice SDK not loaded');
+
+ updateConnectionStatus('connecting');
+
+ var hasPerms = await requestMediaPermissions();
+ if (!hasPerms) { updateConnectionStatus('disconnected'); return; }
+
+ if (device) { await device.destroy(); }
+
+ var isAndroid = /Android/i.test(navigator.userAgent);
+ var audioConstraints = {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true
+ };
+ if (isAndroid) {
+ audioConstraints.googEchoCancellation = true;
+ audioConstraints.googNoiseSuppression = true;
+ audioConstraints.googAutoGainControl = true;
+ audioConstraints.googHighpassFilter = true;
+ }
+
+ device = new Twilio.Device(token, {
+ logLevel: 1,
+ codecPreferences: ['opus', 'pcmu'],
+ edge: twpTwilioEdge,
+ enableIceRestart: true,
+ audioConstraints: audioConstraints,
+ maxCallSignalingTimeoutMs: 30000,
+ closeProtection: true
+ });
+
+ device.on('registered', function() {
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ $('#call-btn').prop('disabled', false);
+ updateConnectionStatus('connected');
+ });
+
+ device.on('unregistered', function() { updateConnectionStatus('disconnected'); });
+
+ device.on('error', function(error) {
+ updateConnectionStatus('disconnected');
+ var msg = error.message || error.toString();
+ if (msg.includes('valid callerId')) {
+ msg = 'Select a verified Twilio phone number as Caller ID.';
+ } else if (msg.includes('token') || msg.includes('Token')) {
+ msg = 'Token error: ' + msg;
+ setTimeout(initializeBrowserPhone, 5000);
+ } else if (msg.includes('31005') || msg.includes('Connection error')) {
+ msg = 'Connection error. Check internet connection.';
+ setTimeout(function() { if (device) device.register(); }, 3000);
+ }
+ showError(msg);
+ });
+
+ device.on('incoming', function(call) {
+ currentCall = call;
+ currentCallDirection = 'inbound';
+ var caller = call.parameters.From || 'Unknown';
+ $('#phone-status').text('Incoming Call').css('color', 'var(--warning)');
+ $('#phone-number-display').text(caller);
+ $('#call-btn').hide();
+ $('#answer-btn').show();
+
+ playRingtone();
+ if (!isPageVisible) sendIncomingCallNotification(caller);
+ setupCallHandlers(call);
+
+ // Switch to phone tab on incoming call
+ switchTab('phone');
+
+ if ($('#auto-answer').is(':checked')) call.accept();
+ });
+
+ device.on('tokenWillExpire', function() { refreshToken(); });
+
+ await device.register();
+ } catch (error) {
+ showError('Failed to setup device: ' + error.message);
+ }
+ }
+
+ // ============================================================
+ // Call Handlers
+ // ============================================================
+ function setupCallHandlers(call) {
+ call.on('accept', function() {
+ stopRingtone();
+ $('#phone-status').text('Connected').css('color', 'var(--accent)');
+ $('#call-btn').hide();
+ $('#answer-btn').hide();
+ $('#hangup-btn').show();
+ $('#admin-call-controls-panel').show();
+ startCallTimer();
+ });
+
+ call.on('disconnect', function() {
+ stopRingtone();
+ // Capture call info for history before clearing
+ var disconnectedNumber = $('#phone-number-display').text() || $('#phone-number-input').val();
+ var callDuration = $('#call-timer').text();
+ if (disconnectedNumber && callStartTime) {
+ addToCallHistory(disconnectedNumber, currentCallDirection || 'outbound', callDuration);
+ }
+ currentCall = null;
+ currentCallDirection = null;
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ $('#hangup-btn').hide();
+ $('#answer-btn').hide();
+ $('#call-btn').show();
+ $('#admin-call-controls-panel').hide();
+ $('#call-timer').hide();
+ stopCallTimer();
+ $('#phone-number-input').val('');
+ $('#phone-number-display').text('');
+ $('#admin-hold-btn').html('⏸ Hold').removeClass('btn-active');
+ $('#admin-record-btn').html('⏺ Record').removeClass('btn-active');
+ adminIsOnHold = false;
+ adminIsRecording = false;
+ adminRecordingSid = null;
+ });
+
+ call.on('reject', function() {
+ stopRingtone();
+ currentCall = null;
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ $('#answer-btn').hide();
+ $('#call-btn').show();
+ });
+
+ call.on('cancel', function() {
+ stopRingtone();
+ currentCall = null;
+ $('#phone-status').text('Missed Call').css('color', 'var(--warning)');
+ $('#answer-btn').hide();
+ $('#call-btn').show();
+ setTimeout(function() { $('#phone-status').text('Ready').css('color', 'var(--success)'); }, 3000);
+ });
+
+ call.on('error', function(error) {
+ stopRingtone();
+ var msg = error.message || error.toString();
+ if (error.code === 31005) msg = 'Connection failed. Check network.';
+ else if (error.code === 31201 || error.code === 31204) msg = 'Call setup failed. Try again.';
+ else if (error.code === 31208) msg = 'Media failed. Check microphone.';
+ showError('Call error: ' + msg);
+ });
+ }
+
+ // ============================================================
+ // Token Refresh
+ // ============================================================
+ function refreshToken() {
+ if (currentCall) { setTimeout(refreshToken, 60000); return; }
+
+ twpPost({
+ action: 'twp_generate_capability_token',
+ nonce: twpNonce
+ }, function(response) {
+ if (response.success && device) {
+ device.updateToken(response.data.token);
+ tokenExpiry = Date.now() + (response.data.expires_in || 3600) * 1000;
+ scheduleTokenRefresh();
+ } else {
+ showError('Failed to refresh connection. Please reload.');
+ }
+ }, function() {
+ setTimeout(refreshToken, 30000);
+ });
+ }
+
+ function scheduleTokenRefresh() {
+ if (tokenRefreshTimer) clearTimeout(tokenRefreshTimer);
+ if (!tokenExpiry) return;
+ var ms = tokenExpiry - Date.now() - 5 * 60 * 1000;
+ if (ms <= 0) refreshToken();
+ else tokenRefreshTimer = setTimeout(refreshToken, ms);
+ }
+
+ // ============================================================
+ // Timer
+ // ============================================================
+ function startCallTimer() {
+ callStartTime = new Date();
+ $('#call-timer').show();
+ callTimer = setInterval(function() {
+ var s = Math.floor((new Date() - callStartTime) / 1000);
+ var m = Math.floor(s / 60);
+ s = s % 60;
+ $('#call-timer').text((m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s);
+ }, 1000);
+ }
+
+ function stopCallTimer() {
+ if (callTimer) { clearInterval(callTimer); callTimer = null; }
+ $('#call-timer').text('00:00');
+ }
+
+ // ============================================================
+ // Caller ID loading
+ // ============================================================
+ twpPost({
+ action: 'twp_get_phone_numbers',
+ nonce: twpNonce
+ }, function(response) {
+ if (response.success) {
+ var opts = '';
+ response.data.forEach(function(n) { opts += ''; });
+ $('#caller-id-select').html(opts);
+ // Restore saved caller ID from localStorage
+ var savedCallerId = localStorage.getItem('twp_caller_id');
+ if (savedCallerId) {
+ $('#caller-id-select').val(savedCallerId);
+ }
+ } else {
+ $('#caller-id-select').html('');
+ }
+ }, function() {
+ $('#caller-id-select').html('');
+ });
+
+ // Persist caller ID selection to localStorage
+ $('#caller-id-select').on('change', function() {
+ localStorage.setItem('twp_caller_id', $(this).val());
+ });
+
+ // ============================================================
+ // Dialpad
+ // ============================================================
+ $('.dialpad-btn').on('click touchend', function(e) {
+ e.preventDefault();
+ var digit = $(this).data('digit');
+ $('#phone-number-input').val($('#phone-number-input').val() + digit);
+ initializeAudioContext();
+
+ // Send DTMF during active call
+ if (currentCall) {
+ currentCall.sendDigits(String(digit));
+ }
+ });
+
+ // ============================================================
+ // Call / Hangup / Answer
+ // ============================================================
+ $('#call-btn').on('click', async function() {
+ var num = $('#phone-number-input').val().trim();
+ var cid = $('#caller-id-select').val();
+ if (!num) { showNotice('Enter a phone number', 'error'); return; }
+ if (!cid) { showNotice('Select a caller ID', 'error'); return; }
+ if (!device) { showNotice('Phone not initialized. Reload page.', 'error'); return; }
+
+ num = num.replace(/\D/g, '');
+ if (num.length === 10) num = '+1' + num;
+ else if (num.length === 11 && num.charAt(0) === '1') num = '+' + num;
+ else if (!num.startsWith('+')) num = '+' + num;
+
+ $('#phone-number-display').text(num);
+ $('#phone-status').text('Calling...').css('color', 'var(--warning)');
+ currentCallDirection = 'outbound';
+
+ try {
+ currentCall = await device.connect({ params: { To: num, From: cid } });
+ setupCallHandlers(currentCall);
+ } catch (err) {
+ showError('Failed to call: ' + err.message);
+ $('#phone-status').text('Ready').css('color', 'var(--success)');
+ }
+ });
+
+ $('#hangup-btn').on('click', function() { if (currentCall) currentCall.disconnect(); });
+
+ $('#answer-btn').on('click', function() {
+ if (!currentCall) { showError('No incoming call'); return; }
+ if (deviceConnectionState !== 'connected') {
+ showError('Phone not connected. Reconnecting...');
+ if (device) device.register().then(function() { if (currentCall) currentCall.accept(); }).catch(function() { showError('Reconnect failed. Reload page.'); });
+ return;
+ }
+ initializeAudioContext();
+ try { currentCall.accept(); } catch (e) { showError('Failed to answer: ' + e.message); }
+ });
+
+ // ============================================================
+ // Call Controls: Hold / Transfer / Requeue / Record
+ // ============================================================
+ var adminIsOnHold = false;
+ var adminIsRecording = false;
+ var adminRecordingSid = null;
+
+ function getCallSid() {
+ if (!currentCall) return null;
+ return currentCall.parameters.CallSid ||
+ (currentCall.customParameters && currentCall.customParameters.CallSid) ||
+ currentCall.outgoingConnectionId ||
+ currentCall.sid;
+ }
+
+ $('#admin-hold-btn').on('click', function() {
+ var sid = getCallSid();
+ if (!sid) return;
+ var $btn = $(this);
+ twpPost({
+ action: 'twp_toggle_hold',
+ call_sid: sid,
+ hold: !adminIsOnHold,
+ nonce: twpNonce
+ }, function(r) {
+ if (r.success) {
+ adminIsOnHold = !adminIsOnHold;
+ $btn.html(adminIsOnHold ? '▶ Unhold' : '⏸ Hold').toggleClass('btn-active', adminIsOnHold);
+ showNotice(adminIsOnHold ? 'Call on hold' : 'Call resumed', 'info');
+ } else { showNotice('Hold failed: ' + (r.data || ''), 'error'); }
+ });
+ });
+
+ $('#admin-transfer-btn').on('click', function() {
+ if (!currentCall) return;
+ twpPost({
+ action: 'twp_get_transfer_targets',
+ nonce: twpNonce
+ }, function(r) {
+ if (r.success && r.data && (r.data.users || r.data.queues)) {
+ showEnhancedTransferDialog(r.data);
+ } else {
+ twpPost({ action: 'twp_get_online_agents', nonce: twpNonce }, function(lr) {
+ if (lr.success && lr.data.length > 0) showAgentTransferDialog(lr.data);
+ else showManualTransferDialog();
+ }, function() { showManualTransferDialog(); });
+ }
+ }, function() { showManualTransferDialog(); });
+ });
+
+ $('#admin-requeue-btn').on('click', function() {
+ if (!currentCall) return;
+ twpPost({ action: 'twp_get_all_queues', nonce: twpNonce }, function(r) {
+ if (r.success && r.data.length > 0) showRequeueDialog(r.data);
+ else showNotice('No queues available', 'error');
+ }, function() { showNotice('Failed to load queues', 'error'); });
+ });
+
+ $('#admin-record-btn').on('click', function() {
+ if (!currentCall) return;
+ if (adminIsRecording) stopRecording();
+ else startRecording();
+ });
+
+ function startRecording() {
+ var sid = getCallSid();
+ if (!sid) { showNotice('Cannot determine call SID', 'error'); return; }
+ twpPost({ action: 'twp_start_recording', call_sid: sid, nonce: twpNonce }, function(r) {
+ if (r.success) {
+ adminIsRecording = true;
+ adminRecordingSid = r.data.recording_sid;
+ $('#admin-record-btn').html('⏹ Stop Rec').addClass('btn-active');
+ showNotice('Recording started', 'success');
+ } else { showNotice('Recording failed: ' + (r.data || ''), 'error'); }
+ });
+ }
+
+ function stopRecording() {
+ if (!adminRecordingSid) return;
+ var sid = getCallSid() || '';
+ twpPost({ action: 'twp_stop_recording', call_sid: sid, recording_sid: adminRecordingSid, nonce: twpNonce }, function(r) {
+ if (r.success) {
+ adminIsRecording = false;
+ adminRecordingSid = null;
+ $('#admin-record-btn').html('⏺ Record').removeClass('btn-active');
+ showNotice('Recording stopped', 'info');
+ } else { showNotice('Stop recording failed: ' + (r.data || ''), 'error'); }
+ });
+ }
+
+ // ============================================================
+ // Transfer Dialogs
+ // ============================================================
+ function closeDialog() { $('.twp-overlay, .twp-dialog').remove(); }
+
+ function showEnhancedTransferDialog(data) {
+ var html = 'Transfer Call
';
+
+ if (data.users && data.users.length > 0) {
+ html += '
Agents
';
+ data.users.forEach(function(u) {
+ var status = u.is_logged_in ? '🟢 Online' : '🔴 Offline';
+ html += '
';
+ html += '
' + u.display_name + '
Ext: ' + u.extension + '
';
+ html += '
' + status + '
';
+ });
+ }
+ if (data.queues && data.queues.length > 0) {
+ html += '
Queues
';
+ data.queues.forEach(function(q) {
+ html += '
';
+ html += '
' + q.queue_name + '
';
+ html += '
' + q.waiting_calls + ' waiting
';
+ });
+ }
+
+ html += '
Manual
';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+
+ $('body').append(html);
+ var selected = null;
+
+ $('.twp-dialog .agent-option, .twp-dialog .queue-option').on('click', function() {
+ $('.agent-option, .queue-option').removeClass('selected');
+ $(this).addClass('selected');
+ selected = { type: $(this).data('type'), target: $(this).data('target') };
+ $('#transfer-manual-input').val('');
+ $('#confirm-transfer').prop('disabled', false);
+ });
+
+ $('#transfer-manual-input').on('input', function() {
+ var v = $(this).val().trim();
+ if (v) {
+ $('.agent-option, .queue-option').removeClass('selected');
+ selected = { type: /^\d{3,4}$/.test(v) ? 'extension' : 'phone', target: v };
+ $('#confirm-transfer').prop('disabled', false);
+ }
+ });
+
+ $('#confirm-transfer').on('click', function() { if (selected) executeTransfer(selected.type, selected.target); });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ function showAgentTransferDialog(agents) {
+ var html = 'Transfer to Agent
';
+ agents.forEach(function(a) {
+ var st = a.is_available ? '🟢' : '🔴';
+ html += '
';
+ html += '
' + a.name + '' + st + '
';
+ });
+ html += '
Or enter number:
';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ $('body').append(html);
+
+ var sel = null;
+ $('.agent-option').on('click', function() {
+ $('.agent-option').removeClass('selected');
+ $(this).addClass('selected');
+ sel = { id: $(this).data('agent-id'), method: $(this).data('method'), value: $(this).data('value') };
+ $('#transfer-manual-input').val('');
+ $('#confirm-transfer').prop('disabled', false);
+ });
+ $('#transfer-manual-input').on('input', function() {
+ if ($(this).val().trim()) { sel = null; $('#confirm-transfer').prop('disabled', false); }
+ });
+ $('#confirm-transfer').on('click', function() {
+ var manual = $('#transfer-manual-input').val().trim();
+ if (manual) transferToNumber(manual);
+ else if (sel) transferToAgent(sel);
+ });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ function showManualTransferDialog() {
+ var html = 'Transfer Call
';
+ html += '
Enter phone number:
';
+ html += '
';
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ $('body').append(html);
+
+ $('#confirm-transfer').on('click', function() {
+ var n = $('#transfer-manual-input').val().trim();
+ if (n) transferToNumber(n);
+ });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ function executeTransfer(type, target) {
+ var sid = getCallSid();
+ if (!sid) { showNotice('No call SID', 'error'); return; }
+
+ var data = { action: 'twp_transfer_call', call_sid: sid, nonce: twpNonce };
+ if (/^\d{3,4}$/.test(target)) data.target_queue_id = target;
+ else { data.transfer_type = 'phone'; data.transfer_target = target; }
+
+ twpPost(data, function(r) {
+ if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); }
+ else showNotice('Transfer failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Transfer failed', 'error'); });
+ }
+
+ function transferToNumber(num) {
+ var sid = getCallSid();
+ if (!sid) return;
+ twpPost({ action: 'twp_transfer_call', call_sid: sid, agent_number: num, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
+ else showNotice('Transfer failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Transfer failed', 'error'); });
+ }
+
+ function transferToAgent(agent) {
+ var sid = getCallSid();
+ if (!sid) return;
+ twpPost({ action: 'twp_transfer_to_agent_queue', call_sid: sid, agent_id: agent.id, transfer_method: agent.method, transfer_value: agent.value, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Call transferred', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
+ else showNotice('Transfer failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Transfer failed', 'error'); });
+ }
+
+ // ============================================================
+ // Requeue Dialog
+ // ============================================================
+ function showRequeueDialog(queues) {
+ var html = 'Requeue Call
';
+ html += '
Select a queue:
';
+ queues.forEach(function(q) {
+ html += '
' + q.queue_name + '
';
+ });
+ html += '
';
+ html += '';
+ html += '';
+ html += '
';
+ $('body').append(html);
+
+ var selQ = null;
+ $('.twp-dialog .queue-option').on('click', function() {
+ $('.queue-option').removeClass('selected');
+ $(this).addClass('selected');
+ selQ = $(this).data('queue-id');
+ $('#confirm-requeue').prop('disabled', false);
+ });
+ $('#confirm-requeue').on('click', function() {
+ if (!selQ) return;
+ var sid = getCallSid();
+ if (!sid) return;
+ twpPost({ action: 'twp_requeue_call', call_sid: sid, queue_id: selQ, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Call requeued', 'success'); closeDialog(); if (currentCall) currentCall.disconnect(); }
+ else showNotice('Requeue failed: ' + (r.data || ''), 'error');
+ }, function() { showNotice('Requeue failed', 'error'); });
+ });
+ $('.close-dialog, .twp-overlay').on('click', closeDialog);
+ }
+
+ // ============================================================
+ // Call History (Recent tab)
+ // ============================================================
+ function addToCallHistory(number, direction, duration) {
+ if (!number || number === 'Unknown') return;
+ callHistory.unshift({
+ number: number,
+ direction: direction || 'outbound',
+ time: new Date(),
+ duration: duration || '00:00'
+ });
+ // Keep last 50 entries
+ if (callHistory.length > 50) callHistory.pop();
+ renderCallHistory();
+ }
+
+ function renderCallHistory() {
+ var $list = $('#recent-call-list');
+ if (callHistory.length === 0) {
+ $list.html('No calls yet this session.
');
+ return;
+ }
+ var h = '';
+ callHistory.forEach(function(entry, idx) {
+ var icon = entry.direction === 'inbound' ? '📥' : '📤';
+ var dirLabel = entry.direction === 'inbound' ? 'Inbound' : 'Outbound';
+ var timeStr = entry.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ h += '';
+ h += '
' + icon + '
';
+ h += '
';
+ h += '
' + entry.number + '
';
+ h += '
' + dirLabel + '' + timeStr + '' + entry.duration + '
';
+ h += '
';
+ h += '
';
+ h += '
';
+ });
+ $list.html(h);
+ }
+
+ $(document).on('click', '.recent-item', function() {
+ var num = $(this).data('number');
+ if (num) {
+ $('#phone-number-input').val(num);
+ switchTab('phone');
+ }
+ });
+
+ $(document).on('click', '.recent-callback', function(e) {
+ e.stopPropagation();
+ var num = $(this).data('number');
+ if (num) {
+ $('#phone-number-input').val(num);
+ switchTab('phone');
+ }
+ });
+
+ $('#clear-history-btn').on('click', function() {
+ callHistory = [];
+ renderCallHistory();
+ });
+
+ // ============================================================
+ // Queue Management
+ // ============================================================
+ var adminUserQueues = [];
+
+ function loadAdminQueues() {
+ twpPost({ action: 'twp_get_agent_queues', nonce: twpNonce }, function(r) {
+ if (r.success) { adminUserQueues = r.data; displayAdminQueues(); }
+ else { $('#admin-queue-list').html('Failed to load queues
'); }
+ }, function() { $('#admin-queue-list').html('Failed to load queues
'); });
+ }
+
+ function displayAdminQueues() {
+ var $list = $('#admin-queue-list');
+ if (adminUserQueues.length === 0) { $list.html('No queues assigned.
'); return; }
+
+ var h = '';
+ adminUserQueues.forEach(function(q) {
+ var hasW = parseInt(q.current_waiting) > 0;
+ var wc = q.current_waiting || 0;
+ var qt = q.queue_type || 'general';
+ var icon = qt === 'personal' ? '👤' : qt === 'hold' ? '⏸' : '📋';
+ var desc = qt === 'personal' ? (q.extension ? ' (Ext: ' + q.extension + ')' : '') : qt === 'hold' ? ' (Hold)' : ' (Team)';
+
+ h += '';
+ h += '
' + icon + ' ' + q.queue_name + desc + '
';
+ h += '
' + wc + ' waiting';
+ h += 'Max: ' + q.max_size + '
';
+ h += '
';
+ h += '
';
+ });
+ $list.html(h);
+ }
+
+ $(document).on('click', '.accept-queue-call', function() {
+ var qid = $(this).data('queue-id');
+ var $btn = $(this);
+ $btn.prop('disabled', true).text('...');
+ twpPost({ action: 'twp_accept_next_queue_call', queue_id: qid, nonce: twpNonce }, function(r) {
+ if (r.success) { showNotice('Connecting to caller...', 'success'); setTimeout(loadAdminQueues, 1000); }
+ else showNotice(r.data || 'No calls waiting', 'info');
+ }, function() { showNotice('Failed to accept call', 'error'); });
+ $btn.prop('disabled', false).text('Accept');
+ });
+
+ $('#admin-refresh-queues').on('click', loadAdminQueues);
+
+ // Load queues immediately and poll every 5 seconds.
+ loadAdminQueues();
+ setInterval(loadAdminQueues, 5000);
+
+ // ============================================================
+ // Mode Switching
+ // ============================================================
+ $('input[name="call_mode"]').on('change', function() {
+ var sel = $(this).val();
+ var cur = $('#mode-text').text().indexOf('Browser') !== -1 ? 'browser' : 'cell';
+ if (sel !== cur) {
+ $('#save-mode-btn').show();
+ $('.mode-option').removeClass('active');
+ $(this).closest('.mode-option').addClass('active');
+ $('#mode-text').text((sel === 'browser' ? 'Browser Phone' : 'Cell Phone') + ' (unsaved)').css('color', 'var(--warning)');
+ $('.mode-info > div').hide();
+ $('.' + sel + '-mode-info').show();
+ }
+ });
+
+ $('#save-mode-btn').on('click', function() {
+ var $btn = $(this);
+ var sel = $('input[name="call_mode"]:checked').val();
+ $btn.prop('disabled', true).text('...');
+ twpPost({ action: 'twp_save_call_mode', mode: sel, nonce: twpNonce }, function(r) {
+ if (r.success) {
+ $('#mode-text').text(sel === 'browser' ? 'Browser Phone' : 'Cell Phone').css('color', '');
+ $('#save-mode-btn').hide();
+ showNotice('Call mode saved', 'success');
+ } else { showNotice('Failed to save mode', 'error'); }
+ }, function() { showNotice('Failed to save mode', 'error'); });
+ $btn.prop('disabled', false).text('Save');
+ });
+
+ // ============================================================
+ // Agent Status Bar
+ // ============================================================
+ window.toggleAgentLogin = function() {
+ twpPost({ action: 'twp_toggle_agent_login', nonce: twpNonce }, function(r) {
+ if (r.success) location.reload();
+ else showNotice('Failed to change login status', 'error');
+ }, function() { showNotice('Failed to change login status', 'error'); });
+ };
+
+ window.updateAgentStatus = function(status) {
+ twpPost({ action: 'twp_set_agent_status', status: status, nonce: twpNonce }, function(r) {
+ if (r.success) showNotice('Status: ' + status, 'success');
+ else showNotice('Failed to update status', 'error');
+ }, function() { showNotice('Failed to update status', 'error'); });
+ };
+
+ // ============================================================
+ // Dark Mode Toggle
+ // ============================================================
+ function applyTheme(theme) {
+ var $html = $('html');
+ $html.removeClass('dark-mode light-mode');
+ if (theme === 'dark') {
+ $html.addClass('dark-mode');
+ $('meta[name="theme-color"]').attr('content', '#0f0f23');
+ } else if (theme === 'light') {
+ $html.addClass('light-mode');
+ $('meta[name="theme-color"]').attr('content', '#f5f6fa');
+ } else {
+ // System default — no class override
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ $('meta[name="theme-color"]').attr('content', prefersDark ? '#0f0f23' : '#1a1a2e');
+ }
+ // Update button states
+ $('.dark-mode-opt').removeClass('active');
+ $('.dark-mode-opt[data-theme="' + theme + '"]').addClass('active');
+ }
+
+ // Initialize theme from localStorage
+ var savedTheme = localStorage.getItem('twp_theme') || 'system';
+ applyTheme(savedTheme);
+
+ // Listen for system theme changes
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
+ var currentTheme = localStorage.getItem('twp_theme') || 'system';
+ if (currentTheme === 'system') applyTheme('system');
+ });
+
+ // Theme option buttons
+ $('.dark-mode-opt').on('click', function() {
+ var theme = $(this).data('theme');
+ localStorage.setItem('twp_theme', theme);
+ applyTheme(theme);
+ });
+
+ // ============================================================
+ // Clipboard helper
+ // ============================================================
+ window.copyToClipboard = function(text) {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(function() { showNotice('Copied!', 'success'); });
+ } else {
+ var ta = document.createElement('textarea');
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ showNotice('Copied!', 'success');
+ }
+ };
+
+ // ============================================================
+ // Initialize
+ // ============================================================
+ $(window).on('beforeunload', function() {
+ if (tokenRefreshTimer) clearTimeout(tokenRefreshTimer);
+ if (device) device.destroy();
+ });
+
+ // SDK init
+ var sdkAttempts = 0;
+ function checkAndInit() {
+ sdkAttempts++;
+ if (typeof Twilio !== 'undefined' && Twilio.Device) { initializeBrowserPhone(); }
+ else if (sdkAttempts < 100) { setTimeout(checkAndInit, 50); }
+ else { showError('Twilio Voice SDK failed to load. Check internet connection.'); }
+ }
+
+ if (typeof Twilio !== 'undefined' && Twilio.Device) initializeBrowserPhone();
+ else checkAndInit();
+
+ $(window).on('load', function() {
+ if (typeof Twilio !== 'undefined' && !device) initializeBrowserPhone();
+ // Signal Flutter that page is fully loaded.
+ notifyFlutterReady();
+ });
+
+})(jQuery);
diff --git a/includes/class-twp-mobile-phone-page.php b/includes/class-twp-mobile-phone-page.php
index bd71702..6e727bc 100644
--- a/includes/class-twp-mobile-phone-page.php
+++ b/includes/class-twp-mobile-phone-page.php
@@ -202,1795 +202,10 @@ class TWP_Mobile_Phone_Page {
$twilio_edge = esc_js(get_option('twp_twilio_edge', 'roaming'));
$smart_routing_webhook = home_url('/wp-json/twilio-webhook/v1/smart-routing');
- // Begin output.
- ?>
-
-
-
-
-
-
-
-Phone -
+ // Plugin file reference for plugins_url() in template.
+ $plugin_file = dirname(__FILE__) . '/../twilio-wp-plugin.php';
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- extension) : '—'; ?>
-
-
-
-
-
-
- Today:
- Total:
- Avg: s
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Ready
-
Loading...
-
-
00:00
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Loading your queues...
-
-
-
-
-
-
-
-
-
-
-
-
-
Outbound Caller ID
-
-
-
-
-
-
-
-
-
-
-
Call Reception Mode
-
-
-
-
-
-
- Current:
-
-
-
-
-
-
-
Keep this page open to receive calls.
-
-
-
Calls forwarded to: Not configured'; ?>
-
-
-
-
-
-
-
Setup Required
-
Update your phone number webhook to:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- with WidgetsBindingObserver {
return result ?? false;
}
- void _showMenu() {
- showModalBottomSheet(
- context: context,
- builder: (context) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- ListTile(
- leading: const Icon(Icons.refresh),
- title: const Text('Reload'),
- onTap: () {
- Navigator.pop(context);
- _controller.reload();
- },
- ),
- ListTile(
- leading: const Icon(Icons.logout),
- title: const Text('Logout'),
- onTap: () {
- Navigator.pop(context);
- _confirmLogout();
- },
- ),
- ],
- ),
- ),
- );
- }
-
void _confirmLogout() async {
final result = await showDialog(
context: context,
@@ -256,7 +227,45 @@ class _PhoneScreenState extends State with WidgetsBindingObserver {
return WillPopScope(
onWillPop: _onWillPop,
child: Scaffold(
+ appBar: _hasError
+ ? null
+ : AppBar(
+ toolbarHeight: 40,
+ titleSpacing: 12,
+ title: Text(
+ 'TWP Softphone',
+ style: Theme.of(context).textTheme.titleSmall,
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh, size: 20),
+ tooltip: 'Reload',
+ visualDensity: VisualDensity.compact,
+ onPressed: () => _controller.reload(),
+ ),
+ PopupMenuButton(
+ icon: const Icon(Icons.more_vert, size: 20),
+ tooltip: 'Menu',
+ padding: EdgeInsets.zero,
+ onSelected: (value) {
+ if (value == 'logout') _confirmLogout();
+ },
+ itemBuilder: (context) => [
+ const PopupMenuItem(
+ value: 'logout',
+ child: ListTile(
+ leading: Icon(Icons.logout),
+ title: Text('Logout'),
+ dense: true,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
body: SafeArea(
+ top: _hasError, // AppBar already handles top safe area when visible
child: Stack(
children: [
if (!_hasError) WebViewWidget(controller: _controller),
@@ -266,34 +275,33 @@ class _PhoneScreenState extends State with WidgetsBindingObserver {
],
),
),
- floatingActionButton: (!_hasError && !_loading)
- ? FloatingActionButton.small(
- onPressed: _showMenu,
- child: const Icon(Icons.more_vert),
- )
- : null,
),
);
}
Widget _buildErrorView() {
+ final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- const Icon(Icons.wifi_off, size: 64, color: Colors.grey),
+ Icon(Icons.wifi_off, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
- const Text(
+ Text(
'Connection Error',
- style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: colorScheme.onSurface,
+ ),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Could not load the phone page.',
textAlign: TextAlign.center,
- style: const TextStyle(color: Colors.grey),
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
FilledButton.icon(