From d00a906d076b63a2b2b804d5af3ccea71cd8863b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 10:02:04 -0700 Subject: [PATCH] Add call history, dark mode toggle, caller ID persistence, and refactor phone page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone page improvements: - Clear caller number display after call disconnects - Add "Recent" tab with session call history (tap to call back) - Persist outbound caller ID selection in localStorage - Fix button overlap with proper z-index layering - Add manual dark mode toggle (System/Light/Dark) in Settings - Improve dark mode CSS for all UI elements Refactor phone page into separate files: - assets/mobile/phone.css (848 lines) — all CSS - assets/mobile/phone.js (1065 lines) — all JavaScript - assets/mobile/phone-template.php (267 lines) — HTML template - includes/class-twp-mobile-phone-page.php (211 lines) — PHP controller - PHP values passed to JS via window.twpConfig bridge Flutter app: - Replace FAB with slim AppBar (refresh + menu buttons) - Fix dark mode colors using theme-aware colorScheme Co-Authored-By: Claude Opus 4.6 --- assets/mobile/phone-template.php | 267 ++++ assets/mobile/phone.css | 848 ++++++++++ assets/mobile/phone.js | 1065 +++++++++++++ includes/class-twp-mobile-phone-page.php | 1793 +--------------------- mobile/lib/screens/phone_screen.dart | 86 +- 5 files changed, 2231 insertions(+), 1828 deletions(-) create mode 100644 assets/mobile/phone-template.php create mode 100644 assets/mobile/phone.css create mode 100644 assets/mobile/phone.js 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 - <?php echo esc_html(get_bloginfo('name')); ?> + + + + + + + + + + + + + + + +
+ + +
+
+ extension) : '—'; ?> + + + + +
+
+ Today: + Total: + Avg: s +
+
+ + +
+ + + + +
+ + +
+ + + + + +
+ + +
+
+
+
Ready
+
Loading...
+
+ +
+ + + +
+ + + + + + + + + + + + +
+ +
+ + + +
+ + +
+
+ + +
+
+
+

Recent Calls

+ +
+
+
No calls yet this session.
+
+
+
+ + +
+
+
+

Your Queues

+ +
Ext: extension); ?>
+ +
+
+
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 - <?php echo esc_html(get_bloginfo('name')); ?> + // Plugin file reference for plugins_url() in template. + $plugin_file = dirname(__FILE__) . '/../twilio-wp-plugin.php'; - - - - - - - - - - - - -
- - -
-
- extension) : '—'; ?> - - - - -
-
- Today: - Total: - Avg: s -
-
- - -
- - - -
- - -
- - - - - -
- - -
-
-
-
Ready
-
Loading...
-
- -
- - - -
- - - - - - - - - - - - -
- -
- - - -
- - -
-
- - -
-
-
-

Your Queues

- -
Ext: extension); ?>
- -
-
-
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(