mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: add headless mode, redesign floating widget, refactor lifecycle callbacks (#268)
* feat: add headless widget for deployment * feat: call callbacks at the right time * feat: add onCallConnected & onCallDisconnected callback * feat: add a button with text for floating widget * feat: add headless widget for deployment * feat: call callbacks at the right time * feat: add onCallConnected & onCallDisconnected callback * feat: add a button with text for floating widget * docs: web widget * fix: format issue in pre-pr drift check * fix: fix CD to rely on pipecat dev dependey * chore: update message --------- Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
parent
31e2c135b0
commit
d2a119c38a
75 changed files with 803 additions and 485 deletions
|
|
@ -33,6 +33,8 @@
|
|||
callbacks: {
|
||||
onReady: null,
|
||||
onCallStart: null,
|
||||
onCallConnected: null,
|
||||
onCallDisconnected: null,
|
||||
onCallEnd: null,
|
||||
onError: null,
|
||||
onStatusChange: null
|
||||
|
|
@ -114,7 +116,7 @@
|
|||
containerId: configData.settings?.containerId || 'dograh-inline-container',
|
||||
position: configData.position || DEFAULT_CONFIG.position,
|
||||
buttonColor: configData.settings?.buttonColor || '#10b981',
|
||||
buttonText: configData.settings?.buttonText || 'Start Call',
|
||||
buttonText: configData.settings?.buttonText || 'Talk to Agent',
|
||||
callToActionText: configData.settings?.callToActionText || 'Click to start voice conversation',
|
||||
autoStart: configData.auto_start || false
|
||||
};
|
||||
|
|
@ -125,13 +127,14 @@
|
|||
|
||||
state.isInitialized = true;
|
||||
|
||||
// Load styles
|
||||
injectStyles();
|
||||
|
||||
// Create widget UI based on mode
|
||||
if (state.config.embedMode === 'inline') {
|
||||
injectStyles();
|
||||
createInlineWidget();
|
||||
} else if (state.config.embedMode === 'headless') {
|
||||
createHeadlessWidget();
|
||||
} else {
|
||||
injectStyles();
|
||||
createFloatingWidget();
|
||||
}
|
||||
|
||||
|
|
@ -192,68 +195,43 @@
|
|||
left: 20px;
|
||||
}
|
||||
|
||||
.dograh-widget-button {
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
.dograh-widget-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100vw - 40px);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
|
||||
transition: filter 150ms ease, transform 100ms ease, box-shadow 200ms ease;
|
||||
animation: dograh-cta-in 220ms ease-out;
|
||||
}
|
||||
|
||||
.dograh-widget-button:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
|
||||
.dograh-widget-cta:hover {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.dograh-widget-cta:active { transform: scale(0.98); }
|
||||
|
||||
.dograh-widget-cta.dograh-state-connecting { background: #f59e0b !important; animation: dograh-pulse 1.6s infinite; }
|
||||
.dograh-widget-cta.dograh-state-connected { background: #ef4444 !important; }
|
||||
.dograh-widget-cta.dograh-state-failed { background: #ef4444 !important; opacity: 0.85; }
|
||||
|
||||
@keyframes dograh-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.dograh-widget-button:active {
|
||||
transform: scale(0.95);
|
||||
@keyframes dograh-cta-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Green button for idle/ready state */
|
||||
.dograh-widget-button-idle {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.dograh-widget-button-idle:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* Orange button for connecting state */
|
||||
.dograh-widget-button-connecting {
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Red button for connected state (to end call) */
|
||||
.dograh-widget-button-connected {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.dograh-widget-button-connected:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* Red button for failed state */
|
||||
.dograh-widget-button-failed {
|
||||
background: #ef4444;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const styleSheet = document.createElement('style');
|
||||
|
|
@ -262,39 +240,81 @@
|
|||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
function ctaLabelForStatus(status) {
|
||||
switch (status) {
|
||||
case 'connecting': return 'Connecting…';
|
||||
case 'connected': return 'End Call';
|
||||
case 'failed': return 'Retry';
|
||||
default: return state.config.buttonText || 'Talk to Agent';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create floating widget UI (simplified - no modal)
|
||||
* Create floating widget UI — a single CTA pill button anchored to the
|
||||
* configured corner of the viewport.
|
||||
*/
|
||||
function createFloatingWidget() {
|
||||
// Create container
|
||||
const container = document.createElement('div');
|
||||
container.className = `dograh-widget-container ${state.config.position}`;
|
||||
container.id = 'dograh-widget';
|
||||
container.id = 'dograh-widget-root';
|
||||
|
||||
// Create button (configured color to start, red to end)
|
||||
const button = document.createElement('button');
|
||||
button.className = 'dograh-widget-button dograh-widget-button-idle';
|
||||
button.id = 'dograh-widget-button';
|
||||
button.style.backgroundColor = state.config.buttonColor;
|
||||
button.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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"/>
|
||||
</svg>
|
||||
`;
|
||||
button.onclick = toggleCall;
|
||||
|
||||
// Create hidden audio element
|
||||
const audio = document.createElement('audio');
|
||||
audio.id = 'dograh-widget-audio';
|
||||
audio.autoplay = true;
|
||||
audio.style.display = 'none';
|
||||
|
||||
// Append elements
|
||||
container.appendChild(button);
|
||||
container.appendChild(audio);
|
||||
document.body.appendChild(container);
|
||||
state.audioElement = audio;
|
||||
|
||||
// Store audio element reference
|
||||
document.body.appendChild(container);
|
||||
renderFloating();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the floating CTA pill. Re-renders preserve the hidden audio
|
||||
* element so an in-progress call is not interrupted on status changes.
|
||||
*/
|
||||
function renderFloating() {
|
||||
const container = document.getElementById('dograh-widget-root');
|
||||
if (!container) return;
|
||||
|
||||
Array.from(container.children).forEach((child) => {
|
||||
if (child !== state.audioElement) container.removeChild(child);
|
||||
});
|
||||
|
||||
const status = state.connectionStatus || 'idle';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.id = 'dograh-widget-cta';
|
||||
button.type = 'button';
|
||||
button.className = `dograh-widget-cta dograh-state-${status}`;
|
||||
// Idle uses configured color; status states use CSS-defined colors.
|
||||
if (status === 'idle') {
|
||||
button.style.backgroundColor = state.config.buttonColor;
|
||||
}
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 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>
|
||||
<span></span>
|
||||
`;
|
||||
button.querySelector('span').textContent = ctaLabelForStatus(status);
|
||||
button.onclick = toggleCall;
|
||||
|
||||
container.appendChild(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create headless widget (no UI — host page drives everything via window.DograhWidget API)
|
||||
*/
|
||||
function createHeadlessWidget() {
|
||||
const audio = document.createElement('audio');
|
||||
audio.id = 'dograh-widget-audio';
|
||||
audio.autoplay = true;
|
||||
audio.style.display = 'none';
|
||||
document.body.appendChild(audio);
|
||||
state.audioElement = audio;
|
||||
}
|
||||
|
||||
|
|
@ -309,30 +329,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update floating widget button appearance
|
||||
*/
|
||||
function updateFloatingButton(status) {
|
||||
const button = document.getElementById('dograh-widget-button');
|
||||
if (!button) return;
|
||||
|
||||
// Remove all status classes
|
||||
button.classList.remove('dograh-widget-button-idle', 'dograh-widget-button-connecting', 'dograh-widget-button-connected', 'dograh-widget-button-failed');
|
||||
|
||||
// Add current status class
|
||||
button.classList.add(`dograh-widget-button-${status}`);
|
||||
|
||||
// Apply configured color only for idle state, let CSS handle other states
|
||||
button.style.backgroundColor = status === 'idle' ? state.config.buttonColor : '';
|
||||
|
||||
// Update title attribute for tooltip
|
||||
const titles = {
|
||||
idle: 'Start Call',
|
||||
connecting: 'Connecting...',
|
||||
connected: 'End Call',
|
||||
failed: 'Retry Call'
|
||||
};
|
||||
button.title = titles[status] || 'Voice Call';
|
||||
state.connectionStatus = status;
|
||||
renderFloating();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -582,6 +581,10 @@
|
|||
// Use appropriate update function based on mode
|
||||
if (state.config.embedMode === 'inline') {
|
||||
updateInlineStatus(status, text, subtext);
|
||||
} else if (state.config.embedMode === 'headless') {
|
||||
if (state.callbacks.onStatusChange) {
|
||||
state.callbacks.onStatusChange(status, text, subtext);
|
||||
}
|
||||
} else {
|
||||
updateFloatingButton(status);
|
||||
}
|
||||
|
|
@ -610,7 +613,6 @@
|
|||
async function startCall() {
|
||||
updateStatus('connecting', 'Connecting...', 'Please wait while we establish the connection');
|
||||
|
||||
// Trigger call start callback
|
||||
if (state.callbacks.onCallStart) {
|
||||
state.callbacks.onCallStart();
|
||||
}
|
||||
|
|
@ -768,9 +770,18 @@
|
|||
console.log('ICE connection state:', state.pc.iceConnectionState);
|
||||
|
||||
if (state.pc.iceConnectionState === 'connected' || state.pc.iceConnectionState === 'completed') {
|
||||
const wasAlreadyConnected = state.callStartedAt !== null;
|
||||
updateStatus('connected', 'Connected', 'Your voice call is now active');
|
||||
state.callStartedAt = Date.now();
|
||||
emitMessage('dograh:call_started', {});
|
||||
if (!wasAlreadyConnected) {
|
||||
state.callStartedAt = Date.now();
|
||||
if (state.callbacks.onCallConnected) {
|
||||
state.callbacks.onCallConnected({
|
||||
agentId: state.config.workflowId || null,
|
||||
token: state.config.token || null,
|
||||
workflowRunId: state.workflowRunId || null
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (state.pc.iceConnectionState === 'failed' || state.pc.iceConnectionState === 'disconnected') {
|
||||
updateStatus('failed', 'Connection lost', 'The call has been disconnected');
|
||||
stopCall();
|
||||
|
|
@ -903,16 +914,21 @@
|
|||
* Stop voice call
|
||||
*/
|
||||
function stopCall() {
|
||||
// Emit end message before clearing state so identifiers are still available
|
||||
const durationSeconds = state.callStartedAt
|
||||
? Math.round((Date.now() - state.callStartedAt) / 1000)
|
||||
: 0;
|
||||
emitMessage('dograh:call_ended', { durationSeconds });
|
||||
// Fire onCallDisconnected only if the call had actually connected, with
|
||||
// identifiers and duration. Must run before we clear callStartedAt.
|
||||
if (state.callStartedAt && state.callbacks.onCallDisconnected) {
|
||||
const durationSeconds = Math.round((Date.now() - state.callStartedAt) / 1000);
|
||||
state.callbacks.onCallDisconnected({
|
||||
agentId: state.config.workflowId || null,
|
||||
token: state.config.token || null,
|
||||
workflowRunId: state.workflowRunId || null,
|
||||
durationSeconds
|
||||
});
|
||||
}
|
||||
state.callStartedAt = null;
|
||||
|
||||
updateStatus('idle', 'Call ended', 'Click below to start a new call');
|
||||
|
||||
// Trigger call end callback
|
||||
if (state.callbacks.onCallEnd) {
|
||||
state.callbacks.onCallEnd();
|
||||
}
|
||||
|
|
@ -949,22 +965,6 @@
|
|||
setTimeout(() => startCall(), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a postMessage event to the host window
|
||||
* Allows the embedding website to listen for agent lifecycle events via:
|
||||
* window.addEventListener('message', (event) => { ... })
|
||||
*/
|
||||
function emitMessage(eventType, detail) {
|
||||
const message = {
|
||||
type: eventType,
|
||||
agentId: state.config.workflowId || null,
|
||||
token: state.config.token || null,
|
||||
workflowRunId: state.workflowRunId || null,
|
||||
...detail
|
||||
};
|
||||
window.postMessage(message, '*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique peer ID
|
||||
*/
|
||||
|
|
@ -993,6 +993,8 @@
|
|||
getState: () => state,
|
||||
onReady: (callback) => { state.callbacks.onReady = callback; },
|
||||
onCallStart: (callback) => { state.callbacks.onCallStart = callback; },
|
||||
onCallConnected: (callback) => { state.callbacks.onCallConnected = callback; },
|
||||
onCallDisconnected: (callback) => { state.callbacks.onCallDisconnected = callback; },
|
||||
onCallEnd: (callback) => { state.callbacks.onCallEnd = callback; },
|
||||
onError: (callback) => { state.callbacks.onError = callback; },
|
||||
onStatusChange: (callback) => { state.callbacks.onStatusChange = callback; },
|
||||
|
|
@ -1018,6 +1020,8 @@
|
|||
// Set callbacks if provided
|
||||
if (options.onReady) state.callbacks.onReady = options.onReady;
|
||||
if (options.onCallStart) state.callbacks.onCallStart = options.onCallStart;
|
||||
if (options.onCallConnected) state.callbacks.onCallConnected = options.onCallConnected;
|
||||
if (options.onCallDisconnected) state.callbacks.onCallDisconnected = options.onCallDisconnected;
|
||||
if (options.onCallEnd) state.callbacks.onCallEnd = options.onCallEnd;
|
||||
if (options.onError) state.callbacks.onError = options.onError;
|
||||
if (options.onStatusChange) state.callbacks.onStatusChange = options.onStatusChange;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Check, Copy, Loader2, Plus, Rocket, Trash2 } from "lucide-react";
|
||||
import { Check, Copy, Loader2, Mic, Plus, Rocket, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
|
|
@ -61,9 +61,9 @@ export function EmbedDialog({
|
|||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
const [newDomain, setNewDomain] = useState("");
|
||||
const [embedMode, setEmbedMode] = useState<"floating" | "inline">("floating");
|
||||
const [embedMode, setEmbedMode] = useState<"floating" | "inline" | "headless">("floating");
|
||||
const [position, setPosition] = useState("bottom-right");
|
||||
const [buttonText, setButtonText] = useState("Start Call");
|
||||
const [buttonText, setButtonText] = useState("Talk to Agent");
|
||||
const [buttonColor, setButtonColor] = useState("#10b981");
|
||||
const [callToActionText, setCallToActionText] = useState("Click to start voice conversation");
|
||||
|
||||
|
|
@ -81,9 +81,9 @@ export function EmbedDialog({
|
|||
// Load settings
|
||||
if (response.data.settings) {
|
||||
const settings = response.data.settings as Record<string, string>;
|
||||
setEmbedMode((settings.embedMode as "floating" | "inline") || "floating");
|
||||
setEmbedMode((settings.embedMode as "floating" | "inline" | "headless") || "floating");
|
||||
setPosition(settings.position || "bottom-right");
|
||||
setButtonText(settings.buttonText || "Start Call");
|
||||
setButtonText(settings.buttonText || "Talk to Agent");
|
||||
setButtonColor(settings.buttonColor || "#10b981");
|
||||
setCallToActionText(settings.callToActionText || "Click to start voice conversation");
|
||||
}
|
||||
|
|
@ -266,7 +266,7 @@ export function EmbedDialog({
|
|||
{/* Embed Mode Selection */}
|
||||
<div className="space-y-4">
|
||||
<Label>Embed Mode</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbedMode("floating")}
|
||||
|
|
@ -299,6 +299,22 @@ export function EmbedDialog({
|
|||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbedMode("headless")}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
embedMode === "headless"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted hover:border-muted-foreground/20"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">Headless (Bring Your Own UI)</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No UI — drive calls from your own buttons via the JS API
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -306,26 +322,40 @@ export function EmbedDialog({
|
|||
<div className="space-y-4">
|
||||
<Label>Configuration</Label>
|
||||
|
||||
{/* Shared: Button Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-color" className="text-sm">Button Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="button-color-picker"
|
||||
type="color"
|
||||
value={buttonColor}
|
||||
onChange={(e) => setButtonColor(e.target.value)}
|
||||
className="w-14 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
id="button-color"
|
||||
value={buttonColor}
|
||||
onChange={(e) => setButtonColor(e.target.value)}
|
||||
placeholder="#10b981"
|
||||
className="flex-1"
|
||||
/>
|
||||
{/* Shared: Button Text + Button Color (skipped in headless — host renders its own UI) */}
|
||||
{embedMode !== "headless" && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-text" className="text-sm">Button Text</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder="Talk to Agent"
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-color" className="text-sm">Button Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="button-color-picker"
|
||||
type="color"
|
||||
value={buttonColor}
|
||||
onChange={(e) => setButtonColor(e.target.value)}
|
||||
className="w-14 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
id="button-color"
|
||||
value={buttonColor}
|
||||
onChange={(e) => setButtonColor(e.target.value)}
|
||||
placeholder="#10b981"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating mode: Position */}
|
||||
{embedMode === "floating" && (
|
||||
|
|
@ -345,52 +375,29 @@ export function EmbedDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline mode: Button Text, CTA Text */}
|
||||
{/* Inline mode: Call to Action Text */}
|
||||
{embedMode === "inline" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button-text" className="text-sm">Button Text</Label>
|
||||
<Input
|
||||
id="button-text"
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder="Start Call"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cta-text" className="text-sm">Call to Action Text</Label>
|
||||
<Input
|
||||
id="cta-text"
|
||||
value={callToActionText}
|
||||
onChange={(e) => setCallToActionText(e.target.value)}
|
||||
placeholder="Click to start voice conversation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cta-text" className="text-sm">Call to Action Text</Label>
|
||||
<Input
|
||||
id="cta-text"
|
||||
value={callToActionText}
|
||||
onChange={(e) => setCallToActionText(e.target.value)}
|
||||
placeholder="Click to start voice conversation"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{embedMode === "floating" ? (
|
||||
<div className="rounded-lg border bg-background p-4 flex items-center justify-center">
|
||||
<div
|
||||
className="w-[60px] h-[60px] rounded-full flex items-center justify-center shadow-lg"
|
||||
style={{
|
||||
backgroundColor: buttonColor,
|
||||
}}
|
||||
{/* Preview (skipped for headless — host renders its own UI) */}
|
||||
{embedMode === "headless" ? null : embedMode === "floating" ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 flex items-center justify-center">
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-full px-5 py-3 font-medium text-white shadow-lg whitespace-nowrap"
|
||||
style={{ backgroundColor: buttonColor }}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<Mic className="h-4 w-4" />
|
||||
{buttonText || "Talk to Agent"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-background p-6 flex items-center justify-center">
|
||||
|
|
@ -410,6 +417,64 @@ export function EmbedDialog({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Headless mode: Integration Instructions */}
|
||||
{embedMode === "headless" && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-muted/50 p-4">
|
||||
<h4 className="font-medium mb-2">Integration Instructions</h4>
|
||||
<ul className="text-sm space-y-2 text-muted-foreground">
|
||||
<li>• Add the embed script tag to your page (see below).</li>
|
||||
<li>• The widget renders no UI — render your own buttons.</li>
|
||||
<li>• Call <code className="text-xs">window.DograhWidget.start()</code> to begin a call.</li>
|
||||
<li>• Call <code className="text-xs">window.DograhWidget.end()</code> to end it.</li>
|
||||
<li>• Subscribe to <code className="text-xs">onCallStart</code>, <code className="text-xs">onCallEnd</code>, <code className="text-xs">onStatusChange</code>, <code className="text-xs">onError</code> to drive your UI.</li>
|
||||
<li>• <code className="text-xs">start()</code> must run inside a user-gesture handler (click) so the browser grants microphone access.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-950/20 p-4 border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="font-medium mb-2 text-blue-900 dark:text-blue-100">Example — track status in your own state</h4>
|
||||
<p className="text-xs text-blue-900/80 dark:text-blue-100/80 mb-2">
|
||||
Mirror the call status into a variable you control, then render whatever UI you like from it. The status values are <code className="text-xs">idle</code>, <code className="text-xs">connecting</code>, <code className="text-xs">connected</code>, <code className="text-xs">failed</code>.
|
||||
</p>
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
<code className="text-blue-800 dark:text-blue-200">{`// Vanilla JS — keep your own state, render however you want
|
||||
let callStatus = 'idle';
|
||||
|
||||
window.DograhWidget?.onStatusChange((status) => {
|
||||
callStatus = status;
|
||||
// ...trigger your render here (re-paint DOM, dispatch event, etc.)
|
||||
});
|
||||
|
||||
document.getElementById('talk-btn').addEventListener('click', () => {
|
||||
if (callStatus === 'connected' || callStatus === 'connecting') {
|
||||
window.DograhWidget.end();
|
||||
} else {
|
||||
window.DograhWidget.start();
|
||||
}
|
||||
});`}</code>
|
||||
</pre>
|
||||
<p className="text-xs text-blue-900/80 dark:text-blue-100/80 mt-3 mb-2">React:</p>
|
||||
<pre className="text-xs overflow-x-auto">
|
||||
<code className="text-blue-800 dark:text-blue-200">{`function TalkButton() {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
useEffect(() => {
|
||||
window.DograhWidget?.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const isLive = status === 'connected' || status === 'connecting';
|
||||
return (
|
||||
<button onClick={() => isLive ? window.DograhWidget.end() : window.DograhWidget.start()}>
|
||||
{/* render anything you want from \`status\` */}
|
||||
</button>
|
||||
);
|
||||
}`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline mode: Integration Instructions */}
|
||||
{embedMode === "inline" && (
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue