@helllo-ai/spark-chat-widget
Version:
Helllo AI Spark Chat Widget — guided navigation embed for website assistants
9,033 lines • 389 kB
JavaScript
;(function () {
if (typeof window === 'undefined') return
const VERSION = 'spark-core-0.1.66-staging'
// Replace a non-spark-core widget (e.g. agent-chat.prod.js from other console pages).
if (window.AgentChatWidget) {
if (window.AgentChatWidget.version === VERSION) return
try {
window.AgentChatWidget.destroy && window.AgentChatWidget.destroy()
} catch (_) {}
}
/** Browser IIFE bundle (dist/daily-iframe.js). Older packages used build/daily.js — that path 404s on unpkg. */
const DEFAULT_DAILY_SCRIPT_URL =
'https://cdn.jsdelivr.net/npm/@daily-co/daily-js@0.87.0/dist/daily-iframe.js'
/**
* Partner CSPs often allow connect-src https://*.helllo.ai but block c.daily.co.
* Route Daily HTTPS + WSS signaling through dash so /daily/start can join on any site.
*/
function dailyProxyUrl() {
var base = ''
if (VERSION.includes('-staging')) base = 'https://dash-staging.helllo.ai'
else if (VERSION.includes('-prod')) base = 'https://dash.helllo.ai'
else {
try {
var host = window.location.hostname
if (host === 'localhost' || host === '127.0.0.1') base = window.location.origin
else if (host === 'helllo.ai' || host.endsWith('.helllo.ai')) base = window.location.origin
else base = 'https://dash.helllo.ai'
} catch (_) {
base = 'https://dash.helllo.ai'
}
}
return String(base).replace(/\/$/, '') + '/api/daily-proxy'
}
function createDailyCallObject(Daily, extras) {
var opts = Object.assign({ audioSource: true, videoSource: false }, extras || {})
opts.dailyConfig = Object.assign({}, opts.dailyConfig || {}, { proxyUrl: dailyProxyUrl() })
return Daily.createCallObject(opts)
}
const DEFAULT_SUPPORT_AGENT_IMAGE_URL =
'https://images.pexels.com/photos/6535381/pexels-photo-6535381.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940'
/** Same-origin static assets shipped alongside this bundle (spark widget files live at /spark on both hosts). */
const SPARK_ASSETS_BASE = VERSION.includes('-staging')
? 'https://dash-staging.helllo.ai'
: VERSION.includes('-prod')
? 'https://dash.helllo.ai'
: (function () {
try {
return new URL(document.currentScript?.src || window.location.href).origin
} catch (_) {
return ''
}
})()
const PRECALL_HERO_IMAGE_URL =
'https://ik.imagekit.io/ise7sbyg9/tr:w-760,h-300,q-80,f-auto/precall-hero.jpeg'
/** Pipecat Daily public agent id: staging vs production bundles (VERSION suffix -prod / -staging). */
function defaultDailyAgentName() {
if (VERSION.includes('-prod')) return 'voice-web-prod'
return 'voice-web-001'
}
const ACW_SPARKLE_SVG = '<svg class="acw-launcher-sparkle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2l1.2 5.2L18 9l-4.8 1.8L12 16l-1.2-5.2L6 9l4.8-1.8L12 2z" fill="currentColor"/><path d="M18.5 4l.4 1.4L20 6l-1.1.6-.4 1.4-.4-1.4L17 6l1.1-.6.4-1.4z" fill="currentColor"/><circle cx="7" cy="17" r="1.2" fill="currentColor"/></svg>'
const ACW_LAUNCHER_X_SVG = '<svg class="acw-launcher-x" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12"/></svg>'
const ACW_ICON_END_CHAT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="m14.5 8.5-5 5"/><path d="m9.5 8.5 5 5"/></svg>'
const ACW_ICON_END_CALL = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/><line x1="1" y1="1" x2="23" y2="23"/></svg>'
const ACW_ICON_CLOSE_WIDGET = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" width="18" height="18"><path d="M18 6L6 18M6 6l12 12"/></svg>'
const ACW_ICON_EXPAND_WINDOW = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
const ACW_ICON_COLLAPSE_WINDOW = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
const ACW_ICON_PAPERCLIP = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="20" height="20" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
const ACW_ICON_SEND = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>'
const ACW_ICON_THUMBS_UP =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>'
const ACW_ICON_THUMBS_DOWN =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/></svg>'
const ACW_ICON_HANDOVER_ACK =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m16 11 2 2 4-4"/></svg>'
const ACW_UI_LANG_CODES = { en: 1, id: 1, tl: 1, ms: 1, hi: 1, es: 1, fr: 1 }
function normalizeWidgetUiLanguage(raw) {
var s = String(raw == null ? 'en' : raw).trim().toLowerCase()
var base = s.split('-')[0]
return ACW_UI_LANG_CODES[base] ? base : 'en'
}
function acwT(uiLang, key) {
var pack = ACW_I18N[uiLang] || ACW_I18N.en
if (pack[key] != null) return pack[key]
return ACW_I18N.en[key] != null ? ACW_I18N.en[key] : key
}
const ACW_I18N = {
en: {
always_online: 'Always Online',
back_to_home: 'Back to home',
end_chat_close: 'End chat and close',
end_chat: 'End chat',
close_widget: 'Close widget',
expand_window: 'Expand window',
collapse_window: 'Collapse window',
end_call: 'End call',
status_disconnected: 'Disconnected',
status_connected: 'Online',
status_connecting: 'Connecting…',
status_reconnecting: 'Reconnecting…',
connection_lost: 'Connection lost. Your messages are saved.',
conversation_ended: 'You are now disconnected from live chat',
connection_reconnecting: 'Reconnecting to your conversation…',
reconnect: 'Reconnect',
start_again: 'Start again',
connecting_wait: 'Connecting... Please wait.',
how_can_we_help: 'How can we help?',
choose_communication_style: 'Choose your communication style',
text_chat_title: 'Text Chat',
text_chat_desc: 'Start chatting',
voice_chat_title: 'Voice Chat',
voice_chat_desc: 'Speak naturally',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'Continue on WhatsApp',
placeholder_type_message: 'Type a message...',
placeholder_connect_first: 'Connect to start chatting',
attach_image: 'Attach file',
remove_attachment: 'Remove attachment',
file_too_large: 'File is too large (max {size}).',
file_type_not_allowed: 'That file type is not supported.',
max_files_reached: 'Maximum number of attachments reached.',
file_upload_failed: 'File upload failed. Please try again.',
file_upload_disabled: 'File uploads are not enabled.',
send_message: 'Send message',
message_aria: 'Message',
powered_by: 'Powered by ',
form_title_start: 'Share details',
form_subtitle_details: 'We just need a few details',
label_name: 'Your Name',
placeholder_name: 'Enter your name',
label_phone: 'Mobile Number',
placeholder_phone: 'Enter your mobile',
conversation_language: 'Conversation language',
start_conversation: 'Start conversation →',
skip: 'Skip',
back_to_options: '← Back to options',
error_name_required: 'Please enter your name',
error_phone_required: 'Please enter your mobile number',
error_phone_invalid: 'Please enter a valid mobile number',
label_country_code: 'Country code',
search_country: 'Search country or code',
no_country_match: 'No countries found',
alert_name_phone_required: 'Please fill in both name and phone number',
alert_session_establish: 'Session not established. Please wait for connection.',
ws_not_connected: 'WebSocket not connected. Please wait for connection.',
submit_error: 'Error submitting information. Please try again.',
voice_title: 'Voice Conversation',
voice_tap_mic: 'Tap the mic to speak',
voice_connecting: 'Connecting…',
voice_loading_sdk: 'Loading voice SDK…',
voice_url_or_key_required: 'Voice service URL or Daily API key required',
voice_error: 'Voice error',
voice_connected_speak: 'Connected — speak when ready',
voice_starting_session: 'Starting voice session…',
voice_joining_room: 'Joining voice room…',
voice_connection_failed: 'Connection failed',
voice_url_not_set: 'Voice service URL not set',
voice_establishing_link: 'Establishing voice link…',
voice_connecting_agent: 'Connecting to agent…',
voice_negotiating: 'Negotiating with agent…',
voice_signing_in: 'Signing in with agent…',
voice_coming_soon: 'Voice coming soon',
failed_connect: 'Failed to connect. Please try again or check your connection.',
choose_text_chat_home: 'Please go to the home screen and choose Text Chat to start.',
image_too_large: 'Image is too large (max 2MB).',
image_read_error: 'Could not read the image. Please try again.',
voice_end_conversation: 'End Conversation',
voice_mic_aria: 'Microphone',
launcher_default: 'Chat with us',
navigation_blocked: 'Sorry, I cannot open that page.',
navigation_scrolled: 'Scrolled to that section.',
navigation_navigated: 'Navigated to that page.',
navigation_opened_new_tab: 'Opened that page in a new tab.',
navigation_popup_blocked: 'Could not open a new tab. Please allow pop-ups for this site and try again.',
voice_status_connected: 'Connected',
voice_status_disconnected: 'Disconnected',
voice_listening: 'Listening…',
voice_thinking: 'Thinking…',
voice_speaking: 'Speaking…',
voice_close_transcript: 'Close',
voice_back_to_options: 'Back to options',
nav_offer_yes: 'Yes, take me there',
nav_offer_no: 'No thanks',
navigation_continued: 'Continued on {page}',
reconnected_success: 'Reconnected',
copy_message: 'Copy',
copied_message: 'Copied',
feedback_helpful: 'Helpful',
feedback_not_helpful: 'Not helpful',
handover_ack_default: 'Message forwarded to human agent.',
product_carousel_aria: 'Product results',
product_view: 'View product',
product_variants: '{count} variants',
product_no_image: 'No image',
},
id: {
always_online: 'Selalu online',
back_to_home: 'Kembali ke beranda',
end_chat_close: 'Akhiri obrolan dan tutup',
status_disconnected: 'Terputus',
status_connected: 'Online',
status_connecting: 'Menyambungkan…',
status_reconnecting: 'Menyambungkan kembali…',
connection_lost: 'Koneksi terputus. Pesan Anda tersimpan.',
conversation_ended: 'Anda sekarang terputus dari live chat',
connection_reconnecting: 'Menyambungkan kembali ke percakapan…',
reconnect: 'Sambungkan lagi',
start_again: 'Mulai lagi',
connecting_wait: 'Menyambungkan... Harap tunggu.',
how_can_we_help: 'Bagaimana kami bisa membantu?',
choose_communication_style: 'Pilih gaya komunikasi Anda',
text_chat_title: 'Obrolan teks',
text_chat_desc: 'Ketik pesan dengan AI',
voice_chat_title: 'Obrolan suara',
voice_chat_desc: 'Bicara alami dengan AI',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'Lanjutkan di WhatsApp',
placeholder_type_message: 'Ketik pesan...',
placeholder_connect_first: 'Sambungkan untuk mulai mengobrol',
attach_image: 'Lampirkan gambar',
send_message: 'Kirim pesan',
message_aria: 'Pesan',
powered_by: 'Didayakan oleh ',
form_title_start: 'Mari mulai',
form_subtitle_details: 'Kami hanya perlu beberapa detail',
label_name: 'Nama Anda',
placeholder_name: 'Masukkan nama Anda',
label_phone: 'Nomor ponsel',
placeholder_phone: 'Masukkan nomor ponsel',
conversation_language: 'Bahasa percakapan',
start_conversation: 'Mulai percakapan →',
skip: 'Lewati',
back_to_options: '← Kembali ke pilihan',
error_name_required: 'Harap masukkan nama Anda',
error_phone_required: 'Harap masukkan nomor ponsel Anda',
alert_name_phone_required: 'Harap isi nama dan nomor telepon',
alert_session_establish: 'Sesi belum tersedia. Harap tunggu sambungan.',
ws_not_connected: 'WebSocket tidak terhubung. Harap tunggu sambungan.',
submit_error: 'Gagal mengirim informasi. Coba lagi.',
voice_title: 'Percakapan suara',
voice_tap_mic: 'Ketuk mikrofon untuk berbicara',
voice_connecting: 'Menyambungkan…',
voice_loading_sdk: 'Memuat SDK suara…',
voice_url_or_key_required: 'URL layanan suara atau kunci Daily diperlukan',
voice_error: 'Kesalahan suara',
voice_connected_speak: 'Terhubung — silakan berbicara',
voice_starting_session: 'Memulai sesi suara…',
voice_joining_room: 'Bergabung ke ruang suara…',
voice_connection_failed: 'Sambungan gagal',
voice_url_not_set: 'URL layanan suara tidak diatur',
voice_establishing_link: 'Menjalin tautan suara…',
voice_connecting_agent: 'Menyambung ke agen…',
voice_negotiating: 'Bernegosiasi dengan agen…',
voice_signing_in: 'Masuk ke agen…',
voice_coming_soon: 'Suara segera hadir',
failed_connect: 'Gagal menyambung. Coba lagi atau periksa koneksi Anda.',
choose_text_chat_home: 'Buka layar utama dan pilih Obrolan teks untuk memulai.',
image_too_large: 'Gambar terlalu besar (maks. 2MB).',
image_read_error: 'Tidak dapat membaca gambar. Coba lagi.',
voice_end_conversation: 'Akhiri percakapan',
voice_mic_aria: 'Mikrofon',
launcher_default: 'Ngobrol dengan kami',
},
tl: {
always_online: 'Palaging online',
back_to_home: 'Bumalik sa home',
end_chat_close: 'Tapusin ang chat at isara',
status_disconnected: 'Hindi konektado',
status_connected: 'Online',
status_connecting: 'Kumokonekta…',
status_reconnecting: 'Muling kumokonekta…',
connection_lost: 'Nawala ang koneksyon. Naka-save ang mga mensahe.',
conversation_ended: 'Nadiskonekta ka na mula sa live chat',
connection_reconnecting: 'Muling kumokonekta sa usapan…',
reconnect: 'Kumonekta muli',
start_again: 'Magsimula ulit',
connecting_wait: 'Kumokonekta... Maghintay po.',
how_can_we_help: 'Paano kami makakatulong?',
choose_communication_style: 'Piliin ang istilo ng komunikasyon',
text_chat_title: 'Text chat',
text_chat_desc: 'Mag-type ng mensahe gamit ang AI',
voice_chat_title: 'Voice chat',
voice_chat_desc: 'Magsalita nang natural sa AI',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'Magpatuloy sa WhatsApp',
placeholder_type_message: 'Mag-type ng mensahe...',
placeholder_connect_first: 'Kumonekta para magsimula ng chat',
attach_image: 'Maglakip ng larawan',
send_message: 'Ipadala ang mensahe',
message_aria: 'Mensahe',
powered_by: 'Pinapagana ng ',
form_title_start: 'Magsimula na tayo',
form_subtitle_details: 'Kailangan lang namin ng ilang detalye',
label_name: 'Iyong pangalan',
placeholder_name: 'Ilagay ang iyong pangalan',
label_phone: 'Mobile number',
placeholder_phone: 'Ilagay ang iyong mobile',
conversation_language: 'Wika ng pag-uusap',
start_conversation: 'Simulan ang pag-uusap →',
skip: 'Laktawan',
back_to_options: '← Bumalik sa mga opsyon',
error_name_required: 'Ilagay ang iyong pangalan',
error_phone_required: 'Ilagay ang iyong mobile number',
alert_name_phone_required: 'Punan ang pangalan at numero ng telepono',
alert_session_establish: 'Hindi pa handa ang session. Maghintay sa koneksyon.',
ws_not_connected: 'Hindi nakakonekta ang WebSocket. Maghintay.',
submit_error: 'Error sa pagsusumite. Subukan muli.',
voice_title: 'Usapang boses',
voice_tap_mic: 'I-tap ang mic para magsalita',
voice_connecting: 'Kumokonekta…',
voice_loading_sdk: 'Naglo-load ng voice SDK…',
voice_url_or_key_required: 'Kailangan ang voice service URL o Daily API key',
voice_error: 'Error sa boses',
voice_connected_speak: 'Nakakonekta — magsalita kapag handa na',
voice_starting_session: 'Nagsisimula ng voice session…',
voice_joining_room: 'Sumasali sa voice room…',
voice_connection_failed: 'Nabigo ang koneksyon',
voice_url_not_set: 'Hindi naka-set ang voice service URL',
voice_establishing_link: 'Itinatatag ang voice link…',
voice_connecting_agent: 'Kumokonekta sa agent…',
voice_negotiating: 'Nakikipag-negotiate sa agent…',
voice_signing_in: 'Nag-sign in sa agent…',
voice_coming_soon: 'Darating na ang boses',
failed_connect: 'Nabigo ang koneksyon. Subukan muli o tingnan ang koneksyon.',
choose_text_chat_home: 'Pumunta sa home at piliin ang Text chat para magsimula.',
image_too_large: 'Masyadong malaki ang larawan (max 2MB).',
image_read_error: 'Hindi mabasa ang larawan. Subukan muli.',
voice_end_conversation: 'Tapusin ang usapan',
voice_mic_aria: 'Mikropono',
launcher_default: 'Makipag-chat sa amin',
},
ms: {
always_online: 'Sentiasa dalam talian',
back_to_home: 'Kembali ke laman utama',
end_chat_close: 'Tamatkan sembang dan tutup',
status_disconnected: 'Terputus',
status_connected: 'Dalam talian',
status_connecting: 'Menyambung…',
status_reconnecting: 'Menyambung semula…',
connection_lost: 'Sambungan terputus. Mesej anda disimpan.',
conversation_ended: 'Anda kini terputus dari sembang langsung',
connection_reconnecting: 'Menyambung semula ke perbualan…',
reconnect: 'Sambung semula',
start_again: 'Mula semula',
connecting_wait: 'Menyambung... Sila tunggu.',
how_can_we_help: 'Bagaimana kami boleh membantu?',
choose_communication_style: 'Pilih gaya komunikasi anda',
text_chat_title: 'Sembang teks',
text_chat_desc: 'Taip mesej dengan AI',
voice_chat_title: 'Sembang suara',
voice_chat_desc: 'Bercakap secara semula jadi dengan AI',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'Teruskan di WhatsApp',
placeholder_type_message: 'Taip mesej...',
placeholder_connect_first: 'Sambung untuk mula sembang',
attach_image: 'Lampirkan imej',
send_message: 'Hantar mesej',
message_aria: 'Mesej',
powered_by: 'Dikuasakan oleh ',
form_title_start: 'Mari mulakan',
form_subtitle_details: 'Kami hanya perlukan beberapa butiran',
label_name: 'Nama anda',
placeholder_name: 'Masukkan nama anda',
label_phone: 'Nombor telefon bimbit',
placeholder_phone: 'Masukkan nombor bimbit anda',
conversation_language: 'Bahasa perbualan',
start_conversation: 'Mula perbualan →',
skip: 'Langkau',
back_to_options: '← Kembali ke pilihan',
error_name_required: 'Sila masukkan nama anda',
error_phone_required: 'Sila masukkan nombor telefon bimbit anda',
alert_name_phone_required: 'Sila isi nama dan nombor telefon',
alert_session_establish: 'Sesi belum sedia. Sila tunggu sambungan.',
ws_not_connected: 'WebSocket tidak bersambung. Sila tunggu.',
submit_error: 'Ralat menyerahkan maklumat. Cuba lagi.',
voice_title: 'Perbualan suara',
voice_tap_mic: 'Ketik mikrofon untuk bercakap',
voice_connecting: 'Menyambung…',
voice_loading_sdk: 'Memuatkan SDK suara…',
voice_url_or_key_required: 'URL perkhidmatan suara atau kunci Daily diperlukan',
voice_error: 'Ralat suara',
voice_connected_speak: 'Bersambung — bercakap apabila bersedia',
voice_starting_session: 'Memulakan sesi suara…',
voice_joining_room: 'Menyertai bilik suara…',
voice_connection_failed: 'Sambungan gagal',
voice_url_not_set: 'URL perkhidmatan suara tidak ditetapkan',
voice_establishing_link: 'Menubuhkan pautan suara…',
voice_connecting_agent: 'Menyambung ke ejen…',
voice_negotiating: 'Berunding dengan ejen…',
voice_signing_in: 'Log masuk dengan ejen…',
voice_coming_soon: 'Suara akan datang tidak lama lagi',
failed_connect: 'Gagal menyambung. Cuba lagi atau semak sambungan anda.',
choose_text_chat_home: 'Pergi ke skrin utama dan pilih Sembang teks untuk mula.',
image_too_large: 'Imej terlalu besar (maks. 2MB).',
image_read_error: 'Tidak boleh membaca imej. Cuba lagi.',
voice_end_conversation: 'Tamatkan perbualan',
voice_mic_aria: 'Mikrofon',
launcher_default: 'Sembang dengan kami',
},
hi: {
always_online: 'हमेशा ऑनलाइन',
back_to_home: 'होम पर वापस जाएँ',
end_chat_close: 'चैट समाप्त करें और बंद करें',
status_disconnected: 'डिस्कनेक्ट',
status_connected: 'ऑनलाइन',
status_connecting: 'कनेक्ट हो रहा है…',
status_reconnecting: 'पुनः कनेक्ट हो रहा है…',
connection_lost: 'कनेक्शन टूट गया। आपके संदेश सुरक्षित हैं।',
conversation_ended: 'आप अब लाइव चैट से डिस्कनेक्ट हो गए हैं',
connection_reconnecting: 'बातचीत से पुनः कनेक्ट हो रहा है…',
reconnect: 'पुनः कनेक्ट करें',
start_again: 'फिर से शुरू करें',
connecting_wait: 'कनेक्ट हो रहा है... कृपया प्रतीक्षा करें।',
how_can_we_help: 'हम कैसे मदद कर सकते हैं?',
choose_communication_style: 'अपनी संवाद शैली चुनें',
text_chat_title: 'टेक्स्ट चैट',
text_chat_desc: 'AI के साथ संदेश टाइप करें',
voice_chat_title: 'वॉइस चैट',
voice_chat_desc: 'AI से स्वाभाविक रूप से बात करें',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'WhatsApp पर जारी रखें',
placeholder_type_message: 'संदेश लिखें...',
placeholder_connect_first: 'चैट शुरू करने के लिए कनेक्ट करें',
attach_image: 'छवि संलग्न करें',
send_message: 'संदेश भेजें',
message_aria: 'संदेश',
powered_by: 'संचालित ',
form_title_start: 'चलिए शुरू करते हैं',
form_subtitle_details: 'हमें बस कुछ विवरण चाहिए',
label_name: 'आपका नाम',
placeholder_name: 'अपना नाम दर्ज करें',
label_phone: 'मोबाइल नंबर',
placeholder_phone: 'अपना मोबाइल दर्ज करें',
conversation_language: 'बातचीत की भाषा',
start_conversation: 'बातचीत शुरू करें →',
skip: 'छोड़ें',
back_to_options: '← विकल्पों पर वापस जाएँ',
error_name_required: 'कृपया अपना नाम दर्ज करें',
error_phone_required: 'कृपया अपना मोबाइल नंबर दर्ज करें',
alert_name_phone_required: 'कृपया नाम और फ़ोन नंबर भरें',
alert_session_establish: 'सत्र तैयार नहीं। कृपया कनेक्शन की प्रतीक्षा करें।',
ws_not_connected: 'WebSocket कनेक्ट नहीं। कृपया प्रतीक्षा करें।',
submit_error: 'जानकारी भेजने में त्रुटि। पुनः प्रयास करें।',
voice_title: 'वॉइस बातचीत',
voice_tap_mic: 'बोलने के लिए माइक टैप करें',
voice_connecting: 'कनेक्ट हो रहा है…',
voice_loading_sdk: 'वॉइस SDK लोड हो रहा है…',
voice_url_or_key_required: 'वॉइस सेवा URL या Daily कुंजी आवश्यक',
voice_error: 'वॉइस त्रुटि',
voice_connected_speak: 'कनेक्ट — तैयार होने पर बोलें',
voice_starting_session: 'वॉइस सेशन शुरू हो रहा है…',
voice_joining_room: 'वॉइस रूम में शामिल हो रहे…',
voice_connection_failed: 'कनेक्शन विफल',
voice_url_not_set: 'वॉइस सेवा URL सेट नहीं',
voice_establishing_link: 'वॉइस लिंक स्थापित हो रहा है…',
voice_connecting_agent: 'एजेंट से कनेक्ट…',
voice_negotiating: 'एजेंट से बातचीत…',
voice_signing_in: 'एजेंट में साइन इन…',
voice_coming_soon: 'वॉइस जल्द आ रहा है',
failed_connect: 'कनेक्ट नहीं हो सका। पुनः प्रयास करें या अपना कनेक्शन जाँचें।',
choose_text_chat_home: 'होम पर जाएँ और टेक्स्ट चैट चुनकर शुरू करें।',
image_too_large: 'छवि बहुत बड़ी (अधिकतम 2MB)।',
image_read_error: 'छवि नहीं पढ़ पा रहे। पुनः प्रयास करें।',
voice_end_conversation: 'बातचीत समाप्त करें',
voice_mic_aria: 'माइक्रोफ़ोन',
launcher_default: 'हमसे चैट करें',
},
es: {
always_online: 'Siempre en línea',
back_to_home: 'Volver al inicio',
end_chat_close: 'Finalizar chat y cerrar',
status_disconnected: 'Desconectado',
status_connected: 'En línea',
status_connecting: 'Conectando…',
status_reconnecting: 'Reconectando…',
connection_lost: 'Conexión perdida. Sus mensajes están guardados.',
conversation_ended: 'Ya no está conectado al chat en vivo',
connection_reconnecting: 'Reconectando a su conversación…',
reconnect: 'Reconectar',
start_again: 'Empezar de nuevo',
connecting_wait: 'Conectando... Espere.',
how_can_we_help: '¿Cómo podemos ayudarle?',
choose_communication_style: 'Elija su estilo de comunicación',
text_chat_title: 'Chat de texto',
text_chat_desc: 'Escriba mensajes con la IA',
voice_chat_title: 'Chat de voz',
voice_chat_desc: 'Hable con naturalidad con la IA',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'Continuar en WhatsApp',
placeholder_type_message: 'Escriba un mensaje...',
placeholder_connect_first: 'Conéctese para empezar a chatear',
attach_image: 'Adjuntar imagen',
send_message: 'Enviar mensaje',
message_aria: 'Mensaje',
powered_by: 'Con tecnología de ',
form_title_start: 'Empecemos',
form_subtitle_details: 'Solo necesitamos algunos datos',
label_name: 'Su nombre',
placeholder_name: 'Introduzca su nombre',
label_phone: 'Número móvil',
placeholder_phone: 'Introduzca su móvil',
conversation_language: 'Idioma de la conversación',
start_conversation: 'Iniciar conversación →',
skip: 'Omitir',
back_to_options: '← Volver a opciones',
error_name_required: 'Introduzca su nombre',
error_phone_required: 'Introduzca su número móvil',
alert_name_phone_required: 'Complete nombre y teléfono',
alert_session_establish: 'Sesión no lista. Espere la conexión.',
ws_not_connected: 'WebSocket no conectado. Espere.',
submit_error: 'Error al enviar. Intente de nuevo.',
voice_title: 'Conversación por voz',
voice_tap_mic: 'Pulse el micrófono para hablar',
voice_connecting: 'Conectando…',
voice_loading_sdk: 'Cargando SDK de voz…',
voice_url_or_key_required: 'Se requiere URL del servicio de voz o clave de Daily',
voice_error: 'Error de voz',
voice_connected_speak: 'Conectado — hable cuando esté listo',
voice_starting_session: 'Iniciando sesión de voz…',
voice_joining_room: 'Uniéndose a la sala de voz…',
voice_connection_failed: 'Conexión fallida',
voice_url_not_set: 'URL del servicio de voz no configurada',
voice_establishing_link: 'Estableciendo enlace de voz…',
voice_connecting_agent: 'Conectando con el agente…',
voice_negotiating: 'Negociando con el agente…',
voice_signing_in: 'Iniciando sesión con el agente…',
voice_coming_soon: 'Voz próximamente',
failed_connect: 'No se pudo conectar. Intente de nuevo o compruebe su conexión.',
choose_text_chat_home: 'Vaya al inicio y elija chat de texto para empezar.',
image_too_large: 'Imagen demasiado grande (máx. 2 MB).',
image_read_error: 'No se pudo leer la imagen. Intente de nuevo.',
voice_end_conversation: 'Finalizar conversación',
voice_mic_aria: 'Micrófono',
launcher_default: 'Chatea con nosotros',
},
fr: {
always_online: 'Toujours en ligne',
back_to_home: "Retour à l'accueil",
end_chat_close: 'Terminer le chat et fermer',
status_disconnected: 'Déconnecté',
status_connected: 'En ligne',
status_connecting: 'Connexion…',
status_reconnecting: 'Reconnexion…',
connection_lost: 'Connexion perdue. Vos messages sont enregistrés.',
conversation_ended: 'Vous êtes maintenant déconnecté du chat en direct',
connection_reconnecting: 'Reconnexion à votre conversation…',
reconnect: 'Reconnecter',
start_again: 'Recommencer',
connecting_wait: 'Connexion en cours... Veuillez patienter.',
how_can_we_help: 'Comment pouvons-nous vous aider ?',
choose_communication_style: 'Choisissez votre mode de communication',
text_chat_title: 'Chat texte',
text_chat_desc: 'Écrivez des messages avec l’IA',
voice_chat_title: 'Chat vocal',
voice_chat_desc: 'Parlez naturellement avec l’IA',
whatsapp_title: 'WhatsApp',
whatsapp_desc: 'Continuer sur WhatsApp',
placeholder_type_message: 'Saisissez un message...',
placeholder_connect_first: 'Connectez-vous pour démarrer le chat',
attach_image: 'Joindre une image',
send_message: 'Envoyer le message',
message_aria: 'Message',
powered_by: 'Propulsé par ',
form_title_start: 'Commençons',
form_subtitle_details: 'Il nous faut quelques informations',
label_name: 'Votre nom',
placeholder_name: 'Entrez votre nom',
label_phone: 'Numéro de mobile',
placeholder_phone: 'Entrez votre mobile',
conversation_language: 'Langue de la conversation',
start_conversation: 'Démarrer la conversation →',
skip: 'Ignorer',
back_to_options: '← Retour aux options',
error_name_required: 'Veuillez saisir votre nom',
error_phone_required: 'Veuillez saisir votre numéro de mobile',
alert_name_phone_required: 'Veuillez remplir le nom et le téléphone',
alert_session_establish: 'Session non établie. Veuillez attendre.',
ws_not_connected: 'WebSocket non connecté. Veuillez attendre.',
submit_error: "Erreur d'envoi. Réessayez.",
voice_title: 'Conversation vocale',
voice_tap_mic: 'Appuyez sur le micro pour parler',
voice_connecting: 'Connexion…',
voice_loading_sdk: 'Chargement du SDK vocal…',
voice_url_or_key_required: 'URL du service vocal ou clé Daily requise',
voice_error: 'Erreur vocale',
voice_connected_speak: 'Connecté — parlez quand vous êtes prêt',
voice_starting_session: 'Démarrage de la session vocale…',
voice_joining_room: 'Connexion à la salle vocale…',
voice_connection_failed: 'Échec de la connexion',
voice_url_not_set: 'URL du service vocal non définie',
voice_establishing_link: 'Établissement du lien vocal…',
voice_connecting_agent: "Connexion à l'agent…",
voice_negotiating: "Négociation avec l'agent…",
voice_signing_in: "Connexion à l'agent…",
voice_coming_soon: 'La voix arrive bientôt',
failed_connect: 'Échec de la connexion. Réessayez ou vérifiez votre connexion.',
choose_text_chat_home: "Allez à l'accueil et choisissez le chat texte pour commencer.",
image_too_large: 'Image trop volumineuse (max 2 Mo).',
image_read_error: 'Impossible de lire l’image. Réessayez.',
voice_end_conversation: 'Terminer la conversation',
voice_mic_aria: 'Microphone',
launcher_default: 'Discutez avec nous',
},
}
function normalizeChatRole(role) {
var r = String(role == null ? 'assistant' : role).toLowerCase().trim()
if (r === 'user' || r === 'human' || r === 'customer' || r === 'visitor' || r === 'end_user') return 'user'
return 'bot'
}
function normalizeWhatsAppDigits(phone) {
return String(phone || '').replace(/\D/g, '')
}
var PHONE_COUNTRY_CODES = [
{ iso: 'AU', dial: '61', name: 'Australia' },
{ iso: 'AT', dial: '43', name: 'Austria' },
{ iso: 'BH', dial: '973', name: 'Bahrain' },
{ iso: 'BD', dial: '880', name: 'Bangladesh' },
{ iso: 'BE', dial: '32', name: 'Belgium' },
{ iso: 'BR', dial: '55', name: 'Brazil' },
{ iso: 'CA', dial: '1', name: 'Canada' },
{ iso: 'CN', dial: '86', name: 'China' },
{ iso: 'CO', dial: '57', name: 'Colombia' },
{ iso: 'CZ', dial: '420', name: 'Czech Republic' },
{ iso: 'DK', dial: '45', name: 'Denmark' },
{ iso: 'EG', dial: '20', name: 'Egypt' },
{ iso: 'FI', dial: '358', name: 'Finland' },
{ iso: 'FR', dial: '33', name: 'France' },
{ iso: 'DE', dial: '49', name: 'Germany' },
{ iso: 'GR', dial: '30', name: 'Greece' },
{ iso: 'HK', dial: '852', name: 'Hong Kong' },
{ iso: 'HU', dial: '36', name: 'Hungary' },
{ iso: 'IN', dial: '91', name: 'India' },
{ iso: 'ID', dial: '62', name: 'Indonesia' },
{ iso: 'IE', dial: '353', name: 'Ireland' },
{ iso: 'IL', dial: '972', name: 'Israel' },
{ iso: 'IT', dial: '39', name: 'Italy' },
{ iso: 'JP', dial: '81', name: 'Japan' },
{ iso: 'KE', dial: '254', name: 'Kenya' },
{ iso: 'KW', dial: '965', name: 'Kuwait' },
{ iso: 'MY', dial: '60', name: 'Malaysia' },
{ iso: 'MX', dial: '52', name: 'Mexico' },
{ iso: 'NL', dial: '31', name: 'Netherlands' },
{ iso: 'NZ', dial: '64', name: 'New Zealand' },
{ iso: 'NG', dial: '234', name: 'Nigeria' },
{ iso: 'NO', dial: '47', name: 'Norway' },
{ iso: 'PK', dial: '92', name: 'Pakistan' },
{ iso: 'PH', dial: '63', name: 'Philippines' },
{ iso: 'PL', dial: '48', name: 'Poland' },
{ iso: 'PT', dial: '351', name: 'Portugal' },
{ iso: 'QA', dial: '974', name: 'Qatar' },
{ iso: 'RO', dial: '40', name: 'Romania' },
{ iso: 'SA', dial: '966', name: 'Saudi Arabia' },
{ iso: 'SG', dial: '65', name: 'Singapore' },
{ iso: 'ZA', dial: '27', name: 'South Africa' },
{ iso: 'KR', dial: '82', name: 'South Korea' },
{ iso: 'ES', dial: '34', name: 'Spain' },
{ iso: 'LK', dial: '94', name: 'Sri Lanka' },
{ iso: 'SE', dial: '46', name: 'Sweden' },
{ iso: 'CH', dial: '41', name: 'Switzerland' },
{ iso: 'TW', dial: '886', name: 'Taiwan' },
{ iso: 'TH', dial: '66', name: 'Thailand' },
{ iso: 'TR', dial: '90', name: 'Turkey' },
{ iso: 'AE', dial: '971', name: 'United Arab Emirates' },
{ iso: 'GB', dial: '44', name: 'United Kingdom' },
{ iso: 'US', dial: '1', name: 'United States' },
{ iso: 'VN', dial: '84', name: 'Vietnam' },
]
PHONE_COUNTRY_CODES.sort(function (a, b) { return a.name.localeCompare(b.name) })
var ACW_TZ_TO_ISO = {
'Asia/Kolkata': 'IN',
'Asia/Calcutta': 'IN',
'Asia/Jakarta': 'ID',
'Asia/Manila': 'PH',
'Asia/Singapore': 'SG',
'Asia/Kuala_Lumpur': 'MY',
'Asia/Dubai': 'AE',
'Asia/Riyadh': 'SA',
'Asia/Bangkok': 'TH',
'Asia/Tokyo': 'JP',
'Asia/Seoul': 'KR',
'Asia/Shanghai': 'CN',
'Asia/Hong_Kong': 'HK',
'Asia/Karachi': 'PK',
'Asia/Dhaka': 'BD',
'Asia/Colombo': 'LK',
'Europe/London': 'GB',
'Europe/Paris': 'FR',
'Europe/Madrid': 'ES',
'Europe/Berlin': 'DE',
'Europe/Rome': 'IT',
'Europe/Amsterdam': 'NL',
'America/New_York': 'US',
'America/Chicago': 'US',
'America/Denver': 'US',
'America/Los_Angeles': 'US',
'America/Toronto': 'CA',
'America/Vancouver': 'CA',
'America/Sao_Paulo': 'BR',
'America/Mexico_City': 'MX',
'Australia/Sydney': 'AU',
'Pacific/Auckland': 'NZ',
}
function acwCountryFlag(iso) {
if (!iso || iso.length !== 2) return ''
var u = iso.toUpperCase()
try {
return String.fromCodePoint(u.charCodeAt(0) + 127397, u.charCodeAt(1) + 127397)
} catch (_) {
return ''
}
}
function acwDetectBrowserCountryIso() {
// Phone country follows where the browser is, not the UI language.
// en-GB is a common language tag in India and was pinning the field to +44.
try {
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone
if (tz && ACW_TZ_TO_ISO[tz]) return ACW_TZ_TO_ISO[tz]
} catch (_) {}
try {
var langs = (navigator.languages && navigator.languages.length)
? navigator.languages
: [navigator.language || '']
for (var i = 0; i < langs.length; i++) {
var tag = String(langs[i] || '').trim()
if (!tag) continue
var parts = tag.split('-')
if (parts.length >= 2) {
var region = parts[parts.length - 1].toUpperCase()
if (/^[A-Z]{2}$/.test(region) && region !== 'XX') return region
}
if (typeof Intl !== 'undefined' && Intl.Locale) {
try {
var loc = new Intl.Locale(tag)
if (loc.region) return loc.region
} catch (_) {}
}
}
} catch (_) {}
return 'US'
}
function acwFindPhoneCountry(iso) {
for (var i = 0; i < PHONE_COUNTRY_CODES.length; i++) {
if (PHONE_COUNTRY_CODES[i].iso === iso) return PHONE_COUNTRY_CODES[i]
}
return null
}
function acwDefaultPhoneCountryIso() {
var iso = acwDetectBrowserCountryIso()
return acwFindPhoneCountry(iso) ? iso : 'US'
}
function acwFormatPhoneE164(dial, national) {
var dialDigits = String(dial || '').replace(/\D/g, '')
var nationalDigits = String(national || '').replace(/\D/g, '')
if (nationalDigits.charAt(0) === '0') nationalDigits = nationalDigits.replace(/^0+/, '')
if (!dialDigits || !nationalDigits) return ''
return '+' + dialDigits + nationalDigits
}
function acwValidateNationalPhone(national) {
var digits = String(national || '').replace(/\D/g, '')
if (digits.charAt(0) === '0') digits = digits.replace(/^0+/, '')
return digits.length >= 6 && digits.length <= 14
}
function domainAllowed(hostname, allowedList) {
if (!Array.isArray(allowedList) || allowedList.length === 0) return true
const host = (hostname || '').toLowerCase()
return allowedList.some((d) => {
const domain = (d || '').toLowerCase().trim()
if (!domain) return false
return host === domain || host.endsWith('.' + domain)
})
}
function stripWwwHost(hostname) {
var host = String(hostname || '').toLowerCase()
return host.indexOf('www.') === 0 ? host.slice(4) : host
}
/** True when target should navigate in the same tab (apex vs www counts as same site). */
function isSameNavigationDomain(targetHostname, currentHostname) {
var target = String(targetHostname || '').toLowerCase()
var current = String(currentHostname || '').toLowerCase()
if (!target || !current) return false
if (target === current) return true
return stripWwwHost(target) === stripWwwHost(current)
}
/**
* Dynamic z-index: scan page for the highest z-index in use, set widget 1 above.
* Runs twice: (1) on load before showing the launcher, (2) on click before opening
* the panel. Falls back to 999 when no competing elements exist.
*/
var ACW_ZINDEX_MAX = 2147483647
var ACW_ZINDEX_FALLBACK = 999
function getHighestPageZIndex(excludeEl) {
if (typeof document === 'undefined') return 0
var highest = 0
var vw = window.innerWidth || document.documentElement.clientWidth || 0
var vh = window.innerHeight || document.documentElement.clientHeight || 0
var all = document.querySelectorAll('*')
var limit = Math.min(all.length, 5000)
for (var i = 0; i < limit; i++) {
var el = all[i]
if (el === excludeEl || (excludeEl && excludeEl.contains(el))) continue
var cs
try { cs = window.getComputedStyle(el) } catch (_) { continue }
// Any positioned element competes on z-index in the root stacking
// context — not just fixed/sticky. Shopify headers are frequently
// `position: relative`/`absolute` wrappers carrying a large z-index
// (or only turn fixed via a scroll class), so a fixed/sticky-only
// filter under-counts and the widget ends up beneath the nav.
if (cs.position === 'static') continue
var z = parseInt(cs.zIndex, 10)
if (isNaN(z) || z <= highest) continue
// Only count layers the user can currently see. Cart drawers, modals,
// and search panels sit in the DOM with huge z-indexes while hidden or
// slid off-canvas; outbidding those would pin the widget above the
// site's own overlays when they open later (e.g. add-to-cart drawer).
if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue
var rect
try { rect = el.getBoundingClientRect() } catch (_) { continue }
if (!rect.width || !rect.height) continue
if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
highest = z
}
return highest
}
function elevateWidget(containerEl) {
if (!containerEl) return
var highest = getHighestPageZIndex(containerEl)
var target = highest > 0 ? highest + 1 : ACW_ZINDEX_FALLBACK
if (target > ACW_ZINDEX_MAX) target = ACW_ZINDEX_MAX
// Use the 'important' priority: merchant/theme/app stylesheets (seen live
// on distacart) override a plain inline z-index with `!important` rules.
// An inline declaration marked important beats any stylesheet rule.
containerEl.style.setProperty('z-index', String(target), 'important')
containerEl.style.setProperty('position', 'fixed', 'important')
// If the page already uses the int32 max (common for consent banners) we
// can only tie it; ties resolve by DOM order, so repaint last by moving
// the container to the end of <body>. Moving preserves the shadow root
// and listeners.
if (
highest >= ACW_ZINDEX_MAX &&
containerEl.parentElement === document.body &&
document.body.lastElementChild !== containerEl
) {
try { document.body.appendChild(containerEl) } catch (_) {}
}
}
/**
* Height of fixed/sticky site chrome pinned to the top of the viewport (Shopify
* announcement bars, sticky navs). Used to keep the panel clear of merchant headers.
*/
function measureViewportTopObstruction() {
if (typeof window === 'undefined' || typeof document === 'undefined') return 16
var vw = window.innerWidth || document.documentElement.clientWidth || 0
var vh = window.innerHeight || document.documentElement.clientHeight || 0
if (!vw || !vh) return 16
var maxBottom = 0
var selectors = [
'header',
'[role="banner"]',
'.site-header',
'.shopify-section-header',
'#shopify-section-header',
'[class*="sticky-header"]',
'[class*="announcement-bar"]',
]
for (var si = 0; si < selectors.length; si++) {
var matches
try {
matches = document.querySelectorAll(selectors[si])
} catch (_) {
continue
}
for (var mi = 0; mi < matches.length && mi < 12; mi++) {
var el = matches[mi]
if (!el || el.nodeType !== 1) continue
var style
try {
style = window.getComputedStyle(el)
} catch (_) {
continue
}
if (style.position !== 'fixed' && style.position !== 'sticky') continue
if (style.visibility === 'hidden' || style.display === 'none') continue
var rect = el.getBoundingClientRect()
if (rect.height < 28 || rect.width < vw * 0.35) continue
if (rect.top > vh * 0.35 || rect.bottom <= 0) continue
if (rect.bottom > maxBottom) maxBottom = rect.bottom
}
}
return maxBottom > 0 ? Math.ceil(maxBottom + 8) : 16
}
/** Open URL in a new tab. Must not pass noopener in windowFeatures — browsers return null on success. */
function openNavigationInNewTab(targetUrl) {
try {
var opened = window.open(targetUrl, '_blank')
if (opened) {
try {
opened.opener = null
} catch (_) {}
return true
}
return false
} catch (_) {
return false
}
}
function acwSessionKey(agentId) {
return 'acw:spark:session:' + agentId
}
function loadPersistedSession(agentId, ttlMs) {
try {
var raw = sessionStorage.getItem(acwSessionKey(agentId))
if (!raw) return null
var data = JSON.parse(raw)
if (!data || !data.savedAt) return null
if (Date.now() - data.savedAt > ttlMs) {
sessionStorage.removeItem(acwSessionKey(agentId))
return null
}
return data
} catch (_) { return null }
}
function persistSessionSnapshot(agentId, snapshot) {
try {
sessionStorage.setItem(acwSessionKey(agentId), JSON.stringify(snapshot))
} catch (_) {}
}
function clearPersistedSession(agentId) {
try { sessionStorage.removeItem(acwSessionKey(agentId)) } catch (_) {}
}
function navigationUrlAllowed(url, allowedDomains) {
try {
var u = new URL(url, window.location.href)
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false
return domainAllowed(u.hostname, allowedDomains)
} catch (_) { return false }
}
function normalizeActionNavUrl(url) {
try {
return String(new URL(url, window.location.href).href).replace(/\/$/, '').split('#')[0]
} catch (_) {
return String(url || '').replace(/\/$/, '')
}
}
var PRODUCT_CAROUSEL_MAX_ITEMS = 10
var PRODUCT_CAROUSEL_TITLE_MAX = 60
var PRODUCT_CAROUSEL_PLACEHOLDER_IMAGE =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240' viewBox='0 0 240 240'%3E%3Crect fill='%23f1f5f9' width='240' height='240'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2394a3b8' font-size='13' font-family='system-ui,sans-serif'%3ENo image%3C/text%3E%3C/svg%3E"
function truncateProductTitle(text, maxLen) {
var s = String(text || '').trim()
var limit = typeof maxLen === 'number' ? maxLen : PRODUCT_CAROUSEL_TITLE_MAX
if (s.length <= limit) return s
return s.slice(0, Math.max(0, limit - 1)).trim() + '…'
}
function formatProductPrice(price) {
if (!price || price.amount == null || price.amount === '') return ''
if (price.label) return String(price.label).trim()
var amount = parseFloat(String(price.amount).replace(/[^0-9.-]/g, ''))
if (isNaN(amount)) return String(price.amount)
var currency = String(price.currency || 'USD').toUpperCase()
try {
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currency }).format(amount)
} catch (_) {
return currency + ' ' + amount.toFixed(2)
}
}
function acwDispatchProductCarouselEvent(eventName, detail) {
try {
window.dispatchEvent(
new CustomEvent('acw:product_carousel', {
detail: Object.assign({ event: eventName }, detail || {}),
}),
)
} catch (_) {}
}
function acwGenerateMessageId() {
try {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return 'msg_' + crypto.randomUUID()
}
} catch (_) {}
return 'msg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10)
}
function acwDispatchMessageFeedbackEvent(detail) {
try {
window.dispatchEvent(
new CustomEvent('acw:message_feedback', {
detail: detail || {},
}),
)
} catch (_) {}
}
var spaClickHandlerRef = null
var spaPopstateHandlerRef = null
function parseHtmlDocument(htmlText) {
return new DOMParser().parseFromString(htmlText, 'text/html')
}
function swapDocumentRegions(fromDoc, selectors) {
var swapped = false
selectors.forEach(function (sel) {
if (!sel) return
var cur = document.querySelector(sel)
var next = fromDoc.querySelector(sel)
if (cur && next) {
cur.innerHTML = next.innerHTML
if (next.className) cur.className = next.className
swapped = true
}
})
return swapped
}
function createStyles(primary, background, headerColor) {
const safePrimary = primary || '#2563eb'
const safeBg = background || '#ffffff'
const safeHeader = (headerColor && String(headerColor).trim()) || safePrimary
const acwFont = "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif"
function hexToRgb(hex) {
var h = String(hex || '#2563eb').replace('#', '')
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
if (h.length !== 6) return { r: 37, g: 99, b: 235 }
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
}
}
function clampChannel(n) {
return Math.max(0, Math.min(255, Math.round(n)))
}
function darkenHex(hex, amount) {
var rgb = hexToRgb(hex)
var f = 1 - amount
return '#' + [rgb.r * f, rgb.g * f, rgb.b * f].map(function (c) {
return clampChannel(c).toString(16).padStart(2, '0')
}).join('')
}
function mixHex(hex, hex2, weight) {
var a = hexToRgb(hex)
var b = hexToRgb(hex2)
var w = Math.max(0, Math.min(1, weight))
return '#' + [a.r + (b.r - a.r) * w, a.g + (b.g - a.g) * w, a.b + (b.b - a.b) * w].map(function (c) {
return clampChannel(c).toString(16).padStart(2, '0')
}).join('')
}
function rgbaFromHex(hex, alpha) {
var rgb = hexToRgb(hex)
return 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' + alpha + ')'
}
function relativeLuminance(hex) {
var rgb = hexToRgb(hex)
function lin(c) {
c = c / 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
}
const brandRgb = hexToRgb(safePrimary)
const brandWashStrong = rgbaFromHex(safePrimary, 0.12)
const brandWashMid = rgbaFromHex(safePrimary, 0.06)
const brandWashSoft = rgbaFromHex(safePrimary, 0.04)
const headerTop = mixHex(safeHeader, '#ffffff', 0.1)
const headerBottom = darkenHex(safeHeader, 0.08)
const headerOnLight = relativeLuminance(safeHeader) > 0.52
const headerFg = headerOnLight ? '#0f172a' : '#ffffff'
const headerFgMuted = headerOnLight ? 'rgba(15, 23, 42, 0.68)' : 'rgba(255, 255, 255, 0.82)'
const headerFgSubtle = headerOnLight ? 'rgba(15, 23, 42, 0.48)' : 'rgba(255, 255, 255, 0.58)'
const headerAccent = headerOnLight ? darkenHex(safeHeader, 0.18) : mixHex(safeHeader, '#ffffff', 0.35)
const headerDivider = headerOnLight ? rgbaFromHex(darkenHex(safeHeader, 0.12), 0.22) : 'rgba(255, 255, 255, 0.16)'
const headerHoverBg = headerOnLight ? 'rgba(15, 23, 42, 0.06)' : 'rgba(255, 255, 255, 0.12)'
const brandBorder = rgbaFromHex(safePrimary, 0.22)
const brandBorderMid = rgbaFromHex(safePrimary, 0.34)
const brandShadow = rgbaFromHex(safePrimary, 0.2)
const brandShadowStrong = rgbaFromHex(safePrimary, 0.32)
const headerBorder = headerDivider
const primaryLight = mixHex(safePrimary, '#ffffff', 0.1)
const surfaceCard = '#ffffff'
const brandGradientBtn = 'linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 42%), ' + safePrimary
const brandGradientSoft = 'linear-gradient(145deg, ' + primaryLight + ' 0%, ' + safePrimary + ' 100%)'
const headerGradient = 'linear-gradient(180deg, ' + headerTop + ' 0%, ' + safeHeader + ' 42%, ' + headerBottom + ' 100%)'
return `
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap');
:host, .acw * { box-sizing: border-box; }
.spark-acw-container { position: fixed; z-index: 999; font-family: ${acwFont}; -webkit-font-smoothing: antialiased; }
.spark-acw-container.bottom-right { right: 24px; bottom: 24px; }
.spark-acw-container.middle-right { right: 24px; top: 50%; transform: translateY(-50%); }
.acw-launcher-wrap { position: relative; display: inline-flex; border-radius: 999px; padding: 0; }
.acw-launcher-wrap::before { content: ""; display: none; }
@keyframes acw-launcher-glow { to { transform: rotate(360deg); } }
.acw-launcher {
position: relative; z-index: 1;
background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 55%), ${safePrimary};
color: #fff; border: none; border-radius: 999px; padding: 10px 14px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 12px 40px ${brandShadowStrong}, 0 4px 12px rgba(0,0,0,0.12);
font-size: 14px; transition: transform 0.2s, box-shadow 0.2s;
}
.acw-launcher:hover { transform: scale(1.03); box-shadow: 0 16px 44px ${brandShadowStrong}, 0 6px 14px rgba(0,0,0,0.14); }
.acw-launcher:active { transform: scale(0.98); }
.acw-launcher-fab { position: relative; width: 64px; height: 64px; padding: 0; border-radius: 50%; }
.acw-launcher-fab .acw-launcher-sparkle, .acw-launcher-fab .acw-launcher-x { width: 28px; height: 28px; transition: transform 0.35s ease; }
.acw-launcher-fab.is-open .acw-launcher-sparkle { transform: rotate(90deg) scale(0); opacity: 0; position: absolute; }
.acw-launcher-fab.is-open .acw-launcher-x { transform: rotate(0deg) scale(1); opacity: 1; }
.acw-launcher-fab:not(.is-open) .acw-launcher-x { transform: rotate(-90deg) scale(0); opacity: 0; position: absolute; }
.acw-launcher-fab:not(.is-open) .acw-launcher-sparkle { position: relative; opacity: 1; }
.acw-launcher img { width: 24px; height: 24px; object-fit: contain; border-radius: 50%; }
.acw-launcher.vertical { flex-direction: column; padding: 14px 10px; min-width: 48px; height: auto; }
.acw-launcher.vertical span { writing-mode: sideways-lr; text-orientation: mixed; letter-spacing: 0.5px; }
.acw-header-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.acw-header-icon { width: 40px; height: 40px; object-fit: cover; object-position: center 78%; flex-shrink: 0; border-radius: 50%;
border: 2px solid ${headerBorder}; box-shadow: 0 2px 10px rgba(0,0,0,0.08); position: relative; }
.acw-header-avatar-fallback {
display: flex; align-items: center; justify-content: center;
background: ${brandGradientSoft}; color: #fff;
}
.acw-header-icon-wrap { position: relative; display: inline-flex; }
.acw-online-dot { position: absolute; right: 2px; bottom: 2px; width: 10px; height: 10px; border-radius: 50%;
background: #22c55e; border: 2px solid #fff; box-shadow: 0 0 0 2px rgba(34,197,94,0.25); transition: background 0.2s ease, box-shadow 0.2s ease; }
.acw-online-dot.is-connecting { background: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,0.3); animation: acw-dot-pulse 1.4s ease-in-out infinite; }
.acw-online-dot.is-disconnected { background: #94a3b8; box-shadow: 0 0 0 2px rgba(148,163,184,0.25); }
.acw-online-dot.is-just-connected { animation: acw-online-pop 0.55s ease-out; }
@keyframes acw-online-pop {
0% { transform: scale(0.7); box-shadow: 0 0 0 0 rgba(34,197,94,0.5); }
60% { transform: scale(1.2); box-shadow: 0 0 0 6px rgba(34,197,94,0.15); }
100% { transform: scale(1); box-shadow: 0 0 0 2px rgba(34,197,94,0.25); }
}
@keyframes acw-dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
.acw-header-title-block { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.acw-header-title-wrap { display: flex; align-items: center; gap: 8px; min-width: 0; }
.acw-header-subtitle { font-size: 12px; font-weight: 600; color: ${headerFgMuted}; transition: color 0.2s ease; }
.acw-header-subtitle.is-connected { color: ${headerAccent}; }
.acw-header-subtitle.is-connecting { color: ${headerOnLight ? '#b45309' : '#fde68a'}; }
.acw-header-subtitle.is-disconnected { color: ${headerFgSubtle}; }
.acw-unread-badge { background: rgba(255,255,255,0.95); color: ${safePrimary}; font-size: 11px; font-weight: 700; min-width: 18px; height: 18px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; padding: 0 5px; }
.acw-unread-badge.hidden { display: none; }
:host {
/* Lock to light unless .spark-acw-theme-dark is set — prevents OS/browser
dark mode (and Chrome Auto Dark) from restyling the embed. */
color-scheme: only light;
--acw-panel-height: min(max(650px, 60vh), calc(100vh - 24px));
--acw-font: ${acwFont};
--acw-text-xs: 11px;
--acw-text-sm: 12px;
--acw-text-base: 14px;
--acw-text-md: 15px;
--acw-text-lg: 16px;
--acw-text-xl: 20px;
--acw-text-2xl: 24px;
--acw-text-display: 24px;
--acw-leading: 1.45;
}
:host(.spark-acw-theme-light) { color-scheme: only light; }
:host(.spark-acw-theme-dark) { color-scheme: only dark; }
:host, .acw-panel {
font-family: var(--acw-font);
font-size: var(--acw-text-base);
line-height: var(--acw-leading);
-webkit-font-smoothing: antialiased;
}
.acw-panel {
position: absolute; width: 380px; max-width: 92vw;
height: var(--acw-panel-height);
min-height: var(--acw-panel-height);
max-height: var(--acw-panel-height);
background: ${safeBg}; backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
border: 1px solid ${brandBorder}; border-radius: 20px;
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.14), 0 8px 20px ${brandShadow};
display: none; flex-direction: column; overflow: hidden;
z-index: 2; isolation: isolate;
transition: width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
height 0.28s cubic-bezier(0.22, 1, 0.36, 1),
min-height 0.28s cubic-bezier(0.22, 1, 0.36, 1),
max-height 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.acw-panel.is-expanded {
width: min(680px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
}
.acw-panel.bottom-right { right: 0; bottom: 76px; }
.acw-panel.middle-right { right: 80px; top: 50%; transform: translateY(-50%); }
.acw-panel.top-right { right: 0; top: max(88px, var(--acw-viewport-top-offset, 88px)); }
.acw-panel.bottom-left { left: 0; bottom: 76px; }
.acw-panel.middle-left { left: 80px; top: 50%; transform: translateY(-50%); }
.acw-panel.top-left { left: 0; top: max(88px, var(--acw-viewport-top-offset, 88px)); }
.acw-header {
position: relative; z-index: 2; flex-shrink: 0;
background: ${headerGradient};
color: ${headerFg}; padding: 14px 16px 12px; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid ${headerDivider};
}
.acw-title { font-weight: 700; font-size: var(--acw-text-md); letter-spacing: -0.02em; line-height: 1.25; color: ${headerFg}; }
.acw-close { background: transparent; border: none; color: ${headerFgSubtle}; cursor: pointer; font-size: 18px; line-height: 1;
padding: 6px; width: 32px; height: 32px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center;
transition: color 0.15s ease, background 0.15s ease; }
.acw-close:hover { color: ${headerFg}; background: ${headerHoverBg}; }
.acw-close svg { display: block; flex-shrink: 0; }
.acw-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.acw-header-expand:hover { color: ${headerFg}; background: ${headerHoverBg}; }
.acw-header-disconnect { color: ${headerFgSubtle}; }
.acw-header-disconnect:hover { color: ${headerOnLight ? '#dc2626' : '#fecaca'}; background: ${headerOnLight ? 'rgba(254, 226, 226, 0.65)' : 'rgba(239, 68, 68, 0.22)'}; }
.acw-header-close:hover { color: ${headerFg}; background: ${headerHoverBg}; }
.acw-messages {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden;
padding: 12px 16px 8px; display: flex; flex-direction: column; justify-content: flex-start; gap: 8px;
background: ${safeBg};
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.acw-messages-spacer {
flex: 1 1 0; min-height: 0; width: 100%;
}
.acw-suggested-questions {
flex-shrink: 0; display: none; flex-wrap: nowrap; gap: 6px;
padding: 4px 12px 10px; overflow-x: auto; overflow-y: hidden;
-webkit-overflow-scrolling: touch; scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
background: ${safeBg};
}
.acw-panel[data-acw-view="chat"] .acw-suggested-questions.is-visible { display: flex !important; }
.acw-composer {
flex-shrink: 0; margin-top: auto; width: 100%;
display: none; flex-direction: column; background: ${safeBg};
}
.acw-panel[data-acw-view="chat"] .acw-composer { display: flex !important; }
.acw-suggested-questions::-webkit-scrollbar { height: 4px; }
.acw-suggested-questions::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45); border-radius: 999px;
}
.acw-suggested-question {
border: 1px solid ${brandBorder}; background: ${surfaceCard}; color: #64748b;
border-radius: 999px; padding: 6px 14px; font-size: 12px; font-family: inherit; font-weight: 500; cursor: pointer;
transition: border-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
flex-shrink: 0; white-space: nowrap; text-align: left;
}
.acw-suggested-question:hover {
border-color: ${safePrimary}; color: ${safePrimary};
transform: translateY(-2px); box-shadow: 0 4px 12px ${brandShadow};
}
.acw-suggested-question:active { transform: translateY(0) scale(0.98); }
.acw-suggested-question.is-sending {
border-color: ${safePrimary}; color: ${safePrimary}; background: ${brandWashSoft};
transform: scale(0.96); opacity: 0.85;
}
.acw-suggested-question.acw-action-navigation {
border-color: rgba(37, 99, 235, 0.35);
}
.acw-suggested-question.acw-action-open-url {
border-color: rgba(16, 185, 129, 0.45);
}
.acw-msg {
padding: 10px 14px; border-radius: 18px; max-width: 88%; font-size: var(--acw-text-base); line-height: var(--acw-leading);
white-space: pre-wrap; word-break: break-word;
animation: acw-msg-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes acw-msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.acw-msg-user {
margin-left: auto; background: ${brandGradientBtn}; color: #fff;
border-radius: 18px 18px 6px 18px;
box-shadow: 0 4px 14px ${brandShadow};
}
.acw-msg-bot {
margin-right: auto; background: ${surfaceCard}; color: #0f172a; border: 1px solid ${brandBorder};
border-radius: 18px 18px 18px 6px; box-shadow: 0 2px 8px rgba(15,23,42,0.06);
}
.acw-msg-typing {
margin-right: auto; display: inline-flex; align-items: center; gap: 3px; padding: 8px 12px; min-height: 0;
background: #fff; border: 1px solid rgba(191, 219, 254, 0.9); border-radius: 18px 18px 18px 6px;
box-shadow: 0 2px 8px rgba(15,23,42,0.06);
}
.acw-msg-typing span {
width: 5px; height: 5px; border-radius: 50%; background: ${safePrimary}; opacity: 0.45;
animation: acw-typing-dot 1.15s ease-in-out infinite;
}
.acw-msg-typing span:nth-child(2) { animation-delay: 0.2s; }
.acw-msg-typing span:nth-child(3) { animation-delay: 0.4s; }
@keyframes acw-typing-dot {
0%, 70%, 100% { opacity: 0.35; transform: translateY(0); }
35% { opacity: 1; transform: translateY(-2px); }
}
.acw-msg-img { max-width: 100%; border-radius: 16px; display: block; margin-top: 8px; max-height: 200px; object-fit: cover; }
.acw-product-carousel-wrap {
margin-right: auto; max-width: 100%; width: 100%;
animation: acw-msg-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.acw-product-carousel {
display: flex; flex-wrap: nowrap; gap: 10px; overflow-x: auto; overflow-y: hidden;
padding: 2px 2px 6px; margin-top: 4px;
scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch;
scrollbar-width: thin; scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.acw-product-carousel::-webkit-scrollbar { height: 4px; }
.acw-product-carousel::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45); border-radius: 999px;
}
.acw-product-card {
flex: 0 0 72%; max-width: 220px; min-width: 168px;
scroll-snap-align: start; scroll-snap-stop: always;
border: 1px solid rgba(191, 219, 254, 0.9); border-radius: 14px;
background: #fff; overflow: hidden;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
display: flex; flex-direction: column;
}
.acw-product-card-img-wrap {
position: relative; width: 100%; aspect-ratio: 1 / 1; background: #f8fafc; overflow: hidden;
}
.acw-product-card-img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.acw-product-card-body {
padding: 10px 10px 12px; display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto;
}
.acw-product-card-title {
margin: 0; font-size: 13px; font-weight: 600; line-height: 1.35; color: #0f172a;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.acw-product-card-price-row {
display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; min-height: 18px;
}
.acw-product-card-price {
font-size: 14px; font-weight: 700; color: ${safePrimary};
}
.acw-product-variants-btn {
width: 100%; border: 1px solid rgba(191, 219, 254, 0.9); border-radius: 999px;
padding: 6px 12px; font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer; background: #fff; color: #64748b;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.acw-product-variants-btn:hover {
border-color: ${safePrimary}; color: ${safePrimary};
}
.acw-product-variants-btn.is-active {
border-color: ${safePrimary}; color: ${safePrimary}; background: rgba(239, 246, 255, 0.9);
}
.acw-product-variants-panel {
margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(226, 232, 240, 0.95);
display: flex; flex-direction: column; gap: 8px;
}
.acw-product-variants-panel[hidden] { display: none !important; }
.acw-product-variants-heading {
margin: 0; font-size: 12px; font-weight: 600; color: #475569;
}
.acw-product-variant-row {
display: flex; align-items: center; gap: 10px;
border: 1px solid rgba(226, 232, 240, 0.95); border-radius: 12px;
padding: 8px; background: #fff;
}
.acw-product-variant-row-img {
width: 52px; height: 52px; border-radius: 8px; object-fit: cover; flex-shrink: 0;
background: #f8fafc;
}
.acw-product-variant-row-body {
flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px;
}
.acw-product-variant-row-title {
margin: 0; font-size: 12px; font-weight: 600; color: #0f172a;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acw-product-variant-row-price {
margin: 0; font-size: 13px; font-weight: 700; color: ${safePrimary};
}
.acw-product-variant-row-cta {
flex-shrink: 0; border: none; border-radius: 999px;
padding: 6px 12px; font-size: 11px; font-weight: 600; font-family: inherit;
cursor: pointer; background: ${safePrimary}; color: #fff;
}
.acw-product-card-cta {
margin-top: auto; width: 100%; border: none; border-radius: 999px;
padding: 7px 12px; font-size: 12px; font-weight: 600; font-family: inherit;
cursor: pointer; background: ${safePrimary}; color: #fff;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.acw-product-card-cta:hover { opacity: 0.92; transform: translateY(-1px); }
.acw-product-card-cta:active { transform: translateY(0); }
.acw-product-show-more {
display: inline-flex; align-items: center; gap: 4px; margin-top: 8px; padding: 0;
border: none; background: transparent; color: ${safePrimary};
font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer;
}
.acw-product-show-more:hover { text-decoration: underline; }
.acw-input {
flex-shrink: 0; width: 100%; box-sizing: border-box;
padding: 10px 12px 12px; display: block;
background: ${safeBg}; border-top: 1px solid rgba(226, 232, 240, 0.9);
}
.acw-composer-box {
display: flex; flex-direction: column; gap: 6px; width: 100%; min-width: 0; box-sizing: border-box;
border: 1.5px solid rgba(15, 23, 42, 0.88); border-radius: 16px;
padding: 10px 12px 8px; background: ${surfaceCard}; overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.acw-composer-box:focus-within {
border-color: ${safePrimary};
box-shadow: 0 0 0 3px ${brandWashMid};
}
.acw-composer-field {
width: 100%; min-width: 0; min-height: 24px; max-height: 96px;
padding: 0; resize: none; border: none; background: transparent;
font-size: var(--acw-text-base); font-family: inherit; line-height: var(--acw-leading);
box-sizing: border-box; overflow: hidden; color: #0f172a;
}
.acw-composer-field::placeholder { color: #94a3b8; }
.acw-composer-field:focus { outline: none; }
.acw-composer-field:disabled { opacity: 0.6; cursor: not-allowed; }
.acw-composer-actions {
display: flex; align-items: center; justify-content: flex-end; gap: 2px;
min-height: 32px;
}
.acw-input-attach {
display: none; flex-shrink: 0; width: 32px; height: 32px; border: none; border-radius: 8px;
background: transparent; cursor: pointer; align-items: center; justify-content: center;
color: #64748b; transition: color 0.15s ease, background 0.15s ease;
}
.acw-file-upload-on .acw-input-attach { display: inline-flex; }
.acw-input-attach:hover:not(:disabled) { color: ${safePrimary}; background: ${brandWashSoft}; }
.acw-input-attach:disabled { opacity: 0.45; cursor: not-allowed; }
.acw-input-attach svg { width: 20px; height: 20px; display: block; }
.acw-attachments {
display: none; flex-direction: column; flex-wrap: nowrap; gap: 6px;
width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box;
padding: 0 0 4px; background: transparent; overflow: hidden;
}
.acw-attachment-chip {
position: relative; display: flex; align-items: center; gap: 8px;
width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box;
padding: 6px 8px 6px 6px; border-radius: 12px; border: 1px solid rgba(148, 163, 184, 0.45);
background: #fff; font-size: 12px; color: #334155; overflow: hidden;
}
.acw-attachment-chip.is-uploading { opacity: 0.72; }
.acw-attachment-chip.is-error { border-color: rgba(239, 68, 68, 0.55); color: #b91c1c; }
.acw-attachment-thumb {
width: 36px; height: 36px; border-radius: 8px; object-fit: cover; flex-shrink: 0; background: #e2e8f0;
}
.acw-attachment-name {
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.acw-attachment-remove {
flex-shrink: 0; width: 22px; height: 22px; border: none; border-radius: 999px;
background: rgba(148, 163, 184, 0.2); color: #475569; cursor: pointer; line-height: 1; font-size: 14px;
}
.acw-attachment-remove:hover { background: rgba(239, 68, 68, 0.15); color: #b91c1c; }
.acw-input-send {
flex-shrink: 0; width: 32px; height: 32px; border-radius: 999px; border: none; cursor: pointer;
background: ${brandGradientBtn}; color: #fff;
display: inline-flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px ${brandShadow};
transition: transform 0.18s ease, filter 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
}
.acw-input-send:hover:not(:disabled) { filter: brightness(1.05); transform: scale(1.04); }
.acw-input-send:active:not(:disabled) { transform: scale(0.96); }
.acw-input-send:disabled, .acw-input-send.is-muted {
opacity: 0.38; cursor: not-allowed; filter: grayscale(0.2); transform: none;
box-shadow: none;
}
.acw-input-send svg { width: 18px; height: 18px; display: block; }
.acw-connection-banner {
flex-shrink: 0; display: none; align-items: center; justify-content: space-between; gap: 10px;
padding: 8px 12px; font-size: 12px; font-weight: 600; color: #92400e;
background: linear-gradient(90deg, rgba(254,243,199,0.95), rgba(255,251,235,0.98));
border-bottom: 1px solid rgba(251, 191, 36, 0.35);
}
.acw-connection-banner.is-reconnecting { color: #1d4ed8; background: linear-gradient(90deg, rgba(219,234,254,0.95), rgba(239,246,255,0.98)); border-bottom-color: rgba(147, 197, 253, 0.45); }
.acw-connection-banner-text { flex: 1; min-width: 0; line-height: 1.35; }
.acw-connection-banner-btn {
flex-shrink: 0; border: 1px solid rgba(37, 99, 235, 0.35); background: #fff; color: ${safePrimary};
border-radius: 999px; padding: 5px 12px; font-size: 11px; font-weight: 700; font-family: inherit;
cursor: pointer; transition: background 0.15s ease, transform 0.15s ease;
}
.acw-connection-banner-btn:hover:not(:disabled) { background: rgba(239, 246, 255, 0.9); transform: translateY(-1px); }
.acw-connection-banner-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.acw-connection-banner.is-success {
color: #166534; background: linear-gradient(90deg, rgba(220,252,231,0.98), rgba(240,253,244,0.98));
border-bottom-color: rgba(134, 239, 172, 0.45);
}
.acw-continuity-toast {
position: absolute; top: 56px; left: 12px; right: 12px; z-index: 12;
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
font-size: 12px; font-weight: 600; color: #1e40af;
background: rgba(255,255,255,0.96); border: 1px solid rgba(147, 197, 253, 0.65);
border-radius: 12px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
opacity: 0; visibility: hidden; transform: translateY(-6px);
transition: opacity 0.22s ease, transform 0.22s ease, visibility 0.22s ease;
pointer-events: none;
}
.acw-continuity-toast.visible {
opacity: 1; visibility: visible; transform: translateY(0);
}
.acw-continuity-toast svg { width: 14px; height: 14px; flex-shrink: 0; color: ${safePrimary}; }
.acw-msg-wrap {
display: flex; flex-direction: column; align-items: flex-start; align-self: flex-start;
width: fit-content; max-width: 88%; gap: 4px;
}
.acw-msg-wrap:has(.acw-product-carousel-wrap) {
width: 100%; align-self: flex-start;
}
.acw-msg-wrap.is-user { align-items: flex-end; align-self: flex-end; max-width: 88%; }
.acw-msg-wrap:has(.acw-msg-bot) .acw-msg-actions { display: flex; }
.acw-msg-wrap:has(.acw-msg-user) .acw-msg-actions,
.acw-msg-user + .acw-msg-actions { display: none !important; }
.acw-msg-actions {
display: none; align-items: center; flex-wrap: wrap; gap: 6px;
width: auto; max-width: 100%; align-self: flex-start;
opacity: 0; transition: opacity 0.18s ease;
}
.acw-msg-wrap:hover .acw-msg-actions,
.acw-msg-wrap:focus-within .acw-msg-actions { opacity: 1; }
.acw-msg-feedback {
display: inline-flex; align-items: center; gap: 2px;
}
.acw-msg-action-btn {
border: 1px solid rgba(148, 163, 184, 0.35); background: rgba(255,255,255,0.92);
color: #64748b; border-radius: 999px; padding: 2px 8px; font-size: 10px; font-weight: 600;
font-family: inherit; cursor: pointer; transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.acw-msg-action-btn:hover { color: ${safePrimary}; border-color: rgba(96, 165, 250, 0.55); background: #fff; }
.acw-msg-action-btn.is-active { color: ${safePrimary}; border-color: ${safePrimary}; background: rgba(239, 246, 255, 0.95); }
.acw-msg-action-btn.is-icon {
padding: 2px; width: auto; height: auto; min-width: 0;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: transparent; border-radius: 0;
}
.acw-msg-action-btn.is-icon:hover { color: ${safePrimary}; background: transparent; border: none; }
.acw-msg-action-btn.is-icon.is-active { color: ${safePrimary}; background: transparent; border: none; }
.acw-msg-action-btn.is-icon svg { display: block; flex-shrink: 0; }
.acw-handover-ack {
align-self: stretch; flex-shrink: 0; width: 100%;
display: flex; align-items: center; gap: 10px;
margin: 4px 0; padding: 0 2px; box-sizing: border-box;
animation: acw-msg-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.acw-handover-ack-line {
flex: 1 1 auto; min-width: 0; height: 1px;
background: rgba(148, 163, 184, 0.38);
}
.acw-handover-ack-icon {
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px;
border-radius: 999px; border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(241, 245, 249, 0.95); color: #64748b;
}
.acw-handover-ack-icon svg { display: block; flex-shrink: 0; }
.acw-rail-voice-eq {
display: none; align-items: flex-end; justify-content: center; gap: 3px;
width: 100%; height: 14px; pointer-events: none;
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-eq { display: flex; }
.acw-rail-voice-eq span {
width: 3px; min-height: 4px; border-radius: 2px; background: ${safePrimary}; opacity: 0.75;
transition: height 0.08s ease;
}
.acw-footer {
flex-shrink: 0; font-size: var(--acw-text-xs); color: #94a3b8; padding: 6px 10px 10px; text-align: center;
background: ${safeBg}; border-top: 1px solid rgba(226, 232, 240, 0.9);
}
.acw-home-footer {
margin-top: auto; flex-shrink: 0; border-top: none; background: transparent;
padding: 16px 4px 4px;
}
.acw-footer a { color: #64748b; text-decoration: none; }
.acw-footer a:hover { text-decoration: underline; }
.acw-status { display: none !important; }
.acw-status-disconnect { margin-left: auto; flex-shrink: 0; width: 32px; height: 32px; padding: 6px; }
.acw-dot { width: 8px; height: 8px; border-radius: 999px; box-shadow: 0 0 0 6px rgba(0,0,0,0.04); }
.acw-dot.connected { background: #22c55e; box-shadow: 0 0 0 6px rgba(34,197,94,0.18); }
.acw-dot.disconnected { background: #ef4444; box-shadow: 0 0 0 6px rgba(239,68,68,0.18); }
.acw-customer-form {
position: relative;
flex: 1 1 auto; min-height: 0;
background: ${safeBg}; z-index: 1;
display: none; flex-direction: column; align-items: stretch; justify-content: flex-start;
padding: 8px 24px 28px; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;
scrollbar-gutter: stable;
}
.acw-panel[data-acw-view="form"] .acw-header { display: none !important; }
.acw-form-hero-banner {
position: relative;
margin: -8px -24px 0; width: calc(100% + 48px); max-width: calc(100% + 48px);
aspect-ratio: 38 / 15;
height: auto; min-height: 120px; max-height: 180px;
overflow: hidden; background: #e2e8f0;
border-radius: 20px 20px 0 0;
flex-shrink: 0;
}
.acw-form-hero-banner-media {
position: absolute; inset: 0; overflow: hidden;
border-radius: inherit; background: #e2e8f0;
}
.acw-form-hero-banner-media img {
display: block; width: 100%; height: 100%; max-width: 100%;
object-fit: cover; object-position: center 30%;
}
.acw-form-hero-close {
position: absolute; top: 12px; right: 12px; z-index: 13;
width: 32px; height: 32px; border-radius: 999px; border: none; padding: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(15, 23, 42, 0.45); color: #fff; cursor: pointer;
transition: background 0.15s ease;
}
.acw-form-hero-close:hover { background: rgba(15, 23, 42, 0.65); }
.acw-form-hero-close svg { display: block; width: 16px; height: 16px; }
.acw-form-hero-name {
position: absolute; top: 14px; left: 50%; transform: translateX(-50%); z-index: 12;
max-width: calc(100% - 90px); box-sizing: border-box;
padding: 6px 14px; border-radius: 10px;
background: rgba(15, 23, 42, 0.45);
color: #fff; font-size: var(--acw-text-sm); font-weight: 700; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acw-form-identity { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 2px; flex-shrink: 0; position: relative; z-index: 10; }
.acw-form-avatar-wrap { position: relative; flex-shrink: 0; display: inline-flex; margin-top: -22px; }
.acw-form-avatar {
width: 44px; height: 44px; border-radius: 999px;
background: ${safePrimary}; color: #fff; line-height: 1;
font-size: var(--acw-text-lg); font-weight: 700;
display: flex; align-items: center; justify-content: center;
border: 3px solid #ffffff;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.22);
}
.acw-form-back {
flex-shrink: 0; margin-top: -22px;
width: 44px; height: 44px; border-radius: 999px;
display: flex; align-items: center; justify-content: center;
background: #f1f5f9; color: #475569; border: 3px solid #ffffff; cursor: pointer; padding: 0;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18);
transition: background 0.15s ease, color 0.15s ease;
}
.acw-form-back:hover { background: #e2e8f0; color: #0f172a; }
.acw-form-back svg { width: 18px; height: 18px; }
.acw-form-head-col { width: 100%; min-width: 0; margin: 0 0 16px; }
.acw-customer-form h3 { margin: 0; font-size: var(--acw-text-lg); font-weight: 700; color: #0f172a; text-align: center; letter-spacing: -0.02em; }
.acw-customer-form p { margin: 0; font-size: var(--acw-text-sm); color: #64748b; text-align: left; line-height: var(--acw-leading); }
.acw-customer-form .field { margin-bottom: 16px; min-width: 0; width: 100%; box-sizing: border-box; }
.acw-customer-form .field.acw-field-phone { margin-bottom: 0; }
.acw-customer-form label { display: block; font-size: var(--acw-text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 8px; color: #475569; }
.acw-customer-form input {
display: block; width: 100%; max-width: 100%; min-height: 0; height: auto; box-sizing: border-box;
padding: 12px 14px; border: 2px solid rgba(37, 99, 235, 0.2); border-radius: 12px; font-size: var(--acw-text-base); line-height: var(--acw-leading);
transition: border-color 0.2s, box-shadow 0.2s;
background: #fff;
color: #0f172a;
}
.acw-customer-form .acw-phone-row { display: flex; gap: 8px; align-items: stretch; }
.acw-customer-form .acw-phone-code-wrap {
flex: 0 0 92px; width: 92px; min-width: 92px; max-width: 92px; position: relative;
}
.acw-customer-form .acw-phone-number-wrap { flex: 1 1 auto; min-width: 0; display: flex; }
.acw-customer-form .acw-phone-number-wrap input { flex: 1; min-width: 0; }
.acw-phone-code-switch { position: relative; width: 100%; height: 100%; box-sizing: border-box; }
.acw-phone-code-native {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.acw-phone-code-trigger {
display: flex; align-items: center; justify-content: flex-start; gap: 4px;
width: 100%; height: 100%; min-height: 48px; box-sizing: border-box;
padding: 12px 8px; border: 2px solid rgba(37, 99, 235, 0.2); border-radius: 12px;
background: #fff; color: #0f172a; font-size: 14px; font-family: inherit; line-height: 1.4;
cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s;
}
.acw-phone-code-trigger:hover { border-color: rgba(37, 99, 235, 0.35); }
.acw-phone-code-trigger:focus { outline: none; border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(191, 219, 254, 0.55); }
.acw-phone-code-trigger[aria-expanded="true"] { border-color: #60a5fa; box-shadow: 0 0 0 4px rgba(191, 219, 254, 0.55); }
.acw-phone-code-flag {
flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 14px; font-size: 16px; line-height: 1;
}
.acw-phone-code-dial {
flex: 0 0 auto; min-width: 0; font-weight: 600; font-size: 13px;
font-variant-numeric: tabular-nums; letter-spacing: -0.01em; white-space: nowrap;
}
.acw-phone-code-chevron {
flex-shrink: 0; margin-left: auto; display: flex; align-items: center; color: #94a3b8;
transition: transform 0.2s;
}
.acw-phone-code-chevron svg { width: 14px; height: 14px; display: block; }
.acw-phone-code-trigger[aria-expanded="true"] .acw-phone-code-chevron { transform: rotate(180deg); }
.acw-phone-code-menu {
display: none; flex-direction: column; position: absolute; left: 0; right: auto;
top: calc(100% + 6px); z-index: 50;
width: min(280px, calc(100vw - 48px)); min-width: 240px; max-height: 280px;
box-sizing: border-box; background: #fff; border: 1px solid rgba(37, 99, 235, 0.15);
border-radius: 12px; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
overflow: hidden;
}
.acw-phone-code-menu.is-open { display: flex; }
.acw-phone-code-menu.is-open.acw-phone-code-menu--floating { position: absolute; z-index: 50; }
.acw-phone-code-search-wrap {
flex-shrink: 0; padding: 8px; border-bottom: 1px solid rgba(37, 99, 235, 0.1); background: #fff;
}
.acw-phone-code-search {
display: block; width: 100%; box-sizing: border-box;
padding: 10px 12px; border: 1px solid rgba(37, 99, 235, 0.2); border-radius: 8px;
font-size: 14px; line-height: 1.4; font-family: inherit; color: #0f172a; background: #f8fafc;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.acw-phone-code-search::placeholder { color: #94a3b8; }
.acw-phone-code-search:focus {
outline: none; background: #fff; border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(191, 219, 254, 0.55);
}
.acw-phone-code-list {
flex: 1 1 auto; min-height: 0; max-height: 220px; overflow-y: auto; padding: 6px; box-sizing: border-box;
}
.acw-phone-code-empty {
display: none; padding: 16px 12px; text-align: center; font-size: 13px; color: #94a3b8;
}
.acw-phone-code-empty.is-visible { display: block; }
.acw-phone-code-option {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 9px 10px; margin: 0; border: none; border-radius: 8px;
background: transparent; text-align: left; font-size: 14px; font-family: inherit; color: #0f172a; cursor: pointer;
}
.acw-phone-code-option:hover { background: rgba(37, 99, 235, 0.08); }
.acw-phone-code-option.is-selected { background: rgba(37, 99, 235, 0.12); font-weight: 600; color: ${safePrimary}; }
.acw-phone-code-option-flag {
flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 16px; font-size: 18px; line-height: 1;
}
.acw-phone-code-option-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acw-phone-code-option-dial { flex-shrink: 0; font-weight: 600; color: #64748b; font-variant-numeric: tabular-nums; }
.acw-phone-code-option.is-selected .acw-phone-code-option-dial { color: ${safePrimary}; }
.acw-customer-form .acw-phone-row input {
min-height: 48px; height: 100%;
}
.acw-customer-form input:focus { outline: none; border-color: ${safePrimary}; box-shadow: 0 0 0 4px ${brandWashMid}; }
.acw-customer-form .field.acw-field-invalid input,
.acw-customer-form .field.acw-field-invalid .acw-phone-code-trigger {
border-color: #f87171;
box-shadow: 0 0 0 4px rgba(254, 202, 202, 0.45);
}
.acw-customer-form .acw-field-error {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.35;
color: #dc2626;
}
.acw-customer-form .acw-form-error {
margin: 0 0 10px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
line-height: 1.4;
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
text-align: center;
}
.acw-panel[data-acw-view="form"] .acw-customer-form {
overflow-x: visible;
overflow-y: auto;
}
.acw-form-hero-banner .acw-lang-switch-field {
position: absolute;
top: 12px;
left: 12px;
z-index: 14;
width: auto;
margin: 0;
padding: 0;
pointer-events: auto;
}
.acw-lang-switch-sr-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.acw-lang-switch { position: relative; width: auto; box-sizing: border-box; }
.acw-lang-switch-native {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0; opacity: 0; pointer-events: none;
}
.acw-lang-switch-field .acw-lang-switch-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
width: 32px;
height: 32px;
padding: 0;
box-sizing: border-box;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.5);
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
font-size: 14px;
font-family: inherit;
text-align: left;
transition: background 0.15s ease;
}
.acw-lang-switch-field .acw-lang-switch-current,
.acw-lang-switch-field .acw-lang-switch-chevron { display: none !important; }
.acw-lang-switch-trigger:hover { background: rgba(15, 23, 42, 0.65); }
.acw-lang-switch-trigger:focus { outline: none; background: rgba(15, 23, 42, 0.65); }
.acw-lang-switch-trigger[aria-expanded="true"] { background: rgba(15, 23, 42, 0.65); }
.acw-lang-switch-field .acw-lang-switch-icon svg { width: 16px; height: 16px; stroke: #fff; }
.acw-lang-switch-icon { flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; }
.acw-lang-switch-icon svg { width: 16px; height: 16px; stroke: #fff; }
.acw-lang-switch-current { flex: 1; min-width: 0; font-weight: 500; line-height: 1.3; }
.acw-lang-switch-chevron { flex-shrink: 0; display: flex; color: #94a3b8; transition: transform 0.2s; }
.acw-lang-switch-chevron svg { width: 18px; height: 18px; }
.acw-lang-switch-trigger[aria-expanded="true"] .acw-lang-switch-chevron { transform: rotate(180deg); }
.acw-lang-switch-field .acw-lang-switch-menu {
left: 0;
right: auto;
min-width: 220px;
width: max-content;
max-width: min(280px, calc(100vw - 48px));
}
.acw-lang-switch-menu.is-open.acw-lang-switch-menu--floating {
position: absolute;
z-index: 50;
right: auto;
}
.acw-lang-switch-menu {
display: none; position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 40;
max-height: 220px; overflow-y: auto; box-sizing: border-box;
background: #fff; border: 1px solid rgba(37, 99, 235, 0.15); border-radius: 12px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12); padding: 6px;
}
.acw-lang-switch-menu.is-open { display: block; }
.acw-lang-option {
display: block; width: 100%; box-sizing: border-box; padding: 10px 12px; margin: 0; border: none; border-radius: 8px;
background: transparent; text-align: left; font-size: 14px; font-family: inherit; color: #0f172a; cursor: pointer;
}
.acw-lang-option:hover { background: rgba(37, 99, 235, 0.08); }
.acw-lang-option.is-selected { background: rgba(37, 99, 235, 0.12); font-weight: 600; color: ${safePrimary}; }
.acw-customer-form .actions { display: flex; flex-direction: column; gap: 12px; margin-top: auto; padding-top: 20px; margin-bottom: 6px; min-width: 0; width: 100%; box-sizing: border-box; }
.acw-form-footer { flex-shrink: 0; font-size: var(--acw-text-xs); color: #94a3b8; padding: 4px 0 0; text-align: center; background: transparent; border-top: none; }
.acw-customer-form .actions-buttons { display: flex; flex-direction: row; align-items: stretch; gap: 10px; min-width: 0; width: 100%; box-sizing: border-box; }
.acw-customer-form .actions-buttons.single { justify-content: center; }
.acw-customer-form .actions-buttons button {
min-width: 0; box-sizing: border-box; padding: 12px 12px; border: none; border-radius: 12px;
font-size: var(--acw-text-md); font-weight: 600; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acw-customer-form .submit-btn {
flex: 13 1 0%; min-height: 48px; padding-top: 12px; padding-bottom: 12px; background: ${brandGradientBtn}; color: #fff;
box-shadow: 0 8px 22px ${brandShadowStrong};
}
.acw-customer-form .actions-buttons.single .submit-btn { flex: 0 1 auto; width: 100%; max-width: 260px; }
.acw-customer-form .submit-btn:hover { filter: brightness(1.05); transform: scale(1.02); }
.acw-customer-form .skip-btn { flex: 7 1 0%; background: #f8fafc; color: #64748b; border: 1px solid rgba(0,0,0,0.08); }
.acw-customer-form .skip-btn:hover { background: #f1f5f9; }
.acw-start-cta { flex: 1 1 auto; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; padding: 12px 14px 16px; min-height: 0; overflow-y: auto; }
.acw-panel[data-acw-view="home"] .acw-start-cta { display: flex !important; }
.acw-panel[data-acw-view="home"] .acw-messages,
.acw-panel[data-acw-view="home"] .acw-composer,
.acw-panel[data-acw-view="home"] .acw-customer-form,
.acw-panel[data-acw-view="home"] .acw-voice-panel,
.acw-panel[data-acw-view="home"] .acw-suggested-questions { display: none !important; }
.acw-panel[data-acw-view="form"] .acw-composer,
.acw-panel[data-acw-view="form"] .acw-suggested-questions,
.acw-panel[data-acw-view="voice"] .acw-composer,
.acw-panel[data-acw-view="voice"] .acw-suggested-questions { display: none !important; }
.acw-panel[data-acw-view="connecting"] .acw-composer,
.acw-panel[data-acw-view="connecting"] .acw-suggested-questions { display: none !important; }
.acw-start-cta-mode { justify-content: flex-start; padding-top: 16px; height: auto; flex: 1 1 auto; min-height: 0; }
.acw-start-cta button { background: ${surfaceCard}; color: #0f172a; border: 2px solid ${brandBorder}; border-radius: 25px; font-size: var(--acw-text-md); font-weight: 600; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.06); }
.acw-start-cta button:hover { background: #f8fafc; border-color: ${safePrimary}; }
/* Mode selector should size to its contents (no forced scroll height) */
.acw-mode-selector { flex: 0 0 auto; display: flex; flex-direction: column; align-items: stretch; padding: 0 4px 8px; gap: 0; overflow: visible; }
.acw-mode-hero-icon { width: 72px; height: 72px; margin: 0 auto 16px; border-radius: 50%; background: transparent; display: flex; align-items: center; justify-content: center; box-shadow: 0 12px 28px rgba(15,23,42,0.10); border: 1px solid rgba(0,0,0,0.06); overflow: hidden; }
.acw-mode-hero-icon svg { width: 36px; height: 36px; color: ${safePrimary}; }
.acw-mode-hero-icon img { width: 100%; height: 100%; padding: 12px; object-fit: contain; border-radius: 0; background: transparent; }
.acw-mode-heading {
margin: 0 0 6px; font-size: var(--acw-text-display); font-weight: 700; line-height: 1.2; text-align: center; letter-spacing: -0.02em;
color: ${safePrimary};
}
.acw-mode-sub { margin: 0 0 20px; font-size: var(--acw-text-base); font-weight: 400; color: #64748b; text-align: center; line-height: var(--acw-leading); }
.acw-mode-options { display: flex; flex-direction: column; gap: 12px; flex: 0 0 auto; height: auto; }
.acw-mode-btn {
display: flex; flex-direction: row; align-items: center; gap: 14px; width: 100%; min-height: 0; padding: 16px 18px;
background: ${surfaceCard}; border: 1px solid rgba(0,0,0,0.06); border-radius: 24px; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; text-align: left;
box-shadow: 0 4px 14px rgba(15,23,42,0.06); position: relative; overflow: hidden;
}
.acw-mode-btn::before {
/* Sweeping highlight band (moves left -> right) */
content: ""; position: absolute; top: 0; right: 0; bottom: 0; left: 0px; opacity: 0; pointer-events: none;
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
z-index: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
rgba(255,255,255,0) 0%,
rgba(255,255,255,0) 35%,
var(--acw-sweep, rgba(37, 99, 235, 0.22)) 50%,
rgba(255,255,255,0) 65%,
rgba(255,255,255,0) 100%
);
transition: opacity 0.15s cubic-bezier(.4, 0, .2, 1);
mix-blend-mode: normal;
}
.acw-mode-btn::after {
/* Static subtle highlight that remains after sweep */
content: ""; position: absolute; top: 0; right: 0; bottom: 0; left: 0px; opacity: 0; pointer-events: none;
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
z-index: 0;
transition: opacity 0.2s cubic-bezier(.4, 0, .2, 1) 0.12s;
}
@keyframes acw-sweep-ltr {
0% { transform: translateX(-60%); opacity: 0; }
10% { opacity: 1; }
100% { transform: translateX(60%); opacity: 0; }
}
.acw-mode-btn:hover { transform: translateY(-2px) scale(1.0); box-shadow: 0 12px 28px ${brandShadow}; }
.acw-mode-btn:hover::before { animation: acw-sweep-ltr 0.65s cubic-bezier(.4, 0, .2, 1) both; }
.acw-mode-btn:hover::after { opacity: 1; }
.acw-mode-btn > * { position: relative; z-index: 1; }
.acw-mode-btn .acw-mode-icon { width: 48px; height: 48px; flex-shrink: 0; border-radius: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 10px 15px -3px rgba(15,23,42,0.12); }
.acw-mode-icon-chat { background: ${brandGradientBtn}; box-shadow: 0 8px 18px ${brandShadow}; }
.acw-mode-icon-voice { background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 42%), #7c3aed; box-shadow: 0 8px 18px rgba(124,58,237,0.28); }
.acw-mode-icon-whatsapp { background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 42%), #16a34a; box-shadow: 0 8px 18px rgba(22,163,74,0.28); }
.acw-mode-btn .acw-mode-icon svg { width: 22px; height: 22px; color: #fff; }
.acw-mode-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.acw-mode-title { font-size: var(--acw-text-md); font-weight: 700; color: #0f172a; margin-bottom: 2px; }
.acw-mode-desc { font-size: var(--acw-text-sm); font-weight: 400; color: #64748b; line-height: var(--acw-leading); }
.acw-mode-chevron { flex-shrink: 0; width: 22px; height: 22px; color: #94a3b8; opacity: 0.5; transition: transform 0.2s, opacity 0.2s; }
.acw-mode-btn:hover .acw-mode-chevron { opacity: 1; transform: translateX(4px); }
/* Per-mode borders + hover highlights */
.acw-mode-btn-chat { border: 2px solid ${brandBorder}; --acw-sweep: ${rgbaFromHex(safePrimary, 0.22)}; }
.acw-mode-btn-chat::after { background: ${brandWashSoft}; }
.acw-mode-btn-chat:hover { border-color: ${brandBorderMid}; }
.acw-mode-btn-chat:hover .acw-mode-chevron { color: ${safePrimary}; }
.acw-mode-btn-voice { border: 2px solid #e0e7ff; --acw-sweep: rgba(99, 102, 241, 0.24); }
.acw-mode-btn-voice::after { background: linear-gradient(to right, rgba(99, 102, 241, 0.1), rgba(168, 85, 247, 0.1), rgba(236, 72, 153, 0.1)); }
.acw-mode-btn-voice:hover { border-color: #818cf8; }
.acw-mode-btn-voice:hover .acw-mode-chevron { color: #4f46e5; }
.acw-mode-btn-whatsapp { border: 2px solid #d1fae5; --acw-sweep: rgba(16, 185, 129, 0.24); }
.acw-mode-btn-whatsapp::after { background: linear-gradient(to right, rgba(16, 185, 129, 0.1), rgba(34, 197, 94, 0.1), rgba(20, 184, 166, 0.1)); }
.acw-mode-btn-whatsapp:hover { border-color: #34d399; }
.acw-mode-btn-whatsapp:hover .acw-mode-chevron { color: #059669; }
.acw-voice-panel {
flex: 1 1 auto; min-height: 480px; display: none; flex-direction: column; align-items: stretch; justify-content: flex-start;
padding: 0; position: relative; overflow: hidden;
background: #0b1220;
color: #fff;
}
.acw-voice-panel.visible { display: flex; }
.acw-voice-orb { position: absolute; border-radius: 50%; filter: blur(48px); opacity: 0.16; pointer-events: none; }
.acw-voice-orb.a { width: 280px; height: 280px; background: ${safePrimary}; top: -80px; right: -60px; }
.acw-voice-orb.b { width: 240px; height: 240px; background: ${primaryLight}; bottom: -40px; left: -50px; }
@keyframes acw-orb-pulse { 0%, 100% { transform: scale(1); opacity: 0.35; } 50% { transform: scale(1.08); opacity: 0.55; } }
.acw-voice-inner { position: relative; z-index: 1; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 28px 20px 24px; gap: 20px; }
.acw-voice-title { font-size: var(--acw-text-2xl); font-weight: 700; letter-spacing: -0.02em; text-align: center; }
.acw-voice-status { font-size: var(--acw-text-base); font-weight: 500; line-height: var(--acw-leading); color: rgba(255,255,255,0.92); text-align: center; min-height: 2.8em; padding: 0 12px; }
.acw-voice-visualizer { display: flex; align-items: flex-end; justify-content: center; gap: 6px; height: 72px; }
.acw-voice-bar {
width: 8px; border-radius: 4px;
background: rgba(255,255,255,0.72);
height: 24px; transition: height 0.08s ease-out;
}
.acw-voice-mic-wrap { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
.acw-voice-mic {
width: 96px; height: 96px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.35); cursor: pointer;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.15); color: #fff; transition: transform 0.2s, background 0.2s, box-shadow 0.2s;
}
.acw-voice-mic:hover { transform: scale(1.03); background: rgba(255,255,255,0.22); }
.acw-voice-mic.recording {
background: #fff; color: ${safePrimary}; border-color: #fff;
box-shadow: 0 0 0 12px rgba(255,255,255,0.15);
animation: none;
}
@keyframes acw-pulse-mic { 0%, 100% { box-shadow: 0 0 0 12px rgba(255,255,255,0.12); } 50% { box-shadow: 0 0 0 20px rgba(255,255,255,0.2); } }
.acw-voice-mic svg { width: 36px; height: 36px; }
.acw-voice-end-row {
width: 100%; max-width: 280px; height: 52px; border-radius: 14px; border: 1px solid rgba(248, 113, 113, 0.45);
background: rgba(239, 68, 68, 0.22); backdrop-filter: blur(8px); color: #fff; font-size: 14px; font-weight: 600;
display: none; align-items: center; justify-content: center; gap: 10px; cursor: pointer;
transition: background 0.18s ease, transform 0.15s ease;
}
.acw-voice-end-row:hover { background: rgba(239, 68, 68, 0.34); transform: translateY(-1px); }
.acw-voice-end-row svg { width: 18px; height: 18px; flex-shrink: 0; }
@keyframes acw-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } }
.acw-nav-offer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.acw-nav-offer-btn {
border: 1px solid ${safePrimary}; background: #fff; color: ${safePrimary};
border-radius: 999px; padding: 6px 14px; font-size: 13px; cursor: pointer;
}
.acw-nav-offer-btn.primary { background: ${safePrimary}; color: #fff; }
.acw-nav-offer-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.acw-rail {
display: flex; flex-direction: column-reverse; align-items: center; gap: 14px;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.acw-dock {
position: relative;
z-index: 1;
transition: opacity 0.26s ease, transform 0.32s cubic-bezier(0.34, 1.25, 0.64, 1), visibility 0.26s;
transform-origin: bottom center;
}
:host(.spark-acw-bottom-right) .acw-dock,
:host(.spark-acw-top-right) .acw-dock { transform-origin: bottom right; }
:host(.spark-acw-bottom-left) .acw-dock,
:host(.spark-acw-top-left) .acw-dock { transform-origin: bottom left; }
.acw-dock.acw-dock-hidden {
opacity: 0;
transform: scale(0.78) translateY(10px);
pointer-events: none;
visibility: hidden;
}
.acw-panel.is-open {
animation: acw-panel-surface 0.34s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes acw-panel-surface {
from { opacity: 0; transform: translateY(14px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Panel open: overlap the dock anchor; dock is hidden underneath */
:host(.spark-acw-panel-visible) .acw-panel.bottom-right,
:host(.spark-acw-panel-visible) .acw-panel-rail.bottom-right {
right: 0 !important;
bottom: 0 !important;
left: auto !important;
top: auto !important;
transform: none !important;
}
:host(.spark-acw-panel-visible) .acw-panel.bottom-left,
:host(.spark-acw-panel-visible) .acw-panel-rail.bottom-left {
left: 0 !important;
bottom: 0 !important;
right: auto !important;
top: auto !important;
transform: none !important;
}
:host(.spark-acw-panel-visible) .acw-panel.middle-right,
:host(.spark-acw-panel-visible) .acw-panel-rail.middle-right {
right: 0 !important;
top: auto !important;
bottom: 0 !important;
transform: none !important;
}
:host(.spark-acw-panel-visible) .acw-panel.middle-left,
:host(.spark-acw-panel-visible) .acw-panel-rail.middle-left {
left: 0 !important;
top: auto !important;
bottom: 0 !important;
transform: none !important;
}
@media (max-width: 480px) {
:host { --acw-panel-height: min(max(650px, 60vh), calc(100vh - 24px - env(safe-area-inset-bottom, 0px))); }
.acw-panel { width: calc(100vw - 24px); }
.acw-panel.is-expanded { width: calc(100vw - 24px); max-width: calc(100vw - 24px); }
.acw-header-expand { display: none !important; }
:host(.spark-acw-panel-visible) .acw-panel.is-open {
box-shadow: 0 25px 50px rgba(15, 23, 42, 0.18), 0 0 0 100vmax rgba(15, 23, 42, 0.14);
}
}
.acw-rail-btn {
width: 60px; height: 60px; min-width: 60px; min-height: 60px;
border: 2px solid rgba(255,255,255,0.95); border-radius: 50%; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; padding: 0;
box-shadow: 0 12px 30px rgba(15,23,42,0.20), 0 4px 10px rgba(0,0,0,0.08);
transition: transform 0.22s cubic-bezier(0.34, 1.25, 0.64, 1), box-shadow 0.22s ease, opacity 0.2s;
}
.acw-rail-btn:hover { transform: translateY(-2px) scale(1.06); box-shadow: 0 16px 36px rgba(15,23,42,0.24), 0 6px 14px rgba(0,0,0,0.10); }
.acw-rail-btn:active { transform: scale(0.96); }
.acw-rail-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255,255,255,0.95), 0 0 0 6px ${safePrimary}66, 0 12px 30px rgba(15,23,42,0.20); }
/* Uniform sizing across channels (primary/secondary kept for hooks, no size difference) */
.acw-rail-btn-primary, .acw-rail-btn-secondary { width: 60px; height: 60px; min-width: 60px; min-height: 60px; }
.acw-rail-btn svg { width: 26px; height: 26px; color: #fff; flex-shrink: 0; }
.acw-rail-btn-chat { background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 55%), ${safePrimary}; }
.acw-rail-btn-voice { background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 55%), #7c3aed; }
.acw-rail-btn-whatsapp { background: linear-gradient(135deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 55%), #16a34a; }
.acw-rail-btn.is-active {
box-shadow: 0 0 0 3px rgba(255,255,255,0.95), 0 0 0 6px ${safePrimary}55, 0 12px 32px rgba(15,23,42,0.2);
}
.acw-panel-rail.bottom-right {
right: calc(var(--acw-rail-width, 60px) + 12px);
bottom: 0;
}
.acw-panel-rail.middle-right {
right: calc(var(--acw-rail-width, 60px) + 12px);
top: 50%;
transform: translateY(-50%);
}
.acw-panel-rail.bottom-left {
left: calc(var(--acw-rail-width, 60px) + 12px);
bottom: 0;
}
.acw-panel-rail.middle-left {
left: calc(var(--acw-rail-width, 60px) + 12px);
top: 50%;
transform: translateY(-50%);
}
.acw-panel-rail.top-right {
right: calc(var(--acw-rail-width, 60px) + 12px);
top: max(88px, var(--acw-viewport-top-offset, 88px));
}
.acw-panel-rail.top-left {
left: calc(var(--acw-rail-width, 60px) + 12px);
top: max(88px, var(--acw-viewport-top-offset, 88px));
}
.acw-panel-rail {
max-height: var(--acw-panel-height, min(max(650px, 60vh), calc(100vh - 48px)));
}
/* Rail voice: full-viewport layer so transcript (left) + mic (right) anchor to browser edges */
:host(.spark-acw-rail-voice-active) {
position: fixed !important;
inset: 0 !important;
top: 0 !important;
right: 0 !important;
bottom: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
max-width: none !important;
transform: none !important;
pointer-events: none;
}
/* Rail voice: floating mic + transcript (no chat panel) */
.acw-rail-voice-overlay {
display: none;
pointer-events: none;
position: absolute;
inset: 0;
z-index: 2147483002;
}
.acw-rail-voice-overlay.visible {
display: block;
pointer-events: none;
}
.acw-rail-voice-transcript-wrap {
display: none;
position: absolute;
right: max(24px, env(safe-area-inset-right, 0px));
left: auto;
bottom: calc(168px + env(safe-area-inset-bottom, 0px));
width: min(420px, calc(100vw - 48px));
max-height: min(440px, calc(100vh - 176px));
pointer-events: auto;
}
.acw-rail-voice-overlay.has-transcript .acw-rail-voice-transcript-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.acw-rail-voice-transcript-toolbar {
display: none;
justify-content: flex-end;
margin-bottom: 8px;
}
.acw-rail-voice-overlay.is-ended .acw-rail-voice-transcript-toolbar {
display: flex;
}
.acw-rail-voice-close {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(148, 163, 184, 0.4);
background: rgba(255, 255, 255, 0.96);
color: #475569;
border-radius: 999px;
padding: 6px 12px 6px 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.acw-rail-voice-close:hover { background: #fff; color: #1e293b; transform: translateY(-1px); }
.acw-rail-voice-close svg { width: 15px; height: 15px; }
.acw-rail-voice-transcript {
width: 100%;
max-height: min(400px, calc(100vh - 196px));
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px 4px 4px 4px;
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 14%, #000 100%);
mask-image: linear-gradient(to bottom, transparent 0%, #000 14%, #000 100%);
}
.acw-rail-voice-transcript::-webkit-scrollbar { width: 5px; }
.acw-rail-voice-transcript::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45);
border-radius: 999px;
}
.acw-rail-tbubble {
max-width: 92%;
animation: acw-rail-tbubble-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom right;
}
@keyframes acw-rail-tbubble-in {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.acw-rail-tbubble.is-bot {
align-self: flex-end;
background: #fff;
border: 1px solid rgba(147, 197, 253, 0.9);
border-radius: 16px;
padding: 11px 15px;
color: #111827;
font-size: 13px;
line-height: 1.45;
font-weight: 400;
box-shadow: 0 10px 28px rgba(59, 130, 246, 0.14);
}
.acw-rail-tbubble.is-user {
align-self: flex-end;
background: ${brandGradientBtn};
border-radius: 999px;
padding: 10px 17px;
color: #fff;
font-weight: 700;
font-size: 13px;
line-height: 1.35;
box-shadow: 0 10px 26px ${brandShadow};
}
.acw-rail-tbubble.is-interim { opacity: 0.82; font-style: italic; }
.acw-rail-tbubble.is-faded-top {
opacity: 0.42;
transform: perspective(900px) rotateX(10deg) scale(0.96);
transform-origin: bottom right;
}
.acw-rail-nav-offer {
align-self: flex-end;
max-width: 92%;
background: #fff;
border: 1px solid rgba(147, 197, 253, 0.9);
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 10px 28px rgba(59, 130, 246, 0.14);
animation: acw-rail-tbubble-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom right;
}
.acw-rail-nav-offer-text {
font-size: 13px;
line-height: 1.45;
color: #111827;
margin-bottom: 10px;
}
.acw-rail-nav-offer-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.acw-rail-nav-offer-btn {
border: 1px solid ${safePrimary};
background: #fff;
color: ${safePrimary};
border-radius: 999px;
padding: 7px 14px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.2s ease;
}
.acw-rail-nav-offer-btn:hover { transform: translateY(-1px); }
.acw-rail-nav-offer-btn.primary {
border: none;
color: #fff;
background: ${brandGradientBtn};
box-shadow: 0 6px 18px ${brandShadow};
}
.acw-rail-nav-offer-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.acw-rail-voice-mic-dock {
position: absolute;
right: max(24px, env(safe-area-inset-right, 0px));
bottom: max(24px, env(safe-area-inset-bottom, 0px));
width: 80px;
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.acw-rail-voice-hint {
width: max(140px, 100%);
text-align: center;
font-size: 12px;
font-weight: 600;
color: #64748b;
line-height: 1.35;
pointer-events: none;
transition: color 0.2s ease;
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-hint { color: ${safePrimary}; }
.acw-rail-voice-home {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(255, 255, 255, 0.96);
color: #475569;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.14);
transition: transform 0.15s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.acw-rail-voice-home:hover {
transform: translateY(-1px) scale(1.05);
background: #fff;
color: #1e293b;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.18);
}
.acw-rail-voice-home:active { transform: scale(0.96); }
.acw-rail-voice-home svg { width: 18px; height: 18px; }
.acw-rail-voice-end {
width: 44px; height: 44px; border-radius: 50%;
border: none; background: #ef4444; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 8px 22px rgba(239, 68, 68, 0.38);
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.acw-rail-voice-end:hover { background: #dc2626; transform: translateY(-1px) scale(1.04); }
.acw-rail-voice-end:active { transform: scale(0.96); }
.acw-rail-voice-end svg { width: 20px; height: 20px; }
.acw-rail-voice-mic-inner {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.acw-rail-voice-halo {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
}
.acw-rail-voice-halo .ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(45, 212, 191, 0.35);
opacity: 0;
animation: none;
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-halo {
opacity: 1;
visibility: visible;
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-halo .ring {
animation: acw-rail-halo-pulse 2.4s ease-out infinite;
}
.acw-rail-voice-halo .ring.r2 {
animation-delay: 0.55s;
border-color: rgba(56, 189, 248, 0.28);
}
.acw-rail-voice-halo .ring.r3 {
animation-delay: 1.1s;
border-color: rgba(99, 102, 241, 0.22);
}
@keyframes acw-rail-halo-pulse {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(1.85); opacity: 0; }
100% { transform: scale(1.85); opacity: 0; }
}
.acw-rail-voice-overlay.is-connected.is-speaking .acw-rail-voice-halo .ring {
animation-duration: 1.6s;
}
.acw-rail-voice-mic {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
border-radius: 50%;
border: 2px solid #e2e8f0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
background: #fff;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}
.acw-rail-voice-mic:hover { transform: scale(1.04); box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18); }
.acw-rail-voice-mic:active { transform: scale(0.97); }
.acw-rail-voice-mic.connecting {
opacity: 0.92;
cursor: wait;
border-color: #bae6fd;
color: #0ea5e9;
box-shadow: 0 0 0 6px rgba(14, 165, 233, 0.12);
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-mic {
border: none;
color: #fff;
background: ${brandGradientSoft};
box-shadow: 0 12px 32px ${brandShadowStrong};
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-mic:hover {
box-shadow: 0 0 0 8px ${brandWashMid}, 0 14px 36px ${brandShadowStrong};
}
.acw-rail-voice-mic svg { width: 28px; height: 28px; }
.acw-rail-voice-status {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #fff;
background: #22c55e;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2);
cursor: help;
display: none;
align-items: center;
justify-content: center;
transition: background 0.25s ease;
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-status { display: flex; }
.acw-rail-voice-status svg { width: 12px; height: 12px; color: #fff; }
.acw-rail-voice-overlay.is-connected .acw-rail-voice-status { background: #22c55e; }
.acw-rail-voice-overlay.is-ended .acw-rail-voice-status { background: #ef4444; }
.acw-rail-voice-status-tip {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
white-space: nowrap;
background: #0f172a;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 6px 10px;
border-radius: 8px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.28);
opacity: 0;
visibility: hidden;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease;
pointer-events: none;
}
.acw-rail-voice-status-tip::after {
content: "";
position: absolute;
top: 100%;
right: 12px;
border: 5px solid transparent;
border-top-color: #0f172a;
}
.acw-rail-voice-status:hover .acw-rail-voice-status-tip {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* Inline embed: panel fills shadow host (Spark console preview card) */
:host(.spark-acw-inline) {
position: relative !important;
display: block;
width: 100%;
height: 100%;
min-height: 0;
z-index: 0;
right: auto !important;
bottom: auto !important;
top: auto !important;
left: auto !important;
transform: none !important;
}
:host(.spark-acw-inline) .acw-panel,
:host(.spark-acw-inline) .acw-panel.acw-panel-inline {
position: relative !important;
display: flex !important;
flex-direction: column;
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
right: auto !important;
bottom: auto !important;
top: auto !important;
left: auto !important;
transform: none !important;
border-radius: 0;
box-shadow: none;
border: none;
overflow: hidden;
}
:host(.spark-acw-inline) .acw-header,
:host(.spark-acw-inline) .acw-status,
:host(.spark-acw-inline) .acw-input,
:host(.spark-acw-inline) .acw-footer {
flex-shrink: 0;
}
:host(.spark-acw-inline) .acw-start-cta,
:host(.spark-acw-inline) .acw-messages,
:host(.spark-acw-inline) .acw-customer-form,
:host(.spark-acw-inline) .acw-voice-panel {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
:host(.spark-acw-inline) .acw-start-cta-mode {
justify-content: flex-start;
}
:host(.spark-acw-inline) .acw-mode-heading {
font-size: var(--acw-text-xl);
}
:host(.spark-acw-inline) .acw-mode-hero-icon {
width: 56px;
height: 56px;
margin-bottom: 12px;
}
:host(.spark-acw-inline) .acw-mode-btn {
padding: 12px 14px;
}
:host(.spark-acw-inline) .acw-launcher,
:host(.spark-acw-inline) .acw-launcher-wrap,
:host(.spark-acw-inline) .acw-rail,
:host(.spark-acw-inline) .acw-dock,
:host(.spark-acw-inline) .acw-header-expand {
display: none !important;
}
/* Dark mode — only when explicitly opted in via :host(.spark-acw-theme-dark).
System prefers-color-scheme is ignored unless themeMode is 'auto' (JS toggles this class). */
:host(.spark-acw-theme-dark) .acw-panel {
background: rgba(15, 23, 42, 0.96);
border-color: rgba(51, 65, 85, 0.65);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.45);
}
:host(.spark-acw-theme-dark) .acw-header {
background: linear-gradient(90deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%);
color: #f1f5f9; border-bottom-color: rgba(51, 65, 85, 0.55);
}
:host(.spark-acw-theme-dark) .acw-messages {
background: linear-gradient(165deg, rgba(30,41,59,0.5) 0%, rgba(15,23,42,0.85) 100%);
}
:host(.spark-acw-theme-dark) .acw-msg-bot {
background: rgba(30, 41, 59, 0.95); color: #e2e8f0;
border-color: rgba(71, 85, 105, 0.65);
}
:host(.spark-acw-theme-dark) .acw-msg-typing {
background: rgba(30, 41, 59, 0.95); border-color: rgba(71, 85, 105, 0.65);
}
:host(.spark-acw-theme-dark) .acw-input {
background: rgba(15, 23, 42, 0.92); border-top-color: rgba(51, 65, 85, 0.55);
}
:host(.spark-acw-theme-dark) .acw-composer-box {
background: rgba(30, 41, 59, 0.92); border-color: rgba(148, 163, 184, 0.45);
}
:host(.spark-acw-theme-dark) .acw-composer-box:focus-within {
border-color: rgba(96, 165, 250, 0.85);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
}
:host(.spark-acw-theme-dark) .acw-composer-field {
color: #f1f5f9;
}
:host(.spark-acw-theme-dark) .acw-composer-field::placeholder { color: #64748b; }
:host(.spark-acw-theme-dark) .acw-input-attach { color: #94a3b8; }
:host(.spark-acw-theme-dark) .acw-input-attach:hover:not(:disabled) {
color: #bfdbfe; background: rgba(59, 130, 246, 0.15);
}
:host(.spark-acw-theme-dark) .acw-suggested-question {
background: rgba(30, 41, 59, 0.9); color: #94a3b8;
border-color: rgba(71, 85, 105, 0.65);
}
:host(.spark-acw-theme-dark) .acw-suggested-questions {
background: #0f172a;
}
:host(.spark-acw-theme-dark) .acw-footer { color: #64748b; border-top-color: rgba(51, 65, 85, 0.55); background: #0f172a; }
:host(.spark-acw-theme-dark) .acw-close { color: #94a3b8; }
:host(.spark-acw-theme-dark) .acw-close:hover { color: #e2e8f0; background: rgba(255,255,255,0.06); }
:host(.spark-acw-theme-dark) .acw-continuity-toast {
background: rgba(30, 41, 59, 0.96); color: #bfdbfe;
border-color: rgba(59, 130, 246, 0.45);
}
:host(.spark-acw-theme-dark) .acw-msg-action-btn {
background: rgba(30, 41, 59, 0.9); color: #94a3b8; border-color: rgba(71, 85, 105, 0.65);
}
:host(.spark-acw-theme-dark) .acw-msg-action-btn.is-icon,
:host(.spark-acw-theme-dark) .acw-msg-action-btn.is-icon:hover,
:host(.spark-acw-theme-dark) .acw-msg-action-btn.is-icon.is-active {
background: transparent; border: none;
}
:host(.spark-acw-theme-dark) .acw-handover-ack-line {
background: rgba(71, 85, 105, 0.55);
}
:host(.spark-acw-theme-dark) .acw-handover-ack-icon {
background: rgba(30, 41, 59, 0.92); border-color: rgba(71, 85, 105, 0.45); color: #94a3b8;
}
@media (prefers-reduced-motion: reduce) {
.acw-msg, .acw-suggested-question, .acw-launcher, .acw-input-send,
.acw-connection-banner-btn, .acw-rail-tbubble, .acw-online-dot,
.acw-continuity-toast, .acw-rail-voice-halo .ring, .acw-panel {
animation: none !important; transition: none !important;
}
.acw-rail-voice-overlay.is-connected .acw-rail-voice-halo .ring { animation: none !important; }
}
`
}
function normalizeSuggestedQuestionsList(raw) {
var out = []
if (Array.isArray(raw)) {
for (var i = 0; i < raw.length; i++) {
var q = String(raw[i] || '').trim()
if (q) out.push(q)
if (out.length >= 3) break
}
}
return out
}
function normalizeSuggestedActionKind(raw) {
var k = String(raw || 'question').trim().toLowerCase().replace(/-/g, '_')
if (k === 'openurl') k = 'open_url'
if (k === 'question' || k === 'navigation' || k === 'open_url' || k === 'tool') return k
return 'question'
}
function questionsListToSuggestedActions(questions) {
var out = []
var list = normalizeSuggestedQuestionsList(questions)
for (var i = 0; i < list.length; i++) {
out.push({
id: 'starter_' + i,
kind: 'question',
label: list[i],
message: list[i],
})
}
return out
}
/** Next.js Pages Router exposes window.next.router — use for client-side route changes. */
function getNextRouter() {
try {
if (typeof window !== 'undefined' && window.next && window.next.router && typeof window.next.router.push === 'function') {
return window.next.router
}
} catch (_) {}
return null
}
function acwRoutePath(parsedUrl, anchor) {
return parsedUrl.pathname + parsedUrl.search + (anchor ? '#' + anchor : '')
}
function acwFinishNavPromise(ret, onSuccess) {
if (ret && typeof ret.then === 'function') {
return ret.then(onSuccess).catch(function () {
return false
})
}
return new Promise(function (resolve) {
setTimeout(function () {
resolve(onSuccess())
}, 0)
})
}
/**
* Resolve a framework-native client router when available.
* Order (auto): custom callback → host bridge → Next.js → Nuxt → Gatsby.
* Most Vue/React SPAs do not expose globals — hosts should use spaNavigate or __ACW_NAVIGATE__.
*/
function createSpaNavigationBridge(config) {
var disabled = config.spaUseFrameworkRouter === false || config.spaUseNextRouter === false
var adapterPref = String(config.spaNavigationAdapter || 'auto').toLowerCase()
var shallow = config.spaNextRouterShallow !== false
var customNavigate = typeof config.spaNavigate === 'function' ? config.spaNavigate : null
var bridge = null
function detectBridge() {
if (disabled) return null
if (adapterPref === 'legacy' || adapterPref === 'off' || adapterPref === 'false') return null
function pick(id, pushFn) {
return { id: id, push: pushFn }
}
if (customNavigate && (adapterPref === 'auto' || adapterPref === 'custom')) {
return pick('custom', function (path, opts) {
return customNavigate(path, opts)
})
}
var tryOrder = adapterPref === 'auto'
? ['host', 'next', 'nuxt', 'gatsby']
: [adapterPref]
for (var i = 0; i < tryOrder.length; i++) {
var kind = tryOrder[i]
if (kind === 'host' && typeof window.__ACW_NAVIGATE__ === 'function') {
return pick('host', function (path, opts) {
return window.__ACW_NAVIGATE__(path, opts)
})
}
if (kind === 'next') {
var nextRouter = getNextRouter()
if (nextRouter) {
return pick('next', function (path, opts) {
return nextRouter.push(path, undefined, {
shallow: shallow,
scroll: opts && opts.scroll === false ? false : true,
})
})
}
}
if (kind === 'nuxt') {
try {
if (window.$nuxt && window.$nuxt.$router && typeof window.$nuxt.$router.push === 'function') {
return pick('nuxt2', function (path) {
return window.$nuxt.$router.push(path)
})
}
if (window.$nuxt && typeof window.$nuxt.navigateTo === 'function') {
return pick('nuxt3', function (path) {
return window.$nuxt.navigateTo(path)
})
}
} catch (_) {}
}
if (kind === 'gatsby' && typeof window.___navigate === 'function') {
return pick('gatsby', function (path) {
return window.___navigate(path)
})
}
}
return null
}
bridge = detectBridge()
return {
get id() {
return bridge ? bridge.id : null
},
refresh: function () {
bridge = detectBridge()
},
canNavigate: function (parsedUrl) {
if (!bridge) return false
try {
return parsedUrl.origin === window.location.origin
} catch (_) {
return false
}
},
push: function (parsedUrl, anchor, opts) {
if (!bridge || !this.canNavigate(parsedUrl)) return Promise.resolve(false)
var routePath = acwRoutePath(parsedUrl, anchor)
try {
var ret = bridge.push(routePath, opts || {})
return acwFinishNavPromise(ret, function () {
if (opts && opts.scroll === false) {
window.scrollTo(0, 0)
if (anchor) {
var el = document.getElementById(anchor)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
return true
}).catch(function (err) {
console.warn('[AgentChatWidget] Framework navigation failed (' + bridge.id + ')', err)
return false
})
} catch (err) {
console.warn('[AgentChatWidget] Framework navigation failed (' + bridge.id + ')', err)
return Promise.resolve(false)
}
},
}
}
function createWidget(config) {
const {
agentId,
embedKey,
primaryColor,
backgroundColor,
headerColor,
allowedDomains = [],
apiBaseUrl,
voiceServiceUrl,
wsUrl,
title = 'Chat with us',
greeting = null,
captureCustomerInfo = false,
allowSkipCustomerInfo = true,
bannerImageUrl = null,
position = 'bottom-right',
iconUrl = null,
headerIconUrl = null,
showFavicon = true,
showIcon = true,
showLauncherText = true,
launcherText,
enableVoice = true,
isLocalWebrtc = true,
useDailyVoice = false,
dailyApiKey = '',
dailyAgentName = defaultDailyAgentName(),
dailyScriptUrl = DEFAULT_DAILY_SCRIPT_URL,
voiceTenant = 'default',
liveTranscript = false,
voiceSessionData = {},
whatsappPhone = '',
whatsappMessage = 'Hi! I need assistance.',
enableWhatsApp = true,
language = 'en',
enableGuidedNavigation = true,
sessionPersistTtlMs = 1800000,
enableSpaNavigation = false,
spaContentSelector = 'main',
spaSyncSelectors = [],
spaInterceptLinks = null,
spaUseFrameworkRouter = true,
spaUseNextRouter = true,
spaNavigationAdapter = 'auto',
spaNavigate = null,
spaNextRouterShallow = true,
embedMode = 'floating',
mountElement = null,
mountSelector = null,
viewType = 'hub',
primaryChannel = 'chat',
railOrder = null,
showRailTooltips = true,
suggestedQuestions = [],
themeMode = 'light',
viewportTopOffset = null,
} = config
const uiLang = normalizeWidgetUiLanguage(language)
function t(key) {
return acwT(uiLang, key)
}
const trimmedVoiceUrl = voiceServiceUrl != null ? String(voiceServiceUrl).trim() : ''
const voiceHttpBase = trimmedVoiceUrl
? trimmedVoiceUrl.replace(/\/$/, '')
: VERSION.includes('-staging')
? 'https://talk2ai-staging-gcp.helllo.ai'
: VERSION.includes('-prod')
? 'https://talk2ai-prod-gcp.helllo.ai'
: ''
const whatsappDigits = normalizeWhatsAppDigits(whatsappPhone)
const hasWhatsAppOption = enableWhatsApp !== false && whatsappDigits.length > 0
function resolveMountTarget() {
if (mountElement && mountElement.nodeType === 1) return mountElement
if (mountSelector && typeof mountSelector === 'string') {
try {
var found = document.querySelector(mountSelector)
if (found && found.nodeType === 1) return found
} catch (_) {}
}
return null
}
var resolvedMountElement = resolveMountTarget()
const useInline =
embedMode === 'inline' &&
resolvedMountElement &&
resolvedMountElement.nodeType === 1
const normalizedViewType = viewType === 'rail' ? 'rail' : 'hub'
const normalizedPrimaryChannel = primaryChannel === 'voice' ? 'voice' : 'chat'
const availableChannels = ['chat']
if (enableVoice) availableChannels.push('voice')
if (hasWhatsAppOption) availableChannels.push('whatsapp')
const useRail = !useInline && normalizedViewType === 'rail' && availableChannels.length >= 2
const defaultRailOrder = ['chat', 'voice', 'whatsapp']
let orderedRailChannels = defaultRailOrder.filter(function (c) {
return availableChannels.indexOf(c) >= 0
})
if (Array.isArray(railOrder) && railOrder.length) {
orderedRailChannels = railOrder.filter(function (c) {
return availableChannels.indexOf(c) >= 0
})
availableChannels.forEach(function (c) {
if (orderedRailChannels.indexOf(c) < 0) orderedRailChannels.push(c)
})
}
var railHeightPx = 0
var railWidthPx = 60
if (useRail) {
var RAIL_BTN_SIZE = 60
var RAIL_BTN_GAP = 14
orderedRailChannels.forEach(function (ch, idx) {
if (RAIL_BTN_SIZE > railWidthPx) railWidthPx = RAIL_BTN_SIZE
railHeightPx += RAIL_BTN_SIZE
if (idx > 0) railHeightPx += RAIL_BTN_GAP
})
railHeightPx += 16
}
const effectiveLiveTranscript = liveTranscript || useRail
// Resolve favicon for hero/header/launcher fallback (when showFavicon is true).
const resolvedFaviconUrl = (() => {
if (showFavicon === false) return null
try {
const link = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]')
if (link && link.href) return link.href
} catch (_) {}
try {
const origin = window.location.origin || ''
if (origin) return origin + '/favicon.ico'
} catch (_) {}
return null
})()
// Resolve widget icon (only used when showIcon is true): explicit iconUrl, else favicon.
const resolvedIconUrl = (() => {
if (!showIcon) return null
if (iconUrl && typeof iconUrl === 'string' && iconUrl.trim()) return iconUrl.trim()
return resolvedFaviconUrl
})()
const resolvedHeaderIconUrl =
typeof headerIconUrl === 'string' && headerIconUrl.trim()
? headerIconUrl.trim()
: null
const headerIconConfigured = headerIconUrl !== undefined
const launcherLabel = (typeof launcherText === 'string' && launcherText.trim())
? launcherText.trim()
: t('launcher_default')
const resolvedBannerImageUrl =
typeof bannerImageUrl === 'string' && bannerImageUrl.trim()
? bannerImageUrl.trim()
: PRECALL_HERO_IMAGE_URL
var conversationLanguageCode = null
var supportedLanguagesList = []
var widgetFileUploadPolicy = null
var stagedAttachments = []
var languageSelect = null
var languageFieldWrap = null
var langSwitchRoot = null
var langSwitchTrigger = null
var langSwitchMenu = null
var langSwitchCurrent = null
var langMenuDocClose = null
function resolveLanguagesApiBase() {
var b = voiceHttpBase || apiBaseUrl
if (b) return String(b).replace(/\/$/, '')
try {
var sc = document.currentScript || document.querySelector('script[data-agent-id]')
if (sc && sc.src) return new URL(sc.src).origin
} catch (_) {}
try {
return window.location.origin
} catch (_) {
return ''
}
}
function fetchWidgetConfig(done) {
var base = resolveLanguagesApiBase()
if (!base || !embedKey || !agentId) {
if (done) done(null)
return
}
var url =
base +
'/api/v1/agent-voice/' +
encodeURIComponent(String(agentId)) +
'/widget-config?embed_key=' +
encodeURIComponent(embedKey)
fetch(url)
.then(function (r) {
return r.ok ? r.json() : Promise.reject(new Error('widget-config'))
})
.then(function (data) {
var langs = data && data.languages && Array.isArray(data.languages) ? data.languages : []
supportedLanguagesList = langs
mergeFileUploadPolicy(data && data.file_upload)
if (done) done(data)
})
.catch(function () {
if (done) done(null)
})
}
function mergeFileUploadPolicy(policy) {
if (!policy || typeof policy !== 'object') return
var maxFiles = parseInt(policy.max_files, 10)
if (isNaN(maxFiles)) maxFiles = 3
maxFiles = Math.max(0, Math.min(10, maxFiles))
var enabled = policy.enabled !== false && maxFiles > 0
var maxSize = parseInt(policy.max_file_size_bytes, 10)
if (isNaN(maxSize) || maxSize <= 0) maxSize = 1048576
var allowed = Array.isArray(policy.allowed_mime_types)
? policy.allowed_mime_types.filter(function (m) { return typeof m === 'string' && m.trim() })
: []
widgetFileUploadPolicy = {
enabled: enabled,
max_files: enabled ? maxFiles : 0,
max_file_size_bytes: maxSize,
allowed_mime_types: allowed.length ? allowed : ['image/jpeg', 'image/png'],
}
applyFileUploadUi()
}
// Valid positions
const validPositions = ['bottom-right', 'middle-right', 'top-right', 'bottom-left', 'middle-left', 'top-left']
const finalPosition = validPositions.includes(position) ? position : 'bottom-right'
const isVertical = finalPosition === 'middle-right' || finalPosition === 'middle-left'
const isLeft = finalPosition.includes('left')
if (!agentId) {
console.warn('[AgentChatWidget] Missing agentId. Provide it via init({ agentId: "..." }) or data-agent-id attribute')
return
}
if (!embedKey) {
console.warn('[AgentChatWidget] Missing embedKey. Provide it via init({ embedKey: "..." }) or data-embed-key attribute')
return
}
const hostOk = domainAllowed(window.location.hostname, allowedDomains)
if (!hostOk) {
console.warn('[AgentChatWidget] Host not in allowed domains, widget will not load')
return
}
if (embedMode === 'inline' && !useInline) {
console.warn('[AgentChatWidget] inline embed requires a valid mountElement (HTMLElement)')
return null
}
const container = document.createElement('div')
container.className = useInline ? 'spark-acw-container spark-acw-inline' : `spark-acw-container spark-acw-${finalPosition}`
container.setAttribute('data-spark-widget', VERSION)
container.style.fontFamily = "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif"
function applyThemeFromPreference() {
if (themeMode === 'dark') {
container.classList.add('spark-acw-theme-dark')
container.classList.remove('spark-acw-theme-light')
return
}
if (themeMode === 'auto' && typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches) {
container.classList.add('spark-acw-theme-dark')
container.classList.remove('spark-acw-theme-light')
return
}
// Default / light — always pin light so OS dark mode cannot restyle the widget
container.classList.add('spark-acw-theme-light')
container.classList.remove('spark-acw-theme-dark')
}
applyThemeFromPreference()
if (themeMode === 'auto' && typeof window.matchMedia === 'function') {
var themeMq = window.matchMedia('(prefers-color-scheme: dark)')
var onThemeMq = function () { applyThemeFromPreference() }
if (themeMq.addEventListener) themeMq.addEventListener('change', onThemeMq)
else if (themeMq.addListener) themeMq.addListener(onThemeMq)
}
if (useInline) {
container.style.position = 'relative'
container.style.width = '100%'
container.style.height = '100%'
container.style.zIndex = '0'
resolvedMountElement.appendChild(container)
} else {
container.style.position = 'fixed'
elevateWidget(container)
// Set container position based on finalPosition
if (finalPosition === 'bottom-right') {
container.style.right = '24px'
container.style.bottom = '24px'
} else if (finalPosition === 'middle-right') {
container.style.right = '16px'
container.style.top = '50%'
container.style.transform = 'translateY(-50%)'
} else if (finalPosition === 'top-right') {
container.style.right = '16px'
container.style.top = '16px'
} else if (finalPosition === 'bottom-left') {
container.style.left = '16px'
container.style.bottom = '16px'
} else if (finalPosition === 'middle-left') {
container.style.left = '16px'
container.style.top = '50%'
container.style.transform = 'translateY(-50%)'
} else if (finalPosition === 'top-left') {
container.style.left = '16px'
container.style.top = '16px'
}
document.body.appendChild(container)
}
const shadow = container.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = createStyles(primaryColor, backgroundColor, headerColor)
shadow.appendChild(style)
const panel = document.createElement('div')
panel.setAttribute('data-acw-view', 'home')
panel.className = useInline
? 'acw-panel acw-panel-inline' + (useRail ? ' acw-panel-rail' : '')
: `acw-panel ${finalPosition}` + (useRail ? ' acw-panel-rail' : '')
if (useRail) {
container.style.setProperty('--acw-rail-height', railHeightPx + 'px')
container.style.setProperty('--acw-rail-width', railWidthPx + 'px')
}
const showLauncherLabel = showLauncherText !== false
const useFabLauncher = !useRail && !isVertical && (!resolvedIconUrl || !showLauncherLabel)
const launcherWrap = useFabLauncher ? document.createElement('span') : null
if (launcherWrap) launcherWrap.className = 'acw-launcher-wrap'
const launcher = document.createElement('button')
launcher.className = isVertical ? 'acw-launcher vertical' : 'acw-launcher'
if (!isVertical && !resolvedIconUrl) {
launcher.classList.add('acw-launcher-fab')
launcher.innerHTML = ACW_SPARKLE_SVG + ACW_LAUNCHER_X_SVG
launcher.setAttribute('aria-label', launcherLabel)
} else if (!isVertical && resolvedIconUrl && !showLauncherLabel) {
launcher.classList.add('acw-launcher-fab')
const launcherImg = document.createElement('img')
launcherImg.src = resolvedIconUrl
launcherImg.alt = launcherLabel
launcher.appendChild(launcherImg)
launcher.setAttribute('aria-label', launcherLabel)
} else if (resolvedIconUrl) {
const launcherImg = document.createElement('img')
launcherImg.src = resolvedIconUrl
launcherImg.alt = launcherLabel
launcher.appendChild(launcherImg)
if (showLauncherLabel) {
const launcherTextSpan = document.createElement('span')
launcherTextSpan.textContent = launcherLabel
launcher.appendChild(launcherTextSpan)
} else {
launcher.setAttribute('aria-label', launcherLabel)
}
} else if (showLauncherLabel) {
const span = document.createElement('span')
span.textContent = launcherLabel
launcher.appendChild(span)
} else {
launcher.setAttribute('aria-label', launcherLabel)
}
var dockEl = null
if (!useRail) {
if (launcherWrap) {
launcherWrap.classList.add('acw-dock')
dockEl = launcherWrap
} else {
launcher.classList.add('acw-dock')
dockEl = launcher
}
}
const header = document.createElement('div')
header.className = 'acw-header'
const headerLeft = document.createElement('div')
headerLeft.className = 'acw-header-left'
var headerOnlineDots = []
if (headerIconConfigured) {
if (resolvedHeaderIconUrl) {
const headerIconWrap = document.createElement('div')
headerIconWrap.className = 'acw-header-icon-wrap'
const headerIcon = document.createElement('img')
headerIcon.className = 'acw-header-icon'
headerIcon.src = resolvedHeaderIconUrl
headerIcon.alt = ''
const headerOnline = document.createElement('span')
headerOnline.className = 'acw-online-dot'
headerOnline.setAttribute('aria-hidden', 'true')
headerOnlineDots.push(headerOnline)
headerIconWrap.appendChild(headerIcon)
headerIconWrap.appendChild(headerOnline)
headerLeft.appendChild(headerIconWrap)
}
} else if (showFavicon !== false && resolvedFaviconUrl) {
const headerIconWrap = document.createElement('div')
headerIconWrap.className = 'acw-header-icon-wrap'
const headerIcon = document.createElement('img')
headerIcon.className = 'acw-header-icon'
headerIcon.src = DEFAULT_SUPPORT_AGENT_IMAGE_URL
headerIcon.alt = ''
const headerOnline = document.createElement('span')
headerOnline.className = 'acw-online-dot'
headerOnline.setAttribute('aria-hidden', 'true')
headerOnlineDots.push(headerOnline)
headerIconWrap.appendChild(headerIcon)
headerIconWrap.appendChild(headerOnline)
headerLeft.appendChild(headerIconWrap)
} else if (showFavicon !== false) {
const headerIconWrap = document.createElement('div')
headerIconWrap.className = 'acw-header-icon-wrap'
const headerAvatar = document.createElement('div')
headerAvatar.className = 'acw-header-icon acw-header-avatar-fallback'
headerAvatar.style.display = 'flex'
headerAvatar.style.alignItems = 'center'
headerAvatar.style.justifyContent = 'center'
headerAvatar.innerHTML = ACW_SPARKLE_SVG.replace('class="acw-launcher-sparkle"', 'style="width:22px;height:22px;color:#fff"')
const headerOnline = document.createElement('span')
headerOnline.className = 'acw-online-dot'
headerOnline.setAttribute('aria-hidden', 'true')
headerOnlineDots.push(headerOnline)
headerIconWrap.appendChild(headerAvatar)
headerIconWrap.appendChild(headerOnline)
headerLeft.appendChild(headerIconWrap)
}
const titleWrap = document.createElement('div')
titleWrap.className = 'acw-header-title-wrap'
const titleBlock = document.createElement('div')
titleBlock.className = 'acw-header-title-block'
const titleEl = document.createElement('div')
titleEl.className = 'acw-title'
titleEl.textContent = title
const subtitleEl = document.createElement('div')
subtitleEl.className = 'acw-header-subtitle is-connected'
subtitleEl.textContent = t('status_connected')
const titleRow = document.createElement('div')
titleRow.style.display = 'flex'
titleRow.style.alignItems = 'center'
titleRow.style.gap = '8px'
const unreadBadge = document.createElement('span')
unreadBadge.className = 'acw-unread-badge hidden'
unreadBadge.textContent = '0'
titleRow.appendChild(titleEl)
titleRow.appendChild(unreadBadge)
titleBlock.appendChild(titleRow)
titleBlock.appendChild(subtitleEl)
titleWrap.appendChild(titleBlock)
headerLeft.appendChild(titleWrap)
const headerActions = document.createElement('div')
headerActions.className = 'acw-header-actions'
const disconnectChatBtn = document.createElement('button')
disconnectChatBtn.type = 'button'
disconnectChatBtn.className = 'acw-close acw-header-disconnect'
disconnectChatBtn.setAttribute('aria-label', t('end_chat'))
disconnectChatBtn.title = t('end_chat')
disconnectChatBtn.setAttribute('data-testid', 'acw-chat-disconnect')
disconnectChatBtn.style.display = 'none'
disconnectChatBtn.innerHTML = ACW_ICON_END_CHAT
const expandBtn = document.createElement('button')
expandBtn.type = 'button'
expandBtn.className = 'acw-close acw-header-expand'
expandBtn.setAttribute('aria-label', t('expand_window'))
expandBtn.title = t('expand_window')
expandBtn.setAttribute('aria-pressed', 'false')
expandBtn.setAttribute('data-testid', 'acw-chat-expand')
expandBtn.innerHTML = ACW_ICON_EXPAND_WINDOW
if (useInline) expandBtn.style.display = 'none'
const closeBtn = document.createElement('button')
closeBtn.type = 'button'
closeBtn.className = 'acw-close acw-header-close'
closeBtn.setAttribute('aria-label', t('close_widget'))
closeBtn.title = t('close_widget')
closeBtn.setAttribute('data-testid', 'acw-widget-close')
closeBtn.innerHTML = ACW_ICON_CLOSE_WIDGET
headerActions.appendChild(disconnectChatBtn)
headerActions.appendChild(expandBtn)
headerActions.appendChild(closeBtn)
header.appendChild(headerLeft)
header.appendChild(headerActions)
const statusEl = document.createElement('div')
statusEl.className = 'acw-status'
const statusDot = document.createElement('div')
statusDot.className = 'acw-dot connected'
const statusText = document.createElement('span')
statusText.textContent = t('status_connected')
statusEl.appendChild(statusDot)
statusEl.appendChild(statusText)
const connectionBannerEl = document.createElement('div')
connectionBannerEl.className = 'acw-connection-banner'
connectionBannerEl.setAttribute('role', 'alert')
connectionBannerEl.setAttribute('aria-live', 'polite')
connectionBannerEl.style.display = 'none'
const connectionBannerTextEl = document.createElement('span')
connectionBannerTextEl.className = 'acw-connection-banner-text'
const connectionReconnectBtn = document.createElement('button')
connectionReconnectBtn.type = 'button'
connectionReconnectBtn.className = 'acw-connection-banner-btn'
connectionReconnectBtn.textContent = t('reconnect')
connectionBannerEl.appendChild(connectionBannerTextEl)
connectionBannerEl.appendChild(connectionReconnectBtn)
var connectionState = 'idle'
const startCtaEl = document.createElement('div')
startCtaEl.className = 'acw-start-cta'
let pendingMode = null // 'chat' | 'voice' | 'whatsapp'
var railButtons = {}
var activeRailChannel = null
function setRailActive(channel) {
if (!useRail) return
activeRailChannel = channel
Object.keys(railButtons).forEach(function (key) {
var btn = railButtons[key]
if (!btn) return
if (key === channel) btn.classList.add('is-active')
else btn.classList.remove('is-active')
})
}
const chatModeSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
const micModeSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>'
const waModeSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>'
const modeChevronSvg = '<svg class="acw-mode-chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>'
function buildModeButton(iconClass, title, desc, iconSvg, btnClass) {
const btn = document.createElement('button')
btn.className = 'acw-mode-btn' + (btnClass ? (' ' + btnClass) : '')
btn.type = 'button'
btn.innerHTML =
'<span class="acw-mode-icon ' + iconClass + '">' + iconSvg + '</span>' +
'<span class="acw-mode-text"><span class="acw-mode-title">' + title + '</span><span class="acw-mode-desc">' + desc + '</span></span>' +
modeChevronSvg
return btn
}
startCtaEl.classList.add('acw-start-cta-mode')
const modeSelector = document.createElement('div')
modeSelector.className = 'acw-mode-selector'
const modeHero = document.createElement('div')
modeHero.className = 'acw-mode-hero-icon'
if (showFavicon !== false && resolvedFaviconUrl) {
const heroImg = document.createElement('img')
heroImg.src = resolvedFaviconUrl
heroImg.alt = ''
modeHero.appendChild(heroImg)
} else if (showFavicon !== false) {
modeHero.innerHTML = ACW_SPARKLE_SVG.replace('class="acw-launcher-sparkle"', 'style="width:36px;height:36px;color:#fff"')
} else {
modeHero.style.display = 'none'
}
const modeHeading = document.createElement('h2')
modeHeading.className = 'acw-mode-heading'
modeHeading.textContent = t('how_can_we_help')
const modeSub = document.createElement('p')
modeSub.className = 'acw-mode-sub'
modeSub.textContent = t('choose_communication_style')
modeSelector.appendChild(modeHero)
modeSelector.appendChild(modeHeading)
modeSelector.appendChild(modeSub)
const modeOptions = document.createElement('div')
modeOptions.className = 'acw-mode-options'
const chatBtn = buildModeButton('acw-mode-icon-chat', t('text_chat_title'), t('text_chat_desc'), chatModeSvg, 'acw-mode-btn-chat')
chatBtn.setAttribute('data-testid', 'acw-mode-chat')
modeOptions.appendChild(chatBtn)
chatBtn.onclick = function () {
pendingMode = 'chat'
conversationStarted = true
if (captureCustomerInfo && customerForm && !customerInfoSubmitted) {
formShownBeforeConnect = true
showFormOnly()
const nameInput = customerForm.querySelector('#acw-customer-name')
if (nameInput) setTimeout(function () { nameInput.focus() }, 100)
} else if (connected || connecting) {
showChat()
} else {
connect()
}
}
if (enableVoice) {
const voiceBtn = buildModeButton('acw-mode-icon-voice', t('voice_chat_title'), t('voice_chat_desc'), micModeSvg, 'acw-mode-btn-voice')
voiceBtn.setAttribute('data-testid', 'acw-mode-voice')
modeOptions.appendChild(voiceBtn)
voiceBtn.onclick = function () {
pendingMode = 'voice'
conversationStarted = true
startCtaEl.style.display = 'none'
if (captureCustomerInfo && customerForm && !customerInfoSubmitted) {
formShownBeforeConnect = true
showFormOnly()
const nameInput = customerForm.querySelector('#acw-customer-name')
if (nameInput) setTimeout(function () { nameInput.focus() }, 100)
} else {
showVoicePanel()
}
}
}
if (hasWhatsAppOption) {
const waBtn = buildModeButton('acw-mode-icon-whatsapp', t('whatsapp_title'), t('whatsapp_desc'), waModeSvg, 'acw-mode-btn-whatsapp')
waBtn.setAttribute('data-testid', 'acw-mode-whatsapp')
modeOptions.appendChild(waBtn)
waBtn.onclick = function () {
const msg = typeof whatsappMessage === 'string' ? whatsappMessage : 'Hi! I need assistance.'
const url = 'https://wa.me/' + whatsappDigits + '?text=' + encodeURIComponent(msg)
try {
window.open(url, '_blank', 'noopener,noreferrer')
} catch (_) {}
pendingMode = null
conversationStarted = false
showStartCta()
}
}
modeSelector.appendChild(modeOptions)
startCtaEl.appendChild(modeSelector)
const homeFooterEl = document.createElement('div')
homeFooterEl.className = 'acw-footer acw-home-footer'
const homeFooterLink = document.createElement('a')
homeFooterLink.href = 'https://helllo.ai'
homeFooterLink.target = '_blank'
homeFooterLink.rel = 'noopener noreferrer'
homeFooterLink.textContent = 'helllo.ai'
homeFooterEl.appendChild(document.createTextNode(t('powered_by')))
homeFooterEl.appendChild(homeFooterLink)
startCtaEl.appendChild(homeFooterEl)
const messages = document.createElement('div')
messages.className = 'acw-messages'
const messagesSpacer = document.createElement('div')
messagesSpacer.className = 'acw-messages-spacer'
messagesSpacer.setAttribute('aria-hidden', 'true')
messages.appendChild(messagesSpacer)
const inputWrap = document.createElement('div')
inputWrap.className = 'acw-input'
const composerBox = document.createElement('div')
composerBox.className = 'acw-composer-box'
const attachmentsStripEl = document.createElement('div')
attachmentsStripEl.className = 'acw-attachments'
attachmentsStripEl.setAttribute('aria-live', 'polite')
const composerActions = document.createElement('div')
composerActions.className = 'acw-composer-actions'
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = 'image/*'
fileInput.style.display = 'none'
fileInput.setAttribute('aria-hidden', 'true')
const attachBtn = document.createElement('button')
attachBtn.type = 'button'
attachBtn.className = 'acw-input-attach'
attachBtn.setAttribute('aria-label', t('attach_image'))
attachBtn.innerHTML = ACW_ICON_PAPERCLIP
const input = document.createElement('textarea')
input.className = 'acw-composer-field'
input.rows = 1
input.placeholder = t('placeholder_type_message')
input.setAttribute('aria-label', t('message_aria'))
const sendBtn = document.createElement('button')
sendBtn.type = 'button'
sendBtn.className = 'acw-input-send'
sendBtn.setAttribute('aria-label', t('send_message'))
sendBtn.innerHTML = ACW_ICON_SEND
composerActions.appendChild(attachBtn)
composerActions.appendChild(sendBtn)
composerBox.appendChild(attachmentsStripEl)
composerBox.appendChild(input)
composerBox.appendChild(composerActions)
inputWrap.appendChild(fileInput)
inputWrap.appendChild(composerBox)
const footerEl = document.createElement('div')
footerEl.className = 'acw-footer'
footerEl.style.display = 'none'
const footerLink = document.createElement('a')
footerLink.href = 'https://helllo.ai'
footerLink.target = '_blank'
footerLink.rel = 'noopener noreferrer'
footerLink.textContent = 'helllo.ai'
footerEl.appendChild(document.createTextNode(t('powered_by')))
footerEl.appendChild(footerLink)
// Customer info form (if enabled)
let customerForm = null
let sessionId = null
let customerInfoSubmitted = false
let customerFormGatePassed = false
let conversationStarted = false
let formShownBeforeConnect = false
let pendingCustomerName = null
let pendingCustomerPhone = null
let unreadCount = 0
var messageLog = []
var persistSessionTimer = null
var restoringSession = false
/** PDP hosts from the in-flight carousel payload (unioned with allowedDomains during normalize). */
var productCarouselExtraDomains = null
var pendingNavigationOffer = null
var navigationCommitted = false
var lastExternalNavUrl = null
var lastExternalNavAt = 0
/** After cross-domain new-tab navigation the visitor stays on this page; auto-reconnect if talk2ai closes WS. */
var externalNavKeepAliveUntil = 0
var externalNavAutoReconnectPending = false
var spaNavBridge = createSpaNavigationBridge({
spaUseFrameworkRouter: spaUseFrameworkRouter,
spaUseNextRouter: spaUseNextRouter,
spaNavigationAdapter: spaNavigationAdapter,
spaNextRouterShallow: spaNextRouterShallow,
spaNavigate: spaNavigate,
})
function isCrossDomainNavigationUrl(url) {
if (!url) return false
try {
var parsed = new URL(url, window.location.href)
return !isSameNavigationDomain(parsed.hostname, window.location.hostname)
} catch (_) {
return false
}
}
function scheduleKeepAliveOnCurrentPageAfterExternalNav() {
externalNavKeepAliveUntil = Date.now() + 45000
externalNavAutoReconnectPending = true
}
function clearExternalNavKeepAlive() {
externalNavKeepAliveUntil = 0
externalNavAutoReconnectPending = false
}
function shouldAutoReconnectAfterExternalNavClose() {
return (
externalNavAutoReconnectPending &&
externalNavKeepAliveUntil > Date.now() &&
!!sessionId &&
conversationStarted
)
}
function tryFrameworkNavigation(parsedUrl, anchor, label, opts) {
spaNavBridge.refresh()
prepareNavigationHandshake(parsedUrl, anchor, opts)
return spaNavBridge.push(parsedUrl, anchor, opts).then(function (ok) {
if (!ok) return false
var scrolledOnly = opts && opts.scrolledOnly
var msg = scrolledOnly ? t('navigation_scrolled') : t('navigation_navigated')
appendMessage(label ? msg + ' (' + label + ')' : msg, 'bot')
return true
})
}
function canUseFrameworkNavigation(parsedUrl) {
spaNavBridge.refresh()
return spaNavBridge.canNavigate(parsedUrl)
}
var activeSuggestedActions = questionsListToSuggestedActions(suggestedQuestions)
var initSuggestedActions = activeSuggestedActions.slice()
if (captureCustomerInfo) {
customerForm = document.createElement('div')
customerForm.className = 'acw-customer-form'
customerForm.style.display = 'none'
const formHero = document.createElement('div')
formHero.id = 'acw-form-hero'
formHero.className = 'acw-form-hero-banner'
const formHeroImg = document.createElement('img')
formHeroImg.src = resolvedBannerImageUrl
formHeroImg.alt = ''
formHeroImg.setAttribute('aria-hidden', 'true')
const formHeroMedia = document.createElement('div')
formHeroMedia.className = 'acw-form-hero-banner-media'
formHeroMedia.appendChild(formHeroImg)
formHero.appendChild(formHeroMedia)
const formHeroClose = document.createElement('button')
formHeroClose.type = 'button'
formHeroClose.className = 'acw-form-hero-close'
formHeroClose.setAttribute('aria-label', t('close_widget'))
formHeroClose.title = t('close_widget')
formHeroClose.innerHTML = ACW_ICON_CLOSE_WIDGET
formHeroClose.onclick = function () {
softClosePanel()
}
formHero.appendChild(formHeroClose)
const formHeroName = document.createElement('div')
formHeroName.className = 'acw-form-hero-name'
formHeroName.textContent = title
formHero.appendChild(formHeroName)
const formIdentity = document.createElement('div')
formIdentity.className = 'acw-form-identity'
const backToOptions = document.createElement('button')
backToOptions.type = 'button'
backToOptions.className = 'acw-form-back'
backToOptions.setAttribute('aria-label', t('back_to_options'))
backToOptions.title = t('back_to_options')
backToOptions.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>'
formIdentity.appendChild(backToOptions)
const formAvatarWrap = document.createElement('div')
formAvatarWrap.className = 'acw-form-avatar-wrap'
const formAvatar = document.createElement('div')
formAvatar.className = 'acw-form-avatar'
formAvatar.textContent = (typeof title === 'string' && title.trim() ? title.trim().charAt(0) : '?').toUpperCase()
formAvatar.setAttribute('aria-hidden', 'true')
const formAvatarDot = document.createElement('span')
formAvatarDot.className = 'acw-online-dot'
formAvatarDot.setAttribute('aria-hidden', 'true')
headerOnlineDots.push(formAvatarDot)
formAvatarWrap.appendChild(formAvatar)
formAvatarWrap.appendChild(formAvatarDot)
formIdentity.appendChild(formAvatarWrap)
const formHeadCol = document.createElement('div')
formHeadCol.className = 'acw-form-head-col'
const formTitle = document.createElement('h3')
formTitle.textContent = t('form_title_start')
formHeadCol.appendChild(formTitle)
const nameField = document.createElement('div')
nameField.className = 'field'
const nameLabel = document.createElement('label')
nameLabel.textContent = t('label_name')
nameLabel.setAttribute('for', 'acw-customer-name')
const nameInput = document.createElement('input')
nameInput.id = 'acw-customer-name'
nameInput.type = 'text'
nameInput.placeholder = t('placeholder_name')
nameField.appendChild(nameLabel)
nameField.appendChild(nameInput)
const nameError = document.createElement('p')
nameError.className = 'acw-field-error'
nameError.setAttribute('role', 'alert')
nameError.style.display = 'none'
nameField.appendChild(nameError)
const phoneField = document.createElement('div')
phoneField.className = 'field acw-field-phone'
const phoneLabel = document.createElement('label')
phoneLabel.textContent = t('label_phone')
phoneLabel.setAttribute('for', 'acw-customer-phone')
const phoneRow = document.createElement('div')
phoneRow.className = 'acw-phone-row'
const phoneCodeWrap = document.createElement('div')
phoneCodeWrap.className = 'acw-phone-code-wrap'
const phoneCodeRoot = document.createElement('div')
phoneCodeRoot.className = 'acw-phone-code-switch'
const phoneCodeTrigger = document.createElement('button')
phoneCodeTrigger.type = 'button'
phoneCodeTrigger.className = 'acw-phone-code-trigger'
phoneCodeTrigger.id = 'acw-customer-phone-code-trigger'
phoneCodeTrigger.setAttribute('aria-expanded', 'false')
phoneCodeTrigger.setAttribute('aria-haspopup', 'listbox')
phoneCodeTrigger.setAttribute('aria-label', t('label_country_code'))
const phoneCodeFlag = document.createElement('span')
phoneCodeFlag.className = 'acw-phone-code-flag'
phoneCodeFlag.setAttribute('aria-hidden', 'true')
const phoneCodeDial = document.createElement('span')
phoneCodeDial.className = 'acw-phone-code-dial'
const phoneCodeChevron = document.createElement('span')
phoneCodeChevron.className = 'acw-phone-code-chevron'
phoneCodeChevron.setAttribute('aria-hidden', 'true')
phoneCodeChevron.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>'
phoneCodeTrigger.appendChild(phoneCodeFlag)
phoneCodeTrigger.appendChild(phoneCodeDial)
phoneCodeTrigger.appendChild(phoneCodeChevron)
const phoneCodeMenu = document.createElement('div')
phoneCodeMenu.className = 'acw-phone-code-menu'
phoneCodeMenu.id = 'acw-customer-phone-code-menu'
phoneCodeTrigger.setAttribute('aria-controls', 'acw-customer-phone-code-menu')
const phoneCodeSearchWrap = document.createElement('div')
phoneCodeSearchWrap.className = 'acw-phone-code-search-wrap'
const phoneCodeSearch = document.createElement('input')
phoneCodeSearch.type = 'search'
phoneCodeSearch.className = 'acw-phone-code-search'
phoneCodeSearch.id = 'acw-customer-phone-code-search'
phoneCodeSearch.placeholder = t('search_country')
phoneCodeSearch.setAttribute('aria-label', t('search_country'))
phoneCodeSearch.setAttribute('autocomplete', 'off')
phoneCodeSearch.setAttribute('autocorrect', 'off')
phoneCodeSearch.setAttribute('spellcheck', 'false')
phoneCodeSearchWrap.appendChild(phoneCodeSearch)
const phoneCodeList = document.createElement('div')
phoneCodeList.className = 'acw-phone-code-list'
phoneCodeList.id = 'acw-customer-phone-code-list'
phoneCodeList.setAttribute('role', 'listbox')
phoneCodeList.setAttribute('aria-label', t('label_country_code'))
const phoneCodeEmpty = document.createElement('p')
phoneCodeEmpty.className = 'acw-phone-code-empty'
phoneCodeEmpty.textContent = t('no_country_match')
phoneCodeEmpty.setAttribute('role', 'status')
const phoneCodeSelect = document.createElement('select')
phoneCodeSelect.id = 'acw-customer-phone-code'
phoneCodeSelect.className = 'acw-phone-code-native'
phoneCodeSelect.setAttribute('aria-hidden', 'true')
phoneCodeSelect.setAttribute('tabindex', '-1')
var defaultPhoneIso = acwDefaultPhoneCountryIso()
var phoneMenuDocClose = null
function acwFilterPhoneCountries(query) {
if (!phoneCodeList) return
var q = String(query || '').trim().toLowerCase().replace(/^\+/, '')
var opts = phoneCodeList.querySelectorAll('.acw-phone-code-option')
var visible = 0
for (var fi = 0; fi < opts.length; fi++) {
var btn = opts[fi]
var name = (btn.getAttribute('data-name') || '').toLowerCase()
var dial = btn.getAttribute('data-dial') || ''
var iso = (btn.getAttribute('data-iso') || '').toLowerCase()
var match = !q || name.indexOf(q) !== -1 || dial.indexOf(q) !== -1 || iso.indexOf(q) !== -1
btn.style.display = match ? '' : 'none'
if (match) visible++
}
if (phoneCodeEmpty) phoneCodeEmpty.classList.toggle('is-visible', q.length > 0 && visible === 0)
}
function acwResetPhoneCountrySearch() {
if (!phoneCodeSearch) return
phoneCodeSearch.value = ''
acwFilterPhoneCountries('')
}
phoneCodeList.appendChild(phoneCodeEmpty)
for (var pci = 0; pci < PHONE_COUNTRY_CODES.length; pci++) {
var phoneCountry = PHONE_COUNTRY_CODES[pci]
var phoneCodeOpt = document.createElement('option')
phoneCodeOpt.value = phoneCountry.iso
phoneCodeOpt.textContent = phoneCountry.name + ' (+' + phoneCountry.dial + ')'
if (phoneCountry.iso === defaultPhoneIso) phoneCodeOpt.selected = true
phoneCodeSelect.appendChild(phoneCodeOpt)
var phoneCodeBtn = document.createElement('button')
phoneCodeBtn.type = 'button'
phoneCodeBtn.className = 'acw-phone-code-option'
phoneCodeBtn.setAttribute('role', 'option')
phoneCodeBtn.setAttribute('data-iso', phoneCountry.iso)
phoneCodeBtn.setAttribute('data-name', phoneCountry.name)
phoneCodeBtn.setAttribute('data-dial', phoneCountry.dial)
var phoneOptFlag = document.createElement('span')
phoneOptFlag.className = 'acw-phone-code-option-flag'
phoneOptFlag.setAttribute('aria-hidden', 'true')
phoneOptFlag.textContent = acwCountryFlag(phoneCountry.iso)
var phoneOptName = document.createElement('span')
phoneOptName.className = 'acw-phone-code-option-name'
phoneOptName.textContent = phoneCountry.name
var phoneOptDial = document.createElement('span')
phoneOptDial.className = 'acw-phone-code-option-dial'
phoneOptDial.textContent = '+' + phoneCountry.dial
phoneCodeBtn.appendChild(phoneOptFlag)
phoneCodeBtn.appendChild(phoneOptName)
phoneCodeBtn.appendChild(phoneOptDial)
phoneCodeBtn.addEventListener('click', function (ev) {
ev.stopPropagation()
var iso = this.getAttribute('data-iso')
if (!iso || !phoneCodeSelect) return
phoneCodeSelect.value = iso
acwSyncPhoneCodeFromSelect()
acwSetPhoneCodeMenuOpen(false)
clearPhoneFieldError()
})
phoneCodeList.appendChild(phoneCodeBtn)
}
function acwVisiblePhoneOptions() {
var opts = phoneCodeList.querySelectorAll('.acw-phone-code-option')
var visible = []
for (var vi = 0; vi < opts.length; vi++) {
if (opts[vi].style.display !== 'none') visible.push(opts[vi])
}
return visible
}
function acwChoosePhoneOption(btn) {
if (!btn || !phoneCodeSelect) return
var iso = btn.getAttribute('data-iso')
if (!iso) return
phoneCodeSelect.value = iso
acwSyncPhoneCodeFromSelect()
acwSetPhoneCodeMenuOpen(false)
clearPhoneFieldError()
}
function acwExactPhoneOption(query) {
var q = String(query || '').trim().toLowerCase().replace(/^\+/, '')
if (!q) return null
var visible = acwVisiblePhoneOptions()
var exact = []
for (var ei = 0; ei < visible.length; ei++) {
var btn = visible[ei]
var name = (btn.getAttribute('data-name') || '').toLowerCase()
var dial = btn.getAttribute('data-dial') || ''
var iso = (btn.getAttribute('data-iso') || '').toLowerCase()
if (dial === q || name === q || iso === q) exact.push(btn)
}
return exact.length === 1 ? exact[0] : null
}
phoneCodeSearch.addEventListener('input', function () {
acwFilterPhoneCountries(phoneCodeSearch.value)
var exact = acwExactPhoneOption(phoneCodeSearch.value)
if (exact) acwChoosePhoneOption(exact)
})
phoneCodeSearch.addEventListener('click', function (ev) {
ev.stopPropagation()
})
phoneCodeSearch.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') {
ev.stopPropagation()
acwSetPhoneCodeMenuOpen(false)
return
}
if (ev.key === 'Enter') {
ev.preventDefault()
ev.stopPropagation()
var visible = acwVisiblePhoneOptions()
acwChoosePhoneOption(acwExactPhoneOption(phoneCodeSearch.value) || visible[0])
}
})
phoneCodeTrigger.addEventListener('keydown', function (ev) {
if (ev.key === 'ArrowDown' || ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault()
acwSetPhoneCodeMenuOpen(true)
return
}
if (ev.key.length !== 1 || ev.metaKey || ev.ctrlKey || ev.altKey) return
ev.preventDefault()
var typed = ev.key
acwSetPhoneCodeMenuOpen(true)
setTimeout(function () {
if (!phoneCodeSearch) return
phoneCodeSearch.value = typed
phoneCodeSearch.dispatchEvent(new Event('input'))
phoneCodeSearch.focus()
}, 0)
})
function acwSyncPhoneCodeFromSelect() {
if (!phoneCodeSelect) return
var country = acwFindPhoneCountry(phoneCodeSelect.value)
if (phoneCodeFlag) phoneCodeFlag.textContent = country ? acwCountryFlag(country.iso) : ''
if (phoneCodeDial) phoneCodeDial.textContent = country ? '+' + country.dial : ''
if (phoneCodeTrigger && country) {
var al = t('label_country_code') + ': ' + country.name + ' +' + country.dial
phoneCodeTrigger.setAttribute('aria-label', al)
phoneCodeTrigger.setAttribute('title', al)
}
if (phoneCodeList) {
var opts = phoneCodeList.querySelectorAll('.acw-phone-code-option')
for (var oi = 0; oi < opts.length; oi++) {
var optBtn = opts[oi]
var sel = optBtn.getAttribute('data-iso') === phoneCodeSelect.value
optBtn.setAttribute('aria-selected', sel ? 'true' : 'false')
optBtn.classList.toggle('is-selected', sel)
}
}
}
function acwPositionPhoneCodeMenuInPanel() {
if (!phoneCodeMenu || !phoneCodeTrigger || !panel) return
var triggerRect = phoneCodeTrigger.getBoundingClientRect()
var panelRect = panel.getBoundingClientRect()
var menuW = Math.min(280, panelRect.width - 24)
var left = triggerRect.left - panelRect.left
var maxLeft = Math.max(0, panelRect.width - menuW)
left = Math.max(12, Math.min(left, maxLeft))
phoneCodeMenu.style.position = 'absolute'
phoneCodeMenu.style.left = Math.round(left) + 'px'
phoneCodeMenu.style.top = Math.round(triggerRect.bottom - panelRect.top + 6) + 'px'
phoneCodeMenu.style.right = 'auto'
phoneCodeMenu.style.width = menuW + 'px'
phoneCodeMenu.style.minWidth = '240px'
phoneCodeMenu.style.maxWidth = menuW + 'px'
phoneCodeMenu.style.zIndex = '50'
}
function acwRestorePhoneCodeMenuInRoot() {
if (!phoneCodeMenu || !phoneCodeRoot) return
phoneCodeMenu.classList.remove('acw-phone-code-menu--floating')
phoneCodeMenu.style.position = ''
phoneCodeMenu.style.left = ''
phoneCodeMenu.style.top = ''
phoneCodeMenu.style.right = ''
phoneCodeMenu.style.width = ''
phoneCodeMenu.style.minWidth = ''
phoneCodeMenu.style.maxWidth = ''
phoneCodeMenu.style.zIndex = ''
if (phoneCodeMenu.parentNode !== phoneCodeRoot) {
phoneCodeRoot.appendChild(phoneCodeMenu)
}
}
function acwSetPhoneCodeMenuOpen(open) {
if (!phoneCodeMenu || !phoneCodeTrigger) return
if (open) {
if (customerForm._acwCloseLangMenu) customerForm._acwCloseLangMenu()
acwResetPhoneCountrySearch()
phoneCodeMenu.classList.add('is-open', 'acw-phone-code-menu--floating')
phoneCodeTrigger.setAttribute('aria-expanded', 'true')
if (panel && phoneCodeMenu.parentNode !== panel) {
panel.appendChild(phoneCodeMenu)
}
acwPositionPhoneCodeMenuInPanel()
setTimeout(function () {
if (phoneCodeSearch) {
phoneCodeSearch.focus()
try { phoneCodeSearch.select() } catch (_) {}
}
}, 0)
if (phoneMenuDocClose) {
document.removeEventListener('click', phoneMenuDocClose, true)
phoneMenuDocClose = null
}
phoneMenuDocClose = function (ev) {
var path = typeof ev.composedPath === 'function' ? ev.composedPath() : []
if (path.indexOf(phoneCodeRoot) === -1 && path.indexOf(phoneCodeMenu) === -1) {
acwSetPhoneCodeMenuOpen(false)
}
}
setTimeout(function () {
document.addEventListener('click', phoneMenuDocClose, true)
}, 0)
} else {
acwResetPhoneCountrySearch()
phoneCodeMenu.classList.remove('is-open')
acwRestorePhoneCodeMenuInRoot()
phoneCodeTrigger.setAttribute('aria-expanded', 'false')
if (phoneMenuDocClose) {
document.removeEventListener('click', phoneMenuDocClose, true)
phoneMenuDocClose = null
}
}
}
phoneCodeTrigger.addEventListener('click', function (ev) {
ev.stopPropagation()
acwSetPhoneCodeMenuOpen(!phoneCodeMenu.classList.contains('is-open'))
})
phoneCodeMenu.appendChild(phoneCodeSearchWrap)
phoneCodeMenu.appendChild(phoneCodeList)
phoneCodeRoot.appendChild(phoneCodeTrigger)
phoneCodeRoot.appendChild(phoneCodeMenu)
phoneCodeRoot.appendChild(phoneCodeSelect)
phoneCodeWrap.appendChild(phoneCodeRoot)
acwSyncPhoneCodeFromSelect()
customerForm._acwSyncPhoneCode = acwSyncPhoneCodeFromSelect
customerForm._acwClosePhoneCodeMenu = function () {
acwSetPhoneCodeMenuOpen(false)
}
const phoneNumberWrap = document.createElement('div')
phoneNumberWrap.className = 'acw-phone-number-wrap'
const phoneInput = document.createElement('input')
phoneInput.id = 'acw-customer-phone'
phoneInput.type = 'tel'
phoneInput.inputMode = 'tel'
phoneInput.autocomplete = 'tel-national'
phoneInput.placeholder = t('placeholder_phone')
phoneNumberWrap.appendChild(phoneInput)
phoneRow.appendChild(phoneCodeWrap)
phoneRow.appendChild(phoneNumberWrap)
phoneField.appendChild(phoneLabel)
phoneField.appendChild(phoneRow)
const phoneError = document.createElement('p')
phoneError.className = 'acw-field-error'
phoneError.setAttribute('role', 'alert')
phoneError.style.display = 'none'
phoneField.appendChild(phoneError)
languageFieldWrap = document.createElement('div')
languageFieldWrap.className = 'acw-lang-switch-field'
languageFieldWrap.id = 'acw-customer-language-field'
languageFieldWrap.style.display = 'none'
const langLabel = document.createElement('label')
langLabel.id = 'acw-customer-lang-label'
langLabel.className = 'acw-lang-switch-sr-label'
langLabel.textContent = t('conversation_language')
langLabel.setAttribute('for', 'acw-customer-language')
langSwitchRoot = document.createElement('div')
langSwitchRoot.className = 'acw-lang-switch'
langSwitchTrigger = document.createElement('button')
langSwitchTrigger.type = 'button'
langSwitchTrigger.className = 'acw-lang-switch-trigger'
langSwitchTrigger.setAttribute('aria-expanded', 'false')
langSwitchTrigger.setAttribute('aria-haspopup', 'listbox')
langSwitchTrigger.setAttribute('aria-labelledby', 'acw-customer-lang-label')
langSwitchTrigger.setAttribute('aria-label', t('conversation_language'))
const langIconWrap = document.createElement('span')
langIconWrap.className = 'acw-lang-switch-icon'
langIconWrap.setAttribute('aria-hidden', 'true')
langIconWrap.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>'
langSwitchCurrent = document.createElement('span')
langSwitchCurrent.className = 'acw-lang-switch-current'
const langChevronWrap = document.createElement('span')
langChevronWrap.className = 'acw-lang-switch-chevron'
langChevronWrap.setAttribute('aria-hidden', 'true')
langChevronWrap.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>'
langSwitchTrigger.appendChild(langIconWrap)
langSwitchTrigger.appendChild(langSwitchCurrent)
langSwitchTrigger.appendChild(langChevronWrap)
langSwitchMenu = document.createElement('div')
langSwitchMenu.className = 'acw-lang-switch-menu'
langSwitchMenu.id = 'acw-customer-lang-menu'
langSwitchMenu.setAttribute('role', 'listbox')
langSwitchTrigger.setAttribute('aria-controls', 'acw-customer-lang-menu')
languageSelect = document.createElement('select')
languageSelect.id = 'acw-customer-language'
languageSelect.className = 'acw-lang-switch-native'
languageSelect.setAttribute('aria-hidden', 'true')
languageSelect.setAttribute('tabindex', '-1')
function acwSyncLangSwitcherFromSelect() {
if (!languageSelect || !langSwitchCurrent) return
var opt = languageSelect.options[languageSelect.selectedIndex]
langSwitchCurrent.textContent = opt ? opt.textContent : ''
if (langSwitchTrigger) {
var al = opt && opt.textContent
? t('conversation_language') + ': ' + opt.textContent
: t('conversation_language')
langSwitchTrigger.setAttribute('aria-label', al)
langSwitchTrigger.setAttribute('title', al)
}
if (langSwitchMenu) {
var btns = langSwitchMenu.querySelectorAll('.acw-lang-option')
for (var bi = 0; bi < btns.length; bi++) {
var b = btns[bi]
var sel = b.getAttribute('data-code') === languageSelect.value
b.setAttribute('aria-selected', sel ? 'true' : 'false')
b.classList.toggle('is-selected', sel)
}
}
}
function acwPositionLangMenuInPanel() {
if (!langSwitchMenu || !langSwitchTrigger || !panel) return
var triggerRect = langSwitchTrigger.getBoundingClientRect()
var panelRect = panel.getBoundingClientRect()
var left = triggerRect.left - panelRect.left
var maxLeft = Math.max(0, panelRect.width - 240)
left = Math.max(12, Math.min(left, maxLeft))
langSwitchMenu.style.position = 'absolute'
langSwitchMenu.style.left = Math.round(left) + 'px'
langSwitchMenu.style.top = Math.round(triggerRect.bottom - panelRect.top + 8) + 'px'
langSwitchMenu.style.right = 'auto'
langSwitchMenu.style.minWidth = '220px'
langSwitchMenu.style.maxWidth = Math.min(280, panelRect.width - 24) + 'px'
langSwitchMenu.style.zIndex = '50'
}
function acwRestoreLangMenuInRoot() {
if (!langSwitchMenu || !langSwitchRoot) return
langSwitchMenu.classList.remove('acw-lang-switch-menu--floating')
langSwitchMenu.style.position = ''
langSwitchMenu.style.left = ''
langSwitchMenu.style.top = ''
langSwitchMenu.style.right = ''
langSwitchMenu.style.minWidth = ''
langSwitchMenu.style.maxWidth = ''
langSwitchMenu.style.zIndex = ''
if (langSwitchMenu.parentNode !== langSwitchRoot) {
langSwitchRoot.appendChild(langSwitchMenu)
}
}
function acwSetLangMenuOpen(open) {
if (!langSwitchMenu || !langSwitchTrigger) return
if (open) {
if (customerForm._acwClosePhoneCodeMenu) customerForm._acwClosePhoneCodeMenu()
langSwitchMenu.classList.add('is-open', 'acw-lang-switch-menu--floating')
langSwitchTrigger.setAttribute('aria-expanded', 'true')
if (panel && langSwitchMenu.parentNode !== panel) {
panel.appendChild(langSwitchMenu)
}
acwPositionLangMenuInPanel()
if (langMenuDocClose) {
document.removeEventListener('click', langMenuDocClose, true)
langMenuDocClose = null
}
langMenuDocClose = function (ev) {
var path = typeof ev.composedPath === 'function' ? ev.composedPath() : []
if (path.indexOf(langSwitchRoot) === -1 && path.indexOf(langSwitchMenu) === -1) {
acwSetLangMenuOpen(false)
}
}
setTimeout(function () {
document.addEventListener('click', langMenuDocClose, true)
}, 0)
} else {
langSwitchMenu.classList.remove('is-open')
acwRestoreLangMenuInRoot()
langSwitchTrigger.setAttribute('aria-expanded', 'false')
if (langMenuDocClose) {
document.removeEventListener('click', langMenuDocClose, true)
langMenuDocClose = null
}
}
}
langSwitchTrigger.addEventListener('click', function (ev) {
ev.stopPropagation()
acwSetLangMenuOpen(!langSwitchMenu.classList.contains('is-open'))
})
langSwitchRoot.appendChild(langSwitchTrigger)
langSwitchRoot.appendChild(langSwitchMenu)
langSwitchRoot.appendChild(languageSelect)
languageFieldWrap.appendChild(langLabel)
languageFieldWrap.appendChild(langSwitchRoot)
formHero.appendChild(languageFieldWrap)
languageSelect.addEventListener('change', function () {
conversationLanguageCode = languageSelect.value || null
acwSyncLangSwitcherFromSelect()
})
customerForm._acwSyncLangSwitcher = acwSyncLangSwitcherFromSelect
customerForm._acwCloseLangMenu = function () {
acwSetLangMenuOpen(false)
}
customerForm.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
if (langSwitchMenu && langSwitchMenu.classList.contains('is-open')) {
acwSetLangMenuOpen(false)
}
if (phoneCodeMenu && phoneCodeMenu.classList.contains('is-open')) {
acwSetPhoneCodeMenuOpen(false)
}
}
})
const formActions = document.createElement('div')
formActions.className = 'actions'
const formError = document.createElement('p')
formError.className = 'acw-form-error'
formError.setAttribute('role', 'alert')
formError.style.display = 'none'
const actionButtons = document.createElement('div')
actionButtons.className = 'actions-buttons' + (allowSkipCustomerInfo ? '' : ' single')
const submitBtn = document.createElement('button')
submitBtn.className = 'submit-btn'
submitBtn.type = 'button'
submitBtn.textContent = t('start_conversation')
const skipBtn = document.createElement('button')
skipBtn.className = 'skip-btn'
skipBtn.type = 'button'
skipBtn.textContent = t('skip')
actionButtons.appendChild(submitBtn)
if (allowSkipCustomerInfo) {
actionButtons.appendChild(skipBtn)
}
formActions.appendChild(formError)
formActions.appendChild(actionButtons)
const formFooter = document.createElement('div')
formFooter.className = 'acw-footer acw-form-footer'
const formFooterLink = document.createElement('a')
formFooterLink.href = 'https://helllo.ai'
formFooterLink.target = '_blank'
formFooterLink.rel = 'noopener noreferrer'
formFooterLink.textContent = 'helllo.ai'
formFooter.appendChild(document.createTextNode(t('powered_by')))
formFooter.appendChild(formFooterLink)
customerForm.appendChild(formHero)
customerForm.appendChild(formIdentity)
customerForm.appendChild(formHeadCol)
customerForm.appendChild(nameField)
customerForm.appendChild(phoneField)
customerForm.appendChild(formActions)
customerForm.appendChild(formFooter)
function clearCustomerFormErrors() {
nameError.textContent = ''
nameError.style.display = 'none'
phoneError.textContent = ''
phoneError.style.display = 'none'
formError.textContent = ''
formError.style.display = 'none'
nameField.classList.remove('acw-field-invalid')
phoneField.classList.remove('acw-field-invalid')
}
function showCustomerFieldError(fieldEl, errorEl, message) {
errorEl.textContent = message
errorEl.style.display = 'block'
fieldEl.classList.add('acw-field-invalid')
}
function showCustomerFormError(message) {
formError.textContent = message
formError.style.display = 'block'
}
nameInput.addEventListener('input', function () {
if (nameInput.value.trim()) {
nameError.style.display = 'none'
nameField.classList.remove('acw-field-invalid')
}
})
function clearPhoneFieldError() {
phoneError.style.display = 'none'
phoneField.classList.remove('acw-field-invalid')
}
phoneInput.addEventListener('input', function () {
if (phoneInput.value.trim()) clearPhoneFieldError()
})
phoneCodeSelect.addEventListener('change', clearPhoneFieldError)
customerForm._acwClearErrors = clearCustomerFormErrors
function submitCustomerInfo() {
const name = nameInput.value.trim()
const phoneNational = phoneInput.value.trim()
const phoneCountry = acwFindPhoneCountry(phoneCodeSelect.value)
if (languageSelect && languageSelect.value) {
conversationLanguageCode = languageSelect.value
}
clearCustomerFormErrors()
var hasFieldError = false
if (!name) {
showCustomerFieldError(nameField, nameError, t('error_name_required'))
hasFieldError = true
}
if (!phoneNational) {
showCustomerFieldError(phoneField, phoneError, t('error_phone_required'))
hasFieldError = true
} else if (!phoneCountry || !acwValidateNationalPhone(phoneNational)) {
showCustomerFieldError(phoneField, phoneError, t('error_phone_invalid'))
hasFieldError = true
}
if (hasFieldError) {
if (!name) nameInput.focus()
else phoneInput.focus()
return
}
pendingCustomerName = name
pendingCustomerPhone = acwFormatPhoneE164(phoneCountry.dial, phoneNational)
customerFormGatePassed = true
customerForm.style.display = 'none'
syncPanelLayout()
if (pendingMode === 'voice') {
if (useRail) {
panel.style.display = 'none'
container.classList.remove('spark-acw-panel-visible')
panel.classList.remove('is-open')
showRailVoiceOverlay()
} else {
showVoicePanel()
}
unlockVoiceRemoteAudio()
setTimeout(function () { startVoiceConnection() }, 0)
} else {
connect()
}
}
function skipCustomerInfo() {
clearCustomerFormErrors()
if (languageSelect && languageSelect.value) {
conversationLanguageCode = languageSelect.value
}
pendingCustomerName = null
pendingCustomerPhone = null
customerFormGatePassed = true
customerForm.style.display = 'none'
syncPanelLayout()
if (pendingMode === 'voice') {
if (useRail) {
panel.style.display = 'none'
container.classList.remove('spark-acw-panel-visible')
panel.classList.remove('is-open')
showRailVoiceOverlay()
} else {
showVoicePanel()
}
unlockVoiceRemoteAudio()
setTimeout(function () { startVoiceConnection() }, 0)
} else {
connect()
}
}
submitBtn.onclick = submitCustomerInfo
if (allowSkipCustomerInfo) skipBtn.onclick = skipCustomerInfo
backToOptions.onclick = function () {
conversationStarted = false
formShownBeforeConnect = false
customerFormGatePassed = false
pendingMode = null
nameInput.value = ''
phoneInput.value = ''
clearCustomerFormErrors()
if (languageSelect && supportedLanguagesList.length) {
languageSelect.selectedIndex = 0
conversationLanguageCode = supportedLanguagesList[0].language_code || null
if (customerForm._acwSyncLangSwitcher) customerForm._acwSyncLangSwitcher()
}
if (customerForm._acwCloseLangMenu) customerForm._acwCloseLangMenu()
customerForm.style.display = 'none'
if (useRail) {
softClosePanel()
return
}
showStartCta()
}
// Allow Enter key to submit
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
phoneInput.focus()
}
})
phoneInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
submitCustomerInfo()
}
})
}
fetchWidgetConfig(function (data) {
var langs = data && data.languages && Array.isArray(data.languages) ? data.languages : supportedLanguagesList
if (!langs || !langs.length) {
if (languageFieldWrap) languageFieldWrap.style.display = 'none'
return
}
if (!conversationLanguageCode) {
conversationLanguageCode = langs[0].language_code || null
}
if (languageSelect && languageFieldWrap && langSwitchMenu) {
languageFieldWrap.style.display = 'block'
languageSelect.innerHTML = ''
langSwitchMenu.innerHTML = ''
for (var li = 0; li < langs.length; li++) {
var L = langs[li]
var code = L.language_code || ''
var lbl = L.language || L.language_code || ''
var opt = document.createElement('option')
opt.value = code
opt.textContent = lbl
languageSelect.appendChild(opt)
var optBtn = document.createElement('button')
optBtn.type = 'button'
optBtn.className = 'acw-lang-option'
optBtn.setAttribute('role', 'option')
optBtn.setAttribute('data-code', code)
optBtn.textContent = lbl
optBtn.addEventListener('click', function (c) {
return function (evt) {
evt.stopPropagation()
languageSelect.value = c
conversationLanguageCode = c || null
try {
languageSelect.dispatchEvent(new Event('change', { bubbles: true }))
} catch (_) {
conversationLanguageCode = languageSelect.value || null
}
if (customerForm && customerForm._acwSyncLangSwitcher) {
customerForm._acwSyncLangSwitcher()
}
if (customerForm && customerForm._acwCloseLangMenu) {
customerForm._acwCloseLangMenu()
}
}
}(code))
langSwitchMenu.appendChild(optBtn)
}
if (langs[0] && langs[0].language_code) {
languageSelect.value = langs[0].language_code
conversationLanguageCode = languageSelect.value
}
if (customerForm && customerForm._acwSyncLangSwitcher) {
customerForm._acwSyncLangSwitcher()
}
}
})
const voicePanelEl = document.createElement('div')
voicePanelEl.className = 'acw-voice-panel'
const voiceOrbA = document.createElement('div')
voiceOrbA.className = 'acw-voice-orb a'
const voiceOrbB = document.createElement('div')
voiceOrbB.className = 'acw-voice-orb b'
const voiceInner = document.createElement('div')
voiceInner.className = 'acw-voice-inner'
const voiceTitleEl = document.createElement('div')
voiceTitleEl.className = 'acw-voice-title'
voiceTitleEl.textContent = t('voice_title')
const voiceStatusText = document.createElement('div')
voiceStatusText.className = 'acw-voice-status'
voiceStatusText.textContent = t('voice_tap_mic')
const voiceVisualizerEl = document.createElement('div')
voiceVisualizerEl.className = 'acw-voice-visualizer'
for (var vi = 0; vi < 7; vi++) {
var bar = document.createElement('div')
bar.className = 'acw-voice-bar'
voiceVisualizerEl.appendChild(bar)
}
const voiceMicWrap = document.createElement('div')
voiceMicWrap.className = 'acw-voice-mic-wrap'
const voiceMicBtn = document.createElement('button')
voiceMicBtn.className = 'acw-voice-mic'
voiceMicBtn.type = 'button'
voiceMicBtn.setAttribute('aria-label', t('voice_mic_aria'))
voiceMicBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>'
const voiceEndBtn = document.createElement('button')
voiceEndBtn.className = 'acw-voice-end-row'
voiceEndBtn.type = 'button'
voiceEndBtn.style.display = 'none'
voiceEndBtn.innerHTML = ACW_ICON_END_CALL
var voiceEndSpan = document.createElement('span')
voiceEndSpan.textContent = t('end_call')
voiceEndBtn.appendChild(voiceEndSpan)
voiceMicWrap.appendChild(voiceMicBtn)
voiceMicWrap.appendChild(voiceEndBtn)
voiceInner.appendChild(voiceTitleEl)
voiceInner.appendChild(voiceStatusText)
voiceInner.appendChild(voiceVisualizerEl)
voiceInner.appendChild(voiceMicWrap)
voicePanelEl.appendChild(voiceOrbA)
voicePanelEl.appendChild(voiceOrbB)
voicePanelEl.appendChild(voiceInner)
var railVoiceOverlayEl = null
var railVoiceTranscriptEl = null
var railVoiceMicBtn = null
var railVoiceStatusEl = null
var railVoiceStatusTipEl = null
var railVoiceHintEl = null
var railVoiceEqEl = null
var railEqBars = []
var railVoiceCloseBtn = null
var railVoiceHomeBtn = null
var railVoiceEndBtn = null
var railVoiceOverlayActive = false
var lastRailUserInterimEl = null
var lastRailBotInterimEl = null
var railBotLlmBuffer = ''
var railBotTtsBuffer = ''
var railBotTranscriptionText = ''
var railBotResponseBuffer = ''
var railPendingBotFinalText = ''
if (useRail) {
railVoiceOverlayEl = document.createElement('div')
railVoiceOverlayEl.className = 'acw-rail-voice-overlay'
railVoiceOverlayEl.setAttribute('aria-hidden', 'true')
var railTranscriptWrap = document.createElement('div')
railTranscriptWrap.className = 'acw-rail-voice-transcript-wrap'
var railTranscriptToolbar = document.createElement('div')
railTranscriptToolbar.className = 'acw-rail-voice-transcript-toolbar'
railVoiceCloseBtn = document.createElement('button')
railVoiceCloseBtn.type = 'button'
railVoiceCloseBtn.className = 'acw-rail-voice-close'
railVoiceCloseBtn.setAttribute('aria-label', t('voice_close_transcript'))
railVoiceCloseBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg><span>' + t('voice_close_transcript') + '</span>'
railTranscriptToolbar.appendChild(railVoiceCloseBtn)
railVoiceTranscriptEl = document.createElement('div')
railVoiceTranscriptEl.className = 'acw-rail-voice-transcript'
railVoiceTranscriptEl.setAttribute('role', 'log')
railVoiceTranscriptEl.setAttribute('aria-live', 'polite')
railVoiceTranscriptEl.setAttribute('aria-relevant', 'additions text')
railTranscriptWrap.appendChild(railTranscriptToolbar)
railTranscriptWrap.appendChild(railVoiceTranscriptEl)
railVoiceTranscriptEl.addEventListener('scroll', function () {
updateRailTranscriptFade()
})
var railMicDock = document.createElement('div')
railMicDock.className = 'acw-rail-voice-mic-dock'
railVoiceHomeBtn = document.createElement('button')
railVoiceHomeBtn.type = 'button'
railVoiceHomeBtn.className = 'acw-rail-voice-home'
railVoiceHomeBtn.setAttribute('aria-label', t('voice_back_to_options'))
railVoiceHomeBtn.title = t('voice_back_to_options')
railVoiceHomeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5L12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M9.5 21v-6h5v6"/></svg>'
railVoiceEndBtn = document.createElement('button')
railVoiceEndBtn.type = 'button'
railVoiceEndBtn.className = 'acw-rail-voice-end'
railVoiceEndBtn.setAttribute('aria-label', t('end_call'))
railVoiceEndBtn.title = t('end_call')
railVoiceEndBtn.innerHTML = ACW_ICON_END_CALL
var railMicInner = document.createElement('div')
railMicInner.className = 'acw-rail-voice-mic-inner'
var railHalo = document.createElement('div')
railHalo.className = 'acw-rail-voice-halo'
railHalo.innerHTML = '<span class="ring r1"></span><span class="ring r2"></span><span class="ring r3"></span>'
railVoiceMicBtn = document.createElement('button')
railVoiceMicBtn.type = 'button'
railVoiceMicBtn.className = 'acw-rail-voice-mic'
railVoiceMicBtn.setAttribute('aria-label', t('voice_mic_aria'))
railVoiceMicBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>'
railVoiceStatusEl = document.createElement('div')
railVoiceStatusEl.className = 'acw-rail-voice-status'
railVoiceStatusEl.setAttribute('role', 'status')
railVoiceStatusEl.setAttribute('aria-label', t('voice_status_disconnected'))
railMicInner.appendChild(railHalo)
railMicInner.appendChild(railVoiceMicBtn)
railMicInner.appendChild(railVoiceStatusEl)
railVoiceHintEl = document.createElement('div')
railVoiceHintEl.className = 'acw-rail-voice-hint'
railVoiceHintEl.textContent = t('voice_tap_mic')
railVoiceEqEl = document.createElement('div')
railVoiceEqEl.className = 'acw-rail-voice-eq'
railVoiceEqEl.setAttribute('aria-hidden', 'true')
for (var eqi = 0; eqi < 5; eqi++) {
var eqBar = document.createElement('span')
railVoiceEqEl.appendChild(eqBar)
railEqBars.push(eqBar)
}
railMicDock.appendChild(railVoiceHomeBtn)
railMicDock.appendChild(railVoiceEndBtn)
railMicDock.appendChild(railMicInner)
railMicDock.appendChild(railVoiceEqEl)
railMicDock.appendChild(railVoiceHintEl)
railVoiceOverlayEl.appendChild(railTranscriptWrap)
railVoiceOverlayEl.appendChild(railMicDock)
}
function acwCleanTranscriptText(text) {
var s = String(text || '').replace(/\s+/g, ' ').trim()
if (!s) return ''
s = s.replace(/\b(um+|uh+|er+|ah+|like|you know|i mean)\b/gi, '')
s = s.replace(/\s+/g, ' ').replace(/\s+([,.!?;:])/g, '$1').trim()
var words = s.split(' ')
if (words.length > 6) {
var half = Math.floor(words.length / 2)
var first = words.slice(0, half).join(' ')
var second = words.slice(half).join(' ')
if (first && first === second) s = first
}
return s
}
function mergeRailPartialText(existingText, incomingText) {
var existing = String(existingText || '')
var incoming = String(incomingText || '')
if (!incoming) return existing
if (!existing) return incoming
if (incoming.indexOf(existing) === 0) return incoming
if (existing.indexOf(incoming) === 0) return existing
if (/^[,.;:!?]/.test(incoming) || /^\s/.test(incoming) || /\s$/.test(existing)) return existing + incoming
return existing + ' ' + incoming
}
function scrollRailTranscriptToBottom() {
if (!railVoiceTranscriptEl) return
requestAnimationFrame(function () {
railVoiceTranscriptEl.scrollTop = railVoiceTranscriptEl.scrollHeight
})
}
function updateRailTranscriptFade() {
if (!railVoiceTranscriptEl || !railVoiceOverlayEl) return
var bubbles = railVoiceTranscriptEl.querySelectorAll('.acw-rail-tbubble:not(.is-interim)')
for (var i = 0; i < bubbles.length; i++) {
bubbles[i].classList.remove('is-faded-top')
}
if (bubbles.length < 2) return
var atBottom = railVoiceTranscriptEl.scrollHeight - railVoiceTranscriptEl.scrollTop - railVoiceTranscriptEl.clientHeight < 8
if (!atBottom) return
var visibleCount = Math.min(6, bubbles.length)
var fadeIndex = bubbles.length - visibleCount
if (fadeIndex >= 0 && bubbles[fadeIndex]) {
bubbles[fadeIndex].classList.add('is-faded-top')
}
}
function appendRailTranscriptBubble(text, role, opts) {
opts = opts || {}
if (!railVoiceTranscriptEl || !text) return null
var displayText = opts.clean === false ? text : acwCleanTranscriptText(text)
if (!displayText) return null
if (railVoiceOverlayEl) railVoiceOverlayEl.classList.add('has-transcript')
var bubble = document.createElement('div')
bubble.className = 'acw-rail-tbubble ' + (role === 'user' ? 'is-user' : 'is-bot')
if (opts.interim) bubble.classList.add('is-interim')
bubble.textContent = displayText
railVoiceTranscriptEl.appendChild(bubble)
scrollRailTranscriptToBottom()
updateRailTranscriptFade()
return bubble
}
var railLastNavUrl = null
var railLastNavAt = 0
/** Navigate once; ignore duplicate requests for the same URL within a short window. */
function railDoNavigate(nav) {
if (!nav || !nav.url) return
var now = Date.now()
if (nav.url === railLastNavUrl && (now - railLastNavAt) < 4000) return
railLastNavUrl = nav.url
railLastNavAt = now
performNavigation({ url: nav.url, anchor: nav.anchor, label: nav.label })
}
function appendRailNavigationOffer(offer) {
if (!railVoiceTranscriptEl || !offer || !offer.url) return
if (railVoiceOverlayEl) railVoiceOverlayEl.classList.add('has-transcript')
var wrap = document.createElement('div')
wrap.className = 'acw-rail-nav-offer'
var promptText = offer.promptText || t('navigation_navigated')
var textEl = document.createElement('div')
textEl.className = 'acw-rail-nav-offer-text'
textEl.textContent = promptText
var actions = document.createElement('div')
actions.className = 'acw-rail-nav-offer-actions'
var yesBtn = document.createElement('button')
yesBtn.type = 'button'
yesBtn.className = 'acw-rail-nav-offer-btn primary'
yesBtn.textContent = offer.yesLabel || t('nav_offer_yes')
var noBtn = document.createElement('button')
noBtn.type = 'button'
noBtn.className = 'acw-rail-nav-offer-btn'
noBtn.textContent = offer.noLabel || t('nav_offer_no')
actions.appendChild(yesBtn)
actions.appendChild(noBtn)
wrap.appendChild(textEl)
wrap.appendChild(actions)
railVoiceTranscriptEl.appendChild(wrap)
scrollRailTranscriptToBottom()
updateRailTranscriptFade()
function disableButtons() {
yesBtn.disabled = true
noBtn.disabled = true
}
yesBtn.onclick = function () {
disableButtons()
railDoNavigate({ url: offer.url, anchor: offer.anchor, label: offer.label })
}
noBtn.onclick = function () {
disableButtons()
}
}
function appendOrUpdateRailUserTranscription(text, isFinal) {
if (!useRail || !railVoiceTranscriptEl) return
if (isFinal) {
if (lastRailUserInterimEl && lastRailUserInterimEl.parentNode) {
lastRailUserInterimEl.parentNode.removeChild(lastRailUserInterimEl)
lastRailUserInterimEl = null
}
if (text) appendRailTranscriptBubble(text, 'user')
setRailVoicePhase('listening')
return
}
if (!text) return
setRailVoicePhase('listening')
if (lastRailUserInterimEl && lastRailUserInterimEl.parentNode) {
lastRailUserInterimEl.textContent = text
} else {
lastRailUserInterimEl = appendRailTranscriptBubble(text, 'user', { interim: true, clean: false })
}
scrollRailTranscriptToBottom()
}
function flushRailUserInterimToFinal() {
if (!lastRailUserInterimEl || !lastRailUserInterimEl.parentNode) return
var finalText = String(lastRailUserInterimEl.textContent || '').trim()
lastRailUserInterimEl.parentNode.removeChild(lastRailUserInterimEl)
lastRailUserInterimEl = null
if (finalText) appendRailTranscriptBubble(finalText, 'user')
}
function appendOrUpdateRailBotInterim(displayText) {
if (!useRail || !railVoiceTranscriptEl || !displayText) return
if (lastRailBotInterimEl && lastRailBotInterimEl.parentNode) {
lastRailBotInterimEl.textContent = displayText
} else {
lastRailBotInterimEl = appendRailTranscriptBubble(displayText, 'bot', { interim: true })
}
scrollRailTranscriptToBottom()
}
function finalizeRailAssistantText(text) {
var candidates = [railBotLlmBuffer || '', railBotTranscriptionText || '', railBotTtsBuffer || '', text || '']
var finalText = candidates.reduce(function (a, b) {
return (b || '').trim().length > (a || '').trim().length ? b : a
}, '').trim()
railBotResponseBuffer = ''
railBotLlmBuffer = ''
railBotTtsBuffer = ''
railBotTranscriptionText = ''
if (lastRailBotInterimEl && lastRailBotInterimEl.parentNode) {
lastRailBotInterimEl.parentNode.removeChild(lastRailBotInterimEl)
}
lastRailBotInterimEl = null
if (!finalText) return
railPendingBotFinalText = finalText
appendRailTranscriptBubble(finalText, 'bot')
}
function appendRailBotOutput(text) {
flushRailUserInterimToFinal()
var trimmed = String(text || '').trim()
if (railPendingBotFinalText) {
if (trimmed && trimmed === railPendingBotFinalText) {
railPendingBotFinalText = ''
return
}
railPendingBotFinalText = ''
}
if (lastRailBotInterimEl || railBotLlmBuffer || railBotTtsBuffer || railBotTranscriptionText) {
finalizeRailAssistantText(text)
} else if (text) {
appendRailTranscriptBubble(trimmed, 'bot')
}
}
function bufferRailAssistantLlm(text) {
var next = String(text || '').trim()
if (!next) return
railPendingBotFinalText = ''
railBotLlmBuffer = mergeRailPartialText(railBotLlmBuffer, next)
railBotResponseBuffer = railBotLlmBuffer
appendOrUpdateRailBotInterim(railBotResponseBuffer)
setRailVoicePhase('thinking')
}
function bufferRailAssistantTts(text) {
var next = String(text || '').trim()
if (!next) return
railPendingBotFinalText = ''
railBotTtsBuffer = mergeRailPartialText(railBotTtsBuffer, next)
railBotResponseBuffer = railBotLlmBuffer || railBotTtsBuffer
appendOrUpdateRailBotInterim(railBotResponseBuffer)
setRailVoicePhase('thinking')
}
function handleRailRtviParsedMessage(msg, sourceLabel) {
if (!useRail || !railVoiceOverlayActive || !msg) return
var type = msg.type || msg.event
var label = msg.label || sourceLabel || null
var payload = (msg.data && typeof msg.data === 'object') ? msg.data : msg
// RTVI wraps custom server events as { type: 'server-message', data: { type: '<real>', ... } }.
// Unwrap so the real event type (e.g. navigation_offer) is used for routing.
if ((type === 'server-message' || type === 'server_message' || type === 'server-response') &&
payload && typeof payload.type === 'string') {
type = payload.type
}
function handleTyped() {
// Voice-triggered navigation offer: show the prompt with Yes/No so the visitor can confirm.
if (enableGuidedNavigation && (type === 'navigation_offer' || type === 'navigation-offer')) {
var offerUrl = payload.url || msg.url
if (offerUrl) {
appendRailNavigationOffer({
offerId: payload.offer_id || msg.offer_id,
url: offerUrl,
anchor: payload.anchor || msg.anchor,
label: payload.label || msg.label,
promptText: payload.prompt_text || payload.text || payload.message || msg.prompt_text,
yesLabel: payload.yes_label || msg.yes_label,
noLabel: payload.no_label || msg.no_label,
})
}
return
}
if (type === 'product_carousel') {
appendProductCarousel(
payload.intro_text || payload.text || msg.intro_text || '',
payload.carousel || payload.product_carousel || msg.carousel || msg.product_carousel,
)
if (payload.suggested_actions || msg.suggested_actions) {
applySuggestedActions(payload.suggested_actions || msg.suggested_actions, true)
}
return
}
if (type === 'handover_ack') {
appendHandoverAck(
payload.content || payload.text || payload.message || msg.content || msg.text || '',
)
return
}
if (type === 'assistant_message' && (payload.product_carousel || msg.product_carousel)) {
var railAssistantIntro = payload.text || payload.content || payload.message || msg.text || ''
if (railAssistantIntro) appendRailTranscriptBubble(railAssistantIntro, 'bot')
appendProductCarousel('', payload.product_carousel || msg.product_carousel)
if (payload.suggested_actions || msg.suggested_actions) {
applySuggestedActions(payload.suggested_actions || msg.suggested_actions, true)
}
return
}
// Direct navigation action from the agent → navigate immediately (new tab).
if (enableGuidedNavigation && (
(type === 'client_action' && (payload.action === 'navigate' || msg.action === 'navigate')) ||
type === 'navigation' || type === 'navigate'
)) {
var navUrl = payload.url || msg.url
if (navUrl) railDoNavigate({ url: navUrl, anchor: payload.anchor || msg.anchor, label: payload.label || msg.label })
var navFollow = payload.follow_up_text || msg.follow_up_text
if (navFollow) appendRailTranscriptBubble(navFollow, 'bot')
return
}
if (type === 'user-transcription' || type === 'user_transcription') {
appendOrUpdateRailUserTranscription(payload.text || payload.message || payload.content || '', payload.final === true)
} else if (type === 'user-partial' || type === 'user-tts-text' || type === 'user-stt-text' || type === 'user-transcription-partial' || type === 'user-interim-transcription') {
var partialUserText = payload.text || payload.message || payload.content || ''
if (partialUserText) appendOrUpdateRailUserTranscription(partialUserText, false)
} else if (type === 'bot-output' || type === 'bot_output') {
appendRailBotOutput(payload.text || payload.message || payload.content || '')
} else if (type === 'bot-llm-text' || type === 'bot-partial') {
var partialBotText = payload.text || payload.message || payload.content || ''
if (partialBotText) { flushRailUserInterimToFinal(); bufferRailAssistantLlm(partialBotText) }
} else if (type === 'bot-tts-text') {
var ttsWord = payload.text || payload.message || payload.content || ''
if (ttsWord) { flushRailUserInterimToFinal(); bufferRailAssistantTts(ttsWord) }
} else if (type === 'bot-transcription') {
var transcriptionText = payload.text || payload.message || payload.content || ''
if (transcriptionText) railBotTranscriptionText = transcriptionText
} else if (type === 'bot-final' || type === 'bot-response-final') {
finalizeRailAssistantText(payload.text || payload.message || payload.content || '')
} else if (type === 'bot-stopped-speaking') {
finalizeRailAssistantText('')
} else if (type === 'transcript' || type === 'text_response') {
var trText = payload.text || payload.message || payload.content || ''
var trRole = normalizeChatRole(payload.role || 'assistant')
if (trText) appendRailTranscriptBubble(trText, trRole)
}
}
if (label === 'rtvi-ai') {
handleTyped()
return
}
handleTyped()
}
function parseRailRtviCandidate(candidate, sourceLabel, depth) {
if (candidate == null || !useRail || !railVoiceOverlayActive) return false
var maxDepth = 4
var currentDepth = typeof depth === 'number' ? depth : 0
if (currentDepth > maxDepth) return false
var value = candidate
if (typeof value === 'string') {
var text = value.trim()
if (!text) return false
try { value = JSON.parse(text) } catch (_) { return false }
}
if (Array.isArray(value)) {
var anyHandled = false
value.forEach(function (item) {
if (parseRailRtviCandidate(item, sourceLabel, currentDepth + 1)) anyHandled = true
})
return anyHandled
}
if (!value || typeof value !== 'object') return false
if (value.type || value.event || value.label) {
handleRailRtviParsedMessage(value, sourceLabel)
return true
}
var keys = ['message', 'payload', 'data', 'body']
var nestedHandled = false
keys.forEach(function (k) {
if (parseRailRtviCandidate(value[k], sourceLabel, currentDepth + 1)) nestedHandled = true
})
return nestedHandled
}
function handleRailDailyRtviEvent(kind, ev) {
if (!useRail || !railVoiceOverlayActive) return
var data = ev && typeof ev === 'object' && Object.prototype.hasOwnProperty.call(ev, 'data') ? ev.data : ev
var handled = parseRailRtviCandidate(data, 'rtvi-ai', 0)
if (!handled && kind === 'transcription-message' && data && typeof data === 'object') {
var text = data.text || data.transcript || data.message || ''
var role = normalizeChatRole(data.role || 'assistant')
if (text) appendRailTranscriptBubble(text, role)
}
}
function setupRailRtviDataChannel(channel) {
if (!channel || !useRail) return
channel.onmessage = function (e) {
if (!railVoiceOverlayActive) return
try {
if (typeof Blob !== 'undefined' && e.data instanceof Blob) {
e.data.text().then(function (text) {
try { handleRailRtviParsedMessage(JSON.parse(text)) } catch (_) {}
}).catch(function () {})
return
}
var parsed = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
if (parsed != null) parseRailRtviCandidate(parsed, 'rtvi-ai', 0)
} catch (_) {}
}
}
function clearRailTranscript() {
if (!railVoiceTranscriptEl) return
railVoiceTranscriptEl.innerHTML = ''
lastRailUserInterimEl = null
lastRailBotInterimEl = null
railBotLlmBuffer = ''
railBotTtsBuffer = ''
railBotTranscriptionText = ''
railBotResponseBuffer = ''
railPendingBotFinalText = ''
if (railVoiceOverlayEl) railVoiceOverlayEl.classList.remove('has-transcript')
}
function updateDockVisibility() {
if (!dockEl) return
var panelOpen = panel.style.display === 'flex' && container.classList.contains('spark-acw-panel-visible')
var hideDock = panelOpen || railVoiceOverlayActive
if (hideDock) {
dockEl.classList.add('acw-dock-hidden')
dockEl.setAttribute('aria-hidden', 'true')
} else {
dockEl.classList.remove('acw-dock-hidden')
dockEl.setAttribute('aria-hidden', 'false')
}
}
function showRailVoiceOverlay() {
if (!useRail || !railVoiceOverlayEl) {
showVoicePanelInternal()
return
}
railVoiceOverlayActive = true
container.classList.add('spark-acw-rail-voice-active')
panel.style.display = 'none'
container.classList.remove('spark-acw-panel-visible')
panel.classList.remove('is-open')
railVoiceOverlayEl.classList.add('visible')
railVoiceOverlayEl.classList.remove('is-ended')
railVoiceOverlayEl.setAttribute('aria-hidden', 'false')
clearRailTranscript()
setVoiceMicConnected(false)
setRailVoicePhase('tap')
if (railVoiceMicBtn) {
railVoiceMicBtn.classList.remove('recording', 'connecting')
railVoiceMicBtn.disabled = false
railVoiceMicBtn.setAttribute('aria-pressed', 'false')
}
voiceMicIdle = true
updateDockVisibility()
updateTextChatHeaderActions()
}
function hideRailVoiceOverlay() {
if (!railVoiceOverlayEl) return
railVoiceOverlayActive = false
container.classList.remove('spark-acw-rail-voice-active')
railVoiceOverlayEl.classList.remove('visible', 'is-speaking', 'has-transcript', 'is-connected', 'is-ended')
railVoiceOverlayEl.setAttribute('aria-hidden', 'true')
if (railVoiceMicBtn) {
railVoiceMicBtn.classList.remove('recording', 'connecting')
railVoiceMicBtn.disabled = false
}
updateDockVisibility()
}
function getActiveVoiceMicBtn() {
return useRail && railVoiceOverlayActive && railVoiceMicBtn ? railVoiceMicBtn : voiceMicBtn
}
function setVoiceUiStatus(text) {
if (voiceStatusText) voiceStatusText.textContent = text
}
function setRailVoiceHint(text) {
if (railVoiceHintEl) railVoiceHintEl.textContent = text || ''
}
var railVoicePhase = 'idle'
function setRailVoicePhase(phase) {
railVoicePhase = phase
if (!useRail || !railVoiceOverlayActive) return
if (phase === 'listening') setRailVoiceHint(t('voice_listening'))
else if (phase === 'thinking') setRailVoiceHint(t('voice_thinking'))
else if (phase === 'speaking') setRailVoiceHint(t('voice_speaking'))
else if (phase === 'connecting') setRailVoiceHint(t('voice_connecting'))
else setRailVoiceHint(t('voice_tap_mic'))
}
function setVoiceMicRecording(isRecording) {
var mic = getActiveVoiceMicBtn()
if (mic) {
if (isRecording) mic.classList.add('recording')
else mic.classList.remove('recording')
}
if (voiceMicBtn && mic !== voiceMicBtn) {
if (isRecording) voiceMicBtn.classList.add('recording')
else voiceMicBtn.classList.remove('recording')
}
}
function setVoiceMicConnected(isConnected) {
if (!railVoiceOverlayEl) return
if (isConnected) {
railVoiceOverlayEl.classList.add('is-connected')
if (railVoiceMicBtn) railVoiceMicBtn.setAttribute('aria-pressed', 'true')
setRailVoicePhase('listening')
} else {
railVoiceOverlayEl.classList.remove('is-connected', 'is-speaking')
if (railVoiceMicBtn) railVoiceMicBtn.setAttribute('aria-pressed', 'false')
setRailVoicePhase('tap')
}
if (railVoiceStatusEl) {
var statusLabel = isConnected ? t('voice_status_connected') : t('voice_status_disconnected')
var statusIcon = isConnected
? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>'
: ''
railVoiceStatusEl.innerHTML = statusIcon
railVoiceStatusEl.setAttribute('aria-label', statusLabel)
if (railVoiceStatusTipEl && railVoiceStatusTipEl.parentNode) {
railVoiceStatusTipEl.parentNode.removeChild(railVoiceStatusTipEl)
}
if (isConnected) {
railVoiceStatusTipEl = document.createElement('span')
railVoiceStatusTipEl.className = 'acw-rail-voice-status-tip'
railVoiceStatusTipEl.textContent = statusLabel
railVoiceStatusEl.appendChild(railVoiceStatusTipEl)
} else {
railVoiceStatusTipEl = null
}
}
}
/** End of a rail voice call: keep transcript + close button visible instead of tearing down. */
function endRailVoiceSession() {
if (!useRail || !railVoiceOverlayEl) return false
var hasTranscript = railVoiceTranscriptEl && railVoiceTranscriptEl.querySelector('.acw-rail-tbubble')
setVoiceMicConnected(false)
setVoiceMicRecording(false)
setVoiceMicConnecting(false)
if (!hasTranscript) {
hideRailVoiceOverlay()
clearRailTranscript()
setRailActive(null)
return true
}
railVoiceOverlayEl.classList.add('is-ended')
return true
}
function markVoiceSessionConnected() {
setVoiceMicConnecting(false)
if (useRail && railVoiceOverlayActive) {
setVoiceMicConnected(true)
} else {
setVoiceMicRecording(true)
}
}
function markVoiceSessionDisconnected() {
setVoiceMicConnected(false)
setVoiceMicRecording(false)
setVoiceMicConnecting(false)
}
function setVoiceMicConnecting(isConnecting) {
var mic = getActiveVoiceMicBtn()
if (mic) {
if (isConnecting) mic.classList.add('connecting')
else mic.classList.remove('connecting')
}
}
let voicePeerConnection = null
let voiceStream = null
let voiceRemoteAudio = null
let voiceServerPcId = null
let voiceDailyCall = null
let voiceDailySessionId = null
let voiceMicIdle = true
let voiceVizCtx = null
let voiceVizRaf = null
let voiceAnalyserLocal = null
let voiceAnalyserRemote = null
var SILENT_WAV = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA'
function ensureVoiceRemoteAudio() {
if (voiceRemoteAudio) return voiceRemoteAudio
voiceRemoteAudio = document.createElement('audio')
voiceRemoteAudio.setAttribute('playsinline', '')
voiceRemoteAudio.setAttribute('aria-hidden', 'true')
voiceRemoteAudio.autoplay = true
voiceRemoteAudio.playsInline = true
voiceRemoteAudio.preload = 'auto'
voiceRemoteAudio.muted = false
voiceRemoteAudio.volume = 1
voiceRemoteAudio.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;overflow:hidden'
// Rail mode hides .acw-panel. A media element inside display:none does not play, so keep remote audio on document.body.
document.body.appendChild(voiceRemoteAudio)
return voiceRemoteAudio
}
function unlockVoiceRemoteAudio() {
var el = ensureVoiceRemoteAudio()
try {
if (!el.srcObject) el.src = SILENT_WAV
var p = el.play()
if (p && p.catch) p.catch(function () {})
} catch (_) {}
try {
var ctx = ensureVoiceAudioContext()
if (ctx && ctx.resume) ctx.resume()
} catch (_) {}
}
function playVoiceRemoteAudio() {
if (!voiceRemoteAudio) return
var p = voiceRemoteAudio.play()
if (p && p.catch) {
p.catch(function (err) {
console.warn('[AgentChatWidget] Remote audio play failed', err)
})
}
}
function attachRemoteAudioTrack(track) {
if (!track) return
var el = ensureVoiceRemoteAudio()
try { el.removeAttribute('src') } catch (_) {}
var stream = new MediaStream([track])
el.srcObject = stream
el.muted = false
el.volume = 1
playVoiceRemoteAudio()
if (!voiceAnalyserRemote) {
attachVoiceAnalyserForStream(stream, true)
if (!voiceVizRaf) startVoiceVisualizerLoop()
}
}
function stopVoiceVisualizer() {
if (voiceVizRaf) {
cancelAnimationFrame(voiceVizRaf)
voiceVizRaf = null
}
voiceAnalyserLocal = null
voiceAnalyserRemote = null
if (voiceVizCtx) {
try { voiceVizCtx.close() } catch (_) {}
voiceVizCtx = null
}
var bars = voiceVisualizerEl.querySelectorAll('.acw-voice-bar')
for (var bi = 0; bi < bars.length; bi++) {
bars[bi].style.height = '24px'
}
}
function ensureVoiceAudioContext() {
var Ctx = window.AudioContext || window.webkitAudioContext
if (!Ctx) return null
if (!voiceVizCtx || voiceVizCtx.state === 'closed') {
voiceVizCtx = new Ctx()
}
if (voiceVizCtx.state === 'suspended') {
voiceVizCtx.resume().catch(function () {})
}
return voiceVizCtx
}
function attachVoiceAnalyserForStream(stream, isRemote) {
if (!stream) return
var ctx = ensureVoiceAudioContext()
if (!ctx) return
try {
var src = ctx.createMediaStreamSource(stream)
var analyser = ctx.createAnalyser()
analyser.fftSize = 256
analyser.smoothingTimeConstant = 0.55
src.connect(analyser)
if (isRemote) voiceAnalyserRemote = analyser
else voiceAnalyserLocal = analyser
} catch (e) {
console.warn('[AgentChatWidget] Voice visualizer analyser failed', e)
}
}
function voiceAnalyserRms(analyser) {
if (!analyser) return 0
var buf = new Uint8Array(analyser.fftSize)
analyser.getByteTimeDomainData(buf)
var sum = 0
for (var i = 0; i < buf.length; i++) {
var c = (buf[i] - 128) / 128
sum += c * c
}
return Math.sqrt(sum / buf.length)
}
function startVoiceVisualizerLoop() {
if (voiceVizRaf) {
cancelAnimationFrame(voiceVizRaf)
voiceVizRaf = null
}
var bars = voiceVisualizerEl.querySelectorAll('.acw-voice-bar')
var n = bars.length
var minH = 24
var maxH = 72
var threshold = 0.035
function tick() {
var useRailOverlay = useRail && railVoiceOverlayActive
if (!voicePanelEl.classList.contains('visible') && !useRailOverlay) {
voiceVizRaf = null
return
}
var localL = voiceAnalyserRms(voiceAnalyserLocal)
var remoteL = voiceAnalyserRms(voiceAnalyserRemote)
var active = Math.max(localL, remoteL) > threshold
if (useRailOverlay && railVoiceOverlayEl) {
var voiceConnected = railVoiceOverlayEl.classList.contains('is-connected')
var speaking = voiceConnected && active
railVoiceOverlayEl.classList.toggle('is-speaking', speaking)
if (voiceConnected) {
if (speaking) setRailVoicePhase('speaking')
else if (railBotLlmBuffer || railBotTtsBuffer || lastRailBotInterimEl) setRailVoicePhase('thinking')
else setRailVoicePhase('listening')
}
}
var i
var buf
if (active) {
if (voiceAnalyserRemote && voiceAnalyserLocal) {
var len = voiceAnalyserRemote.frequencyBinCount
buf = new Uint8Array(len)
var bufL = new Uint8Array(len)
voiceAnalyserRemote.getByteFrequencyData(buf)
voiceAnalyserLocal.getByteFrequencyData(bufL)
for (i = 0; i < len; i++) buf[i] = Math.max(buf[i], bufL[i])
} else if (voiceAnalyserRemote) {
buf = new Uint8Array(voiceAnalyserRemote.frequencyBinCount)
voiceAnalyserRemote.getByteFrequencyData(buf)
} else if (voiceAnalyserLocal) {
buf = new Uint8Array(voiceAnalyserLocal.frequencyBinCount)
voiceAnalyserLocal.getByteFrequencyData(buf)
} else {
buf = new Uint8Array(32)
}
for (i = 0; i < n; i++) {
var idx = Math.min(buf.length - 1, Math.floor(((i + 0.5) / n) * buf.length))
var v = buf[idx] / 255
var h = minH + v * (maxH - minH)
bars[i].style.height = Math.min(maxH, h) + 'px'
}
if (useRailOverlay && railEqBars.length) {
var eqMin = 4
var eqMax = 14
for (i = 0; i < railEqBars.length; i++) {
var eqIdx = Math.min(buf.length - 1, Math.floor(((i + 0.5) / railEqBars.length) * buf.length))
var eqV = buf[eqIdx] / 255
railEqBars[i].style.height = Math.round(eqMin + eqV * (eqMax - eqMin)) + 'px'
}
}
} else {
for (i = 0; i < n; i++) {
bars[i].style.height = minH + 'px'
}
if (useRailOverlay && railEqBars.length) {
for (i = 0; i < railEqBars.length; i++) {
railEqBars[i].style.height = '4px'
}
}
}
voiceVizRaf = requestAnimationFrame(tick)
}
voiceVizRaf = requestAnimationFrame(tick)
}
function disconnectVoice() {
stopVoiceVisualizer()
if (railVoiceOverlayEl) railVoiceOverlayEl.classList.remove('is-speaking')
var sid = voiceDailySessionId
var dailyCleanupBase = voiceHttpBase
voiceDailySessionId = null
if (sid && dailyCleanupBase && agentId) {
fetch(dailyCleanupBase + '/api/v1/agent-voice/agents/' + agentId + '/daily/' + sid, { method: 'DELETE' }).catch(function (err) {
console.warn('[AgentChatWidget] Daily session cleanup DELETE failed', err)
})
}
if (voiceDailyCall && !voiceDailyCall.isDestroyed()) {
var call = voiceDailyCall
voiceDailyCall = null
try { call.stopRemoteParticipantsAudioLevelObserver() } catch (_) {}
call.leave().then(function () { call.destroy() }).catch(function () {})
}
if (voicePeerConnection) {
try { voicePeerConnection.close() } catch (_) {}
voicePeerConnection = null
}
voiceServerPcId = null
if (voiceStream) {
voiceStream.getTracks().forEach(function (t) { t.stop() })
voiceStream = null
}
if (voiceRemoteAudio) {
voiceRemoteAudio.pause()
try { voiceRemoteAudio.srcObject = null } catch (_) {}
try { voiceRemoteAudio.removeAttribute('src') } catch (_) {}
}
setVoiceUiStatus(t('voice_tap_mic'))
markVoiceSessionDisconnected()
voiceEndBtn.style.display = 'none'
voiceMicIdle = true
}
panel.appendChild(header)
panel.appendChild(statusEl)
panel.appendChild(connectionBannerEl)
const continuityToastEl = document.createElement('div')
continuityToastEl.className = 'acw-continuity-toast'
continuityToastEl.setAttribute('role', 'status')
continuityToastEl.setAttribute('aria-live', 'polite')
panel.appendChild(continuityToastEl)
panel.appendChild(startCtaEl)
panel.appendChild(voicePanelEl)
panel.appendChild(messages)
var composerEl = document.createElement('div')
composerEl.className = 'acw-composer'
var suggestedQuestionsEl = document.createElement('div')
suggestedQuestionsEl.className = 'acw-suggested-questions'
suggestedQuestionsEl.style.display = 'none'
composerEl.appendChild(suggestedQuestionsEl)
composerEl.appendChild(inputWrap)
composerEl.appendChild(footerEl)
if (customerForm) panel.appendChild(customerForm)
panel.appendChild(composerEl)
shadow.appendChild(panel)
if (railVoiceOverlayEl) shadow.appendChild(railVoiceOverlayEl)
if (launcherWrap) {
launcherWrap.appendChild(launcher)
shadow.appendChild(launcherWrap)
} else {
shadow.appendChild(launcher)
}
function setPanelOpenState(isOpen) {
if (isOpen) {
container.classList.add('spark-acw-panel-visible')
panel.classList.add('is-open')
if (!useInline) elevateWidget(container)
fitPanelToViewport()
} else {
container.classList.remove('spark-acw-panel-visible')
panel.classList.remove('is-open')
if (useRail && !railVoiceOverlayActive) setRailActive(null)
}
updateDockVisibility()
}
function openPanelUI() {
panel.style.display = 'flex'
setPanelOpenState(true)
clearIdleDisconnectTimer()
enablePanelFocusTrap()
var resumingTextChat =
conversationStarted &&
pendingMode !== 'voice' &&
!railVoiceOverlayActive &&
!(captureCustomerInfo && !customerInfoSubmitted)
if (!connected && !connecting && conversationStarted && messageLog.length > 0) {
setConnectionState('reconnecting')
connect()
} else if ((connected || connecting) && resumingTextChat) {
showChat()
updateConnectionBanner()
} else {
updateConnectionBanner()
}
syncPanelLayout()
}
// Initial state: show start CTA, hide messages and input until user starts conversation
messages.style.display = 'none'
inputWrap.style.display = 'none'
/** Panel never shrinks below this, even if site chrome leaves less room. */
var ACW_PANEL_MIN_HEIGHT = 380
var panelExpanded = false
var viewportFitTimer = null
var viewportFitListenersAttached = false
/**
* Opt-in only. The widget is a floating overlay and by default paints above site
* chrome, so it must not resize itself while a sticky header scrolls in and out.
* Set `viewportTopOffset` to a number (or "auto") to reserve room for a fixed header.
*/
var viewportClampMode =
viewportTopOffset === 'auto'
? 'auto'
: !isNaN(parseInt(viewportTopOffset, 10))
? 'fixed'
: 'off'
function updateExpandButton() {
if (!expandBtn) return
var label = panelExpanded ? t('collapse_window') : t('expand_window')
expandBtn.setAttribute('aria-label', label)
expandBtn.title = label
expandBtn.setAttribute('aria-pressed', panelExpanded ? 'true' : 'false')
expandBtn.innerHTML = panelExpanded ? ACW_ICON_COLLAPSE_WINDOW : ACW_ICON_EXPAND_WINDOW
}
function setPanelExpanded(expanded) {
if (useInline) return
panelExpanded = !!expanded
panel.classList.toggle('is-expanded', panelExpanded)
container.classList.toggle('spark-acw-expanded', panelExpanded)
updateExpandButton()
if (viewportClampMode === 'off') {
container.style.setProperty(
'--acw-panel-height',
panelExpanded
? 'min(max(780px, 85vh), calc(100vh - 24px))'
: 'min(max(650px, 60vh), calc(100vh - 24px))',
)
} else {
fitPanelToViewport()
}
}
/**
* Clamp the panel height to the space left by site chrome. Height only — the panel
* keeps its single-edge CSS anchoring, because both `top` and `bottom` would resolve
* against the small launcher anchor container rather than the viewport.
*/
function fitPanelToViewport() {
if (useInline || viewportClampMode === 'off') return
var vh = window.innerHeight || document.documentElement.clientHeight || 0
if (!vh) return
var raw =
viewportClampMode === 'auto'
? measureViewportTopObstruction()
: Math.max(0, parseInt(viewportTopOffset, 10))
var topOffset = Math.min(raw, Math.floor(vh * 0.5))
container.style.setProperty('--acw-viewport-top-offset', topOffset + 'px')
var available
if (finalPosition === 'top-right' || finalPosition === 'top-left') {
available = vh - Math.max(88, topOffset) - 16
} else {
var anchorBottom = container.getBoundingClientRect().bottom
if (!(anchorBottom > 0)) anchorBottom = vh - 24
available = anchorBottom - topOffset
}
var preferred = panelExpanded
? Math.max(780, Math.floor(vh * 0.85))
: Math.max(650, Math.floor(vh * 0.6))
var height = Math.max(ACW_PANEL_MIN_HEIGHT, Math.min(preferred, Math.floor(available)))
container.style.setProperty('--acw-panel-height', height + 'px')
}
function scheduleViewportFit() {
if (useInline || viewportClampMode === 'off') return
if (viewportFitTimer) clearTimeout(viewportFitTimer)
viewportFitTimer = setTimeout(function () {
viewportFitTimer = null
fitPanelToViewport()
}, 120)
}
function teardownViewportFit() {
if (viewportFitListenersAttached) {
window.removeEventListener('resize', scheduleViewportFit)
viewportFitListenersAttached = false
}
if (viewportFitTimer) {
clearTimeout(viewportFitTimer)
viewportFitTimer = null
}
}
if (!useInline && viewportClampMode !== 'off') {
fitPanelToViewport()
// Resize only. Listening to scroll would resize the panel as sticky chrome moves.
window.addEventListener('resize', scheduleViewportFit)
viewportFitListenersAttached = true
}
function syncPanelLayout() {
header.style.display = 'flex'
}
function showStartCta() {
if (useRail) {
hideRailVoiceOverlay()
clearRailTranscript()
voicePanelEl.classList.remove('visible')
messages.style.display = 'none'
inputWrap.style.display = 'none'
footerEl.style.display = 'none'
suggestedQuestionsEl.style.display = 'none'
if (customerForm) customerForm.style.display = 'none'
setRailActive(null)
clearSuggestedActions()
updateTextChatHeaderActions()
softClosePanel()
return
}
panel.setAttribute('data-acw-view', 'home')
startCtaEl.style.display = 'flex'
voicePanelEl.classList.remove('visible')
messages.style.display = 'none'
clearSuggestedActions()
inputWrap.style.display = 'none'
footerEl.style.display = 'none'
if (customerForm) customerForm.style.display = 'none'
updateTextChatHeaderActions()
syncPanelLayout()
}
function showFormOnly() {
panel.setAttribute('data-acw-view', 'form')
startCtaEl.style.display = 'none'
voicePanelEl.classList.remove('visible')
messages.style.display = 'none'
inputWrap.style.display = 'none'
footerEl.style.display = 'none'
clearSuggestedActions()
if (customerForm) {
const backBtn = customerForm.querySelector('.acw-form-back')
if (backBtn) backBtn.style.display = formShownBeforeConnect ? 'flex' : 'none'
customerForm.style.display = 'flex'
}
updateTextChatHeaderActions()
syncPanelLayout()
}
function showChat() {
panel.setAttribute('data-acw-view', 'chat')
startCtaEl.style.display = 'none'
voicePanelEl.classList.remove('visible')
if (customerForm) customerForm.style.display = 'none'
messages.style.display = 'flex'
inputWrap.style.display = 'block'
footerEl.style.display = 'block'
updateTextChatHeaderActions()
renderSuggestedActions()
updateConnectionBanner()
syncPanelLayout()
}
function showVoicePanelInternal() {
panel.setAttribute('data-acw-view', 'voice')
startCtaEl.style.display = 'none'
if (customerForm) customerForm.style.display = 'none'
messages.style.display = 'none'
inputWrap.style.display = 'none'
footerEl.style.display = 'none'
clearSuggestedActions()
voicePanelEl.classList.add('visible')
setVoiceUiStatus(t('voice_tap_mic'))
setVoiceMicRecording(false)
voiceEndBtn.style.display = 'flex'
voiceMicIdle = true
updateTextChatHeaderActions()
syncPanelLayout()
}
function showVoicePanel() {
if (useRail) {
showRailVoiceOverlay()
return
}
showVoicePanelInternal()
}
function updateTextChatHeaderActions() {
var inTextChat = messages.style.display === 'flex' && !voicePanelEl.classList.contains('visible')
var inVoice = voicePanelEl.classList.contains('visible') || (useRail && railVoiceOverlayActive)
var showDisconnect = inTextChat || inVoice
disconnectChatBtn.style.display = showDisconnect ? 'inline-flex' : 'none'
if (inVoice) {
disconnectChatBtn.innerHTML = ACW_ICON_END_CALL
disconnectChatBtn.setAttribute('aria-label', t('end_call'))
disconnectChatBtn.title = t('end_call')
} else {
disconnectChatBtn.innerHTML = ACW_ICON_END_CHAT
disconnectChatBtn.setAttribute('aria-label', t('end_chat'))
disconnectChatBtn.title = t('end_chat')
}
}
function returnToChannelPicker() {
hideRailVoiceOverlay()
disconnectVoice()
voicePanelEl.classList.remove('visible')
pendingMode = null
showStartCta()
}
function endActiveVoiceSession() {
disconnectVoice()
if (useRail && railVoiceOverlayActive) {
endRailVoiceSession()
} else {
voicePanelEl.classList.remove('visible')
showStartCta()
}
updateTextChatHeaderActions()
}
function isSuggestedActionUrlAllowed(url, kind) {
if (!url) return false
try {
var u = new URL(url, window.location.href)
if (u.protocol !== 'https:' && u.protocol !== 'http:') return false
if (kind === 'navigation') return navigationUrlAllowed(url, allowedDomains)
if (kind === 'open_url') return true
} catch (_) {}
return false
}
function normalizeSuggestedActions(raw) {
var out = []
if (!Array.isArray(raw)) return out
for (var i = 0; i < raw.length; i++) {
var item = raw[i]
if (!item || typeof item !== 'object') continue
var kind = normalizeSuggestedActionKind(item.kind)
var label = String(item.label || item.text || '').trim()
if (!label) continue
if (kind === 'navigation' || kind === 'open_url') {
var actionUrl = String(item.url || '').trim()
if (!actionUrl || !isSuggestedActionUrlAllowed(actionUrl, kind)) continue
}
if (kind === 'tool') continue
out.push({
id: String(item.id || kind + '_' + i),
kind: kind,
label: label,
message: String(item.message || label).trim(),
url: item.url ? String(item.url).trim() : null,
anchor: item.anchor ? String(item.anchor).trim() : null,
label_page: item.label_page ? String(item.label_page).trim() : null,
prompt_text: item.prompt_text ? String(item.prompt_text).trim() : null,
yes_label: item.yes_label ? String(item.yes_label).trim() : null,
no_label: item.no_label ? String(item.no_label).trim() : null,
open_in_new_tab: item.open_in_new_tab !== false,
icon: item.icon ? String(item.icon).trim() : null,
priority: typeof item.priority === 'number' ? item.priority : i,
})
if (out.length >= 3) break
}
out.sort(function (a, b) {
return (a.priority || 0) - (b.priority || 0)
})
return out.slice(0, 3)
}
function hasNavigationChipForUrl(url) {
if (!url) return false
var target = normalizeActionNavUrl(url)
for (var i = 0; i < activeSuggestedActions.length; i++) {
var action = activeSuggestedActions[i]
if (action.kind === 'navigation' && normalizeActionNavUrl(action.url) === target) return true
}
return false
}
function clearSuggestedActions() {
activeSuggestedActions = []
suggestedQuestionsEl.classList.remove('is-visible')
suggestedQuestionsEl.innerHTML = ''
}
function applySuggestedActions(raw, replace) {
if (replace === false && activeSuggestedActions.length) return
var normalized = normalizeSuggestedActions(raw)
if (!normalized.length && replace !== false) {
clearSuggestedActions()
activeSuggestedActions = []
syncPanelLayout()
return
}
if (!normalized.length) return
activeSuggestedActions = normalized
renderSuggestedActions()
}
function canShowSuggestedActions() {
return (
panel.getAttribute('data-acw-view') === 'chat' &&
messages.style.display === 'flex' &&
inputWrap.style.display !== 'none' &&
!(customerForm && customerForm.style.display !== 'none' && !customerInfoSubmitted)
)
}
function renderSuggestedActions() {
suggestedQuestionsEl.innerHTML = ''
if (!canShowSuggestedActions() || !activeSuggestedActions.length) {
suggestedQuestionsEl.classList.remove('is-visible')
syncPanelLayout()
return
}
suggestedQuestionsEl.classList.add('is-visible')
activeSuggestedActions.forEach(function (action) {
var btn = document.createElement('button')
btn.type = 'button'
btn.className = 'acw-suggested-question acw-action-' + action.kind.replace(/_/g, '-')
btn.textContent = action.label
btn.setAttribute('data-acw-action-id', action.id)
btn.setAttribute('data-acw-action-kind', action.kind)
btn.onclick = function () {
btn.classList.add('is-sending')
setTimeout(function () {
btn.classList.remove('is-sending')
handleSuggestedActionTap(action)
}, 180)
}
suggestedQuestionsEl.appendChild(btn)
})
syncPanelLayout()
}
function fetchSuggestedQuestionsFromApi(done) {
var base = apiBaseUrl ? String(apiBaseUrl).replace(/\/$/, '') : ''
if (!base || !agentId || !embedKey) {
if (done) done(activeSuggestedActions)
return
}
fetch(
base +
'/api/v1/ai-agents/' +
encodeURIComponent(agentId) +
'/embed-widget-config?embed_key=' +
encodeURIComponent(embedKey),
)
.then(function (res) {
return res.ok ? res.json() : null
})
.then(function (data) {
if (data && Array.isArray(data.suggested_questions) && data.suggested_questions.length) {
activeSuggestedActions = questionsListToSuggestedActions(data.suggested_questions)
}
if (done) done(activeSuggestedActions)
})
.catch(function () {
if (done) done(activeSuggestedActions)
})
}
function resolveAndRenderSuggestedActions(wsQuestions, wsActions) {
if (wsActions && normalizeSuggestedActions(wsActions).length) {
applySuggestedActions(wsActions, true)
return
}
if (wsQuestions && normalizeSuggestedQuestionsList(wsQuestions).length) {
activeSuggestedActions = questionsListToSuggestedActions(wsQuestions)
renderSuggestedActions()
return
}
if (activeSuggestedActions.length) {
renderSuggestedActions()
return
}
fetchSuggestedQuestionsFromApi(function () {
renderSuggestedActions()
})
}
function dispatchUserMessage(text) {
var trimmed = String(text || '').trim()
if (!trimmed) return false
if (captureCustomerInfo && !customerInfoSubmitted) return false
if (!ws || ws.readyState !== WebSocket.OPEN) {
if (connecting) {
appendMessage(t('connecting_wait'), 'bot')
} else if (messageLog.length > 0 || conversationStarted) {
setConnectionState('reconnecting')
connect()
} else {
appendMessage(t('choose_text_chat_home'), 'bot')
}
return false
}
appendMessage(trimmed, 'user')
var payload = {
type: 'user_message',
text: trimmed,
role: 'user',
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name,
}
if (sessionId) payload.session_id = sessionId
ws.send(JSON.stringify(payload))
showTypingIndicator()
return true
}
function handleSuggestedActionTap(action) {
if (!action) return
var kind = action.kind || 'question'
clearSuggestedActions()
if (kind === 'question') {
dispatchUserMessage(action.message || action.label)
return
}
if (kind === 'navigation' && enableGuidedNavigation) {
if (!isSuggestedActionUrlAllowed(action.url, 'navigation')) {
appendMessage(t('navigation_blocked'), 'bot')
return
}
appendNavigationOfferButtons({
offerId: action.id,
url: action.url,
anchor: action.anchor,
label: action.label_page || action.label,
yesLabel: action.yes_label,
noLabel: action.no_label,
})
return
}
if (kind === 'open_url') {
if (!isSuggestedActionUrlAllowed(action.url, 'open_url')) return
var chipMessage = String(action.message || '').trim()
if (chipMessage) dispatchUserMessage(chipMessage)
try {
if (action.open_in_new_tab !== false) {
openNavigationInNewTab(action.url)
} else {
window.location.assign(action.url)
}
} catch (_) {}
return
}
dispatchUserMessage(action.message || action.label)
}
function sendSuggestedQuestion(text) {
clearSuggestedActions()
dispatchUserMessage(text)
}
function setLauncherOpen(open) {
if (!launcher.classList.contains('acw-launcher-fab')) return
if (open) launcher.classList.add('is-open')
else launcher.classList.remove('is-open')
}
function updateUnreadBadge() {
unreadBadge.textContent = String(unreadCount)
if (unreadCount > 0) {
unreadBadge.classList.remove('hidden')
} else {
unreadBadge.classList.add('hidden')
}
}
var typingIndicatorEl = null
/** After human handover the customer waits for a person, not the AI — no typing spinner. */
var humanHandoverActive = false
var panelFocusTrapActive = false
var lastFocusBeforePanel = null
var pendingReconnectFlash = false
var continuityToastTimer = null
function getPanelFocusables() {
return Array.prototype.slice.call(
panel.querySelectorAll('button:not([disabled]), textarea:not([disabled]), input:not([disabled])')
).filter(function (el) {
return el.offsetParent !== null || el === input
})
}
function onPanelFocusTrapKey(e) {
if (e.key !== 'Tab') return
var focusables = getPanelFocusables()
if (!focusables.length) return
var first = focusables[0]
var last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function enablePanelFocusTrap() {
if (panelFocusTrapActive || useInline) return
panelFocusTrapActive = true
lastFocusBeforePanel = document.activeElement
panel.addEventListener('keydown', onPanelFocusTrapKey)
var focusables = getPanelFocusables()
if (focusables.length) {
setTimeout(function () { focusables[0].focus() }, 50)
}
}
function disablePanelFocusTrap() {
if (!panelFocusTrapActive) return
panelFocusTrapActive = false
panel.removeEventListener('keydown', onPanelFocusTrapKey)
if (lastFocusBeforePanel && typeof lastFocusBeforePanel.focus === 'function') {
try { lastFocusBeforePanel.focus() } catch (_) {}
}
lastFocusBeforePanel = null
}
function formatPageLabel() {
var title = String(document.title || '').trim()
if (title) {
var shortTitle = title.split('|')[0].split('–')[0].split('-')[0].trim()
if (shortTitle && shortTitle.length <= 48) return shortTitle
}
var path = window.location.pathname || ''
if (!path || path === '/') return 'this page'
var segment = path.split('/').filter(Boolean).pop() || 'page'
return segment.replace(/[-_]+/g, ' ')
}
function showContinuityToast(text) {
if (!continuityToastEl || !text) return
continuityToastEl.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5L20 7"/></svg>' +
'<span>' + text + '</span>'
continuityToastEl.classList.add('visible')
if (continuityToastTimer) clearTimeout(continuityToastTimer)
continuityToastTimer = setTimeout(function () {
continuityToastEl.classList.remove('visible')
continuityToastTimer = null
}, 4200)
}
function stripBotMessageActions() {
var existing = messages.querySelectorAll('.acw-msg-actions')
for (var ai = 0; ai < existing.length; ai++) {
var actionEl = existing[ai]
var wrap = actionEl.closest('.acw-msg-wrap')
if (wrap && wrap.querySelector('.acw-msg-user')) continue
if (actionEl.parentNode) actionEl.parentNode.removeChild(actionEl)
}
}
function stripUserMessageActions() {
var userMsgs = messages.querySelectorAll('.acw-msg-user')
for (var ui = 0; ui < userMsgs.length; ui++) {
var parent = userMsgs[ui].parentElement
if (!parent) continue
var siblingActions = parent.querySelectorAll(':scope > .acw-msg-actions')
for (var si = 0; si < siblingActions.length; si++) {
if (siblingActions[si].parentNode) siblingActions[si].parentNode.removeChild(siblingActions[si])
}
}
}
function submitMessageFeedback(messageId, rating, messageText, messageType) {
if (!messageId || !rating) return
var feedback = { rating: rating, at: Date.now() }
for (var fi = messageLog.length - 1; fi >= 0; fi--) {
if (messageLog[fi].messageId === messageId) {
messageLog[fi].feedback = feedback
break
}
}
schedulePersistSession()
var pageCtx = acwPageContext()
acwDispatchMessageFeedbackEvent({
message_id: messageId,
rating: rating,
message_type: messageType || 'text',
session_id: sessionId || null,
agent_id: agentId || null,
page_url: pageCtx.page_url || null,
page_path: pageCtx.page_path || null,
})
if (!ws || ws.readyState !== WebSocket.OPEN || !sessionId) return
try {
ws.send(
JSON.stringify({
type: 'message_feedback',
session_id: sessionId,
message_id: messageId,
rating: rating,
message_text: String(messageText || '').slice(0, 2000),
message_type: messageType || 'text',
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name,
page_url: pageCtx.page_url || null,
page_path: pageCtx.page_path || null,
feedback_at: new Date(feedback.at).toISOString(),
}),
)
} catch (err) {
console.warn('[AgentChatWidget] message_feedback send failed', err)
}
}
function buildBotMessageActions(text, messageId, existingFeedback) {
var actions = document.createElement('div')
actions.className = 'acw-msg-actions'
var copyBtn = document.createElement('button')
copyBtn.type = 'button'
copyBtn.className = 'acw-msg-action-btn'
copyBtn.textContent = t('copy_message')
copyBtn.onclick = function () {
var toCopy = String(text || '')
if (!toCopy) return
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(toCopy).then(function () {
copyBtn.textContent = t('copied_message')
setTimeout(function () { copyBtn.textContent = t('copy_message') }, 1500)
}).catch(function () {})
}
}
var upBtn = document.createElement('button')
upBtn.type = 'button'
upBtn.className = 'acw-msg-action-btn is-icon'
upBtn.setAttribute('aria-label', t('feedback_helpful'))
upBtn.innerHTML = ACW_ICON_THUMBS_UP
var downBtn = document.createElement('button')
downBtn.type = 'button'
downBtn.className = 'acw-msg-action-btn is-icon'
downBtn.setAttribute('aria-label', t('feedback_not_helpful'))
downBtn.innerHTML = ACW_ICON_THUMBS_DOWN
function applyFeedbackUi(rating) {
upBtn.disabled = true
downBtn.disabled = true
upBtn.classList.toggle('is-active', rating === 'helpful')
downBtn.classList.toggle('is-active', rating === 'not_helpful')
}
upBtn.onclick = function () {
if (upBtn.disabled) return
applyFeedbackUi('helpful')
submitMessageFeedback(messageId, 'helpful', text, 'text')
}
downBtn.onclick = function () {
if (downBtn.disabled) return
applyFeedbackUi('not_helpful')
submitMessageFeedback(messageId, 'not_helpful', text, 'text')
}
if (existingFeedback && existingFeedback.rating) {
applyFeedbackUi(existingFeedback.rating)
}
actions.appendChild(copyBtn)
var feedbackGroup = document.createElement('div')
feedbackGroup.className = 'acw-msg-feedback'
feedbackGroup.appendChild(upBtn)
feedbackGroup.appendChild(downBtn)
actions.appendChild(feedbackGroup)
return actions
}
function attachActionsToLastBotMessage() {
stripUserMessageActions()
stripBotMessageActions()
var botMsgs = messages.querySelectorAll('.acw-msg-bot:not(.acw-msg-typing)')
if (!botMsgs.length) return
var lastMsg = botMsgs[botMsgs.length - 1]
if (!lastMsg.classList.contains('acw-msg-bot') || lastMsg.classList.contains('acw-msg-user')) return
var wrap = lastMsg.parentElement
if (!wrap || !wrap.classList.contains('acw-msg-wrap')) {
wrap = document.createElement('div')
wrap.className = 'acw-msg-wrap'
lastMsg.parentNode.insertBefore(wrap, lastMsg)
wrap.appendChild(lastMsg)
}
if (wrap.querySelector('.acw-msg-user')) return
var msgText = lastMsg.textContent || ''
if (!msgText.trim()) return
var messageId = lastMsg.getAttribute('data-acw-message-id') || acwGenerateMessageId()
lastMsg.setAttribute('data-acw-message-id', messageId)
var existingFeedback = null
for (var mi = messageLog.length - 1; mi >= 0; mi--) {
if (messageLog[mi].messageId === messageId) {
existingFeedback = messageLog[mi].feedback || null
break
}
}
wrap.appendChild(buildBotMessageActions(msgText, messageId, existingFeedback))
}
function removeTypingIndicator() {
if (typingIndicatorEl && typingIndicatorEl.parentNode) {
typingIndicatorEl.parentNode.removeChild(typingIndicatorEl)
}
typingIndicatorEl = null
}
function scrollMessagesToBottom() {
requestAnimationFrame(function () {
messages.scrollTop = messages.scrollHeight
})
}
function clearMessagesDom() {
var node = messages.lastChild
while (node) {
var prev = node.previousSibling
if (node !== messagesSpacer) {
try { messages.removeChild(node) } catch (_) {}
}
node = prev
}
}
function showTypingIndicator() {
if (humanHandoverActive) return
removeTypingIndicator()
var wrap = document.createElement('div')
wrap.className = 'acw-msg acw-msg-bot acw-msg-typing'
wrap.setAttribute('aria-label', 'Assistant is typing')
wrap.setAttribute('role', 'status')
for (var ti = 0; ti < 3; ti++) {
wrap.appendChild(document.createElement('span'))
}
messages.appendChild(wrap)
typingIndicatorEl = wrap
scrollMessagesToBottom()
}
function schedulePersistSession() {
if (restoringSession || !enableGuidedNavigation) return
if (persistSessionTimer) clearTimeout(persistSessionTimer)
persistSessionTimer = setTimeout(function () {
var pageCtx = acwPageContext()
persistSessionSnapshot(agentId, {
sessionId: sessionId,
panelOpen: panel.style.display === 'flex',
messages: messageLog.slice(),
savedAt: Date.now(),
pagePath: pageCtx.page_path || null,
})
}, 300)
}
function collectProductCatalogDomains(sourceItems) {
var hosts = []
if (!Array.isArray(sourceItems)) return hosts
function addHost(rawUrl) {
try {
var host = new URL(String(rawUrl || '').trim(), window.location.href).hostname.toLowerCase()
if (!host) return
if (hosts.indexOf(host) === -1) hosts.push(host)
var apex = stripWwwHost(host)
if (apex && apex !== host && hosts.indexOf(apex) === -1) hosts.push(apex)
} catch (_) {}
}
for (var i = 0; i < sourceItems.length; i++) {
var item = sourceItems[i]
if (!item || typeof item !== 'object') continue
addHost(item.pdp_url || item.url)
if (Array.isArray(item.variants)) {
for (var vi = 0; vi < item.variants.length; vi++) {
var variant = item.variants[vi]
if (variant && typeof variant === 'object') addHost(variant.pdp_url || variant.url)
}
}
}
return hosts
}
function productUrlAllowed(url) {
var domains = allowedDomains
if (productCarouselExtraDomains && productCarouselExtraDomains.length) {
domains = allowedDomains.concat(productCarouselExtraDomains)
}
return navigationUrlAllowed(url, domains)
}
function sanitizeProductImageUrl(raw) {
var imageUrl = String(raw || '').trim()
if (!imageUrl) return ''
try {
var imageParsed = new URL(imageUrl, window.location.href)
if (imageParsed.protocol !== 'https:' && imageParsed.protocol !== 'http:') return ''
return imageUrl
} catch (_) {
return ''
}
}
function formatPriceDisplay(rawPrice, currency) {
if (rawPrice == null || rawPrice === '') return ''
if (typeof rawPrice === 'object') {
if (rawPrice.label) return String(rawPrice.label).trim()
if (rawPrice.amount != null && rawPrice.amount !== '') {
return formatProductPrice({
amount: rawPrice.amount,
currency: rawPrice.currency || currency || 'USD',
})
}
return ''
}
var amount = parseFloat(String(rawPrice).replace(/[^0-9.-]/g, ''))
if (isNaN(amount)) return String(rawPrice).trim()
return formatProductPrice({ amount: amount, currency: currency || 'USD' })
}
function normalizeProductPdpUrl(rawUrl) {
return String(rawUrl || '').trim()
}
function normalizeProductCurrency(rawCurrency, fallback) {
var value = String(rawCurrency || fallback || 'USD').trim().toUpperCase()
return value || 'USD'
}
function normalizeProductVariants(rawVariants, productFallback) {
var out = []
if (!Array.isArray(rawVariants)) return out
var fallbackUrl = productFallback && productFallback.pdp_url
var fallbackImage = productFallback && productFallback.image_url
var fallbackCurrency = productFallback && productFallback.currency
for (var vi = 0; vi < rawVariants.length; vi++) {
var variant = rawVariants[vi]
if (!variant || typeof variant !== 'object') continue
var variantId = String(variant.id || '').trim()
if (!variantId) continue
var pdpUrl = normalizeProductPdpUrl(variant.pdp_url || variant.url || fallbackUrl)
if (!pdpUrl || !productUrlAllowed(pdpUrl)) continue
var variantCurrency = normalizeProductCurrency(
variant.currency ||
(variant.price && typeof variant.price === 'object' && variant.price.currency),
fallbackCurrency,
)
var imageUrl = sanitizeProductImageUrl(
variant.image_url ||
(variant.images && variant.images[0] && variant.images[0].url) ||
fallbackImage,
)
out.push({
id: variantId,
title: String(variant.title || '').trim() || 'Default',
image_url: imageUrl || PRODUCT_CAROUSEL_PLACEHOLDER_IMAGE,
pdp_url: pdpUrl,
currency: variantCurrency,
price: formatPriceDisplay(variant.price, variantCurrency),
})
}
return out
}
function normalizeProductCarouselItem(item, index) {
if (!item || typeof item !== 'object') return null
var productId = String(item.id || '').trim()
if (!productId) return null
var title = String(item.title || '').trim()
if (!title) return null
var currency = normalizeProductCurrency(
item.currency || (item.price && typeof item.price === 'object' && item.price.currency),
'USD',
)
var imageUrl = sanitizeProductImageUrl(
item.image_url || (item.images && item.images[0] && item.images[0].url),
)
var productSeed = {
pdp_url: normalizeProductPdpUrl(item.pdp_url || item.url),
image_url: imageUrl || PRODUCT_CAROUSEL_PLACEHOLDER_IMAGE,
currency: currency,
}
var variants = normalizeProductVariants(item.variants, productSeed)
var pdpUrl = productSeed.pdp_url || (variants[0] && variants[0].pdp_url) || ''
if (!pdpUrl || !productUrlAllowed(pdpUrl)) return null
return {
id: productId,
title: truncateProductTitle(title),
image_url: productSeed.image_url,
pdp_url: pdpUrl,
currency: currency,
price: formatPriceDisplay(item.price, currency) || (variants[0] && variants[0].price) || '',
variants: variants,
cta_label: String(item.cta_label || t('product_view')).trim() || t('product_view'),
}
}
function normalizeProductCarousel(raw) {
if (!raw || typeof raw !== 'object') return null
var sourceItems = raw.items || raw.results
if (!Array.isArray(sourceItems)) return null
productCarouselExtraDomains = collectProductCatalogDomains(sourceItems)
var items = []
try {
for (var i = 0; i < sourceItems.length && items.length < PRODUCT_CAROUSEL_MAX_ITEMS; i++) {
var normalizedItem = normalizeProductCarouselItem(sourceItems[i], i)
if (normalizedItem) items.push(normalizedItem)
}
} finally {
productCarouselExtraDomains = null
}
if (!items.length) return null
var showMore = null
if (raw.show_more && typeof raw.show_more === 'object') {
var showLabel = String(raw.show_more.label || '').trim()
var showMessage = String(raw.show_more.message || '').trim()
if (showLabel && showMessage) showMore = { label: showLabel, message: showMessage }
}
return {
id: String(raw.id || 'product_carousel_' + Date.now()),
source: raw.source ? String(raw.source) : null,
total: typeof raw.total === 'number' ? raw.total : items.length,
items: items,
show_more: showMore,
}
}
function handleProductPdpTap(product, pdpUrl, variantId) {
if (!pdpUrl || !productUrlAllowed(pdpUrl)) {
appendMessage(t('navigation_blocked'), 'bot')
return
}
acwDispatchProductCarouselEvent('product_card_click', {
carousel_id: product._carouselId || null,
product_id: product.id,
variant_id: variantId || null,
url: pdpUrl,
source: product._carouselSource || null,
})
performNavigation({ url: pdpUrl, label: product.title })
}
function buildProductCardPriceEl(priceText) {
var row = document.createElement('div')
row.className = 'acw-product-card-price-row'
if (!priceText) return row
var priceEl = document.createElement('span')
priceEl.className = 'acw-product-card-price'
priceEl.textContent = priceText
row.appendChild(priceEl)
return row
}
function buildVariantRow(product, variant) {
var row = document.createElement('div')
row.className = 'acw-product-variant-row'
var img = document.createElement('img')
img.className = 'acw-product-variant-row-img'
img.loading = 'lazy'
img.alt = variant.title || product.title
img.src = variant.image_url || PRODUCT_CAROUSEL_PLACEHOLDER_IMAGE
row.appendChild(img)
var body = document.createElement('div')
body.className = 'acw-product-variant-row-body'
var titleEl = document.createElement('p')
titleEl.className = 'acw-product-variant-row-title'
titleEl.textContent = variant.title
body.appendChild(titleEl)
if (variant.price) {
var priceEl = document.createElement('p')
priceEl.className = 'acw-product-variant-row-price'
priceEl.textContent = variant.price
body.appendChild(priceEl)
}
row.appendChild(body)
var cta = document.createElement('button')
cta.type = 'button'
cta.className = 'acw-product-variant-row-cta'
cta.textContent = product.cta_label || t('product_view')
cta.onclick = function () {
handleProductPdpTap(product, variant.pdp_url, variant.id)
}
row.appendChild(cta)
return row
}
function renderProductVariantsPanel(panel, product) {
panel.innerHTML = ''
if (!product || !product.variants || product.variants.length <= 1) {
panel.hidden = true
return
}
panel.hidden = false
var heading = document.createElement('p')
heading.className = 'acw-product-variants-heading'
heading.textContent = product.title
panel.appendChild(heading)
product.variants.forEach(function (variant) {
panel.appendChild(buildVariantRow(product, variant))
})
}
function buildProductCard(item, toggleVariantsPanel) {
var card = document.createElement('article')
card.className = 'acw-product-card'
card.setAttribute('data-acw-product-id', item.id)
var imgWrap = document.createElement('div')
imgWrap.className = 'acw-product-card-img-wrap'
var img = document.createElement('img')
img.className = 'acw-product-card-img'
img.loading = 'lazy'
img.alt = item.title || t('product_no_image')
img.src = item.image_url || PRODUCT_CAROUSEL_PLACEHOLDER_IMAGE
imgWrap.appendChild(img)
card.appendChild(imgWrap)
var body = document.createElement('div')
body.className = 'acw-product-card-body'
var titleEl = document.createElement('h4')
titleEl.className = 'acw-product-card-title'
titleEl.textContent = item.title
body.appendChild(titleEl)
body.appendChild(buildProductCardPriceEl(item.price))
if (item.variants && item.variants.length > 1 && toggleVariantsPanel) {
var variantsBtn = document.createElement('button')
variantsBtn.type = 'button'
variantsBtn.className = 'acw-product-variants-btn'
variantsBtn.textContent = t('product_variants').replace('{count}', String(item.variants.length))
variantsBtn.onclick = function () {
toggleVariantsPanel(item, variantsBtn)
}
body.appendChild(variantsBtn)
}
var cta = document.createElement('button')
cta.type = 'button'
cta.className = 'acw-product-card-cta'
cta.textContent = item.cta_label || t('product_view')
cta.onclick = function () {
handleProductPdpTap(item, item.pdp_url, null)
}
body.appendChild(cta)
card.appendChild(body)
return card
}
function cloneProductCarouselForPersist(carousel) {
if (!carousel || typeof carousel !== 'object') return null
return {
id: carousel.id,
source: carousel.source,
total: carousel.total,
show_more: carousel.show_more
? { label: carousel.show_more.label, message: carousel.show_more.message }
: null,
items: (carousel.items || []).map(function (item) {
return {
id: item.id,
title: item.title,
image_url: item.image_url,
pdp_url: item.pdp_url,
currency: item.currency,
price: item.price,
cta_label: item.cta_label,
variants: (item.variants || []).map(function (variant) {
return {
id: variant.id,
title: variant.title,
image_url: variant.image_url,
pdp_url: variant.pdp_url,
currency: variant.currency,
price: variant.price,
}
}),
}
}),
}
}
function renderProductCarouselMessage(introText, carousel) {
if (!carousel || !Array.isArray(carousel.items) || !carousel.items.length) return false
removeTypingIndicator()
stripBotMessageActions()
var carouselHost = messages
var scrollCarouselIntoView = scrollMessagesToBottom
if (useRail && railVoiceOverlayActive && railVoiceTranscriptEl) {
carouselHost = railVoiceTranscriptEl
scrollCarouselIntoView = function () {
scrollRailTranscriptToBottom()
updateRailTranscriptFade()
}
if (railVoiceOverlayEl) railVoiceOverlayEl.classList.add('has-transcript')
}
var wrap = document.createElement('div')
wrap.className = 'acw-msg-wrap'
var intro = String(introText || '').trim()
if (intro) {
if (useRail && railVoiceOverlayActive && railVoiceTranscriptEl) {
appendRailTranscriptBubble(intro, 'bot')
} else {
var introMsg = document.createElement('div')
introMsg.className = 'acw-msg acw-msg-bot'
introMsg.appendChild(document.createTextNode(intro))
wrap.appendChild(introMsg)
}
}
var carouselWrap = document.createElement('div')
carouselWrap.className = 'acw-product-carousel-wrap'
var track = document.createElement('div')
track.className = 'acw-product-carousel'
track.setAttribute('role', 'region')
track.setAttribute('aria-label', t('product_carousel_aria'))
var variantsPanel = document.createElement('div')
variantsPanel.className = 'acw-product-variants-panel'
variantsPanel.hidden = true
var activeVariantsProductId = null
var activeVariantsBtn = null
function toggleVariantsPanel(product, btn) {
if (activeVariantsProductId === product.id && !variantsPanel.hidden) {
variantsPanel.hidden = true
activeVariantsProductId = null
if (activeVariantsBtn) activeVariantsBtn.classList.remove('is-active')
activeVariantsBtn = null
syncPanelLayout()
return
}
if (activeVariantsBtn) activeVariantsBtn.classList.remove('is-active')
activeVariantsProductId = product.id
activeVariantsBtn = btn
btn.classList.add('is-active')
renderProductVariantsPanel(variantsPanel, product)
acwDispatchProductCarouselEvent('product_variants_expand', {
carousel_id: carousel.id,
product_id: product.id,
variant_count: product.variants.length,
source: carousel.source,
})
scrollCarouselIntoView()
syncPanelLayout()
}
carousel.items.forEach(function (item) {
item._carouselId = carousel.id
item._carouselSource = carousel.source
track.appendChild(buildProductCard(item, toggleVariantsPanel))
})
carouselWrap.appendChild(track)
carouselWrap.appendChild(variantsPanel)
if (carousel.show_more && carousel.show_more.message) {
var showMoreBtn = document.createElement('button')
showMoreBtn.type = 'button'
showMoreBtn.className = 'acw-product-show-more'
showMoreBtn.textContent = carousel.show_more.label + ' ›'
showMoreBtn.onclick = function () {
acwDispatchProductCarouselEvent('product_show_more_click', {
carousel_id: carousel.id,
source: carousel.source,
total: carousel.total,
})
dispatchUserMessage(carousel.show_more.message)
}
carouselWrap.appendChild(showMoreBtn)
}
wrap.appendChild(carouselWrap)
carouselHost.appendChild(wrap)
scrollCarouselIntoView()
if (!restoringSession && panel.style.display !== 'flex') {
unreadCount++
updateUnreadBadge()
}
if (!restoringSession) {
acwDispatchProductCarouselEvent('product_carousel_impression', {
carousel_id: carousel.id,
source: carousel.source,
item_count: carousel.items.length,
total: carousel.total,
})
}
syncPanelLayout()
return true
}
function appendProductCarousel(introText, rawCarousel) {
var carousel = normalizeProductCarousel(rawCarousel)
if (!carousel) {
if (rawCarousel && (rawCarousel.items || rawCarousel.results)) {
console.warn(
'[AgentChatWidget] product_carousel dropped: no valid items after normalization (check id, title, pdp_url, allowed_domains)',
rawCarousel,
)
}
return false
}
var rendered = renderProductCarouselMessage(introText, carousel)
if (rendered && !restoringSession) {
messageLog.push({
role: 'bot',
type: 'product_carousel',
introText: String(introText || '').trim(),
carousel: cloneProductCarouselForPersist(carousel),
})
schedulePersistSession()
}
return rendered
}
function appendHandoverAck(content) {
humanHandoverActive = true
removeTypingIndicator()
var label = String(content || t('handover_ack_default')).trim() || t('handover_ack_default')
var existingAcks = messages.querySelectorAll('.acw-handover-ack')
if (existingAcks.length) {
var lastAck = existingAcks[existingAcks.length - 1]
if (lastAck.getAttribute('data-acw-handover-label') === label) return
}
stripBotMessageActions()
var el = document.createElement('div')
el.className = 'acw-handover-ack'
el.setAttribute('role', 'status')
el.setAttribute('aria-label', label)
el.setAttribute('title', label)
el.setAttribute('data-acw-handover-label', label)
var lineLeft = document.createElement('span')
lineLeft.className = 'acw-handover-ack-line'
lineLeft.setAttribute('aria-hidden', 'true')
var iconWrap = document.createElement('span')
iconWrap.className = 'acw-handover-ack-icon'
iconWrap.setAttribute('aria-hidden', 'true')
iconWrap.innerHTML = ACW_ICON_HANDOVER_ACK
var lineRight = document.createElement('span')
lineRight.className = 'acw-handover-ack-line'
lineRight.setAttribute('aria-hidden', 'true')
el.appendChild(lineLeft)
el.appendChild(iconWrap)
el.appendChild(lineRight)
messages.appendChild(el)
scrollMessagesToBottom()
if (!restoringSession) {
messageLog.push({ role: 'system', type: 'handover_ack', text: label })
schedulePersistSession()
}
syncPanelLayout()
}
function restoreMessageFromLog(entry) {
if (entry && entry.type === 'handover_ack') {
appendHandoverAck(entry.text || t('handover_ack_default'))
return
}
if (entry && entry.type === 'product_carousel' && entry.carousel) {
renderProductCarouselMessage(entry.introText || '', entry.carousel)
return
}
var opts = null
if (entry && entry.messageId) {
opts = { messageId: entry.messageId, feedback: entry.feedback || null }
}
appendMessage(entry.text, normalizeChatRole(entry.role), null, null, opts)
}
function appendMessage(text, role, imageUrl, imageUrls, opts) {
role = normalizeChatRole(role)
if (role === 'bot') removeTypingIndicator()
const msg = document.createElement('div')
msg.className = 'acw-msg ' + (role === 'user' ? 'acw-msg-user' : 'acw-msg-bot')
if (text) {
const textNode = document.createTextNode(text)
msg.appendChild(textNode)
}
var urls = Array.isArray(imageUrls) && imageUrls.length ? imageUrls : (imageUrl ? [imageUrl] : [])
for (var ui = 0; ui < urls.length; ui++) {
const img = document.createElement('img')
img.className = 'acw-msg-img'
img.src = urls[ui]
img.alt = ''
msg.appendChild(img)
}
var messageId = null
if (role === 'bot') {
messageId =
(opts && opts.messageId) ||
(opts && opts.message_id) ||
acwGenerateMessageId()
msg.setAttribute('data-acw-message-id', messageId)
}
if (role === 'bot') {
stripBotMessageActions()
var botWrap = document.createElement('div')
botWrap.className = 'acw-msg-wrap'
botWrap.appendChild(msg)
if (!restoringSession && text) {
botWrap.appendChild(
buildBotMessageActions(
text,
messageId,
opts && opts.feedback ? opts.feedback : null,
),
)
}
messages.appendChild(botWrap)
} else {
messages.appendChild(msg)
stripUserMessageActions()
}
scrollMessagesToBottom()
if (role === 'bot' && panel.style.display !== 'flex') {
unreadCount++
updateUnreadBadge()
}
if (!restoringSession && text) {
if (role === 'bot') {
messageLog.push({
role: 'bot',
text: text,
messageId: messageId,
messageType: 'text',
feedback: opts && opts.feedback ? opts.feedback : null,
})
} else {
messageLog.push({ role: 'user', text: text })
}
schedulePersistSession()
}
syncPanelLayout()
}
function sendNavigationReply(confirmed, offer) {
clearSuggestedActions()
if (!ws || ws.readyState !== WebSocket.OPEN) return
var label = confirmed ? t('nav_offer_yes') : t('nav_offer_no')
appendMessage(label, 'user')
var isCrossDomainExternal =
confirmed && offer && offer.url && isCrossDomainNavigationUrl(offer.url)
try {
var confirmPayload = {
type: 'navigation_confirm',
confirmed: confirmed,
offer_id: offer && offer.offerId,
url: offer && offer.url,
anchor: offer && offer.anchor,
session_id: sessionId,
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name,
}
if (isCrossDomainExternal) {
confirmPayload.open_in_new_tab = true
confirmPayload.stays_on_current_page = true
}
ws.send(JSON.stringify(confirmPayload))
ws.send(JSON.stringify({
type: 'user_message',
text: label,
role: 'user',
session_id: sessionId,
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name,
}))
} catch (e) {
console.error('[AgentChatWidget] navigation confirm error', e)
}
showTypingIndicator()
schedulePersistSession()
if (confirmed && offer && offer.url) {
if (isCrossDomainExternal) scheduleKeepAliveOnCurrentPageAfterExternalNav()
performNavigation({ url: offer.url, anchor: offer.anchor, label: offer.label })
}
}
function appendNavigationOfferButtons(offer) {
pendingNavigationOffer = offer
var wrap = document.createElement('div')
wrap.className = 'acw-msg acw-msg-bot'
var yesBtn = document.createElement('button')
yesBtn.type = 'button'
yesBtn.className = 'acw-nav-offer-btn primary'
yesBtn.textContent = (offer && offer.yesLabel) || t('nav_offer_yes')
var noBtn = document.createElement('button')
noBtn.type = 'button'
noBtn.className = 'acw-nav-offer-btn'
noBtn.textContent = (offer && offer.noLabel) || t('nav_offer_no')
var row = document.createElement('div')
row.className = 'acw-nav-offer'
row.appendChild(yesBtn)
row.appendChild(noBtn)
wrap.appendChild(row)
messages.appendChild(wrap)
scrollMessagesToBottom()
function disableButtons() {
yesBtn.disabled = true
noBtn.disabled = true
}
yesBtn.onclick = function () {
disableButtons()
sendNavigationReply(true, offer)
}
noBtn.onclick = function () {
disableButtons()
sendNavigationReply(false, offer)
}
}
function spaRegionSelectors() {
var list = spaContentSelector ? [spaContentSelector] : []
if (spaSyncSelectors && spaSyncSelectors.length) {
spaSyncSelectors.forEach(function (sel) {
if (sel && list.indexOf(sel) === -1) list.push(sel)
})
}
return list
}
function scrollToPageAnchor(anchor) {
if (!anchor) return false
var el = document.getElementById(anchor)
if (!el) return false
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
return true
}
function fetchAndSwapSpaPage(parsedUrl) {
var fetchUrl = parsedUrl.href.split('#')[0]
return fetch(fetchUrl, {
credentials: 'same-origin',
headers: { Accept: 'text/html' },
})
.then(function (res) {
if (!res.ok) return null
return res.text()
})
.then(function (html) {
if (!html) return false
var doc = parseHtmlDocument(html)
if (!swapDocumentRegions(doc, spaRegionSelectors())) return false
if (doc.title) document.title = doc.title
return true
})
.catch(function () {
return false
})
}
function performSpaNavigation(parsedUrl, anchor, label, historyMode) {
prepareNavigationHandshake(parsedUrl, anchor, null)
return fetchAndSwapSpaPage(parsedUrl).then(function (ok) {
if (!ok) return false
var historyUrl = parsedUrl.pathname + parsedUrl.search + (anchor ? '#' + anchor : '')
var state = { acwSpa: true, url: parsedUrl.href.split('#')[0] }
if (historyMode === 'replace') {
history.replaceState(state, document.title, historyUrl)
} else {
history.pushState(state, document.title, historyUrl)
}
window.scrollTo(0, 0)
scrollToPageAnchor(anchor)
appendMessage(label ? t('navigation_navigated') + ' (' + label + ')' : t('navigation_navigated'), 'bot')
return true
})
}
function persistSessionForNavigation(nextPageUrl, nextPagePath) {
if (!enableGuidedNavigation) return
if (persistSessionTimer) {
clearTimeout(persistSessionTimer)
persistSessionTimer = null
}
persistSessionSnapshot(agentId, {
sessionId: sessionId,
panelOpen: panel.style.display === 'flex',
messages: messageLog.slice(),
savedAt: Date.now(),
navigationPending: true,
nextPageUrl: nextPageUrl || null,
nextPagePath: nextPagePath || null,
})
}
function sendSessionPauseForNavigation(parsedUrl, anchor, opts) {
if (!enableGuidedNavigation || !sessionId) return
if (!ws || ws.readyState !== WebSocket.OPEN) return
var routePath = acwRoutePath(parsedUrl, anchor)
var pageCtx = acwPageContext()
try {
var payload = {
type: 'session_pause',
session_id: sessionId,
reason: opts && opts.scrolledOnly ? 'anchor_scroll' : 'navigation',
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name,
next_page_url: parsedUrl.origin + routePath,
next_page_path: routePath,
}
if (pageCtx.page_url) payload.page_url = pageCtx.page_url
if (pageCtx.page_path) payload.page_path = pageCtx.page_path
ws.send(JSON.stringify(payload))
} catch (err) {
console.warn('[AgentChatWidget] session_pause send failed', err)
}
}
/** Persist sessionStorage + tell talk2ai to defer session close before route change. */
function prepareNavigationHandshake(parsedUrl, anchor, opts) {
if (!enableGuidedNavigation) return
var routePath = acwRoutePath(parsedUrl, anchor)
var nextUrl = parsedUrl.origin + routePath
persistSessionForNavigation(nextUrl, routePath)
if (!(opts && opts.scrolledOnly)) {
sendSessionPauseForNavigation(parsedUrl, anchor, opts)
}
}
function normalizeNavDedupeUrl(url) {
try {
return new URL(url, window.location.href).href.replace(/#.*$/, '')
} catch (_) {
return String(url || '').replace(/#.*$/, '')
}
}
function wasExternalNavigationRecently(url) {
var key = normalizeNavDedupeUrl(url)
return !!key && key === lastExternalNavUrl && Date.now() - lastExternalNavAt < 5000
}
function markExternalNavigationHandled(url) {
lastExternalNavUrl = normalizeNavDedupeUrl(url)
lastExternalNavAt = Date.now()
scheduleKeepAliveOnCurrentPageAfterExternalNav()
}
/** Cross-domain guided navigation: new tab only — never change the current page. */
function navigateCrossDomainExternal(target, label) {
if (wasExternalNavigationRecently(target)) return true
if (openNavigationInNewTab(target)) {
markExternalNavigationHandled(target)
appendMessage(
label ? t('navigation_opened_new_tab') + ' (' + label + ')' : t('navigation_opened_new_tab'),
'bot'
)
return true
}
appendMessage(t('navigation_popup_blocked'), 'bot')
return false
}
function assignFullPageNavigation(target, parsed, anchor) {
navigationCommitted = true
if (enableGuidedNavigation) {
if (parsed) {
prepareNavigationHandshake(parsed, anchor, null)
} else {
try {
prepareNavigationHandshake(new URL(target, window.location.href), anchor, null)
} catch (_) {
persistSessionForNavigation(target, null)
}
}
}
window.location.assign(target)
}
function performNavigation(opts) {
if (navigationCommitted) return
var url = opts && opts.url
var anchor = opts && opts.anchor
var label = opts && opts.label
if (!url || !navigationUrlAllowed(url, allowedDomains)) {
appendMessage(t('navigation_blocked'), 'bot')
return
}
var parsed
try {
parsed = new URL(url, window.location.href)
} catch (_) {
appendMessage(t('navigation_blocked'), 'bot')
return
}
var base = parsed.origin + parsed.pathname + parsed.search
var currentParsed = new URL(window.location.href)
var currentBase = currentParsed.origin + currentParsed.pathname + currentParsed.search
var onSamePage = currentBase === base
var target = anchor ? base + '#' + anchor : parsed.href
var crossDomain = !isSameNavigationDomain(parsed.hostname, currentParsed.hostname)
if (onSamePage && anchor) {
tryFrameworkNavigation(parsed, anchor, label, { scrolledOnly: true, scroll: false }).then(function (ok) {
if (ok) return
if (scrollToPageAnchor(anchor)) {
if (parsed.hash !== window.location.hash) {
history.pushState({ acwSpa: true, anchor: anchor }, document.title, base + '#' + anchor)
}
appendMessage(t('navigation_scrolled'), 'bot')
return
}
})
return
}
if (crossDomain) {
navigateCrossDomainExternal(target, label)
return
}
// Same-origin SPA: use framework router when detected (Next, Nuxt, Gatsby, host hook).
if (canUseFrameworkNavigation(parsed)) {
tryFrameworkNavigation(parsed, anchor, label).then(function (ok) {
if (!ok) performNavigationFallback(parsed, anchor, label, target, onSamePage)
})
return
}
performNavigationFallback(parsed, anchor, label, target, onSamePage)
}
function performNavigationFallback(parsed, anchor, label, target, onSamePage) {
var crossDomain = !isSameNavigationDomain(parsed.hostname, window.location.hostname)
if (crossDomain) {
navigateCrossDomainExternal(target, label)
return
}
if (enableSpaNavigation && parsed.origin === window.location.origin) {
navigationCommitted = true
performSpaNavigation(parsed, anchor, label, 'push').then(function (ok) {
navigationCommitted = false
if (!ok) assignFullPageNavigation(target, parsed, anchor)
})
return
}
assignFullPageNavigation(target, parsed, anchor)
}
if (enableSpaNavigation) {
if (spaClickHandlerRef) {
document.removeEventListener('click', spaClickHandlerRef, true)
}
spaClickHandlerRef = function (e) {
if (!spaInterceptLinks) return
var link = e.target && e.target.closest ? e.target.closest(spaInterceptLinks) : null
if (!link || link.target === '_blank' || link.hasAttribute('download')) return
var href = link.getAttribute('href')
if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return
var linkUrl
try {
linkUrl = new URL(href, window.location.href)
} catch (_) {
return
}
if (linkUrl.origin !== window.location.origin) return
if (!navigationUrlAllowed(linkUrl.href, allowedDomains)) return
e.preventDefault()
performNavigation({
url: linkUrl.href,
anchor: linkUrl.hash ? linkUrl.hash.slice(1) : null,
})
}
document.addEventListener('click', spaClickHandlerRef, true)
if (spaPopstateHandlerRef) {
window.removeEventListener('popstate', spaPopstateHandlerRef)
}
spaPopstateHandlerRef = function () {
if (canUseFrameworkNavigation(new URL(window.location.href))) return
var parsed = new URL(window.location.href)
var anchor = parsed.hash ? parsed.hash.slice(1) : null
fetchAndSwapSpaPage(parsed).then(function (ok) {
if (!ok) {
window.location.reload()
return
}
window.scrollTo(0, 0)
scrollToPageAnchor(anchor)
})
}
window.addEventListener('popstate', spaPopstateHandlerRef)
}
// No default greeting; only show messages after connection
let ws = null
let connected = false
let connecting = false
let idleDisconnectTimer = null
const IDLE_DISCONNECT_MS = 30000
function clearIdleDisconnectTimer() {
if (idleDisconnectTimer) {
clearTimeout(idleDisconnectTimer)
idleDisconnectTimer = null
}
}
/** Hide panel but keep text WebSocket when connected; disconnect after idle timeout. */
function softClosePanel() {
disablePanelFocusTrap()
if (useInline) {
disconnectVoice()
hideRailVoiceOverlay()
voicePanelEl.classList.remove('visible')
panel.style.display = 'flex'
setPanelOpenState(true)
showStartCta()
clearIdleDisconnectTimer()
var wsInline = ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
if (wsInline) {
idleDisconnectTimer = setTimeout(function () {
idleDisconnectTimer = null
disconnect()
}, IDLE_DISCONNECT_MS)
}
return
}
var wasVoice = voicePanelEl.classList.contains('visible') || railVoiceOverlayActive
hideRailVoiceOverlay()
clearRailTranscript()
panel.style.display = 'none'
setPanelOpenState(false)
setLauncherOpen(false)
disconnectVoice()
voicePanelEl.classList.remove('visible')
if (wasVoice) showStartCta()
clearIdleDisconnectTimer()
var wsActive = ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
if (wsActive) {
idleDisconnectTimer = setTimeout(function () {
idleDisconnectTimer = null
disconnect()
}, IDLE_DISCONNECT_MS)
}
}
function acwWebChannelMeta(kind) {
var host = (typeof window !== 'undefined' && window.location && window.location.hostname) ? window.location.hostname : ''
var source = host.indexOf('helllo.ai') !== -1 ? 'internal' : 'partner'
return { channel: kind, is_web: true, source: source, source_name: host }
}
var webChatMeta = acwWebChannelMeta('web_chat')
var webVoiceMeta = acwWebChannelMeta('web_voice')
/** Current page context so the agent knows where the visitor is (for navigation). */
function acwPageContext() {
try {
var loc = window.location
return {
page_url: loc.href,
page_path: loc.pathname + loc.search,
page_title: document.title || '',
}
} catch (_) {
return {}
}
}
function getTextChatWsUrl() {
if (wsUrl) {
try {
var abs = new URL(wsUrl, window.location.href)
if (conversationLanguageCode) abs.searchParams.set('language_code', conversationLanguageCode)
if (sessionId) abs.searchParams.set('session_id', sessionId)
return abs.toString()
} catch (_) {
return wsUrl
}
}
let wsBase
if (VERSION.includes('-staging')) {
wsBase = 'wss://talk2ai-staging-gcp.helllo.ai'
} else if (VERSION.includes('-prod')) {
wsBase = 'wss://talk2ai-prod-gcp.helllo.ai'
} else {
const baseCandidate = voiceHttpBase || apiBaseUrl || new URL(document.currentScript?.src || window.location.href).origin
const voiceBase = (baseCandidate || '').replace(/\/$/, '')
if (voiceBase.startsWith('http://')) {
wsBase = voiceBase.replace(/^http/, 'ws')
} else if (voiceBase.startsWith('https://')) {
wsBase = voiceBase.replace(/^https/, 'wss')
} else if (voiceBase.startsWith('ws://') || voiceBase.startsWith('wss://')) {
wsBase = voiceBase
} else {
wsBase = 'ws://' + voiceBase
}
}
const params = new URLSearchParams()
params.set('embed_key', embedKey)
params.set('host', window.location.hostname)
params.set('channel', webChatMeta.channel)
params.set('is_web', 'true')
params.set('source', webChatMeta.source)
params.set('source_name', webChatMeta.source_name)
var chatPageCtx = acwPageContext()
if (chatPageCtx.page_url) params.set('page_url', chatPageCtx.page_url)
if (chatPageCtx.page_path) params.set('page_path', chatPageCtx.page_path)
if (conversationLanguageCode) params.set('language_code', conversationLanguageCode)
if (sessionId) params.set('session_id', sessionId)
return `${wsBase}/api/v1/agent-voice/agents/${agentId}/text-chat?${params.toString()}`
}
try {
console.info('[AgentChatWidget] Resolved WS URL:', getTextChatWsUrl())
} catch (_) {}
function formatFileSize(bytes) {
if (bytes >= 1048576) return Math.round(bytes / 1048576) + ' MB'
if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB'
return bytes + ' B'
}
function createUploadId() {
try {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
} catch (_) {}
return 'upload-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
}
function applyFileUploadUi() {
var policy = widgetFileUploadPolicy
var on = !!(policy && policy.enabled)
composerBox.classList.toggle('acw-file-upload-on', on)
if (on && policy.allowed_mime_types && policy.allowed_mime_types.length) {
fileInput.accept = policy.allowed_mime_types.join(',')
} else {
fileInput.accept = 'image/*'
}
fileInput.multiple = !!(on && policy.max_files > 1)
updateComposerSendState()
}
function getReadyStagedAttachments() {
return stagedAttachments.filter(function (a) {
return a.status === 'ready' && a.attachmentId
})
}
function updateComposerSendState() {
if (input.disabled) return
var ready = getReadyStagedAttachments()
var hasText = !!input.value.trim()
var uploading = stagedAttachments.some(function (a) { return a.status === 'uploading' })
sendBtn.disabled = uploading || (!hasText && !ready.length)
if (sendBtn.disabled) sendBtn.classList.add('is-muted')
else sendBtn.classList.remove('is-muted')
}
function findStagedByUploadId(uploadId) {
for (var i = 0; i < stagedAttachments.length; i++) {
if (stagedAttachments[i].uploadId === uploadId) return stagedAttachments[i]
}
return null
}
function findStagedByAttachmentId(attachmentId) {
for (var j = 0; j < stagedAttachments.length; j++) {
if (stagedAttachments[j].attachmentId === attachmentId) return stagedAttachments[j]
}
return null
}
function renderAttachmentsStrip() {
if (!attachmentsStripEl) return
attachmentsStripEl.innerHTML = ''
if (!stagedAttachments.length) {
attachmentsStripEl.style.display = 'none'
return
}
attachmentsStripEl.style.display = 'flex'
stagedAttachments.forEach(function (item) {
var chip = document.createElement('div')
chip.className = 'acw-attachment-chip'
if (item.status === 'uploading') chip.classList.add('is-uploading')
if (item.status === 'error') chip.classList.add('is-error')
if (item.previewUrl && String(item.mimeType || '').indexOf('image/') === 0) {
var thumb = document.createElement('img')
thumb.className = 'acw-attachment-thumb'
thumb.src = item.previewUrl
thumb.alt = ''
chip.appendChild(thumb)
}
var nameEl = document.createElement('span')
nameEl.className = 'acw-attachment-name'
nameEl.textContent = item.status === 'uploading'
? (item.name + '…')
: (item.status === 'error' ? (item.errorMessage || item.name) : item.name)
chip.appendChild(nameEl)
if (item.status === 'ready' && item.attachmentId) {
var removeBtn = document.createElement('button')
removeBtn.type = 'button'
removeBtn.className = 'acw-attachment-remove'
removeBtn.setAttribute('aria-label', t('remove_attachment'))
removeBtn.textContent = '×'
removeBtn.addEventListener('click', function (id) {
return function () { removeStagedAttachment(id) }
}(item.attachmentId))
chip.appendChild(removeBtn)
} else if (item.status === 'error') {
var dismissBtn = document.createElement('button')
dismissBtn.type = 'button'
dismissBtn.className = 'acw-attachment-remove'
dismissBtn.setAttribute('aria-label', t('remove_attachment'))
dismissBtn.textContent = '×'
dismissBtn.addEventListener('click', function (uid) {
return function () { dismissFailedUpload(uid) }
}(item.uploadId))
chip.appendChild(dismissBtn)
}
attachmentsStripEl.appendChild(chip)
})
}
function revokeAttachmentPreview(item) {
if (!item || !item.previewUrl || String(item.previewUrl).indexOf('blob:') !== 0) return
try { URL.revokeObjectURL(item.previewUrl) } catch (_) {}
}
function clearStagedAttachments(notifyServer) {
if (notifyServer && ws && ws.readyState === WebSocket.OPEN) {
stagedAttachments.forEach(function (a) {
if (a.attachmentId) {
try {
ws.send(JSON.stringify({
type: 'attachment_remove',
session_id: sessionId,
attachment_id: a.attachmentId,
}))
} catch (_) {}
}
})
}
stagedAttachments.forEach(revokeAttachmentPreview)
stagedAttachments = []
renderAttachmentsStrip()
updateComposerSendState()
}
function dismissFailedUpload(uploadId) {
var item = findStagedByUploadId(uploadId)
if (item) revokeAttachmentPreview(item)
stagedAttachments = stagedAttachments.filter(function (a) { return a.uploadId !== uploadId })
renderAttachmentsStrip()
updateComposerSendState()
}
function removeStagedAttachment(attachmentId) {
var item = findStagedByAttachmentId(attachmentId)
if (!item) return
if (ws && ws.readyState === WebSocket.OPEN && attachmentId) {
try {
ws.send(JSON.stringify({
type: 'attachment_remove',
session_id: sessionId,
attachment_id: attachmentId,
}))
} catch (_) {}
}
revokeAttachmentPreview(item)
stagedAttachments = stagedAttachments.filter(function (a) {
return a.attachmentId !== attachmentId
})
renderAttachmentsStrip()
updateComposerSendState()
}
function validateFileForUpload(file) {
var policy = widgetFileUploadPolicy
if (!policy || !policy.enabled) return t('file_upload_disabled')
if (stagedAttachments.length >= policy.max_files) return t('max_files_reached')
var maxSize = policy.max_file_size_bytes || 1048576
if (file.size > maxSize) {
return t('file_too_large').replace('{size}', formatFileSize(maxSize))
}
var mime = file.type || 'application/octet-stream'
var allowed = policy.allowed_mime_types || []
if (allowed.length && allowed.indexOf(mime) < 0) return t('file_type_not_allowed')
return null
}
function markAttachmentUploadError(uploadId, message) {
var item = findStagedByUploadId(uploadId)
if (!item) {
if (message) appendMessage(message, 'bot')
return
}
item.status = 'error'
item.errorMessage = message || t('file_upload_failed')
renderAttachmentsStrip()
updateComposerSendState()
}
function handleAttachmentUploaded(data) {
var item = findStagedByUploadId(data.upload_id)
if (!item || !data.attachment) return
item.status = 'ready'
item.attachmentId = data.attachment.id
item.name = data.attachment.name || item.name
item.mimeType = data.attachment.mime_type || item.mimeType
if (data.attachment.url && String(item.mimeType || '').indexOf('image/') === 0) {
revokeAttachmentPreview(item)
item.previewUrl = data.attachment.url
}
renderAttachmentsStrip()
updateComposerSendState()
}
function readFileAsDataUrl(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader()
reader.onload = function () { resolve(String(reader.result || '')) }
reader.onerror = function () { reject(new Error('read')) }
reader.readAsDataURL(file)
})
}
function uploadFileAttachment(file) {
var err = validateFileForUpload(file)
if (err) {
appendMessage(err, 'bot')
return
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
appendMessage(t('placeholder_connect_first'), 'bot')
return
}
var uploadId = createUploadId()
var mime = file.type || 'application/octet-stream'
var previewUrl = null
if (mime.indexOf('image/') === 0) {
previewUrl = URL.createObjectURL(file)
}
stagedAttachments.push({
uploadId: uploadId,
attachmentId: null,
name: file.name || 'file',
mimeType: mime,
previewUrl: previewUrl,
status: 'uploading',
errorMessage: null,
})
renderAttachmentsStrip()
updateComposerSendState()
readFileAsDataUrl(file)
.then(function (dataUrl) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
markAttachmentUploadError(uploadId, t('file_upload_failed'))
return
}
ws.send(JSON.stringify({
type: 'attachment_upload',
session_id: sessionId,
upload_id: uploadId,
file: {
name: file.name || 'file',
mime_type: mime,
data_base64: dataUrl,
},
}))
})
.catch(function () {
markAttachmentUploadError(uploadId, t('image_read_error'))
})
}
function setInputEnabled(enabled) {
input.disabled = !enabled
attachBtn.disabled = !enabled
fileInput.disabled = !enabled
if (enabled) {
updateComposerSendState()
} else {
sendBtn.disabled = true
sendBtn.classList.add('is-muted')
}
input.placeholder = enabled ? t('placeholder_type_message') : t('placeholder_connect_first')
}
function autoResizeTextarea() {
input.style.minHeight = '0'
input.style.height = '0px'
var next = Math.min(Math.max(input.scrollHeight, 40), 100)
input.style.minHeight = ''
input.style.height = Math.round(next) + 'px'
}
function updateConnectionBanner() {
var inChat = messages.style.display === 'flex' && !voicePanelEl.classList.contains('visible')
var showBanner = inChat && messageLog.length > 0 &&
(connectionState === 'disconnected' || connectionState === 'reconnecting' ||
connectionState === 'connecting' || connectionState === 'ended')
if (!showBanner || connectionState === 'connected') {
connectionBannerEl.style.display = 'none'
connectionBannerEl.classList.remove('is-reconnecting')
return
}
connectionBannerEl.style.display = 'flex'
if (connectionState === 'ended') {
connectionBannerEl.classList.remove('is-reconnecting')
connectionBannerTextEl.textContent = t('conversation_ended')
connectionReconnectBtn.textContent = t('start_again')
connectionReconnectBtn.disabled = false
connectionReconnectBtn.style.display = 'inline-flex'
return
}
var isReconnecting = connectionState === 'reconnecting' || connectionState === 'connecting'
connectionBannerEl.classList.toggle('is-reconnecting', isReconnecting)
connectionBannerTextEl.textContent = isReconnecting ? t('connection_reconnecting') : t('connection_lost')
connectionReconnectBtn.textContent = t('reconnect')
connectionReconnectBtn.disabled = isReconnecting || connecting
connectionReconnectBtn.style.display = isReconnecting ? 'none' : 'inline-flex'
}
function setConnectionState(state) {
connectionState = state
var isConnected = state === 'connected'
// Always present as Online in the header — connection details live in the banner.
subtitleEl.textContent = t('status_connected')
subtitleEl.className = 'acw-header-subtitle is-connected'
headerOnlineDots.forEach(function (dot) {
dot.classList.remove('is-connecting', 'is-disconnected')
})
setInputEnabled(isConnected)
updateConnectionBanner()
}
function updateStatus(text, isConnected) {
setConnectionState(isConnected ? 'connected' : 'disconnected')
}
function showConnectingState() {
panel.setAttribute('data-acw-view', 'connecting')
startCtaEl.style.display = 'none'
voicePanelEl.classList.remove('visible')
if (customerForm) customerForm.style.display = 'none'
messages.style.display = 'none'
inputWrap.style.display = 'none'
footerEl.style.display = 'none'
clearSuggestedActions()
updateTextChatHeaderActions()
syncPanelLayout()
}
function restoreCustomerFormAfterConnectFailure() {
customerFormGatePassed = false
if (!captureCustomerInfo || !customerForm) return
formShownBeforeConnect = true
if (customerForm._acwClearErrors) customerForm._acwClearErrors()
showFormOnly()
}
function connect() {
if (connected || connecting) {
if (!(captureCustomerInfo && !customerInfoSubmitted)) {
showChat()
updateConnectionBanner()
}
return
}
if (captureCustomerInfo && !customerInfoSubmitted && !customerFormGatePassed) {
formShownBeforeConnect = true
showFormOnly()
return
}
clearIdleDisconnectTimer()
connecting = true
startCtaEl.style.display = 'none'
if (captureCustomerInfo && !customerInfoSubmitted) {
showConnectingState()
setConnectionState('connecting')
} else {
showChat()
setConnectionState(messageLog.length > 0 ? 'reconnecting' : 'connecting')
}
pendingReconnectFlash = messageLog.length > 0
const url = getTextChatWsUrl()
try {
ws = new WebSocket(url)
} catch (err) {
connecting = false
setConnectionState('disconnected')
removeTypingIndicator()
if (captureCustomerInfo && !customerInfoSubmitted) {
restoreCustomerFormAfterConnectFailure()
}
if (messageLog.length === 0) appendMessage(t('failed_connect'), 'bot')
console.error('[AgentChatWidget] WS error', err)
return
}
ws.onopen = () => {
connected = true
connecting = false
if (captureCustomerInfo && !customerInfoSubmitted) {
setConnectionState('connecting')
} else {
setConnectionState('connected')
clearExternalNavKeepAlive()
headerOnlineDots.forEach(function (dot) {
dot.classList.add('is-just-connected')
setTimeout(function () { dot.classList.remove('is-just-connected') }, 650)
})
if (pendingReconnectFlash) {
connectionBannerEl.classList.add('is-success')
connectionBannerTextEl.textContent = t('reconnected_success')
connectionBannerEl.style.display = 'flex'
connectionReconnectBtn.style.display = 'none'
setTimeout(function () {
connectionBannerEl.classList.remove('is-success')
updateConnectionBanner()
}, 2200)
}
}
pendingReconnectFlash = false
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
removeTypingIndicator()
// Handle connection_established message
if (data.type === 'connection_established') {
if (data.session_id) sessionId = data.session_id
if (data.file_upload) mergeFileUploadPolicy(data.file_upload)
clearExternalNavKeepAlive()
if (enableGuidedNavigation && !restoringSession) schedulePersistSession()
if (captureCustomerInfo && !customerInfoSubmitted && !customerFormGatePassed) {
try { ws.close() } catch (_) {}
connected = false
connecting = false
ws = null
restoreCustomerFormAfterConnectFailure()
setConnectionState('disconnected')
return
}
if (captureCustomerInfo && !customerInfoSubmitted) {
if (pendingCustomerName && pendingCustomerPhone && ws && ws.readyState === WebSocket.OPEN) {
try {
var pendingCust = {
type: 'customer_info',
session_id: sessionId,
phone_number: pendingCustomerPhone,
customer_name: pendingCustomerName,
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name
}
if (conversationLanguageCode) pendingCust.language_code = conversationLanguageCode
ws.send(JSON.stringify(pendingCust))
} catch (e) {
console.error('[AgentChatWidget] Error sending customer info:', e)
}
}
pendingCustomerName = null
pendingCustomerPhone = null
customerInfoSubmitted = true
customerFormGatePassed = false
if (data.welcome_message) appendMessage(data.welcome_message, 'bot')
resolveAndRenderSuggestedActions(data.suggested_questions, data.suggested_actions)
setConnectionState('connected')
headerOnlineDots.forEach(function (dot) {
dot.classList.add('is-just-connected')
setTimeout(function () { dot.classList.remove('is-just-connected') }, 650)
})
showChat()
return
}
if (data.welcome_message) appendMessage(data.welcome_message, 'bot')
resolveAndRenderSuggestedActions(data.suggested_questions, data.suggested_actions)
setConnectionState('connected')
showChat()
return
}
if (data.type === 'suggested_actions') {
applySuggestedActions(data.actions || data.suggested_actions, data.replace !== false)
return
}
if (data.type === 'product_carousel') {
appendProductCarousel(
data.intro_text || data.text || '',
data.carousel || data.product_carousel,
)
if (data.suggested_actions) applySuggestedActions(data.suggested_actions, true)
return
}
if (data.type === 'attachment_uploaded') {
handleAttachmentUploaded(data)
return
}
if (data.type === 'attachment_error') {
markAttachmentUploadError(data.upload_id, data.message || t('file_upload_failed'))
return
}
if (data.type === 'attachment_removed') {
if (data.attachment_id) {
var removed = findStagedByAttachmentId(data.attachment_id)
if (removed) revokeAttachmentPreview(removed)
stagedAttachments = stagedAttachments.filter(function (a) {
return a.attachmentId !== data.attachment_id
})
renderAttachmentsStrip()
updateComposerSendState()
}
return
}
if (data.type === 'handover') {
humanHandoverActive = true
removeTypingIndicator()
var handoverText = data.text || data.content || data.message || ''
if (handoverText) {
appendMessage(handoverText, 'bot', null, null, {
messageId: data.message_id || data.id || undefined,
})
}
return
}
if (data.type === 'handover_ack') {
appendHandoverAck(data.content || data.text || data.message || '')
return
}
if (data.type === 'handover_resolved' || data.type === 'handover_end') {
humanHandoverActive = false
removeTypingIndicator()
var resolvedText = data.text || data.content || data.message || ''
if (resolvedText) {
appendMessage(resolvedText, 'bot', null, null, {
messageId: data.message_id || data.id || undefined,
})
}
return
}
if (data.type === 'assistant_message') {
var assistantText = data.text || data.content || data.message || ''
var assistantMsgId = data.message_id || data.id || null
if (assistantText) {
appendMessage(assistantText, 'bot', null, null, {
messageId: assistantMsgId || undefined,
})
}
if (data.product_carousel) {
appendProductCarousel('', data.product_carousel)
}
if (data.suggested_actions) applySuggestedActions(data.suggested_actions, true)
return
}
if (data.type === 'navigation_offer' && enableGuidedNavigation) {
if (hasNavigationChipForUrl(data.url)) {
if (data.prompt_text || data.text) appendMessage(data.prompt_text || data.text || '', 'bot')
return
}
appendMessage(data.prompt_text || data.text || '', 'bot')
appendNavigationOfferButtons({
offerId: data.offer_id,
url: data.url,
anchor: data.anchor,
label: data.label,
yesLabel: data.yes_label,
noLabel: data.no_label,
})
return
}
if (data.type === 'client_action' && data.action === 'navigate' && enableGuidedNavigation) {
if (!navigationCommitted && data.url) {
var clientNavUrl = data.url
var clientNavAnchor = data.anchor
var clientNavLabel = data.label
try {
var clientNavParsed = new URL(clientNavUrl, window.location.href)
var clientTarget = clientNavAnchor
? clientNavParsed.origin + clientNavParsed.pathname + clientNavParsed.search + '#' + clientNavAnchor
: clientNavParsed.href
if (!isSameNavigationDomain(clientNavParsed.hostname, window.location.hostname)) {
navigateCrossDomainExternal(clientTarget, clientNavLabel)
} else {
performNavigation({
url: clientNavUrl,
anchor: clientNavAnchor,
label: clientNavLabel,
})
}
} catch (_) {
performNavigation({
url: clientNavUrl,
anchor: clientNavAnchor,
label: clientNavLabel,
})
}
}
if (data.follow_up_text) appendMessage(data.follow_up_text, 'bot')
if (data.suggested_actions) applySuggestedActions(data.suggested_actions, true)
return
}
// Handle regular chat messages
const text = data.text || data.content || data.message || ''
const rawRole = data.role || 'assistant'
if (String(rawRole).toLowerCase() === 'agent') {
humanHandoverActive = true
}
if (String(rawRole).toLowerCase() === 'system') {
if (data.type === 'handover_ack' || /forwarded to human/i.test(text)) {
appendHandoverAck(text || t('handover_ack_default'))
}
return
}
const role = normalizeChatRole(rawRole)
if (text) {
appendMessage(
text,
role,
null,
null,
role === 'user'
? null
: { messageId: data.message_id || data.id || undefined },
)
}
if (data.product_carousel && role !== 'user') {
appendProductCarousel('', data.product_carousel)
}
if (data.suggested_actions && role !== 'user') {
applySuggestedActions(data.suggested_actions, true)
}
} catch (e) {
removeTypingIndicator()
// Fallback to raw text
if (event.data) {
appendMessage(String(event.data), 'bot')
}
}
}
ws.onerror = (e) => {
console.error('[AgentChatWidget] WS error', e)
removeTypingIndicator()
clearIdleDisconnectTimer()
setConnectionState('disconnected')
if (captureCustomerInfo && !customerInfoSubmitted) {
restoreCustomerFormAfterConnectFailure()
}
if (messageLog.length === 0) appendMessage(t('failed_connect'), 'bot')
}
ws.onclose = (ev) => {
removeTypingIndicator()
clearIdleDisconnectTimer()
connected = false
connecting = false
ws = null
clearStagedAttachments(false)
if (shouldAutoReconnectAfterExternalNavClose()) {
externalNavAutoReconnectPending = false
setConnectionState('reconnecting')
connect()
return
}
if (ev.wasClean || ev.code === 1000) {
setConnectionState('ended')
} else {
setConnectionState('disconnected')
}
if (captureCustomerInfo && !customerInfoSubmitted) {
restoreCustomerFormAfterConnectFailure()
}
if (!ev.wasClean && messageLog.length === 0) {
appendMessage(t('failed_connect'), 'bot')
}
}
}
function disconnect(clearPersisted) {
removeTypingIndicator()
clearIdleDisconnectTimer()
clearExternalNavKeepAlive()
if (ws) {
ws.close()
ws = null
}
connected = false
connecting = false
if (clearPersisted !== false) {
try {
clearMessagesDom()
} catch (_) {}
sessionId = null
messageLog = []
humanHandoverActive = false
if (enableGuidedNavigation) clearPersistedSession(agentId)
customerInfoSubmitted = false
customerFormGatePassed = false
conversationStarted = false
formShownBeforeConnect = false
activeSuggestedActions = initSuggestedActions.slice()
clearSuggestedActions()
pendingMode = null
pendingCustomerName = null
pendingCustomerPhone = null
if (customerForm) {
customerForm.style.display = 'none'
const nameInput = customerForm.querySelector('#acw-customer-name')
const phoneInput = customerForm.querySelector('#acw-customer-phone')
const phoneCodeSelect = customerForm.querySelector('#acw-customer-phone-code')
const langSel = customerForm.querySelector('#acw-customer-language')
if (nameInput) nameInput.value = ''
if (phoneInput) phoneInput.value = ''
if (phoneCodeSelect) {
phoneCodeSelect.value = acwDefaultPhoneCountryIso()
if (customerForm._acwSyncPhoneCode) customerForm._acwSyncPhoneCode()
}
if (customerForm._acwClosePhoneCodeMenu) customerForm._acwClosePhoneCodeMenu()
if (langSel && supportedLanguagesList.length) {
langSel.selectedIndex = 0
conversationLanguageCode = supportedLanguagesList[0].language_code || null
if (customerForm._acwSyncLangSwitcher) customerForm._acwSyncLangSwitcher()
}
if (customerForm._acwCloseLangMenu) customerForm._acwCloseLangMenu()
if (customerForm._acwClearErrors) customerForm._acwClearErrors()
}
clearStagedAttachments(false)
try { fileInput.value = '' } catch (_) {}
setConnectionState('idle')
showStartCta()
} else {
if (enableGuidedNavigation) {
persistSessionSnapshot(agentId, {
sessionId: sessionId,
panelOpen: panel.style.display === 'flex',
messages: messageLog.slice(),
savedAt: Date.now(),
})
}
if (conversationStarted && messageLog.length > 0) {
showChat()
setConnectionState('disconnected')
} else {
setConnectionState('idle')
}
}
}
function sendMessage() {
const text = input.value.trim()
var readyAttachments = getReadyStagedAttachments()
if (!text && !readyAttachments.length) return
if (captureCustomerInfo && !customerInfoSubmitted) {
return
}
if (stagedAttachments.some(function (a) { return a.status === 'uploading' })) {
return
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
if (connecting) {
appendMessage(t('connecting_wait'), 'bot')
} else if (messageLog.length > 0 || conversationStarted) {
setConnectionState('reconnecting')
connect()
} else {
appendMessage(t('choose_text_chat_home'), 'bot')
}
return
}
var previewUrls = readyAttachments
.map(function (a) { return a.previewUrl })
.filter(function (u) { return !!u })
clearSuggestedActions()
appendMessage(
text,
'user',
previewUrls.length === 1 ? previewUrls[0] : null,
previewUrls.length > 1 ? previewUrls : null,
)
var payload = {
type: 'user_message',
text: text || '',
role: 'user',
embed_key: embedKey,
channel: webChatMeta.channel,
is_web: webChatMeta.is_web,
source: webChatMeta.source,
source_name: webChatMeta.source_name,
}
if (sessionId) payload.session_id = sessionId
if (readyAttachments.length) {
payload.attachment_ids = readyAttachments.map(function (a) { return a.attachmentId })
}
ws.send(JSON.stringify(payload))
showTypingIndicator()
readyAttachments.forEach(revokeAttachmentPreview)
stagedAttachments = stagedAttachments.filter(function (a) { return a.status !== 'ready' })
renderAttachmentsStrip()
input.value = ''
try { fileInput.value = '' } catch (_) {}
autoResizeTextarea()
updateComposerSendState()
}
function activateChannel(mode) {
if (mode === 'whatsapp') {
var waMsg = typeof whatsappMessage === 'string' ? whatsappMessage : 'Hi! I need assistance.'
var waUrl = 'https://wa.me/' + whatsappDigits + '?text=' + encodeURIComponent(waMsg)
try {
window.open(waUrl, '_blank', 'noopener,noreferrer')
} catch (_) {}
return
}
var panelOpen = panel.style.display === 'flex'
if ((panelOpen || railVoiceOverlayActive) && activeRailChannel === mode) {
softClosePanel()
return
}
pendingMode = mode
conversationStarted = true
unreadCount = 0
updateUnreadBadge()
setRailActive(mode)
if (captureCustomerInfo && customerForm && !customerInfoSubmitted) {
formShownBeforeConnect = true
showFormOnly()
openPanelUI()
var railNameInput = customerForm.querySelector('#acw-customer-name')
if (railNameInput) setTimeout(function () { railNameInput.focus() }, 100)
} else if (mode === 'voice') {
showRailVoiceOverlay()
} else {
openPanelUI()
if (connected || connecting) {
showChat()
updateConnectionBanner()
} else {
connect()
}
}
}
if (useRail) {
startCtaEl.style.display = 'none'
if (launcherWrap) launcherWrap.style.display = 'none'
else launcher.style.display = 'none'
var railEl = document.createElement('div')
railEl.className = 'acw-rail acw-dock'
railEl.setAttribute('role', 'group')
railEl.setAttribute('aria-label', t('choose_communication_style'))
dockEl = railEl
orderedRailChannels.forEach(function (channel) {
var railBtn = document.createElement('button')
railBtn.type = 'button'
var isPrimarySize = channel === normalizedPrimaryChannel
railBtn.className =
'acw-rail-btn acw-rail-btn-' +
channel +
(isPrimarySize ? ' acw-rail-btn-primary' : ' acw-rail-btn-secondary')
var railLabel =
channel === 'chat'
? t('text_chat_title')
: channel === 'voice'
? t('voice_chat_title')
: t('whatsapp_title')
railBtn.setAttribute('aria-label', railLabel)
if (showRailTooltips !== false) railBtn.title = railLabel
railBtn.innerHTML =
channel === 'chat' ? chatModeSvg : channel === 'voice' ? micModeSvg : waModeSvg
railBtn.setAttribute('data-testid', 'acw-rail-' + channel)
railBtn.onclick = function () {
activateChannel(channel)
}
railButtons[channel] = railBtn
railEl.appendChild(railBtn)
})
shadow.appendChild(railEl)
}
launcher.onclick = function () {
var isOpen = panel.style.display === 'flex'
if (isOpen) {
softClosePanel()
} else {
openPanelUI()
setLauncherOpen(true)
unreadCount = 0
updateUnreadBadge()
if (!connected && !connecting && conversationStarted && messageLog.length > 0) {
setConnectionState('reconnecting')
connect()
} else if (!connected && !connecting && !conversationStarted) {
showStartCta()
}
}
}
closeBtn.onclick = function () {
softClosePanel()
}
expandBtn.onclick = function () {
setPanelExpanded(!panelExpanded)
}
disconnectChatBtn.onclick = function () {
var inVoice = voicePanelEl.classList.contains('visible') || (useRail && railVoiceOverlayActive)
if (inVoice) {
endActiveVoiceSession()
return
}
disconnect()
}
connectionReconnectBtn.onclick = function () {
if (connectionState === 'ended') {
disconnect(true)
return
}
if (connected || connecting) return
setConnectionState('reconnecting')
connect()
}
attachBtn.onclick = function () {
if (attachBtn.disabled) return
fileInput.click()
}
fileInput.addEventListener('change', function () {
var files = fileInput.files
if (!files || !files.length) return
for (var fi = 0; fi < files.length; fi++) {
uploadFileAttachment(files[fi])
}
try { fileInput.value = '' } catch (_) {}
})
sendBtn.onclick = sendMessage
input.addEventListener('input', function () {
autoResizeTextarea()
updateComposerSendState()
})
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
})
async function startVoiceConnection() {
if (!voiceMicIdle) return
voiceMicIdle = false
unlockVoiceRemoteAudio()
setVoiceUiStatus(t('voice_connecting'))
if (useRail && railVoiceOverlayActive) setRailVoicePhase('connecting')
setVoiceMicConnecting(true)
if (!useRail || !railVoiceOverlayActive) {
voiceEndBtn.style.display = 'flex'
setVoiceMicRecording(true)
}
if (useDailyVoice) {
try {
var dailyBase = voiceHttpBase
var selfHostedDaily = !!dailyBase
if (!selfHostedDaily && !dailyApiKey) {
setVoiceUiStatus(t('voice_url_or_key_required'))
markVoiceSessionDisconnected()
if (!useRail || !railVoiceOverlayActive) voiceEndBtn.style.display = 'none'
voiceMicIdle = true
return
}
var Daily = window.Daily
if (!Daily) {
setVoiceUiStatus(t('voice_loading_sdk'))
await new Promise(function (resolve, reject) {
var s = document.createElement('script')
s.src = dailyScriptUrl
s.onload = function () {
if (window.Daily) resolve(); else reject(new Error('Daily SDK not loaded'))
}
s.onerror = function () { reject(new Error('Failed to load Daily SDK')) }
document.head.appendChild(s)
})
Daily = window.Daily
}
function attachRemote(track) {
attachRemoteAudioTrack(track)
}
function wireDailyEvents(dailyCall) {
voiceDailyCall = dailyCall
ensureVoiceRemoteAudio()
dailyCall.on('track-started', function (ev) {
if (!ev || !ev.track || ev.track.kind !== 'audio') return
if (ev.participant && ev.participant.local) return
attachRemote(ev.track)
})
dailyCall.on('participant-joined', function (ev) {
if (ev.participant.local) return
var t = ev.participant.tracks && ev.participant.tracks.audio && ev.participant.tracks.audio.persistentTrack
if (t) attachRemote(t)
})
dailyCall.on('participant-updated', function (ev) {
if (ev.participant.local) return
var t = ev.participant.tracks && ev.participant.tracks.audio && ev.participant.tracks.audio.persistentTrack
if (t) attachRemote(t)
})
dailyCall.on('participant-left', function (ev) {
if (ev.participant && ev.participant.user_name === 'talk2ai-bot') {
disconnectVoice()
if (useRail && railVoiceOverlayActive) {
endRailVoiceSession()
} else {
voicePanelEl.classList.remove('visible')
showStartCta()
}
}
})
dailyCall.on('left-meeting', function () {
disconnectVoice()
if (useRail && railVoiceOverlayActive) {
endRailVoiceSession()
} else {
voicePanelEl.classList.remove('visible')
showStartCta()
}
})
dailyCall.on('app-message', function (ev) {
handleRailDailyRtviEvent('app-message', ev)
})
dailyCall.on('transcription-message', function (ev) {
handleRailDailyRtviEvent('transcription-message', ev)
})
dailyCall.on('error', function (ev) {
console.error('[AgentChatWidget] Daily error', ev)
setVoiceUiStatus(ev.errorMsg || t('voice_error'))
disconnectVoice()
})
}
function completeDailyJoin(dailyCall) {
try {
var parts = dailyCall.participants()
if (parts) {
for (var lid in parts) {
var part = parts[lid]
if (!part || !part.local) continue
var localTrack = part.tracks && part.tracks.audio && part.tracks.audio.persistentTrack
if (localTrack && !voiceAnalyserLocal) {
attachVoiceAnalyserForStream(new MediaStream([localTrack]), false)
if (!voiceVizRaf) startVoiceVisualizerLoop()
}
break
}
}
} catch (_) {}
var participants = dailyCall.participants()
for (var pid in participants) {
if (participants[pid].local) continue
var tr = participants[pid].tracks && participants[pid].tracks.audio && participants[pid].tracks.audio.persistentTrack
if (tr) { attachRemote(tr); break }
}
setVoiceUiStatus(t('voice_connected_speak'))
markVoiceSessionConnected()
}
if (selfHostedDaily) {
setVoiceUiStatus(t('voice_starting_session'))
var voicePageCtx = acwPageContext()
var dataPayload = {
tenant: voiceTenant,
live_transcript: effectiveLiveTranscript,
show_transcript: effectiveLiveTranscript,
channel: webVoiceMeta.channel,
is_web: webVoiceMeta.is_web,
source: webVoiceMeta.source,
source_name: webVoiceMeta.source_name,
page_url: voicePageCtx.page_url,
page_path: voicePageCtx.page_path,
page_title: voicePageCtx.page_title
}
if (pendingCustomerName) dataPayload.customer_name = pendingCustomerName
if (pendingCustomerPhone) dataPayload.phone_number = pendingCustomerPhone
if (voiceSessionData && typeof voiceSessionData === 'object' && !Array.isArray(voiceSessionData)) {
for (var vk in voiceSessionData) {
if (Object.prototype.hasOwnProperty.call(voiceSessionData, vk)) {
dataPayload[vk] = voiceSessionData[vk]
}
}
}
if (conversationLanguageCode) dataPayload.language_code = conversationLanguageCode
var startRes = await fetch(dailyBase + '/api/v1/agent-voice/agents/' + agentId + '/daily/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: dataPayload
})
})
if (!startRes.ok) {
var errJson = await startRes.json().catch(function () { return {} })
var detail = errJson && errJson.detail
throw new Error(detail ? String(detail) : ('Failed to start Daily session: ' + startRes.status))
}
var shData = await startRes.json()
var roomUrl = shData.room_url
var dToken = shData.token
var sessId = shData.session_id
if (!roomUrl || !dToken) throw new Error('Missing room_url or token')
voiceDailySessionId = sessId || null
setVoiceUiStatus(t('voice_joining_room'))
var dailyCallSh = createDailyCallObject(Daily, { audioSource: true, videoSource: false })
wireDailyEvents(dailyCallSh)
await dailyCallSh.join({ url: roomUrl, token: dToken, startVideoOff: true, videoSource: false })
completeDailyJoin(dailyCallSh)
} else {
setVoiceUiStatus(t('voice_starting_session'))
var pipecatPageCtx = acwPageContext()
var bodyPayload = {
agent_id: agentId,
tenant: 'default',
channel: webVoiceMeta.channel,
is_web: webVoiceMeta.is_web,
source: webVoiceMeta.source,
source_name: webVoiceMeta.source_name,
data: {
page_url: pipecatPageCtx.page_url,
page_path: pipecatPageCtx.page_path,
page_title: pipecatPageCtx.page_title
}
}
if (pendingCustomerName) bodyPayload.data.customer_name = pendingCustomerName
if (pendingCustomerPhone) bodyPayload.data.phone_number = pendingCustomerPhone
if (conversationLanguageCode) {
if (!bodyPayload.data) bodyPayload.data = {}
bodyPayload.data.language_code = conversationLanguageCode
}
if (effectiveLiveTranscript) {
if (!bodyPayload.data) bodyPayload.data = {}
bodyPayload.data.live_transcript = effectiveLiveTranscript
bodyPayload.data.show_transcript = effectiveLiveTranscript
}
var startResP = await fetch('https://api.pipecat.daily.co/v1/public/' + dailyAgentName + '/start', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + dailyApiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ createDailyRoom: true, body: bodyPayload })
})
if (!startResP.ok) throw new Error('Daily start failed: ' + startResP.status)
var startDataP = await startResP.json()
var dailyRoom = startDataP.dailyRoom
var dailyToken = startDataP.dailyToken
if (!dailyRoom || !dailyToken) throw new Error('Missing dailyRoom or dailyToken')
setVoiceUiStatus(t('voice_joining_room'))
var dailyCallP = createDailyCallObject(Daily, { startVideoOff: true, videoSource: false })
wireDailyEvents(dailyCallP)
await dailyCallP.join({ url: dailyRoom, token: dailyToken, startVideoOff: true, videoSource: false })
completeDailyJoin(dailyCallP)
}
} catch (err) {
console.error('[AgentChatWidget] Daily voice error', err)
setVoiceUiStatus((err && err.message) ? err.message : t('voice_connection_failed'))
disconnectVoice()
}
return
}
if (isLocalWebrtc || voiceHttpBase) {
const baseUrl = voiceHttpBase
if (!baseUrl) {
setVoiceUiStatus(t('voice_url_not_set'))
markVoiceSessionDisconnected()
if (!useRail || !railVoiceOverlayActive) voiceEndBtn.style.display = 'none'
voiceMicIdle = true
return
}
const startUrl = baseUrl + '/api/v1/agent-voice/agents/' + agentId + '/webrtc/start'
const customerInfo = (pendingCustomerName || pendingCustomerPhone)
? { customer_name: pendingCustomerName || undefined, phone_number: pendingCustomerPhone || undefined }
: {}
try {
setVoiceUiStatus(t('voice_establishing_link'))
const startRes = await fetch(startUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embed_key: embedKey,
host: window.location.hostname,
...customerInfo,
...(conversationLanguageCode && { language_code: conversationLanguageCode }),
channel: webVoiceMeta.channel,
is_web: webVoiceMeta.is_web,
source: webVoiceMeta.source,
source_name: webVoiceMeta.source_name
})
})
if (!startRes.ok) throw new Error('Start failed: ' + startRes.status)
const startData = await startRes.json()
const iceConfig = (startData && startData.iceConfig) || {}
const iceServers = iceConfig.iceServers || []
const pcConfig = { iceServers: iceServers }
if (iceConfig.iceTransportPolicy === 'relay' || iceConfig.iceTransportPolicy === 'all') {
pcConfig.iceTransportPolicy = iceConfig.iceTransportPolicy
}
const pc = new RTCPeerConnection(pcConfig)
function onVoiceClosed() {
disconnectVoice()
if (useRail && railVoiceOverlayActive) {
endRailVoiceSession()
} else {
voicePanelEl.classList.remove('visible')
showStartCta()
}
}
pc.onconnectionstatechange = function () {
if (pc.connectionState === 'closed' || pc.connectionState === 'failed' || pc.connectionState === 'disconnected') onVoiceClosed()
}
pc.oniceconnectionstatechange = function () {
var ice = pc.iceConnectionState
if (ice === 'closed' || ice === 'failed' || ice === 'disconnected') {
onVoiceClosed()
return
}
if (ice === 'checking' || ice === 'connecting') {
setVoiceUiStatus(t('voice_connecting_agent'))
} else if (ice === 'connected' || ice === 'completed') {
setVoiceUiStatus(t('voice_connected_speak'))
markVoiceSessionConnected()
}
}
pc.ondatachannel = function (event) {
if (event && event.channel) setupRailRtviDataChannel(event.channel)
}
var rtviChannel = pc.createDataChannel('rtvi-ai', { ordered: true })
setupRailRtviDataChannel(rtviChannel)
var pendingVoiceIce = []
function postVoiceIce(payload) {
fetch(baseUrl + '/api/v1/agent-voice/agents/' + agentId + '/webrtc/offer', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pc_id: voiceServerPcId, candidates: [payload] })
}).catch(function () {})
}
pc.onicecandidate = function (event) {
if (!event.candidate) return
var payload = {
candidate: event.candidate.candidate,
sdp_mid: event.candidate.sdpMid,
sdp_mline_index: event.candidate.sdpMLineIndex
}
if (!voiceServerPcId) {
pendingVoiceIce.push(payload)
return
}
postVoiceIce(payload)
}
pc.ontrack = function (event) {
const remoteStream = event.streams && event.streams[0]
const remoteTrack = (event.track && event.track.kind === 'audio')
? event.track
: (remoteStream && remoteStream.getAudioTracks()[0])
if (remoteTrack) {
attachRemoteAudioTrack(remoteTrack)
} else if (remoteStream) {
var el = ensureVoiceRemoteAudio()
try { el.removeAttribute('src') } catch (_) {}
el.srcObject = remoteStream
el.muted = false
el.volume = 1
playVoiceRemoteAudio()
if (!voiceAnalyserRemote) {
attachVoiceAnalyserForStream(remoteStream, true)
if (!voiceVizRaf) startVoiceVisualizerLoop()
}
}
}
voiceStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
})
attachVoiceAnalyserForStream(voiceStream, false)
if (!voiceVizRaf) startVoiceVisualizerLoop()
setVoiceUiStatus(t('voice_negotiating'))
voiceStream.getTracks().forEach(function (track) { pc.addTrack(track, voiceStream) })
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const offerRes = await fetch(baseUrl + '/api/v1/agent-voice/agents/' + agentId + '/webrtc/offer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: startData.sessionId,
sdp: offer.sdp,
type: offer.type,
embed_key: embedKey,
host: window.location.hostname,
request_data: {
tenant: 'default',
live_transcript: effectiveLiveTranscript,
show_transcript: effectiveLiveTranscript,
...(pendingCustomerName && { customer_name: pendingCustomerName }),
...(pendingCustomerPhone && { phone_number: pendingCustomerPhone }),
...(conversationLanguageCode && { language_code: conversationLanguageCode }),
channel: webVoiceMeta.channel,
is_web: webVoiceMeta.is_web,
source: webVoiceMeta.source,
source_name: webVoiceMeta.source_name,
...acwPageContext()
}
})
})
if (!offerRes.ok) throw new Error('Offer failed: ' + offerRes.status)
const offerData = await offerRes.json()
voiceServerPcId = offerData.pc_id || null
setVoiceUiStatus(t('voice_signing_in'))
await pc.setRemoteDescription(new RTCSessionDescription({ type: offerData.type, sdp: offerData.sdp }))
var queuedVoiceIce = pendingVoiceIce.splice(0, pendingVoiceIce.length)
for (var iceIdx = 0; iceIdx < queuedVoiceIce.length; iceIdx++) postVoiceIce(queuedVoiceIce[iceIdx])
voicePeerConnection = pc
var iceNow = pc.iceConnectionState
if (iceNow === 'connected' || iceNow === 'completed') {
setVoiceUiStatus(t('voice_connected_speak'))
markVoiceSessionConnected()
} else if (iceNow === 'checking' || iceNow === 'connecting') {
setVoiceUiStatus(t('voice_connecting_agent'))
}
} catch (err) {
console.error('[AgentChatWidget] Voice WebRTC error', err)
setVoiceUiStatus(t('voice_connection_failed'))
disconnectVoice()
}
return
}
setTimeout(function () {
setVoiceUiStatus(t('voice_coming_soon'))
markVoiceSessionDisconnected()
if (!useRail || !railVoiceOverlayActive) voiceEndBtn.style.display = 'none'
voiceMicIdle = true
}, 1500)
}
voiceMicBtn.onclick = startVoiceConnection
if (railVoiceMicBtn) {
railVoiceMicBtn.onclick = function () {
if (!voiceMicIdle) {
// Connected or connecting: end the call but keep the transcript + close button.
disconnectVoice()
endRailVoiceSession()
return
}
// Idle: start a fresh session (drop any prior ended transcript).
if (railVoiceOverlayEl && railVoiceOverlayEl.classList.contains('is-ended')) {
railVoiceOverlayEl.classList.remove('is-ended')
clearRailTranscript()
}
startVoiceConnection()
}
}
if (railVoiceCloseBtn) {
railVoiceCloseBtn.onclick = function () {
disconnectVoice()
hideRailVoiceOverlay()
clearRailTranscript()
setRailActive(null)
}
}
if (railVoiceHomeBtn) {
// Return to the rail so the visitor can pick any conversation option again.
railVoiceHomeBtn.onclick = function () {
disconnectVoice()
hideRailVoiceOverlay()
clearRailTranscript()
setRailActive(null)
}
}
voiceEndBtn.onclick = function () {
endActiveVoiceSession()
}
if (railVoiceEndBtn) {
railVoiceEndBtn.onclick = function () {
endActiveVoiceSession()
}
}
if (enableGuidedNavigation) {
var savedSession = loadPersistedSession(agentId, sessionPersistTtlMs)
if (savedSession && savedSession.messages && savedSession.messages.length) {
restoringSession = true
if (savedSession.sessionId) sessionId = savedSession.sessionId
conversationStarted = true
customerInfoSubmitted = true
savedSession.messages.forEach(function (m) {
restoreMessageFromLog(m)
})
for (var hi = 0; hi < messageLog.length; hi++) {
if (messageLog[hi].type === 'handover_ack') {
humanHandoverActive = true
break
}
}
restoringSession = false
showChat()
attachActionsToLastBotMessage()
if (savedSession.navigationPending || savedSession.nextPagePath || savedSession.pagePath) {
setTimeout(function () {
showContinuityToast(t('navigation_continued').replace('{page}', formatPageLabel()))
}, 700)
}
if (savedSession.panelOpen) {
openPanelUI()
}
setConnectionState('reconnecting')
connect()
}
}
if (useInline) {
closeBtn.style.display = 'none'
disconnectChatBtn.style.display = 'none'
openPanelUI()
if (!connected && !connecting && !conversationStarted) {
showStartCta()
}
}
return {
destroy() {
teardownViewportFit()
if (enableSpaNavigation) {
if (spaClickHandlerRef) {
document.removeEventListener('click', spaClickHandlerRef, true)
spaClickHandlerRef = null
}
if (spaPopstateHandlerRef) {
window.removeEventListener('popstate', spaPopstateHandlerRef)
spaPopstateHandlerRef = null
}
}
disconnect(false)
if (voiceRemoteAudio && voiceRemoteAudio.parentNode) {
voiceRemoteAudio.parentNode.removeChild(voiceRemoteAudio)
}
container.remove()
},
open() {
openPanelUI()
if (!useRail && !connected && !connecting && !conversationStarted) {
showStartCta()
} else if (!connected && !connecting && conversationStarted && messageLog.length > 0) {
setConnectionState('reconnecting')
connect()
}
},
close() {
softClosePanel()
},
getSessionId() { return sessionId },
navigateTo(url, anchor) { performNavigation({ url: url, anchor: anchor }) },
renderProductCarousel(introText, carousel) {
showChat()
return appendProductCarousel(introText, carousel)
},
}
}
function buildConfig(userConfig) {
const scriptEl = document.currentScript || document.querySelector('script[data-agent-id]')
const ds = (scriptEl && scriptEl.dataset) || {}
const allowed = (ds.allowedDomains || ds.allowed_domains || '').split(',').map((d) => d.trim()).filter(Boolean)
const captureCustomerInfo = userConfig.captureCustomerInfo !== undefined
? userConfig.captureCustomerInfo
: ds.captureCustomerInfo === 'true' || ds.capture_customer_info === 'true'
const allowSkipCustomerInfo = userConfig.allowSkipCustomerInfo !== undefined
? userConfig.allowSkipCustomerInfo
: ds.allowSkipCustomerInfo !== 'false' && ds.allow_skip_customer_info !== 'false'
const bannerImageUrlRaw =
userConfig.bannerImageUrl ??
userConfig.banner_image_url ??
userConfig.bannerImage ??
userConfig.banner_image ??
ds.bannerImageUrl ??
ds.banner_image_url ??
ds.bannerImage ??
ds.banner_image ??
null
const bannerImageUrl =
typeof bannerImageUrlRaw === 'string' && bannerImageUrlRaw.trim() ? bannerImageUrlRaw.trim() : null
const position = userConfig.position || ds.position || ds.launcherPosition || 'bottom-right'
const validPositions = ['bottom-right', 'middle-right', 'top-right', 'bottom-left', 'middle-left', 'top-left']
const validPosition = validPositions.includes(position) ? position : 'bottom-right'
const showIcon = userConfig.showIcon !== undefined ? userConfig.showIcon : ds.showIcon !== 'false' && ds.show_icon !== 'false'
const showFavicon = userConfig.showFavicon !== undefined
? userConfig.showFavicon !== false
: userConfig.show_favicon !== undefined
? userConfig.show_favicon !== false
: ds.showFavicon !== 'false' && ds.show_favicon !== 'false'
const showLauncherText = userConfig.showLauncherText !== undefined
? userConfig.showLauncherText
: userConfig.show_launcher_text !== undefined
? userConfig.show_launcher_text
: ds.showLauncherText !== 'false' && ds.show_launcher_text !== 'false'
const launcherTextRaw =
userConfig.launcherText ?? userConfig.launcher_text ?? ds.launcherText ?? ds.launcher_text
const launcherText =
typeof launcherTextRaw === 'string' && launcherTextRaw.trim() ? launcherTextRaw.trim() : undefined
const enableVoice = userConfig.enableVoice !== undefined ? userConfig.enableVoice : ds.enableVoice !== 'false' && ds.enable_voice !== 'false'
const isLocalWebrtc = userConfig.isLocalWebrtc !== undefined ? !!userConfig.isLocalWebrtc : (ds.isLocalWebrtc !== 'false' && ds.is_local_webrtc !== 'false')
const voiceServiceUrlResolved = userConfig.voiceServiceUrl || userConfig.voice_service_url || ds.voiceServiceUrl || ds.voice_service_url
const explicitUseDailyVoice =
userConfig.useDailyVoice !== undefined ||
userConfig.use_daily_voice !== undefined ||
ds.useDailyVoice !== undefined ||
ds.use_daily_voice !== undefined
// Hosted embeds use SmallWebRTC (/webrtc/start). Daily stays opt-in via useDailyVoice: true.
const useDailyVoice = explicitUseDailyVoice
? (userConfig.useDailyVoice === true ||
userConfig.use_daily_voice === true ||
ds.useDailyVoice === 'true' ||
ds.use_daily_voice === 'true')
: false
const dailyApiKey = userConfig.dailyApiKey || userConfig.daily_api_key || ds.dailyApiKey || ds.daily_api_key || ''
const dailyAgentName = userConfig.dailyAgentName || userConfig.daily_agent_name || ds.dailyAgentName || ds.daily_agent_name || defaultDailyAgentName()
const dailyScriptUrl = userConfig.dailyScriptUrl || userConfig.daily_script_url || ds.dailyScriptUrl || ds.daily_script_url || DEFAULT_DAILY_SCRIPT_URL
const voiceTenantRaw = userConfig.voiceTenant ?? userConfig.voice_tenant ?? ds.voiceTenant ?? ds.voice_tenant ?? 'default'
const voiceTenant = (typeof voiceTenantRaw === 'string' ? voiceTenantRaw : String(voiceTenantRaw || 'default')).trim() || 'default'
const liveTranscript = userConfig.liveTranscript === true || userConfig.live_transcript === true || ds.liveTranscript === 'true' || ds.live_transcript === 'true'
const rawVoiceSession = userConfig.voiceSessionData ?? userConfig.voice_session_data ?? ds.voiceSessionData ?? ds.voice_session_data
let voiceSessionData = {}
if (rawVoiceSession != null && rawVoiceSession !== '') {
if (typeof rawVoiceSession === 'object' && !Array.isArray(rawVoiceSession)) {
voiceSessionData = rawVoiceSession
} else if (typeof rawVoiceSession === 'string') {
try {
var parsedSd = JSON.parse(rawVoiceSession)
if (parsedSd && typeof parsedSd === 'object' && !Array.isArray(parsedSd)) voiceSessionData = parsedSd
} catch (_) {}
}
}
const enableWhatsApp = userConfig.enableWhatsApp !== undefined
? userConfig.enableWhatsApp
: ds.enableWhatsApp !== 'false' && ds.enable_whatsapp !== 'false'
const whatsappPhone = userConfig.whatsappPhone || userConfig.whatsapp_phone || ds.whatsappPhone || ds.whatsapp_phone || ''
const whatsappMessage = userConfig.whatsappMessage || userConfig.whatsapp_message || ds.whatsappMessage || ds.whatsapp_message || 'Hi! I need assistance.'
const language = normalizeWidgetUiLanguage(
userConfig.language ?? userConfig.lang ?? ds.language ?? ds.lang ?? 'en'
)
const enableGuidedNavigation = userConfig.enableGuidedNavigation !== undefined
? userConfig.enableGuidedNavigation !== false
: ds.enableGuidedNavigation !== 'false' && ds.enable_guided_navigation !== 'false'
const sessionPersistTtlMsRaw = userConfig.sessionPersistTtlMs ?? userConfig.session_persist_ttl_ms ?? ds.sessionPersistTtlMs ?? ds.session_persist_ttl_ms
const sessionPersistTtlMs = Number(sessionPersistTtlMsRaw) > 0 ? Number(sessionPersistTtlMsRaw) : 1800000
const enableSpaNavigation = userConfig.enableSpaNavigation === true ||
userConfig.enable_spa_navigation === true ||
ds.enableSpaNavigation === 'true' ||
ds.enable_spa_navigation === 'true'
const spaContentSelectorRaw = userConfig.spaContentSelector ?? userConfig.spa_content_selector ?? ds.spaContentSelector ?? ds.spa_content_selector
const spaContentSelector = typeof spaContentSelectorRaw === 'string' && spaContentSelectorRaw.trim()
? spaContentSelectorRaw.trim()
: 'main'
const spaSyncRaw = userConfig.spaSyncSelectors ?? userConfig.spa_sync_selectors ?? ds.spaSyncSelectors ?? ds.spa_sync_selectors
let spaSyncSelectors = []
if (Array.isArray(spaSyncRaw)) {
spaSyncSelectors = spaSyncRaw.filter(function (s) { return typeof s === 'string' && s.trim() }).map(function (s) { return s.trim() })
} else if (typeof spaSyncRaw === 'string' && spaSyncRaw.trim()) {
spaSyncSelectors = spaSyncRaw.split(',').map(function (s) { return s.trim() }).filter(Boolean)
}
const spaInterceptLinksRaw = userConfig.spaInterceptLinks ?? userConfig.spa_intercept_links ?? ds.spaInterceptLinks ?? ds.spa_intercept_links
const spaInterceptLinks = typeof spaInterceptLinksRaw === 'string' && spaInterceptLinksRaw.trim()
? spaInterceptLinksRaw.trim()
: null
const spaUseFrameworkRouter = userConfig.spaUseFrameworkRouter !== false &&
userConfig.spa_use_framework_router !== false &&
ds.spaUseFrameworkRouter !== 'false' &&
ds.spa_use_framework_router !== 'false'
const spaUseNextRouter = userConfig.spaUseNextRouter !== false &&
userConfig.spa_use_next_router !== false &&
ds.spaUseNextRouter !== 'false' &&
ds.spa_use_next_router !== 'false'
const spaNavigationAdapterRaw = userConfig.spaNavigationAdapter ?? userConfig.spa_navigation_adapter ?? ds.spaNavigationAdapter ?? ds.spa_navigation_adapter ?? 'auto'
const spaNavigationAdapter = typeof spaNavigationAdapterRaw === 'string' ? spaNavigationAdapterRaw : 'auto'
const spaNavigate = typeof userConfig.spaNavigate === 'function'
? userConfig.spaNavigate
: typeof userConfig.spa_navigate === 'function'
? userConfig.spa_navigate
: null
const spaNextRouterShallow = userConfig.spaNextRouterShallow !== false &&
userConfig.spa_next_router_shallow !== false &&
ds.spaNextRouterShallow !== 'false' &&
ds.spa_next_router_shallow !== 'false'
const viewTypeRaw = userConfig.viewType ?? userConfig.view_type ?? ds.viewType ?? ds.view_type ?? 'hub'
const viewType = viewTypeRaw === 'rail' ? 'rail' : 'hub'
const primaryChannelRaw = userConfig.primaryChannel ?? userConfig.primary_channel ?? ds.primaryChannel ?? ds.primary_channel ?? 'chat'
const primaryChannel = primaryChannelRaw === 'voice' ? 'voice' : 'chat'
const railOrderRaw = userConfig.railOrder ?? userConfig.rail_order ?? ds.railOrder ?? ds.rail_order
let railOrder = null
if (Array.isArray(railOrderRaw)) {
railOrder = railOrderRaw
} else if (typeof railOrderRaw === 'string' && railOrderRaw.trim()) {
railOrder = railOrderRaw.split(',').map(function (s) { return s.trim() }).filter(Boolean)
}
const showRailTooltips = userConfig.showRailTooltips !== undefined
? userConfig.showRailTooltips !== false
: ds.showRailTooltips !== 'false' && ds.show_rail_tooltips !== 'false'
const embedModeRaw = userConfig.embedMode ?? userConfig.embed_mode ?? ds.embedMode ?? ds.embed_mode ?? 'floating'
const embedMode = embedModeRaw === 'inline' ? 'inline' : 'floating'
const suggestedQuestionsRaw = userConfig.suggestedQuestions ?? userConfig.suggested_questions ?? ds.suggestedQuestions ?? ds.suggested_questions
const themeModeRaw = userConfig.themeMode ?? userConfig.theme_mode ?? ds.themeMode ?? ds.theme_mode ?? 'light'
const themeMode = themeModeRaw === 'dark' || themeModeRaw === 'auto' ? themeModeRaw : 'light'
const viewportTopOffsetRaw =
userConfig.viewportTopOffset ?? userConfig.viewport_top_offset ?? ds.viewportTopOffset ?? ds.viewport_top_offset
const viewportTopOffsetParsed = parseInt(viewportTopOffsetRaw, 10)
const viewportTopOffset =
String(viewportTopOffsetRaw).trim() === 'auto'
? 'auto'
: isNaN(viewportTopOffsetParsed)
? null
: viewportTopOffsetParsed
let suggestedQuestions = []
if (Array.isArray(suggestedQuestionsRaw)) {
suggestedQuestions = suggestedQuestionsRaw
} else if (typeof suggestedQuestionsRaw === 'string' && suggestedQuestionsRaw.trim()) {
try {
var parsedQuestions = JSON.parse(suggestedQuestionsRaw)
if (Array.isArray(parsedQuestions)) suggestedQuestions = parsedQuestions
} catch (_) {}
}
return {
agentId: userConfig.agentId || ds.agentId,
embedKey: userConfig.embedKey || ds.embedKey || ds.embed_key,
primaryColor: userConfig.primaryColor || ds.primaryColor || ds.primary_color,
backgroundColor: userConfig.backgroundColor || ds.backgroundColor || ds.background_color,
headerColor: userConfig.headerColor || userConfig.header_color || ds.headerColor || ds.header_color,
allowedDomains: userConfig.allowedDomains || allowed,
apiBaseUrl: userConfig.apiBaseUrl || ds.apiBaseUrl || ds.api_base_url,
voiceServiceUrl: voiceServiceUrlResolved,
wsUrl: userConfig.wsUrl || ds.wsUrl || ds.ws_url,
title: userConfig.title || ds.title,
greeting: userConfig.greeting || ds.greeting,
captureCustomerInfo: captureCustomerInfo,
allowSkipCustomerInfo: allowSkipCustomerInfo,
bannerImageUrl: bannerImageUrl,
position: validPosition,
iconUrl: userConfig.iconUrl || userConfig.icon_url || ds.iconUrl || ds.icon_url,
headerIconUrl:
userConfig.headerIconUrl !== undefined
? userConfig.headerIconUrl
: userConfig.header_icon_url !== undefined
? userConfig.header_icon_url
: ds.headerIcon !== undefined
? ds.headerIcon
: ds.header_icon !== undefined
? ds.header_icon
: undefined,
showIcon: showIcon,
showFavicon: showFavicon,
showLauncherText: showLauncherText,
launcherText: launcherText,
enableVoice: enableVoice,
isLocalWebrtc: isLocalWebrtc,
useDailyVoice: useDailyVoice,
dailyApiKey: dailyApiKey,
dailyAgentName: dailyAgentName,
dailyScriptUrl: dailyScriptUrl,
voiceTenant: voiceTenant,
liveTranscript: liveTranscript,
voiceSessionData: voiceSessionData,
enableWhatsApp: enableWhatsApp,
whatsappPhone: whatsappPhone,
whatsappMessage: whatsappMessage,
language: language,
enableGuidedNavigation: enableGuidedNavigation,
sessionPersistTtlMs: sessionPersistTtlMs,
enableSpaNavigation: enableSpaNavigation,
spaContentSelector: spaContentSelector,
spaSyncSelectors: spaSyncSelectors,
spaInterceptLinks: spaInterceptLinks,
spaUseFrameworkRouter: spaUseFrameworkRouter,
spaUseNextRouter: spaUseNextRouter,
spaNavigationAdapter: spaNavigationAdapter,
spaNavigate: spaNavigate,
spaNextRouterShallow: spaNextRouterShallow,
viewType: viewType,
primaryChannel: primaryChannel,
railOrder: railOrder,
showRailTooltips: showRailTooltips,
embedMode: embedMode,
suggestedQuestions: suggestedQuestions,
themeMode: themeMode,
viewportTopOffset: viewportTopOffset,
}
}
let instance = null
function init(userConfig = {}) {
if (instance) {
destroy()
}
const cfg = buildConfig(userConfig)
if (userConfig.embedMode === 'inline' || userConfig.embed_mode === 'inline') {
cfg.embedMode = 'inline'
}
if (userConfig.mountElement) {
cfg.mountElement = userConfig.mountElement
}
if (userConfig.mountSelector) {
cfg.mountSelector = userConfig.mountSelector
}
instance = createWidget(cfg)
return instance
}
function destroy() {
if (instance && typeof instance.destroy === 'function') {
instance.destroy()
instance = null
}
}
window.AgentChatWidget = { init, destroy, version: VERSION }
// Auto-init only when the embed script includes data-agent-id (manual init() otherwise)
const autoScript = document.currentScript || document.querySelector('script[data-agent-id]')
if (
autoScript &&
autoScript.dataset &&
autoScript.dataset.autoInit !== 'false' &&
(autoScript.dataset.agentId || autoScript.getAttribute('data-agent-id'))
) {
init()
}
})()