In the GHL agency view, navigate to https://app.gohighlevel.com/settings/company?tab=whitelabel
In the Custom JS section, paste the following:
<script>
/* Data/State */
const DIAL_ICON = `<svg fill="#143FDE" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>dialpad</title><path d="M12,19A2,2 0 0,0 10,21A2,2 0 0,0 12,23A2,2 0 0,0 14,21A2,2 0 0,0 12,19M6,1A2,2 0 0,0 4,3A2,2 0 0,0 6,5A2,2 0 0,0 8,3A2,2 0 0,0 6,1M6,7A2,2 0 0,0 4,9A2,2 0 0,0 6,11A2,2 0 0,0 8,9A2,2 0 0,0 6,7M6,13A2,2 0 0,0 4,15A2,2 0 0,0 6,17A2,2 0 0,0 8,15A2,2 0 0,0 6,13M18,5A2,2 0 0,0 20,3A2,2 0 0,0 18,1A2,2 0 0,0 16,3A2,2 0 0,0 18,5M12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13M18,13A2,2 0 0,0 16,15A2,2 0 0,0 18,17A2,2 0 0,0 20,15A2,2 0 0,0 18,13M18,7A2,2 0 0,0 16,9A2,2 0 0,0 18,11A2,2 0 0,0 20,9A2,2 0 0,0 18,7M12,7A2,2 0 0,0 10,9A2,2 0 0,0 12,11A2,2 0 0,0 14,9A2,2 0 0,0 12,7M12,1A2,2 0 0,0 10,3A2,2 0 0,0 12,5A2,2 0 0,0 14,3A2,2 0 0,0 12,1Z" /></svg>`
const dialIconUrl = `data:image/svg+xml;base64,${btoa(DIAL_ICON)}`
const BASE_URL = 'https://app.symbo.ai'
let isOpen = false
let dialerPosX = 0
let dialerPosY = 0
let isDragging = false
let topBarBtnRendered = false
let floatingBtnRendered = false
let ongoingCall = false
let currentContact = null
let fetchingCurrentContact = false
let currentContactPhones = []
let fetchedContact = null
/* Helpers */
const postMessage = (msg) => {
const iframe = document.getElementById('symbo-dialer-iframe').contentWindow
iframe.postMessage(msg, BASE_URL)
}
/* Imports */
document.body.style.fontFamily = 'Roboto, sans-serif'
const fontAwesome = document.createElement('link')
fontAwesome.rel = 'stylesheet'
fontAwesome.href =
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css' // Font Awesome CDN
document.head.appendChild(fontAwesome)
/* Methods */
// Function to handle the start of dragging
const startDrag = (e) => {
e.preventDefault() // Prevent text selection
const dialerContainer = document.getElementById('symbo-dialer')
dialerPosX = e.clientX - dialerContainer.getBoundingClientRect().left
dialerPosY = e.clientY - dialerContainer.getBoundingClientRect().top
isDragging = true
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('mouseleave', stopDrag) // Stop dragging when cursor leaves viewport
}
// Function to handle dragging
const onDrag = (e) => {
const dialerContainer = document.getElementById('symbo-dialer')
if (!isDragging) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let newLeft = e.clientX - dialerPosX
let newTop = e.clientY - dialerPosY
// Constrain the div within the viewport
const divWidth = dialerContainer.offsetWidth
const divHeight = dialerContainer.offsetHeight
// Ensure the div doesn't move out of the viewport
newLeft = Math.max(0, Math.min(newLeft, viewportWidth - divWidth))
newTop = Math.max(0, Math.min(newTop, viewportHeight - divHeight))
// Apply the constrained position
dialerContainer.style.left = `${newLeft}px`
dialerContainer.style.top = `${newTop}px`
}
// Function to stop dragging
const stopDrag = () => {
if (!isDragging) return
isDragging = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mouseleave', stopDrag)
}
// Create and return drag handler element
// This method creates and returns
// the element with a grip/dots icon to drag the dialer around
const createDragHandler = () => {
const dragHandler = document.createElement('div')
dragHandler.classList = ['drag-handler']
dragHandler.innerHTML = `<i class="fa-solid fa-grip drag-icon" ></i>`
dragHandler.onmousedown = startDrag
return dragHandler
}
// Render dialer
// This method creates the Symbo Dialer frame and adds it into the DOM
// This method does not open the dialer it only renders/adds to the DOM
const renderDialer = () => {
// Create a container for the iframe
const container = document.createElement('div')
container.id = 'symbo-dialer'
container.style.width = '420px' // Initial width
container.style.height = '485px' // Initial height
container.classList = ['dialer-container']
const dialerFrame = document.createElement('iframe')
dialerFrame.src = `${BASE_URL}/dial?ghl=true`
dialerFrame.id = 'symbo-dialer-iframe'
dialerFrame.classList = ['dialer-frame']
dialerFrame.setAttribute('allow', 'microphone')
const dragHandler = createDragHandler()
container.appendChild(dragHandler)
container.appendChild(dialerFrame)
// Append the container to the body
document.body.appendChild(container)
}
// Toggle dialer
const toggleDialer = () => {
const dialer = document.getElementById('symbo-dialer')
const floatingBtn = document.getElementById('dialer-floating-btn')
const defaultTop = window.innerHeight - 485 - 18
const defaultLeft = window.innerWidth - 420 - 100
dialerPosY = defaultTop
dialerPosX = defaultLeft
dialer.style.top = defaultTop
dialer.style.left = defaultLeft
if (isOpen) {
dialer.style.display = 'none'
if (floatingBtn)
floatingBtn.innerHTML = `
<img src="https://marketing.symbo.lol/symbo-icon.svg" class="logo" />
<div>O</div>
<div>P</div>
<div>E</div>
<div>N</div>
`
} else {
dialer.style.display = 'block'
if (floatingBtn)
floatingBtn.innerHTML = `
<img src="https://marketing.symbo.lol/symbo-icon.svg" class="logo" />
<div>C</div>
<div>L</div>
<div>O</div>
<div>S</div>
<div>E</div>
`
}
isOpen = !isOpen
}
const removeFloatingBtn = () => {
const floatingBtn = document.getElementById('dialer-floating-btn')
if (floatingBtn) floatingBtn.remove()
floatingBtnRendered = false
}
// Render dialer button (fallback to renderDialerBtnTopBar)
// This method renders the button to open the Symbo dialer
const renderDialerFloatingBtn = () => {
floatingBtnRendered = true
let floatingBtn = document.createElement('div')
floatingBtn.id = 'dialer-floating-btn'
floatingBtn.classList = ['dialer-floating-btn']
floatingBtn.innerHTML = `
<img src="https://marketing.symbo.lol/symbo-icon.svg" class="logo" />
<div>O</div>
<div>P</div>
<div>E</div>
<div>N</div>
`
floatingBtn.onclick = toggleDialer
document.body.appendChild(floatingBtn)
}
// Render dialer button on the topbar
// This method renders the button to open the Symbo dialer
const renderDialerBtnTopBar = () => {
const header = document.querySelector('header .hl_header--controls')
topBarBtnRendered = true
let topbarBtn = document.createElement('div')
topbarBtn.id = 'dialer-btn-topbar'
topbarBtn.classList = ['dialer-btn-topbar']
topbarBtn.innerHTML = `<img style="height: 24px; width: 24px;" class="rounded-circle" src="https://marketing.symbo.lol/symbo-icon.svg" />`
topbarBtn.title = 'Symbo Dialer'
topbarBtn.onclick = toggleDialer
const icon =
document.getElementById('hl_header--help-icon') ||
document.querySelector('.hl_header--dropdown')
if (icon) header.insertBefore(topbarBtn, icon)
}
// This method asks the Symbo app to fetch the contact by external ID
const requestContactPhoneNumbers = (id) => {
console.log('Requesting contact phone numbers for ID:', id)
currentContact = id
fetchingCurrentContact = true
postMessage({ type: 'fetch-contact', data: { externalId: id } })
}
const createPhoneMenu = (v2 = false) => {
const phonesMenu = document.createElement('div')
phonesMenu.classList = [v2 ? 'phones-menu-v2' : 'phones-menu']
phonesMenu.style.display = 'none'
currentContactPhones.forEach((phone) => {
const phoneRow = document.createElement('div')
const phoneNumber = document.createElement('div')
phoneNumber.innerText = phone.number
const phoneType = document.createElement('div')
phoneType.innerText = `(${phone.phone_type})`
phoneRow.appendChild(phoneNumber)
phoneRow.appendChild(phoneType)
phoneRow.classList = ['phone-row']
phoneRow.onclick = (e) => {
e.stopPropagation()
callNumber(phone.number, currentContact)
phonesMenu.style.display = 'none'
}
phonesMenu.appendChild(phoneRow)
})
return phonesMenu
}
// This method frames phone numbers on the page
// and adds call action buttons to trigger Symbo calls
const framePhoneNumbers = () => {
// For old GHL contact list view
const phoneDivs = [...document.querySelectorAll('.phone')]
phoneDivs.forEach((div) => {
const rowContact = div.parentElement?.parentElement?.id
const phoneIcon = div.querySelector('i')
const phoneNumber = div.querySelector('span')?.innerText || ''
const symboCallAction = div.querySelector('.symbo-call-action')
if (phoneIcon && phoneNumber && !symboCallAction) {
const callThruSymbo = document.createElement('img')
callThruSymbo.src = dialIconUrl
callThruSymbo.classList = ['symbo-call-action']
callThruSymbo.onclick = (e) => {
e.stopPropagation()
callNumber(phoneNumber, rowContact)
}
div.insertBefore(callThruSymbo, phoneIcon)
}
})
// For new GHL contact list view
const phoneIcons = [...document.querySelectorAll('.phone-call-icon')]
const phoneCells = phoneIcons
.map((icon) => icon.parentElement?.parentElement)
.filter((i) => i)
phoneCells.forEach((cell) => {
// GHL has a div inside every cell element
const phoneCellInnerDiv = cell.children[0]
const rowContact = cell.parentElement
const contactNameCell = rowContact.children[1]?.children[0]
const contactExternalId = contactNameCell?.getAttribute('data-id')
const phoneNumber =
phoneCellInnerDiv.children[phoneCellInnerDiv.children.length - 2]
?.innerText || ''
const symboCallAction = cell.querySelector('.symbo-call-action')
if (phoneNumber && !symboCallAction) {
const callThruSymbo = document.createElement('img')
callThruSymbo.src = dialIconUrl
callThruSymbo.classList = ['symbo-call-action']
callThruSymbo.style.cursor = 'pointer'
callThruSymbo.style.marginLeft = '12px'
callThruSymbo.onclick = (e) => {
e.stopPropagation()
callNumber(phoneNumber, contactExternalId)
}
phoneCellInnerDiv.insertBefore(
callThruSymbo,
phoneCellInnerDiv.firstChild
)
}
})
// Contact details page
const url = (window.location.href || '').split('?')[0] // removing any query params from the url
if (url && url.includes('contacts/detail')) {
const contactId = url.split('/contacts/detail/')[1]
if (!contactId) return
const notif = document.querySelector('.n-notification-main')
const newNotif = document.querySelector('.hr-alert-container')
const newNotifTextElement = newNotif?.querySelector(
'.hr-alert-body__content > #hr-ellipsis-id'
)
const phoneUpdated =
(notif &&
notif.innerText?.toLowerCase().includes('phone numbers updated')) ||
(newNotifTextElement &&
newNotifTextElement.innerText
?.toLowerCase()
.includes('changes saved successfully'))
if (
contactId !== currentContact ||
(!fetchedContact && !fetchingCurrentContact) ||
phoneUpdated
)
requestContactPhoneNumbers(contactId)
// Old UI
const contactHeader = document.querySelector(
'.hl_conversations--message-header-new .contact-detail-actions'
)
if (
contactHeader &&
!fetchingCurrentContact &&
!!currentContactPhones.length
) {
const existingCallAction = contactHeader.querySelector(
'.symbo-call-contact-action'
)
// Find and modify the section where callThruSymbo is created in the framePhoneNumbers function:
// Replace the button creation code with:
const callThruSymbo = document.createElement('div')
callThruSymbo.classList.add(
'pointer',
'relative',
'symbo-call-contact-action'
)
callThruSymbo.style.display = 'inline-block'
// callThruSymbo.style.padding = '8px 15px'
callThruSymbo.innerHTML = `<img src="${dialIconUrl}" style="width: 20px; height: 20px; display: inline-block;" />`
callThruSymbo.title = 'Call with Symbo Dialer'
// One number
if (currentContactPhones.length === 1) {
const existingPhonesMenu = contactHeader.querySelector('.phones-menu')
if (existingPhonesMenu) existingPhonesMenu.remove()
// Call action already exists
// Need to update the link
if (existingCallAction) {
existingCallAction.onclick = (e) => {
e.stopPropagation()
callNumber(currentContactPhones[0]?.number, currentContact)
}
}
// Call action doesn't exist
// Need to add call onclick on the new action (which is to be added)
else {
callThruSymbo.onclick = (e) => {
e.stopPropagation()
callNumber(currentContactPhones[0]?.number, currentContact)
}
}
}
// Multiple numbers
else {
const phonesMenu = createPhoneMenu()
// Call action already exists
// Need to update
if (existingCallAction) {
const existingPhonesMenu = contactHeader.querySelector('.phones-menu')
// Previously had only 1 number i.e. no menu (only action btn)
if (!existingPhonesMenu) {
contactHeader.appendChild(phonesMenu)
existingCallAction.onclick = (e) => {
e.stopPropagation()
if (phonesMenu.style.display === 'none')
phonesMenu.style.display = 'block'
else if (phonesMenu.style.display === 'block')
phonesMenu.style.display = 'none'
}
return
}
// Previously had multiple numbers i.e. menu but numbers got updated
// Need to update items in menu
if (
existingPhonesMenu &&
existingPhonesMenu.childElementCount !== currentContactPhones.length
) {
existingPhonesMenu.remove() // Remove the existing menu
// Add new menu
contactHeader.appendChild(phonesMenu)
// Update the link between existing action btn and new menu
existingCallAction.onclick = (e) => {
e.stopPropagation()
if (phonesMenu.style.display === 'none')
phonesMenu.style.display = 'block'
else if (phonesMenu.style.display === 'block')
phonesMenu.style.display = 'none'
}
return
}
}
// Call action doesn't exist
// Need to add
else {
contactHeader.appendChild(phonesMenu)
callThruSymbo.onclick = (e) => {
e.stopPropagation()
if (phonesMenu.style.display === 'none')
phonesMenu.style.display = 'block'
else if (phonesMenu.style.display === 'block')
phonesMenu.style.display = 'none'
}
}
}
if (!existingCallAction) {
const firstChild = contactHeader.firstChild
contactHeader.insertBefore(callThruSymbo, firstChild)
}
}
// New UI
const contactConversationPanel = document.querySelector(
'#contact-conversation-panel'
)
const contactActionsBar = contactConversationPanel?.querySelector(
'.flex.items-center.gap-1'
)
if (
contactActionsBar &&
!fetchingCurrentContact &&
!!currentContactPhones.length
) {
const existingCallAction = contactActionsBar.querySelector(
'.symbo-call-contact-action-v2'
)
// Find and modify the section where callThruSymbo is created in the framePhoneNumbers function:
// Replace the button creation code with:
const callThruSymbo = document.createElement('div')
callThruSymbo.classList.add(
'pointer',
'relative',
'symbo-call-contact-action-v2'
)
callThruSymbo.style.display = 'inline-block'
// callThruSymbo.style.padding = '8px 15px'
callThruSymbo.innerHTML = `<img src="${dialIconUrl}" style="width: 16px; height: 16px; display: inline-block;" />`
callThruSymbo.title = 'Call with Symbo Dialer'
// One number
if (currentContactPhones.length === 1) {
const existingPhonesMenu =
contactActionsBar.querySelector('.phones-menu-v2')
if (existingPhonesMenu) existingPhonesMenu.remove()
// Call action already exists
// Need to update the link
if (existingCallAction) {
existingCallAction.onclick = (e) => {
e.stopPropagation()
callNumber(currentContactPhones[0]?.number, currentContact)
}
}
// Call action doesn't exist
// Need to add call onclick on the new action (which is to be added)
else {
callThruSymbo.onclick = (e) => {
e.stopPropagation()
callNumber(currentContactPhones[0]?.number, currentContact)
}
}
}
// Multiple numbers
else {
const phonesMenu = createPhoneMenu(true)
// Call action already exists
// Need to update
if (existingCallAction) {
const existingPhonesMenu =
contactActionsBar.querySelector('.phones-menu-v2')
// Previously had only 1 number i.e. no menu (only action btn)
if (!existingPhonesMenu) {
contactActionsBar.appendChild(phonesMenu)
existingCallAction.onclick = (e) => {
e.stopPropagation()
if (phonesMenu.style.display === 'none')
phonesMenu.style.display = 'block'
else if (phonesMenu.style.display === 'block')
phonesMenu.style.display = 'none'
}
return
}
// Previously had multiple numbers i.e. menu but numbers got updated
// Need to update items in menu
if (
existingPhonesMenu &&
existingPhonesMenu.childElementCount !== currentContactPhones.length
) {
existingPhonesMenu.remove() // Remove the existing menu
// Add new menu
contactActionsBar.appendChild(phonesMenu)
// Update the link between existing action btn and new menu
existingCallAction.onclick = (e) => {
e.stopPropagation()
if (phonesMenu.style.display === 'none')
phonesMenu.style.display = 'block'
else if (phonesMenu.style.display === 'block')
phonesMenu.style.display = 'none'
}
return
}
}
// Call action doesn't exist
// Need to add
else {
contactActionsBar.appendChild(phonesMenu)
callThruSymbo.onclick = (e) => {
e.stopPropagation()
if (phonesMenu.style.display === 'none')
phonesMenu.style.display = 'block'
else if (phonesMenu.style.display === 'block')
phonesMenu.style.display = 'none'
}
}
}
if (!existingCallAction) {
const phoneChild = contactActionsBar.firstChild
contactActionsBar.insertBefore(callThruSymbo, phoneChild)
}
}
}
}
/* Event Listeners */
// Event listener for call action onclick
// This method relays a message to the symbo iframe to make a call to the number
const callNumber = (number, externalId) => {
if (!isOpen) toggleDialer()
postMessage({
type: 'call-number',
data: { number, externalId },
})
}
let timeoutId
const rafCallback = (mutationsList) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
for (const mutation of mutationsList) {
const header = document.querySelector('header .hl_header--controls')
if (!header) return // app is not loaded completely
const topBarBtn = document.getElementById('dialer-btn-topbar')
framePhoneNumbers()
if (header) {
if (topBarBtnRendered && topBarBtn) return
removeFloatingBtn()
renderDialerBtnTopBar()
} else if (!floatingBtnRendered) renderDialerFloatingBtn()
}
}, 500)
}
// Function to observe mutations
let observer
const observeMutations = () => {
if (observer) observer.disconnect()
observer = new MutationObserver(rafCallback)
observer.observe(document.body, { childList: true, subtree: true })
}
// Messages listeners from Symbo iframe
window.addEventListener(
'message',
(e) => {
if (e.origin !== BASE_URL) return
if (e.data == 'minimize-dialer') toggleDialer()
if (e.data == 'call:incoming') {
if (!isOpen) toggleDialer()
}
if (e.data.type == 'contact-fetched') {
fetchingCurrentContact = false
fetchedContact = e.data.data
if (e.data.data.external_id !== currentContact) return
currentContactPhones = e.data.data?.phoneNumbers || []
framePhoneNumbers()
}
if (e.data == 'dialer:mounted') framePhoneNumbers()
},
false
)
setTimeout(() => observeMutations(), 500)
renderDialer()
// GHL changes customJS display to none/inline when workflows are opened and closed
// Adding mutation observer to switch the widget back to its previous open state
let target = document.getElementById('symbo-dialer')
const widgetObserver = new MutationObserver(() => {
const displayValue = target ? target.style.display : null
if (displayValue === 'inline') {
console.log('GHL changed display property!')
target.style.display = isOpen ? 'block' : 'none'
}
})
// Start observing for style attribute changes
widgetObserver.observe(target, {
attributes: true, // watch for attribute changes
attributeFilter: ['style'], // only watch style changes
})
</script>In the Custom CSS section, paste the following:
.dialer-floating-btn{border-radius:4px 0 0 4px;width:24px;background:#46bcff;position:absolute;bottom:30px;right:0;cursor:pointer;z-index:10000;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:space-between;font-weight:700;font-size:10px;padding-bottom:4px}.dialer-btn-topbar{border-radius:50%;height:32px;width:32px;background:#143fde;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer}.rounded-circle{border-radius:50%}.dialer-container{border:thin solid rgba(0,0,0,.12);z-index:10000;flex-direction:column;align-items:end;box-shadow:0 2px 4px -1px rgba(0,0,0,.1),0 4px 5px 0 rgba(0,0,0,.07),0 1px 10px 0 transparent;border:none;display:none;position:fixed;bottom:18px;right:100px}.dialer-container:hover .drag-handler{display:flex!important;width:100%;align-items:center;justify-content:center}.dialer-container:hover .drag-icon{display:block!important;align-self:center}.drag-icon{display:none!important;align-self:center;color:#46bcff}.drag-handler{display:none;width:100%;height:24px;cursor:move;position:absolute;margin-top:-20px;align-items:center;border-radius:4px 4px 0 0;background:#46bbff2a;justify-content:center}.dialer-frame{height:100%;width:100%;border:none}.logo{height:24px;width:24px;border-radius:4px 0 0;margin-bottom:4px}.symbo-call-action{height:14px!important;width:14px!important;border-radius:2px;cursor:pointer;color:#143fde}.symbo-call-contact-action{padding-top:11px;padding-right:2px;cursor:pointer;display:inline-block}.symbo-call-contact-action-v2{height:24px;width:24px;cursor:pointer;display:inline-block}.phones-menu,.phones-menu-v2{width:210px;max-height:200px;overflow-y:auto;padding-inline:16px;padding-block:12px;position:absolute;background:#fff;margin-left:-24px;z-index:1}.phones-menu{margin-top:48px;border-radius:4px;border:1px solid grey}.phones-menu-v2{margin-top:134px;border-radius:4px;border:1px solid grey}.phone-row{padding-block:6px;font-weight:medium;cursor:pointer;display:flex;justify-content:space-between}.phone-row:hover{color:#143fde}