From fab71b692b84f5ec6cb00ffaa7954342af276ab7 Mon Sep 17 00:00:00 2001 From: Daniel Ossig Date: Tue, 22 Sep 2026 21:25:31 +0000 Subject: [PATCH] feat: add a `labels` prop to localize the widget's built-in UI strings About twenty UI strings were hard-coded in English with no prop to set them: the header status line ("Type a message below", "Chat active", "Assistant is typing...", ...), the End Chat and Reset controls, the buttons after a chat has ended, the consent form's Accept / Cancel and the control tooltips. A non-English site could not change them. `labels?: Partial` overrides any subset; keys left out keep their English default (`defaultLabels`), so existing integrations render exactly as before. The embed parses `labels` / `data-labels` as JSON like `assistant-overrides`. While touching these buttons, the chat-mode send button gets the tooltip the hybrid one already had, and the header's close button gets an accessible title. Covered by tests/widget-labels.spec.ts on a new fixture page with both embed mechanisms, plus a control test on the existing fixture asserting the English defaults are unchanged. Co-Authored-By: Claude Fable 5.1 --- README.md | 73 +++++++++++++++++++ src/components/VapiWidget.tsx | 19 ++++- src/components/constants.ts | 28 +++++++ src/components/index.ts | 2 +- src/components/types.ts | 58 +++++++++++++++ src/components/widget/ConsentForm.tsx | 7 +- src/components/widget/WidgetHeader.tsx | 26 ++++--- .../widget/controls/ChatControls.tsx | 2 + .../widget/controls/HybridControls.tsx | 11 +-- .../widget/controls/VoiceControls.tsx | 5 +- src/index.ts | 2 +- src/widget/index.ts | 10 ++- test-widget-labels.html | 54 ++++++++++++++ tests/widget-labels.spec.ts | 73 +++++++++++++++++++ 14 files changed, 342 insertions(+), 28 deletions(-) create mode 100644 test-widget-labels.html create mode 100644 tests/widget-labels.spec.ts diff --git a/README.md b/README.md index 87ce5be..67ff752 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ The simplest way to add the widget to your website: | `consentTitle` | `string` | `"Terms and conditions"` | Consent form title | | `consentContent` | `string` | _(default message)_ | Terms & conditions content | | `consentStorageKey` | `string` | `"vapi_widget_consent"` | Key for storing consent | +| **Localization** | | | | +| `labels` | `Partial` | _(English)_ | Built-in UI strings to override — see [Localization](#localization) | ### Event Callbacks @@ -301,6 +303,77 @@ Use this approach if your environment doesn't support custom elements or for bet /> ``` +## Localization + +Everything the widget says can be set from outside. Content-like strings have +their own props (`title`, `ctaTitle`, `chatPlaceholder`, `chatEmptyMessage`, +`chatFirstMessage`, `chatEndMessage`, `consentTitle`, `consentContent`, +`startButtonText`, `endButtonText`, ...). The remaining UI strings — the +header's status line, button labels and tooltips — are grouped in the +`labels` prop. Pass any subset; keys you leave out keep their English default. + +| Key | Default | Where it shows | +| ------------------- | --------------------------------- | -------------------------------------------------------------------- | +| `connecting` | `'Connecting...'` | Status line while a call connects; voice button; hybrid call tooltip | +| `assistantSpeaking` | `'Assistant Speaking...'` | Status line during a call | +| `listening` | `'Listening...'` | Status line during a call | +| `assistantTyping` | `'Assistant is typing...'` | Status line while a reply streams | +| `chatActive` | `'Chat active'` | Status line, chat mode, once the conversation has started | +| `readyToAssist` | `'Ready to assist'` | Status line, hybrid mode, once the conversation has started | +| `connected` | `'Connected'` | Status line, voice mode, once the conversation has started | +| `voiceIdle` | `'Click the microphone to start'` | Status line, voice mode, before the first call | +| `chatIdle` | `'Type a message below'` | Status line, chat mode, before the first message | +| `hybridIdle` | `'Choose voice or text'` | Status line, hybrid mode, before the first interaction | +| `endChat` | `'End Chat'` | Header button (chat mode) | +| `resetConversation` | `'Reset conversation'` | Header reset button tooltip | +| `close` | `'Close'` | Header close button tooltip; close button after a chat has ended | +| `startNewChat` | `'Start new chat'` | Button after a chat has ended | +| `consentAccept` | `'Accept'` | Consent form | +| `consentCancel` | `'Cancel'` | Consent form | +| `sendMessage` | `'Send message'` | Send button tooltip | +| `muteMicrophone` | `'Mute microphone'` | Mute button tooltip during a call | +| `unmuteMicrophone` | `'Unmute microphone'` | Mute button tooltip during a call | +| `startVoiceCall` | `'Start voice call'` | Hybrid call button tooltip | +| `stopVoiceCall` | `'Stop voice call'` | Hybrid call button tooltip | + +React: + +```tsx + +``` + +Custom element — `labels` is a JSON attribute, like `assistant-overrides`: + +```html + +``` + +The data-attribute embed takes the same object as `data-labels`. The +`WidgetLabels` type is exported, so a translation table can be typed. + ## Development ### Setup diff --git a/src/components/VapiWidget.tsx b/src/components/VapiWidget.tsx index ae9d8ea..a452ae8 100644 --- a/src/components/VapiWidget.tsx +++ b/src/components/VapiWidget.tsx @@ -3,7 +3,12 @@ import { useVapiWidget } from '../hooks'; import { VapiWidgetProps, ColorScheme, StyleConfig } from './types'; -import { sizeStyles, radiusStyles, positionStyles } from './constants'; +import { + sizeStyles, + radiusStyles, + positionStyles, + defaultLabels, +} from './constants'; import ConsentForm from './widget/ConsentForm'; import FloatingButton from './widget/FloatingButton'; @@ -72,6 +77,8 @@ const VapiWidget: React.FC = ({ termsContent = 'By clicking "Agree," and each time I interact with this AI agent, I consent to the recording, storage, and sharing of my communications with third-party service providers, and as otherwise described in our Terms of Service.', // deprecated consentStorageKey, localStorageKey = 'vapi_widget_consent', // deprecated + // Localization + labels, // Event handlers onVoiceStart, onCallStart, // deprecated @@ -141,6 +148,7 @@ const VapiWidget: React.FC = ({ const effectiveChatPlaceholder = chatPlaceholder ?? 'Type your message...'; const effectiveChatEndMessage = chatEndMessage ?? 'This chat has ended. Thank you.'; + const effectiveLabels = { ...defaultLabels, ...labels }; const vapi = useVapiWidget({ mode, @@ -400,13 +408,13 @@ const VapiWidget: React.FC = ({ color: colors.ctaButtonTextColor, }} > - Start new chat + {effectiveLabels.startNewChat} @@ -491,6 +499,7 @@ const VapiWidget: React.FC = ({ startButtonText={effectiveStartButtonText} endButtonText={effectiveEndButtonText} colors={colors} + labels={effectiveLabels} /> ); } @@ -506,6 +515,7 @@ const VapiWidget: React.FC = ({ styles={styles} inputRef={inputRef} placeholder={effectiveChatPlaceholder} + labels={effectiveLabels} /> ); } @@ -527,6 +537,7 @@ const VapiWidget: React.FC = ({ styles={styles} inputRef={inputRef} placeholder={effectiveChatPlaceholder} + labels={effectiveLabels} /> ); } @@ -545,6 +556,7 @@ const VapiWidget: React.FC = ({ colors={colors} styles={styles} radius={radius} + labels={effectiveLabels} /> ); } @@ -565,6 +577,7 @@ const VapiWidget: React.FC = ({ showEndChatButton={!showEndScreen} colors={colors} styles={styles} + labels={effectiveLabels} /> {/* Conversation Area */} diff --git a/src/components/constants.ts b/src/components/constants.ts index 0a0c12f..cc7c8cd 100644 --- a/src/components/constants.ts +++ b/src/components/constants.ts @@ -1,3 +1,31 @@ +import type { WidgetLabels } from './types'; + +// English defaults for every UI string the widget renders on its own. The +// `labels` prop overrides any subset of them (see README, "Localization"). +export const defaultLabels: WidgetLabels = { + connecting: 'Connecting...', + assistantSpeaking: 'Assistant Speaking...', + listening: 'Listening...', + assistantTyping: 'Assistant is typing...', + chatActive: 'Chat active', + readyToAssist: 'Ready to assist', + connected: 'Connected', + voiceIdle: 'Click the microphone to start', + chatIdle: 'Type a message below', + hybridIdle: 'Choose voice or text', + endChat: 'End Chat', + resetConversation: 'Reset conversation', + close: 'Close', + startNewChat: 'Start new chat', + consentAccept: 'Accept', + consentCancel: 'Cancel', + sendMessage: 'Send message', + muteMicrophone: 'Mute microphone', + unmuteMicrophone: 'Unmute microphone', + startVoiceCall: 'Start voice call', + stopVoiceCall: 'Stop voice call', +}; + export const sizeClasses = { tiny: { button: 'w-12 h-12', diff --git a/src/components/index.ts b/src/components/index.ts index 9b5216d..3ed1f8a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,5 @@ export { default as AnimatedStatusIcon } from './AnimatedStatusIcon'; export { default as VapiWidget } from './VapiWidget'; -export type { VapiWidgetProps } from './types'; +export type { VapiWidgetProps, WidgetLabels } from './types'; export type { AnimatedStatusIconProps } from './AnimatedStatusIcon'; diff --git a/src/components/types.ts b/src/components/types.ts index abf964e..22ea263 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -1,3 +1,53 @@ +/** + * Every piece of UI text the widget renders on its own — status line, + * button labels and tooltips — as opposed to content that already has a + * dedicated prop (`title`, `chatPlaceholder`, `chatEmptyMessage`, + * `consentTitle`, `consentContent`, `chatEndMessage`, ...). Pass any subset + * through the `labels` prop to localize the widget; keys you leave out keep + * their English default. + */ +export interface WidgetLabels { + // Header status line + /** While a voice call is being established. Also the voice button text and the hybrid call button tooltip. */ + connecting: string; + assistantSpeaking: string; + listening: string; + assistantTyping: string; + /** Chat mode, once the conversation has started */ + chatActive: string; + /** Hybrid mode, once the conversation has started */ + readyToAssist: string; + /** Voice mode, once the conversation has started */ + connected: string; + /** Voice mode, before the first call */ + voiceIdle: string; + /** Chat mode, before the first message */ + chatIdle: string; + /** Hybrid mode, before the first interaction */ + hybridIdle: string; + + // Header controls + endChat: string; + /** Tooltip of the reset button */ + resetConversation: string; + /** Tooltip of the close button, and the close button after a chat has ended */ + close: string; + + // After a chat has ended + startNewChat: string; + + // Consent form buttons + consentAccept: string; + consentCancel: string; + + // Control tooltips + sendMessage: string; + muteMicrophone: string; + unmuteMicrophone: string; + startVoiceCall: string; + stopVoiceCall: string; +} + export interface VapiWidgetProps { // API Configuration apiUrl?: string; @@ -58,6 +108,10 @@ export interface VapiWidgetProps { consentContent?: string; consentStorageKey?: string; + // Localization + /** Overrides for the widget's built-in UI strings; see `WidgetLabels` */ + labels?: Partial; + // Event handlers onVoiceStart?: () => void; onVoiceEnd?: () => void; @@ -161,6 +215,7 @@ export interface WidgetHeaderProps { showEndChatButton?: boolean; colors: ColorScheme; styles: StyleConfig; + labels: WidgetLabels; } export interface ConversationMessageProps { @@ -197,6 +252,7 @@ export interface VoiceControlsProps { startButtonText: string; endButtonText: string; colors: ColorScheme; + labels: WidgetLabels; } export interface ChatControlsProps { @@ -208,6 +264,7 @@ export interface ChatControlsProps { styles: StyleConfig; inputRef?: React.RefObject; placeholder?: string; + labels: WidgetLabels; } export interface HybridControlsProps { @@ -225,4 +282,5 @@ export interface HybridControlsProps { styles: StyleConfig; inputRef?: React.RefObject; placeholder?: string; + labels: WidgetLabels; } diff --git a/src/components/widget/ConsentForm.tsx b/src/components/widget/ConsentForm.tsx index 7d78bc5..61247b0 100644 --- a/src/components/widget/ConsentForm.tsx +++ b/src/components/widget/ConsentForm.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { WidgetLabels } from '../types'; interface ColorScheme { baseColor: string; @@ -28,6 +29,7 @@ export interface ConsentFormProps { colors: ColorScheme; styles: StyleConfig; radius: 'none' | 'small' | 'medium' | 'large'; + labels: Pick; } const ConsentForm: React.FC = ({ @@ -38,6 +40,7 @@ const ConsentForm: React.FC = ({ colors, styles, radius, + labels, }) => { // Use the configured base color and derive other colors based on theme const isDark = styles.theme === 'dark'; @@ -154,14 +157,14 @@ const ConsentForm: React.FC = ({ onClick={onCancel} style={cancelButtonStyle} > - Cancel + {labels.consentCancel} diff --git a/src/components/widget/WidgetHeader.tsx b/src/components/widget/WidgetHeader.tsx index a586d38..aafa10a 100644 --- a/src/components/widget/WidgetHeader.tsx +++ b/src/components/widget/WidgetHeader.tsx @@ -17,25 +17,26 @@ const WidgetHeader: React.FC = ({ showEndChatButton, colors, styles, + labels, }) => { const getStatusMessage = () => { - if (connectionStatus === 'connecting') return 'Connecting...'; + if (connectionStatus === 'connecting') return labels.connecting; if (isCallActive) { - return isSpeaking ? 'Assistant Speaking...' : 'Listening...'; + return isSpeaking ? labels.assistantSpeaking : labels.listening; } - if (isTyping) return 'Assistant is typing...'; + if (isTyping) return labels.assistantTyping; if (hasActiveConversation) { - if (mode === 'chat') return 'Chat active'; - if (mode === 'hybrid') return 'Ready to assist'; - return 'Connected'; + if (mode === 'chat') return labels.chatActive; + if (mode === 'hybrid') return labels.readyToAssist; + return labels.connected; } - if (mode === 'voice') return 'Click the microphone to start'; - if (mode === 'chat') return 'Type a message below'; - return 'Choose voice or text'; + if (mode === 'voice') return labels.voiceIdle; + if (mode === 'chat') return labels.chatIdle; + return labels.hybridIdle; }; return ( @@ -74,21 +75,22 @@ const WidgetHeader: React.FC = ({ )} diff --git a/src/components/widget/controls/ChatControls.tsx b/src/components/widget/controls/ChatControls.tsx index a382727..e63d2b1 100644 --- a/src/components/widget/controls/ChatControls.tsx +++ b/src/components/widget/controls/ChatControls.tsx @@ -11,6 +11,7 @@ const ChatControls: React.FC = ({ styles, inputRef, placeholder = 'Type your message...', // Default fallback + labels, }) => (
= ({ backgroundColor: colors.accentColor, color: colors.ctaButtonTextColor || 'white', }} + title={labels.sendMessage} > diff --git a/src/components/widget/controls/HybridControls.tsx b/src/components/widget/controls/HybridControls.tsx index 3bd0ba5..d225f5b 100644 --- a/src/components/widget/controls/HybridControls.tsx +++ b/src/components/widget/controls/HybridControls.tsx @@ -23,6 +23,7 @@ const HybridControls: React.FC = ({ styles, inputRef, placeholder = 'Type your message...', // Default fallback + labels, }) => (
= ({ backgroundColor: colors.accentColor, color: colors.ctaButtonTextColor || 'white', }} - title="Send message" + title={labels.sendMessage} > @@ -78,7 +79,7 @@ const HybridControls: React.FC = ({ backgroundColor: isMuted ? '#ef4444' : colors.accentColor, color: colors.ctaButtonTextColor || 'white', }} - title={isMuted ? 'Unmute microphone' : 'Mute microphone'} + title={isMuted ? labels.unmuteMicrophone : labels.muteMicrophone} > {isMuted ? ( @@ -101,10 +102,10 @@ const HybridControls: React.FC = ({ }} title={ connectionStatus === 'connecting' - ? 'Connecting...' + ? labels.connecting : isCallActive - ? 'Stop voice call' - : 'Start voice call' + ? labels.stopVoiceCall + : labels.startVoiceCall } > {connectionStatus === 'connecting' ? ( diff --git a/src/components/widget/controls/VoiceControls.tsx b/src/components/widget/controls/VoiceControls.tsx index a7d476e..682925b 100644 --- a/src/components/widget/controls/VoiceControls.tsx +++ b/src/components/widget/controls/VoiceControls.tsx @@ -17,6 +17,7 @@ const VoiceControls: React.FC = ({ startButtonText, endButtonText, colors, + labels, }) => (
{isCallActive && connectionStatus === 'connected' && ( @@ -27,7 +28,7 @@ const VoiceControls: React.FC = ({ backgroundColor: isMuted ? '#ef4444' : colors.accentColor, color: colors.ctaButtonTextColor || 'white', }} - title={isMuted ? 'Unmute microphone' : 'Mute microphone'} + title={isMuted ? labels.unmuteMicrophone : labels.muteMicrophone} > {isMuted ? ( @@ -52,7 +53,7 @@ const VoiceControls: React.FC = ({ {connectionStatus === 'connecting' ? ( <>
- Connecting... + {labels.connecting} ) : isCallActive ? ( <> diff --git a/src/index.ts b/src/index.ts index 4291338..0de0ad0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import './styles/globals.css'; export { default as VapiWidget } from './components/VapiWidget'; // Export types -export type { VapiWidgetProps } from './components'; +export type { VapiWidgetProps, WidgetLabels } from './components'; // Export hooks export * from './hooks'; diff --git a/src/widget/index.ts b/src/widget/index.ts index a165dbf..9833d3d 100644 --- a/src/widget/index.ts +++ b/src/widget/index.ts @@ -46,6 +46,9 @@ class WidgetLoader { } } +// Props whose HTML attribute carries a JSON object rather than a string +const JSON_PROPS = ['assistantOverrides', 'assistant', 'labels']; + function kebabToCamel(str: string): string { return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); } @@ -92,7 +95,7 @@ function initializeWidgets() { const propName = kebabToCamel(attr.name.replace('data-', '')); // Special handling for JSON attributes - if (propName === 'assistantOverrides' || propName === 'assistant') { + if (JSON_PROPS.includes(propName)) { try { props[propName] = JSON.parse(attr.value); } catch (e) { @@ -178,6 +181,9 @@ function initializeWidgets() { 'consent-content': 'consentContent', 'consent-storage-key': 'consentStorageKey', + // Localization (a JSON object, see README "Localization") + labels: 'labels', + // API Configuration 'api-url': 'apiUrl', @@ -207,7 +213,7 @@ function initializeWidgets() { const value = htmlElement.getAttribute(htmlAttr); if (value !== null) { // Special handling for JSON attributes - if (propName === 'assistantOverrides' || propName === 'assistant') { + if (JSON_PROPS.includes(propName)) { try { props[propName] = JSON.parse(value); } catch (e) { diff --git a/test-widget-labels.html b/test-widget-labels.html new file mode 100644 index 0000000..fecf53f --- /dev/null +++ b/test-widget-labels.html @@ -0,0 +1,54 @@ + + + + + + VapiWidget Labels Test + + + +

VapiWidget Labels Test

+

+ This page tests the labels prop through both embed + mechanisms. Keys that are left out keep their English default. +

+ + +
+ +
+ + +
+ + + + diff --git a/tests/widget-labels.spec.ts b/tests/widget-labels.spec.ts new file mode 100644 index 0000000..5e265d8 --- /dev/null +++ b/tests/widget-labels.spec.ts @@ -0,0 +1,73 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; + +/** + * The `labels` prop: every UI string the widget renders on its own — the + * header status line, button labels and tooltips — can be overridden, through + * React props, the `labels` JSON attribute and the `data-labels` + * JSON attribute of the data-attribute embed. Keys that are left out keep + * their English default. + */ + +async function openWidget(page: Page, containerId: string): Promise { + await page.goto('/test-widget-labels'); + await page.waitForFunction(() => (window as any).WidgetLoader !== undefined, { + timeout: 5000, + }); + const container = page.locator(`#${containerId}`); + // The floating button shows the widget title. + await container.getByText('Chat', { exact: true }).click(); + return container; +} + +test.describe('VapiWidget labels', () => { + for (const containerId of ['labels-widget-1', 'labels-widget-2']) { + test(`${containerId}: consent buttons, status line and header controls use the given labels`, async ({ + page, + }) => { + const container = await openWidget(page, containerId); + + // Consent form buttons come from `labels` + await expect( + container.getByRole('button', { name: 'Abbrechen' }) + ).toBeVisible(); + await container.getByRole('button', { name: 'Akzeptieren' }).click(); + + // Header: overridden status line and End Chat button + await expect(container.getByText('Nachricht eingeben')).toBeVisible(); + await expect( + container.getByRole('button', { name: 'Beenden' }) + ).toBeVisible(); + + // Keys that were not overridden keep their English default: the reset + // tooltip is overridden only on the first widget, the send tooltip on + // neither. + const resetTitle = + containerId === 'labels-widget-1' + ? 'Zurücksetzen' + : 'Reset conversation'; + await expect(container.getByTitle(resetTitle)).toBeVisible(); + await expect(container.getByTitle('Send message')).toBeVisible(); + }); + } + + test('without labels, the English defaults are unchanged', async ({ + page, + }) => { + await page.goto('/test-widget-embed'); + await page.waitForFunction( + () => (window as any).WidgetLoader !== undefined, + { timeout: 5000 } + ); + + // The custom element on that page runs in hybrid mode with consent + // required, a `chat-first-message` (so the conversation counts as + // started) and no `labels`. + const widget = page.locator('vapi-widget'); + await widget.getByText('CTA Title', { exact: true }).click(); + await expect(widget.getByRole('button', { name: 'Cancel' })).toBeVisible(); + await widget.getByRole('button', { name: 'Accept' }).click(); + await expect(widget.getByText('Ready to assist')).toBeVisible(); + await expect(widget.getByTitle('Reset conversation')).toBeVisible(); + await expect(widget.getByTitle('Send message')).toBeVisible(); + }); +});