Custom CTA buttons for AI Chatbot

Hi! I was able to add 2 CTA buttons to our chatbot with the JS+CSS code below. The code includes sizing, padding, alignment, and responsive layout. (note: I have coded specific sizing and padding to my chatbot, so you will need to customize the code). Feel free to reach out for help with yours.

JS:

const waitForElement = (selector, root = document) =>
	new Promise((res) => {
		const observer = new MutationObserver(() => {
			const element = root.querySelector(selector);
			if (element) {
				res(element);
				observer.disconnect();
			}
		});

		observer.observe(root, { childList: true, subtree: true });
	});

waitForElement('.es-window-body').then((chatBody) => {
	if (!document.querySelector('.es-fixed-buttons')) {
		const wrapper = document.createElement('div');
		wrapper.className = 'es-fixed-buttons';

		// Call button
		const callBtn = document.createElement('a');
		callBtn.href = 'tel:8887298812';
		callBtn.textContent = 'Call Us';
		callBtn.className = 'es-btn es-btn-call';

		// SMS button
		const smsBtn = document.createElement('a');
		smsBtn.href = 'sms:8542239313';
		smsBtn.textContent = 'Text Us';
		smsBtn.className = 'es-btn es-btn-sms';

		wrapper.appendChild(callBtn);
		wrapper.appendChild(smsBtn);

		chatBody.appendChild(wrapper);
	}
});

CSS

/\* bottom CTA buttons */
.es-fixed-buttons{
position:absolute;
bottom:2px;
left:20px;                  /* padding from left */
display:flex;
flex-direction:column;       /* vertical stack */
gap:10px;                    /* vertical gap between buttons \*/
z-index:99999;
}

.es-btn{
display:inline-flex;
align-items:center;
justify-content:center;
padding:7px 18px;
border-radius:20px;
text-decoration:none !important;
font-weight:700;
font-size:14px;
white-space:nowrap;
box-shadow:0 2px 6px rgba(0,0,0,.3);
transition:transform .15s ease, background-color .2s ease;
}

.es-btn:active{ transform:translateY(1px); }

.es-btn-call{
background:#00a7e1;
color:#fff !important;
border:2px solid #00a7e1;
}

.es-btn-call:hover{ background:#008bbd; }

.es-btn-sms{
background:#fff;
color:#00a7e1 !important;
border:2px solid #00a7e1;
}

.es-btn-sms:hover{ background:#f0f9ff; }

/\* responsive labels \*/
.es-btn .label-short{ display:none; }
@media (max-width:420px){
.es-btn{ padding:10px 14px; }
.es-btn .label-full{ display:none; }
.es-btn .label-short{ display:inline; }
}
5 Likes

Wow, that looks awesome! Thanks for sharing your solution with us :slightly_smiling_face:

As far as I understand, these buttons should appear at the end of the chat. However, I’ve tested it in your widget and the buttons didn’t show up:

Could you please describe what should be the condition to trigger these buttons?

2 Likes

At the moment, I decided to remove the CTA buttons. I was running into problems with overlap when the AI Chatbot produced longer responses, particularly on mobile. I am working on code to 1) inject the CTA buttons after a user sends 1 message (prior to this, the CTA buttons were interfering with the pre-select options we use); 2) inject the CTA buttons as separate message containers with proper spacing to avoid overlap

The code I posted does work though and may help others with similar needs.

3 Likes

Got it, thanks!

I’ve checked your code in my widget and it didn’t work anymore. However, I’ve talked to the devs and they’ve slightly adjusted your script:

const waitForElement = (selector, root = document) =>
	new Promise((res) => {
		const observer = new MutationObserver(() => {
			const element = root.querySelector(selector);
			if (element) {
				res(element);
				observer.disconnect();
			}
		});

		observer.observe(root, { childList: true, subtree: true });
	});

waitForElement('.es-window-body').then((chatBody) => {
	if (!document.querySelector('.es-fixed-buttons')) {
		const wrapper = document.createElement('div');
		wrapper.className = 'es-fixed-buttons';

		// Call button
		const callBtn = document.createElement('a');
		callBtn.href = 'tel:8887298812';
		callBtn.textContent = 'Call Us';
		callBtn.className = 'es-btn es-btn-call';

		// SMS button
		const smsBtn = document.createElement('a');
		smsBtn.href = 'sms:8542239313';
		smsBtn.textContent = 'Text Us';
		smsBtn.className = 'es-btn es-btn-sms';

		wrapper.appendChild(callBtn);
		wrapper.appendChild(smsBtn);

		chatBody.appendChild(wrapper);
	}
});

Thus, with this script everything should be working fine :slightly_smiling_face:

3 Likes

Thank you. I already use waitForElement to change all links that would normally open in a new tab/window to instead open in the same tab/window, so the code your Devs provided won’t work for me (my code is below).

const WIDGET_ID = 'e6b5fefa-bada-4235-b3bc-7e9d941415f0';

const waitForElement = (selector, root = document, maxAttempts = 500) =>
  new Promise((resolve) => {
    let attempts = 0;
    const check = () => {
      const element = root.querySelector(selector);
      if (element) {
        resolve(element);
      } else if (attempts < maxAttempts) {
        attempts++;
        setTimeout(check, 100);
      }
    };
    check();
  });

let targetContainer = null;

waitForElement(`.eapps-ai-chatbot-${WIDGET_ID}-custom-css-root .es-window-body`).then((container) => {
  targetContainer = container;

  const fixLinks = () => {
    const links = targetContainer.querySelectorAll("a[target='_blank']");
    links.forEach((link) => {
      link.target = "_self";
    });
  };

  const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.addedNodes.length) {
        fixLinks();
      }
    });
  });

  observer.observe(targetContainer, {
    childList: true,
    subtree: true,
  });

  fixLinks();
});

Max, I’m struggling to find a way to inject the CTA buttons with the following requirements:

  • only display after the User sends 1 message
  • display naturally in the flow of conversation
    • my original code has them fixed at the bottom of the message container, which causes a problem when the AI Assistant sends longer answers, particularly on mobile.
  • first display after the Assistant’s 2nd message and continue to display after each Assistant’s message after that
    • we include 5 pre-select boxes for the user at the start of the chatbot conversation
  • CTA button feature continue to work after User closes and reopens the chatbot

I’ve tried vibe coding this on Claude multiple times wth no luck, so if this is something your team can help accomplish, it would be much appreciated.

1 Like

Hi there, @PeterC :waving_hand:

Do I get it right that you’d like the buttons to appear right in the message container?

Ideally the CTA buttons would be placed in their own transparent container after each AI Assistant’s message (starting after the Assistant’s 2nd message; n=2 to infinity), in order to maintain the conversation’s flow; similar to the picture in my original post, but placed relative to the Assistant’s response to remove the space that is created in my original code when the CTA buttons are in a fixed position at the bottom of the conversation container.

1 Like

Hi there, @PeterC :waving_hand:

I’ve double-checked your code, tested it in your and my test widget, and it didn’t work, unfortunately. Could you please double-check it and let me know if it works on your end?

I am not sure which code you’re referring to. The code in my original post does not accomplsh the specifications I’m hoping for. The code in message 4/8 is code for a different purpose, but it includes waitForElement so the code your Devs provided in messaagge 3/8 doesn’t work for my widget.

1 Like

Got you!

I was referring to this code, and it didn’t for me either when testing:

However, based on this screenshot, it seems that it was working before:


Anyway, I’ll discuss with the devs if we can build a new script that meets all your conditions and, most importantly, runs reliably.

Just a couple of questions before I pass your request to the devs:

  1. Once the assistant sends the 3rd message, should the button move under the 3rd message or they should be kept below the 2nd message?
  1. You’ve mentioned 2 conditions:
  • display after the User sends 1 message

  • first display after the Assistant’s 2nd message

Since the assistant’s first response corresponds to the user’s first message, does that mean the buttons should actually appear only after the user sends their second message — when the assistant has just sent his second message?

1 Like

Understood. I was not the person to enter that code but my understanding is that it changes all links that would normally open in a new tab/window to instead open in the same tab/window.

To answer your other questions:

  1. Ideally, the CTA buttons would continue to move down under the 3rd message, then 4th, then 5th, etc., only appearing once at any given time.

  2. I would like the CTA buttons to appear after each Assistant’s written response to the Customer user. In the example below, the User has selected the option “I’m looking for a puppy” from the pre-select choices. I would still like the CTA buttons to appear after the Assistant’s message, “That’s great! Do you have a specific breed in mind…”

1 Like

Got it, thanks!

Our devs will check if it’s feasible, and I’ll update you once I have their response :slightly_smiling_face:

Hi @PeterC :waving_hand:

Please try to add this code to the Custom CSS field on the General tab of your widget’s settings:

/* bottom CTA buttons */
.es-message-group-assistant {
  min-height: unset !important;
}

.es-cta-buttons {
  display: flex;
  gap: 10px;
  margin-top: 10px;
}

.es-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 7px 18px;
  border-radius: 20px;
  text-decoration: none !important;
  font-weight: 700;
  font-size: 14px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  transition: background 0.2s ease;
}

.es-btn:active {
  transform: translateY(1px);
}

.global-styles,
#__EAAPS_PORTAL .es-btn-call {
  background: #00a7e1;
  color: #fff !important;
  border: 2px solid #00a7e1;
}

.global-styles,
#__EAAPS_PORTAL .es-btn-call:hover {
  background: #008bbd;
}

.es-btn-sms {
  background: #fff;
  color: #00a7e1 !important;
  border: 2px solid #00a7e1;
}

.es-btn-sms:hover {
  background: #f0f9ff;
}

And this code should be added to the Custom JS field:

// Add CTA buttons below assistant messages
const CALL_BUTTON_TEXT = 'Call Us';
const CALL_BUTTON_LINK = 'tel:8887298812';
const SMS_BUTTON_TEXT = 'Text Us';
const SMS_BUTTON_LINK = 'sms:8542239313';

const waitForElement = (selector, root = document) =>
  new Promise((res) => {
    const observer = new MutationObserver(() => {
      const element = root.querySelector(selector);
      if (element) {
        res(element);
        observer.disconnect();
      }
    });

    observer.observe(root, { childList: true, subtree: true });
  });

const observeChildNodes = (element, callback) => {
  const observer = new MutationObserver((mutationsList) => {
    for (const mutation of mutationsList) {
      mutation.addedNodes.forEach(callback);
    }
  });

  observer.observe(element, { childList: true });
  return observer;
};

const createCTAButton = (text, link, className) =>
  Object.assign(document.createElement('a'), {
    href: link,
    textContent: text,
    className: `es-btn ${className}`,
    target: '_blank',
  });

const addButtons = (btns, portal) => {
  const lastAssistanMessage = [
    ...portal.querySelectorAll('.es-message-group-assistant'),
  ].pop();

  lastAssistanMessage.after(btns);
};

const firstAssistantMessageSelector =
  '.es-message-group-assistant:not(:first-child):has(.es-message-content-container)';

waitForElement(firstAssistantMessageSelector).then((assistantFirstMsg) => {
  const portal = assistantFirstMsg.closest('#__EAAPS_PORTAL');
  let messageContainer = portal.querySelector(
    '[class*="widget-window__MessagesContainer-sc"]'
  );

  if (!portal || !messageContainer) return;

  const wrapper = document.createElement('div');
  wrapper.className = 'es-cta-buttons';

  // Call button
  const callBtn = createCTAButton(
    CALL_BUTTON_TEXT,
    CALL_BUTTON_LINK,
    'es-btn-call'
  );

  // SMS button
  const smsBtn = createCTAButton(
    SMS_BUTTON_TEXT,
    SMS_BUTTON_LINK,
    'es-btn-sms'
  );

  wrapper.append(callBtn, smsBtn);
  addButtons(wrapper, portal);

  // track new assistant messages
  const observeNewMessages = () => {
    return observeChildNodes(messageContainer, (el) => {
      if (el.classList.contains('es-message-group-assistant')) {
        addButtons(wrapper, portal);
      }
    });
  };

  let messagesObserver = observeNewMessages();

  // track when chat window opens
  observeChildNodes(portal, (el) => {
    if (el.classList.contains('es-window-container')) {
      addButtons(wrapper, portal);

      messagesObserver.disconnect();
      messageContainer = portal.querySelector(
        '[class*="widget-window__MessagesContainer-sc"]'
      );
      messagesObserver = observeNewMessages();
    }
  });
});

Try it out and let me know how it worked :slightly_smiling_face:

Hi Max, thanks for this. It works great and the solution is fixed.

Note: We already declared ‘const waitForElement’ in JS code to keep users on the same tab when clicking a link inside the chatbot (I included this code in my original post).

With the help of Claude AI, I was able to find a workaround to maintain the same-tab function with the CTA buttons injected. I’ve shared it below for future reference:

// Consolidated code - no duplicate declarations
const WIDGET_ID = 'e6b5fefa-bada-4235-b3bc-7e9d941415f0';

// Single waitForElement function for both features
const waitForChatElement = (selector, root = document, maxAttempts = 500) =>
  new Promise((resolve) => {
    let attempts = 0;
    const check = () => {
      const element = root.querySelector(selector);
      if (element) {
        resolve(element);
      } else if (attempts < maxAttempts) {
        attempts++;
        setTimeout(check, 100);
      }
    };
    check();
  });

// CTA Button Configuration
const CALL_BUTTON_TEXT = 'Call Us';
const CALL_BUTTON_LINK = 'tel:8887298812';
const SMS_BUTTON_TEXT = 'Text Us';
const SMS_BUTTON_LINK = 'sms:8542239313';

let chatContainer = null;

// Initialize both features
waitForChatElement(`.eapps-ai-chatbot-${WIDGET_ID}-custom-css-root .es-window-body`).then((container) => {
  chatContainer = container;
  
  // Feature 1: Fix links to open in same tab
  const fixLinks = () => {
    const links = chatContainer.querySelectorAll("a[target='_blank']");
    links.forEach((link) => {
      link.target = "_self";
    });
  };
  
  const linkObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      if (mutation.addedNodes.length) {
        fixLinks();
      }
    });
  });
  
  linkObserver.observe(chatContainer, {
    childList: true,
    subtree: true,
  });
  
  fixLinks();
});

// Feature 2: Add CTA buttons
const observeChildNodes = (element, callback) => {
  const observer = new MutationObserver((mutationsList) => {
    for (const mutation of mutationsList) {
      mutation.addedNodes.forEach(callback);
    }
  });
  observer.observe(element, { childList: true });
  return observer;
};

const createCTAButton = (text, link, className) =>
  Object.assign(document.createElement('a'), {
    href: link,
    textContent: text,
    className: `es-btn ${className}`,
    target: '_blank',
  });

const addButtons = (btns, portal) => {
  const lastAssistantMessage = [
    ...portal.querySelectorAll('.es-message-group-assistant'),
  ].pop();
  if (lastAssistantMessage) {
    lastAssistantMessage.after(btns);
  }
};

const firstAssistantMessageSelector =
  '.es-message-group-assistant:not(:first-child):has(.es-message-content-container)';

waitForChatElement(firstAssistantMessageSelector).then((assistantFirstMsg) => {
  const portal = assistantFirstMsg.closest('#__EAAPS_PORTAL');
  let messageContainer = portal.querySelector(
    '[class*="widget-window__MessagesContainer-sc"]'
  );
  
  if (!portal || !messageContainer) return;
  
  const wrapper = document.createElement('div');
  wrapper.className = 'es-cta-buttons';
  
  // Call button
  const callBtn = createCTAButton(
    CALL_BUTTON_TEXT,
    CALL_BUTTON_LINK,
    'es-btn-call'
  );
  
  // SMS button
  const smsBtn = createCTAButton(
    SMS_BUTTON_TEXT,
    SMS_BUTTON_LINK,
    'es-btn-sms'
  );
  
  wrapper.append(callBtn, smsBtn);
  addButtons(wrapper, portal);
  
  // Track new assistant messages
  const observeNewMessages = () => {
    return observeChildNodes(messageContainer, (el) => {
      if (el.classList && el.classList.contains('es-message-group-assistant')) {
        addButtons(wrapper, portal);
      }
    });
  };
  
  let messagesObserver = observeNewMessages();
  
  // Track when chat window opens
  observeChildNodes(portal, (el) => {
    if (el.classList && el.classList.contains('es-window-container')) {
      addButtons(wrapper, portal);
      messagesObserver.disconnect();
      messageContainer = portal.querySelector(
        '[class*="widget-window__MessagesContainer-sc"]'
      );
      messagesObserver = observeNewMessages();
    }
  });
});

Thanks so much!

2 Likes

Great, thanks for sharing the adjusted solution!

If anything else comes up, we’re always here to help :slightly_smiling_face: