+
-
+
+
+
-
- Connecting...
-
-
-
-
-
-
-
@@ -400,122 +646,190 @@ export default {
position: fixed;
bottom: 20px;
right: 20px;
- background-color: #1f2937;
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- padding: 12px 16px;
+ background-color: rgba(31, 41, 55, 0.95); /* var(--b-700, #1f2937) with opacity */
+ backdrop-filter: blur(4px);
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+ padding: 16px;
z-index: 10000;
display: flex;
flex-direction: column;
- min-width: 220px;
+ width: 320px;
color: white;
+ transition: all 0.3s ease;
+ border: 1px solid var(--b-600, #374151);
- .call-info {
+ &.is-minimized {
+ min-width: auto;
+ padding: 12px;
+ }
+
+ &.is-fullscreen {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 0;
+ }
+
+ &.is-incoming {
+ animation: pulse 1.5s infinite;
+ border-color: var(--b-600, #374151);
+ background-color: rgba(31, 41, 55, 0.95); /* Same background, no red */
+ }
+}
+
+.call-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+ padding: 0 0 8px 0;
+}
+
+.call-icon-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background-color: var(--b-500, #4b5563);
+ color: white;
+}
+
+.call-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 180px;
+ line-height: 1.2;
+}
+
+.call-subtitle {
+ font-size: 12px;
+ color: var(--s-200, #9ca3af);
+ margin-top: 2px;
+}
+
+.call-duration {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--s-100, #f3f4f6);
+ background-color: var(--b-600, #374151);
+ padding: 4px 8px;
+ border-radius: 12px;
+}
+
+.call-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+
+ .control-button {
display: flex;
- justify-content: space-between;
align-items: center;
- margin-bottom: 10px;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: none;
+ cursor: pointer;
+ background: var(--b-600, #374151);
+ color: white;
+ font-size: 18px;
+ transition: all 0.2s ease;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
- .inbox-name {
- font-weight: 500;
+ &:hover {
+ background: var(--b-500, #4b5563);
+ transform: translateY(-2px);
}
- .call-duration {
- font-variant-numeric: tabular-nums;
+ &:active {
+ transform: translateY(0);
}
- }
- .call-controls {
- display: flex;
- justify-content: space-around;
- gap: 8px;
+ &.active {
+ background: var(--w-500, #2563eb);
+ }
- .control-button {
+ &.end-call-button {
+ background: var(--r-500, #dc2626);
+
+ &:hover {
+ background: var(--r-600, #b91c1c);
+ }
+ }
+
+ &.accept-call-button,
+ &.reject-call-button {
display: flex;
align-items: center;
justify-content: center;
- width: 40px;
- height: 40px;
- border-radius: 50%;
+ gap: 8px;
+ flex: 1;
+ height: 44px;
+ border-radius: 22px;
border: none;
cursor: pointer;
- background: #374151;
+ font-weight: 600;
+ font-size: 14px;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
+ }
+
+ &.accept-call-button {
+ background: var(--g-500, #10b981);
color: white;
- font-size: 18px;
&:hover {
- background: #4b5563;
+ background: var(--g-600, #059669);
+ transform: translateY(-2px);
}
-
- &.active {
- background: #2563eb;
- }
-
- &.end-call-button {
- background: #dc2626;
-
- &:hover {
- background: #b91c1c;
- }
- }
-
- &:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-
- &:hover {
- background: #374151;
- }
+
+ &:active {
+ transform: translateY(0);
}
}
- .status-indicator {
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 40px;
- height: 40px;
- font-size: 12px;
- font-weight: 500;
- background: #2563eb;
- border-radius: 16px;
- padding: 0 12px;
+ &.reject-call-button {
+ background: var(--r-500, #dc2626);
color: white;
- animation: pulse 1.5s infinite;
- }
-
- @keyframes pulse {
- 0% {
- opacity: 0.6;
- }
- 50% {
- opacity: 1;
- }
- 100% {
- opacity: 0.6;
- }
- }
- }
-
- .call-options {
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
-
- button {
- display: block;
- width: 100%;
- text-align: left;
- padding: 6px 0;
- background: transparent;
- border: none;
- color: white;
- cursor: pointer;
&:hover {
- color: #e5e7eb;
+ background: var(--r-600, #b91c1c);
+ transform: translateY(-2px);
}
+
+ &:active {
+ transform: translateY(0);
+ }
+ }
+
+ .button-text {
+ margin-left: 8px;
}
}
}
+
+@keyframes pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.4);
+ transform: scale(1);
+ }
+ 50% {
+ box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
+ transform: scale(1.01);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
+ transform: scale(1);
+ }
+}
diff --git a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js
index 9e79bb3a2..79ee8691c 100644
--- a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js
+++ b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js
@@ -212,6 +212,33 @@ export class DashboardAudioNotificationHelper {
showBadgeOnFavicon();
this.playAudioEvery30Seconds();
};
+
+ onIncomingCall = () => {
+ // Always play audio alerts for incoming calls, regardless of other settings
+ // This ensures users never miss a call notification
+
+ // Use a different tone for calls if available, otherwise use regular tone
+ const originalTone = this.audioConfig.tone;
+ try {
+ // Temporarily set a call-specific tone if it exists
+ this.audioConfig.tone = 'call-ring';
+ this.intializeAudio();
+ this.playAudioAlert();
+ } catch (error) {
+ console.error('Error playing call notification:', error);
+ // Fallback to regular tone
+ this.audioConfig.tone = originalTone;
+ this.intializeAudio();
+ this.playAudioAlert();
+ } finally {
+ // Restore original tone for messages
+ this.audioConfig.tone = originalTone;
+ this.intializeAudio();
+ }
+
+ // Also show badge on favicon
+ showBadgeOnFavicon();
+ };
}
export default new DashboardAudioNotificationHelper(GlobalStore);
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index adb33eb6d..9c5cc20dc 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -30,6 +30,10 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
+
+ // Call events
+ 'incoming_call': this.onIncomingCall,
+ 'call_status_changed': this.onCallStatusChanged
};
}
@@ -191,6 +195,38 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
+
+ onIncomingCall = data => {
+ // Normalize snake_case to camelCase for consistency with frontend code
+ const normalizedPayload = {
+ callSid: data.call_sid,
+ conversationId: data.conversation_id,
+ inboxId: data.inbox_id,
+ inboxName: data.inbox_name,
+ contactName: data.contact_name,
+ contactId: data.contact_id,
+ };
+
+ // Update store
+ this.app.$store.dispatch('calls/setIncomingCall', normalizedPayload);
+
+ // Also update App.vue showCallWidget directly for immediate UI feedback
+ if (window.app && window.app.$data) {
+ window.app.$data.showCallWidget = true;
+ }
+ };
+
+ onCallStatusChanged = data => {
+ // Normalize snake_case to camelCase for consistency with frontend code
+ const normalizedPayload = {
+ callSid: data.call_sid,
+ status: data.status,
+ conversationId: data.conversation_id,
+ };
+
+ // Update store
+ this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
+ };
}
export default {
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index d24444784..dfc539839 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -236,6 +236,20 @@
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
+ },
+ "INCOMING_CALL": "Incoming call",
+ "JOIN_CALL": "Join call",
+ "REJECT_CALL": "Reject call",
+ "END_CALL": "End call",
+ "MINIMIZE_CALL": "Minimize call",
+ "EXPAND_CALL": "Expand call",
+ "CALL_STATUS": {
+ "CONNECTING": "Connecting...",
+ "RINGING": "Ringing...",
+ "CONNECTED": "Connected",
+ "ENDED": "Call ended",
+ "FAILED": "Call failed",
+ "REJECTED": "Call rejected"
}
},
"EMAIL_TRANSCRIPT": {
@@ -382,7 +396,6 @@
"VOICE_CALL": "Call",
"CALL_ERROR": "Failed to initiate call. Please try again.",
"CALL_INITIATED": "Call initiated successfully.",
- "END_CALL": "End call",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback",
"TRANSCRIPTION": "Call transcription",
"COPILOT": {
diff --git a/app/javascript/dashboard/store/modules/calls.js b/app/javascript/dashboard/store/modules/calls.js
index f53a0e004..4ca449f7f 100644
--- a/app/javascript/dashboard/store/modules/calls.js
+++ b/app/javascript/dashboard/store/modules/calls.js
@@ -1,30 +1,77 @@
const state = {
activeCall: null,
+ incomingCall: null,
};
const getters = {
getActiveCall: $state => $state.activeCall,
hasActiveCall: $state => !!$state.activeCall,
+ getIncomingCall: $state => $state.incomingCall,
+ hasIncomingCall: $state => !!$state.incomingCall,
};
const actions = {
setActiveCall({ commit }, callData) {
- console.log('Setting active call in store:', callData);
+ if (!callData || !callData.callSid) {
+ throw new Error('Invalid call data provided');
+ }
+
commit('SET_ACTIVE_CALL', callData);
- // If we're in a browser environment, try to set the app state
+ // Update app state if in browser environment
+ if (typeof window !== 'undefined' && window.app?.$data) {
+ window.app.$data.showCallWidget = true;
+ }
+ },
+
+ clearActiveCall({ commit }) {
+ commit('CLEAR_ACTIVE_CALL');
+
+ // Update app state if in browser environment
+ if (typeof window !== 'undefined' && window.app?.$data) {
+ window.app.$data.showCallWidget = false;
+ }
+ },
+
+ setIncomingCall({ commit, state }, callData) {
+ if (!callData || !callData.callSid) {
+ throw new Error('Invalid call data provided');
+ }
+
+ // Don't set as incoming if call is already active
+ if (state.activeCall?.callSid === callData.callSid) {
+ return;
+ }
+
+ // Don't set as incoming if call is already incoming
+ if (state.incomingCall?.callSid === callData.callSid) {
+ return;
+ }
+
+ commit('SET_INCOMING_CALL', callData);
+
+ // Update app state if in browser environment
if (typeof window !== 'undefined' && window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
},
- clearActiveCall({ commit }) {
- console.log('Clearing active call in store');
- commit('CLEAR_ACTIVE_CALL');
- // If we're in a browser environment, try to clear the app state
- if (typeof window !== 'undefined' && window.app && window.app.$data) {
- window.app.$data.showCallWidget = false;
+ clearIncomingCall({ commit }) {
+ commit('CLEAR_INCOMING_CALL');
+ },
+
+ acceptIncomingCall({ commit, state }) {
+ const incomingCall = state.incomingCall;
+ if (!incomingCall) {
+ throw new Error('No incoming call to accept');
}
+
+ // Move incoming call to active call
+ commit('SET_ACTIVE_CALL', {
+ ...incomingCall,
+ isJoined: true,
+ });
+ commit('CLEAR_INCOMING_CALL');
},
};
@@ -35,6 +82,12 @@ const mutations = {
CLEAR_ACTIVE_CALL($state) {
$state.activeCall = null;
},
+ SET_INCOMING_CALL($state, callData) {
+ $state.incomingCall = callData;
+ },
+ CLEAR_INCOMING_CALL($state) {
+ $state.incomingCall = null;
+ },
};
export default {
diff --git a/config/routes.rb b/config/routes.rb
index c22d2e7ea..55db8e099 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -184,6 +184,8 @@ Rails.application.routes.draw do
# Voice call management
post 'voice/end_call', to: 'voice#end_call'
+ post 'voice/join_call', to: 'voice#join_call'
+ post 'voice/reject_call', to: 'voice#reject_call'
get 'voice/call_status', to: 'voice#call_status'
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
diff --git a/public/audio/dashboard/call-ring.mp3 b/public/audio/dashboard/call-ring.mp3
new file mode 100644
index 000000000..77087072f
Binary files /dev/null and b/public/audio/dashboard/call-ring.mp3 differ