@cognigy/chat-components-vue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +178 -0
- package/dist/assets/svg/ArrowBackIcon.vue.d.ts +9 -0
- package/dist/assets/svg/AudioPauseIcon.vue.d.ts +2 -0
- package/dist/assets/svg/AudioPlayIcon.vue.d.ts +2 -0
- package/dist/assets/svg/CloseIcon.vue.d.ts +2 -0
- package/dist/assets/svg/DownloadIcon.vue.d.ts +2 -0
- package/dist/assets/svg/VideoPlayIcon.vue.d.ts +9 -0
- package/dist/assets/svg/index.d.ts +7 -0
- package/dist/chat-components-vue.css +1 -0
- package/dist/chat-components-vue.js +9858 -0
- package/dist/components/Message.vue.d.ts +11 -0
- package/dist/components/common/ActionButton.vue.d.ts +59 -0
- package/dist/components/common/ActionButtons.vue.d.ts +36 -0
- package/dist/components/common/ChatBubble.vue.d.ts +22 -0
- package/dist/components/common/ChatEvent.vue.d.ts +20 -0
- package/dist/components/common/LinkIcon.vue.d.ts +2 -0
- package/dist/components/common/TypingIndicator.vue.d.ts +21 -0
- package/dist/components/common/Typography.vue.d.ts +38 -0
- package/dist/components/messages/AdaptiveCard.vue.d.ts +2 -0
- package/dist/components/messages/AudioMessage.vue.d.ts +5 -0
- package/dist/components/messages/DatePicker.vue.d.ts +2 -0
- package/dist/components/messages/FileMessage.vue.d.ts +2 -0
- package/dist/components/messages/Gallery.vue.d.ts +2 -0
- package/dist/components/messages/GalleryItem.vue.d.ts +7 -0
- package/dist/components/messages/ImageMessage.vue.d.ts +5 -0
- package/dist/components/messages/List.vue.d.ts +2 -0
- package/dist/components/messages/ListItem.vue.d.ts +16 -0
- package/dist/components/messages/TextMessage.vue.d.ts +15 -0
- package/dist/components/messages/TextWithButtons.vue.d.ts +2 -0
- package/dist/components/messages/VideoMessage.vue.d.ts +5 -0
- package/dist/composables/useChannelPayload.d.ts +47 -0
- package/dist/composables/useCollation.d.ts +47 -0
- package/dist/composables/useImageContext.d.ts +13 -0
- package/dist/composables/useMessageContext.d.ts +18 -0
- package/dist/composables/useSanitize.d.ts +8 -0
- package/dist/index.d.ts +33 -0
- package/dist/types/index.d.ts +275 -0
- package/dist/utils/helpers.d.ts +56 -0
- package/dist/utils/matcher.d.ts +20 -0
- package/dist/utils/sanitize.d.ts +28 -0
- package/dist/utils/theme.d.ts +18 -0
- package/package.json +94 -0
- package/src/assets/svg/ArrowBackIcon.vue +30 -0
- package/src/assets/svg/AudioPauseIcon.vue +20 -0
- package/src/assets/svg/AudioPlayIcon.vue +19 -0
- package/src/assets/svg/CloseIcon.vue +10 -0
- package/src/assets/svg/DownloadIcon.vue +10 -0
- package/src/assets/svg/VideoPlayIcon.vue +25 -0
- package/src/assets/svg/index.ts +7 -0
- package/src/components/Message.vue +152 -0
- package/src/components/common/ActionButton.vue +354 -0
- package/src/components/common/ActionButtons.vue +170 -0
- package/src/components/common/ChatBubble.vue +109 -0
- package/src/components/common/ChatEvent.vue +84 -0
- package/src/components/common/LinkIcon.vue +34 -0
- package/src/components/common/TypingIndicator.vue +202 -0
- package/src/components/common/Typography.vue +196 -0
- package/src/components/messages/AdaptiveCard.vue +292 -0
- package/src/components/messages/AudioMessage.vue +391 -0
- package/src/components/messages/DatePicker.vue +135 -0
- package/src/components/messages/FileMessage.vue +195 -0
- package/src/components/messages/Gallery.vue +296 -0
- package/src/components/messages/GalleryItem.vue +214 -0
- package/src/components/messages/ImageMessage.vue +368 -0
- package/src/components/messages/List.vue +149 -0
- package/src/components/messages/ListItem.vue +344 -0
- package/src/components/messages/TextMessage.vue +203 -0
- package/src/components/messages/TextWithButtons.vue +119 -0
- package/src/components/messages/VideoMessage.vue +343 -0
- package/src/composables/useChannelPayload.ts +101 -0
- package/src/composables/useCollation.ts +163 -0
- package/src/composables/useImageContext.ts +27 -0
- package/src/composables/useMessageContext.ts +41 -0
- package/src/composables/useSanitize.ts +25 -0
- package/src/index.ts +71 -0
- package/src/types/index.ts +373 -0
- package/src/utils/helpers.ts +164 -0
- package/src/utils/matcher.ts +283 -0
- package/src/utils/sanitize.ts +133 -0
- package/src/utils/theme.ts +58 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article
|
|
3
|
+
v-if="matchedComponents.length > 0"
|
|
4
|
+
:class="rootClasses"
|
|
5
|
+
:style="cssVariableStyle"
|
|
6
|
+
:data-message-id="dataMessageId"
|
|
7
|
+
>
|
|
8
|
+
<component
|
|
9
|
+
v-for="(matchedComponent, index) in matchedComponents"
|
|
10
|
+
:key="index"
|
|
11
|
+
:is="matchedComponent"
|
|
12
|
+
/>
|
|
13
|
+
|
|
14
|
+
<!-- Visually hidden focusable target for better keyboard navigation -->
|
|
15
|
+
<div
|
|
16
|
+
:id="`webchat-focus-target-${dataMessageId}`"
|
|
17
|
+
:tabindex="-1"
|
|
18
|
+
:class="$style.srOnly"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
/>
|
|
21
|
+
</article>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
import { computed, useCssModule, type Component } from 'vue'
|
|
26
|
+
import { match } from '../utils/matcher'
|
|
27
|
+
import { configColorsToCssVariables } from '../utils/theme'
|
|
28
|
+
import { provideMessageContext } from '../composables/useMessageContext'
|
|
29
|
+
import { getMessageId, isMessagePlugin } from '../types'
|
|
30
|
+
import type { MessageProps } from '../types'
|
|
31
|
+
|
|
32
|
+
const $style = useCssModule()
|
|
33
|
+
|
|
34
|
+
// Import all message type components
|
|
35
|
+
import TextMessage from './messages/TextMessage.vue'
|
|
36
|
+
import ImageMessage from './messages/ImageMessage.vue'
|
|
37
|
+
import VideoMessage from './messages/VideoMessage.vue'
|
|
38
|
+
import AudioMessage from './messages/AudioMessage.vue'
|
|
39
|
+
import TextWithButtons from './messages/TextWithButtons.vue'
|
|
40
|
+
import Gallery from './messages/Gallery.vue'
|
|
41
|
+
import List from './messages/List.vue'
|
|
42
|
+
import FileMessage from './messages/FileMessage.vue'
|
|
43
|
+
import DatePicker from './messages/DatePicker.vue'
|
|
44
|
+
import AdaptiveCard from './messages/AdaptiveCard.vue'
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<MessageProps>(), {
|
|
47
|
+
action: undefined,
|
|
48
|
+
config: undefined,
|
|
49
|
+
theme: undefined,
|
|
50
|
+
prevMessage: undefined,
|
|
51
|
+
plugins: undefined,
|
|
52
|
+
onEmitAnalytics: undefined,
|
|
53
|
+
disableHeader: false,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Generate a unique message ID for accessibility
|
|
57
|
+
const dataMessageId = computed(() => getMessageId(props.message))
|
|
58
|
+
|
|
59
|
+
// Provide message context for child components
|
|
60
|
+
provideMessageContext({
|
|
61
|
+
message: props.message,
|
|
62
|
+
config: props.config || {},
|
|
63
|
+
action: props.action || (() => {}),
|
|
64
|
+
onEmitAnalytics: props.onEmitAnalytics || (() => {}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Component map for internal match rules (maps rule names to Vue components)
|
|
68
|
+
const componentMap: Record<string, Component> = {
|
|
69
|
+
'Text': TextMessage,
|
|
70
|
+
'Image': ImageMessage,
|
|
71
|
+
'Video': VideoMessage,
|
|
72
|
+
'Audio': AudioMessage,
|
|
73
|
+
'TextWithButtons': TextWithButtons,
|
|
74
|
+
'Gallery': Gallery,
|
|
75
|
+
'List': List,
|
|
76
|
+
'File': FileMessage,
|
|
77
|
+
'DatePicker': DatePicker,
|
|
78
|
+
'AdaptiveCard': AdaptiveCard,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Match message to appropriate components
|
|
82
|
+
const matchedComponents = computed(() => {
|
|
83
|
+
const matched = match(props.message, props.config, props.plugins)
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(matched) || matched.length < 1) {
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Resolve components from match results
|
|
90
|
+
return matched
|
|
91
|
+
.map(rule => {
|
|
92
|
+
// External plugins (MessagePlugin) provide their own component
|
|
93
|
+
if (isMessagePlugin(rule)) {
|
|
94
|
+
return rule.component
|
|
95
|
+
}
|
|
96
|
+
// Internal rules (MatchRule) use name lookup in componentMap
|
|
97
|
+
return componentMap[rule.name] ?? null
|
|
98
|
+
})
|
|
99
|
+
.filter((c): c is Component => c !== null)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Root element classes
|
|
103
|
+
const rootClasses = computed(() => {
|
|
104
|
+
return [
|
|
105
|
+
'webchat-message-row',
|
|
106
|
+
props.message.source,
|
|
107
|
+
$style.message,
|
|
108
|
+
props.message.source === 'bot' && $style.bot,
|
|
109
|
+
props.message.source === 'user' && $style.user,
|
|
110
|
+
props.message.source === 'agent' && $style.agent,
|
|
111
|
+
].filter(Boolean)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// CSS variable injection from config colors
|
|
115
|
+
const cssVariableStyle = computed(() => {
|
|
116
|
+
return configColorsToCssVariables(props.config?.settings?.colors)
|
|
117
|
+
})
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<style module>
|
|
121
|
+
.message {
|
|
122
|
+
display: flex;
|
|
123
|
+
flex-direction: column;
|
|
124
|
+
gap: 8px;
|
|
125
|
+
margin-bottom: 16px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.message.bot {
|
|
129
|
+
align-items: flex-start;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.message.user {
|
|
133
|
+
align-items: flex-end;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.message.agent {
|
|
137
|
+
align-items: flex-start;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Screen reader only */
|
|
141
|
+
.srOnly {
|
|
142
|
+
position: absolute;
|
|
143
|
+
width: 1px;
|
|
144
|
+
height: 1px;
|
|
145
|
+
padding: 0;
|
|
146
|
+
margin: -1px;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
clip: rect(0, 0, 0, 0);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
border-width: 0;
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useCssModule } from 'vue'
|
|
3
|
+
import { sanitizeUrl } from '@braintree/sanitize-url'
|
|
4
|
+
import Typography from './Typography.vue'
|
|
5
|
+
import LinkIcon from './LinkIcon.vue'
|
|
6
|
+
import { sanitizeHTMLWithConfig } from '../../utils/sanitize'
|
|
7
|
+
import { getWebchatButtonLabel, interpolateString, moveFocusToMessageFocusTarget } from '../../utils/helpers'
|
|
8
|
+
import type { IWebchatButton, IWebchatQuickReply, ChatConfig, MessageSender, CustomIcon, AnalyticsEventCallback } from '../../types'
|
|
9
|
+
|
|
10
|
+
type NormalizedActionButton = {
|
|
11
|
+
type?: string
|
|
12
|
+
content_type?: string
|
|
13
|
+
contentType?: string
|
|
14
|
+
title?: string
|
|
15
|
+
payload?: string
|
|
16
|
+
url?: string
|
|
17
|
+
target?: string
|
|
18
|
+
image_url?: string
|
|
19
|
+
imageUrl?: string
|
|
20
|
+
image_alt_text?: string
|
|
21
|
+
imageAltText?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
button: (IWebchatButton | IWebchatQuickReply) & NormalizedActionButton
|
|
26
|
+
action?: MessageSender
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
total: number
|
|
29
|
+
position: number
|
|
30
|
+
customIcon?: CustomIcon
|
|
31
|
+
showUrlIcon?: boolean
|
|
32
|
+
config?: ChatConfig
|
|
33
|
+
dataMessageId?: string
|
|
34
|
+
onEmitAnalytics?: AnalyticsEventCallback
|
|
35
|
+
size?: 'small' | 'large'
|
|
36
|
+
id?: string
|
|
37
|
+
className?: string
|
|
38
|
+
openXAppOverlay?: (url: string | undefined) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
42
|
+
action: undefined,
|
|
43
|
+
disabled: false,
|
|
44
|
+
customIcon: undefined,
|
|
45
|
+
showUrlIcon: false,
|
|
46
|
+
config: undefined,
|
|
47
|
+
dataMessageId: undefined,
|
|
48
|
+
onEmitAnalytics: undefined,
|
|
49
|
+
size: 'small',
|
|
50
|
+
id: undefined,
|
|
51
|
+
className: '',
|
|
52
|
+
openXAppOverlay: undefined,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const styles = useCssModule()
|
|
56
|
+
|
|
57
|
+
// Determine button type
|
|
58
|
+
const buttonType = computed(() => {
|
|
59
|
+
return props.button.type ?? props.button.content_type ?? props.button.contentType ?? null
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Get button image
|
|
63
|
+
const buttonImage = computed(() => {
|
|
64
|
+
if ('image_url' in props.button) return props.button.image_url
|
|
65
|
+
if ('imageUrl' in props.button) return props.button.imageUrl
|
|
66
|
+
return null
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Get button image alt text
|
|
70
|
+
const buttonImageAltText = computed(() => {
|
|
71
|
+
if ('image_alt_text' in props.button) return props.button.image_alt_text
|
|
72
|
+
if ('imageAltText' in props.button) return props.button.imageAltText
|
|
73
|
+
return ''
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Get button label
|
|
77
|
+
const buttonLabel = computed(() => {
|
|
78
|
+
return getWebchatButtonLabel(props.button) || ''
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Sanitize button label HTML
|
|
82
|
+
const sanitizedLabel = computed(() => {
|
|
83
|
+
const customAllowedHtmlTags = props.config?.settings?.widgetSettings?.customAllowedHtmlTags
|
|
84
|
+
return props.config?.settings?.layout?.disableHtmlContentSanitization
|
|
85
|
+
? buttonLabel.value
|
|
86
|
+
: sanitizeHTMLWithConfig(buttonLabel.value, customAllowedHtmlTags)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Check if phone number button
|
|
90
|
+
const isPhoneNumber = computed(() => {
|
|
91
|
+
return props.button.payload && (buttonType.value === 'phone_number' || buttonType.value === 'user_phone_number')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Check if web URL button
|
|
95
|
+
const isWebURL = computed(() => {
|
|
96
|
+
return 'type' in props.button && props.button.type === 'web_url'
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Check if opens in new tab
|
|
100
|
+
const isWebURLButtonTargetBlank = computed(() => {
|
|
101
|
+
return isWebURL.value && props.button.target !== '_self'
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Get aria-label
|
|
105
|
+
const ariaLabel = computed(() => {
|
|
106
|
+
const buttonTitle = props.button.title || ''
|
|
107
|
+
const opensInNewTabLabel = props.config?.settings?.customTranslations?.ariaLabels?.opensInNewTab || 'Opens in new tab'
|
|
108
|
+
|
|
109
|
+
const isURLInNewTab = isWebURL.value && isWebURLButtonTargetBlank.value
|
|
110
|
+
const newTabURLButtonTitle = `${buttonTitle}. ${opensInNewTabLabel}`
|
|
111
|
+
const buttonTitleWithTarget = isURLInNewTab ? newTabURLButtonTitle : props.button.title
|
|
112
|
+
|
|
113
|
+
if (props.total > 1) {
|
|
114
|
+
return (
|
|
115
|
+
interpolateString(
|
|
116
|
+
props.config?.settings?.customTranslations?.ariaLabels?.actionButtonPositionText ?? '{position} of {total}',
|
|
117
|
+
{
|
|
118
|
+
position: props.position.toString(),
|
|
119
|
+
total: props.total.toString(),
|
|
120
|
+
}
|
|
121
|
+
) +
|
|
122
|
+
': ' +
|
|
123
|
+
buttonTitleWithTarget
|
|
124
|
+
)
|
|
125
|
+
} else if (props.total <= 1 && isURLInNewTab) {
|
|
126
|
+
return newTabURLButtonTitle
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return undefined
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Determine which component to render
|
|
133
|
+
const componentTag = computed(() => {
|
|
134
|
+
const isURLComponent = isWebURL.value || isPhoneNumber.value
|
|
135
|
+
if (!isURLComponent) return 'button'
|
|
136
|
+
return 'a'
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Get href for anchor tags
|
|
140
|
+
const href = computed(() => {
|
|
141
|
+
if (isPhoneNumber.value && props.button.payload) {
|
|
142
|
+
return `tel:${props.button.payload}`
|
|
143
|
+
}
|
|
144
|
+
if (isWebURL.value && props.button.url) {
|
|
145
|
+
return props.button.url
|
|
146
|
+
}
|
|
147
|
+
return undefined
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Get target for anchor tags
|
|
151
|
+
const target = computed(() => {
|
|
152
|
+
if (isWebURL.value) {
|
|
153
|
+
return props.button.target
|
|
154
|
+
}
|
|
155
|
+
return undefined
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// CSS classes
|
|
159
|
+
const buttonClasses = computed(() => {
|
|
160
|
+
const classes = [styles.button]
|
|
161
|
+
if (isWebURL.value) classes.push(styles.url)
|
|
162
|
+
if (props.className) classes.push(props.className)
|
|
163
|
+
if (props.disabled) {
|
|
164
|
+
classes.push(styles.disabled)
|
|
165
|
+
classes.push('disabled')
|
|
166
|
+
}
|
|
167
|
+
if (isPhoneNumber.value) classes.push('phone-number-or-url-anchor')
|
|
168
|
+
if (isWebURL.value) classes.push('phone-number-or-url-anchor')
|
|
169
|
+
return classes
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Handle button click
|
|
173
|
+
const handleClick = (event: MouseEvent) => {
|
|
174
|
+
event.stopPropagation()
|
|
175
|
+
props.onEmitAnalytics?.('action', props.button)
|
|
176
|
+
|
|
177
|
+
if (isPhoneNumber.value) {
|
|
178
|
+
if (props.disabled) {
|
|
179
|
+
event.preventDefault()
|
|
180
|
+
}
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (isWebURL.value) {
|
|
185
|
+
const url = props.config?.settings?.layout?.disableUrlButtonSanitization
|
|
186
|
+
? props.button.url
|
|
187
|
+
: sanitizeUrl(props.button.url)
|
|
188
|
+
|
|
189
|
+
// Prevent no-ops from sending you to a blank page
|
|
190
|
+
if (url === 'about:blank') return
|
|
191
|
+
|
|
192
|
+
window.open(url, isWebURLButtonTargetBlank.value ? '_blank' : '_self')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (props.disabled) return
|
|
196
|
+
|
|
197
|
+
event.preventDefault()
|
|
198
|
+
|
|
199
|
+
if (isWebURL.value) {
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (buttonType.value === 'openXApp') {
|
|
204
|
+
props.openXAppOverlay?.(props.button.payload)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
props.action?.(props.button.payload, null, { label: props.button.title })
|
|
209
|
+
|
|
210
|
+
focusHandling()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Focus handling after action
|
|
214
|
+
const focusHandling = () => {
|
|
215
|
+
// Focus the input after postback button click, if focusInputAfterPostback is true
|
|
216
|
+
if (props.config?.settings?.behavior?.focusInputAfterPostback) {
|
|
217
|
+
const textMessageInput = document.getElementById('webchatInputMessageInputInTextMode')
|
|
218
|
+
textMessageInput?.focus?.()
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Focus the visually hidden focus target after postback
|
|
223
|
+
if (props.dataMessageId) {
|
|
224
|
+
moveFocusToMessageFocusTarget(props.dataMessageId)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Render icon
|
|
229
|
+
const showIcon = computed(() => {
|
|
230
|
+
if (props.customIcon) return true
|
|
231
|
+
if (isWebURL.value && props.showUrlIcon) return true
|
|
232
|
+
return false
|
|
233
|
+
})
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<template>
|
|
237
|
+
<component
|
|
238
|
+
:is="componentTag"
|
|
239
|
+
:id="id"
|
|
240
|
+
:href="href"
|
|
241
|
+
:target="target"
|
|
242
|
+
:class="buttonClasses"
|
|
243
|
+
:aria-label="ariaLabel"
|
|
244
|
+
:aria-disabled="disabled"
|
|
245
|
+
:disabled="componentTag === 'button' ? disabled : undefined"
|
|
246
|
+
:tabindex="disabled ? -1 : 0"
|
|
247
|
+
@click="handleClick"
|
|
248
|
+
>
|
|
249
|
+
<div v-if="buttonImage" :class="styles.buttonImageContainer">
|
|
250
|
+
<img
|
|
251
|
+
:src="buttonImage"
|
|
252
|
+
:alt="buttonImageAltText"
|
|
253
|
+
:class="[
|
|
254
|
+
'webchat-template-button-image',
|
|
255
|
+
styles.buttonImage,
|
|
256
|
+
disabled && styles.imageDisabled
|
|
257
|
+
]"
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<Typography
|
|
262
|
+
:variant="size === 'large' ? 'title1-semibold' : 'cta-semibold'"
|
|
263
|
+
component="span"
|
|
264
|
+
:class="buttonImage && styles.buttonLabelWithImage"
|
|
265
|
+
v-html="sanitizedLabel"
|
|
266
|
+
/>
|
|
267
|
+
|
|
268
|
+
<slot v-if="customIcon" name="icon" />
|
|
269
|
+
<LinkIcon v-else-if="showIcon && isWebURL && showUrlIcon" />
|
|
270
|
+
</component>
|
|
271
|
+
</template>
|
|
272
|
+
|
|
273
|
+
<style module>
|
|
274
|
+
button.button,
|
|
275
|
+
a.button {
|
|
276
|
+
border-radius: 19px;
|
|
277
|
+
padding: 8px 10px;
|
|
278
|
+
cursor: pointer;
|
|
279
|
+
display: flex;
|
|
280
|
+
align-items: center;
|
|
281
|
+
justify-content: center;
|
|
282
|
+
gap: 10px;
|
|
283
|
+
text-decoration: none;
|
|
284
|
+
background: var(--cc-primary-color);
|
|
285
|
+
color: var(--cc-primary-contrast-color);
|
|
286
|
+
border: none;
|
|
287
|
+
outline: none;
|
|
288
|
+
position: relative;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
a.button:global(.phone-number-or-url-anchor) {
|
|
292
|
+
background: var(--cc-primary-color);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
button.button svg,
|
|
296
|
+
a.button svg,
|
|
297
|
+
button.button path,
|
|
298
|
+
a.button svg path {
|
|
299
|
+
fill: var(--cc-primary-contrast-color);
|
|
300
|
+
width: 12px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
button.button:hover,
|
|
304
|
+
a.button:hover,
|
|
305
|
+
button.button:focus,
|
|
306
|
+
a.button:focus {
|
|
307
|
+
background: var(--cc-primary-color-hover);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* Explicitly increase the specificity of the :focus-visible selector */
|
|
311
|
+
[data-cognigy-webchat-root] button.button:focus-visible,
|
|
312
|
+
[data-cognigy-webchat-root] a.button:focus-visible,
|
|
313
|
+
[data-cognigy-webchat-root] a.button:global(.phone-number-or-url-anchor):focus-visible {
|
|
314
|
+
outline: 2px solid var(--cc-primary-color-focus);
|
|
315
|
+
outline-offset: 2px;
|
|
316
|
+
box-shadow: 0 0 0 4px var(--cc-primary-contrast-color);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
button.button:disabled,
|
|
320
|
+
button.button:disabled:hover,
|
|
321
|
+
button.button:disabled:focus,
|
|
322
|
+
a.button.disabled,
|
|
323
|
+
a.button.disabled:hover,
|
|
324
|
+
a.button.disabled:focus {
|
|
325
|
+
background: var(--cc-primary-color-disabled);
|
|
326
|
+
cursor: default;
|
|
327
|
+
pointer-events: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.buttonLabelWithImage {
|
|
331
|
+
margin-left: 40px;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.buttonImage {
|
|
335
|
+
width: 100%;
|
|
336
|
+
height: 100%;
|
|
337
|
+
object-fit: cover;
|
|
338
|
+
border-top-left-radius: 19px;
|
|
339
|
+
border-bottom-left-radius: 19px;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.buttonImage.imageDisabled {
|
|
343
|
+
opacity: 0.6;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.buttonImageContainer {
|
|
347
|
+
display: flex;
|
|
348
|
+
position: absolute;
|
|
349
|
+
left: 0;
|
|
350
|
+
width: 40px;
|
|
351
|
+
height: 100%;
|
|
352
|
+
border-right: 2px solid var(--cc-primary-contrast-color);
|
|
353
|
+
}
|
|
354
|
+
</style>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useCssModule, onMounted, CSSProperties } from 'vue'
|
|
3
|
+
import ActionButton from './ActionButton.vue'
|
|
4
|
+
import { getRandomId } from '../../utils/helpers'
|
|
5
|
+
import type { IWebchatButton, IWebchatQuickReply, ChatConfig, MessageSender, CustomIcon, AnalyticsEventCallback } from '../../types'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
payload: (IWebchatButton | IWebchatQuickReply)[]
|
|
9
|
+
action?: MessageSender
|
|
10
|
+
className?: string
|
|
11
|
+
containerClassName?: string
|
|
12
|
+
containerStyle?: CSSProperties
|
|
13
|
+
buttonClassName?: string
|
|
14
|
+
buttonListItemClassName?: string
|
|
15
|
+
customIcon?: CustomIcon
|
|
16
|
+
showUrlIcon?: boolean
|
|
17
|
+
config?: ChatConfig
|
|
18
|
+
dataMessageId?: string
|
|
19
|
+
onEmitAnalytics?: AnalyticsEventCallback
|
|
20
|
+
size?: 'small' | 'large'
|
|
21
|
+
templateTextId?: string
|
|
22
|
+
openXAppOverlay?: (url: string | undefined) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
26
|
+
action: undefined,
|
|
27
|
+
className: '',
|
|
28
|
+
containerClassName: '',
|
|
29
|
+
containerStyle: undefined,
|
|
30
|
+
buttonClassName: '',
|
|
31
|
+
buttonListItemClassName: '',
|
|
32
|
+
customIcon: undefined,
|
|
33
|
+
showUrlIcon: false,
|
|
34
|
+
config: undefined,
|
|
35
|
+
dataMessageId: undefined,
|
|
36
|
+
onEmitAnalytics: undefined,
|
|
37
|
+
size: 'small',
|
|
38
|
+
templateTextId: undefined,
|
|
39
|
+
openXAppOverlay: undefined,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const styles = useCssModule()
|
|
43
|
+
|
|
44
|
+
// Generate unique ID for buttons
|
|
45
|
+
const webchatButtonTemplateButtonId = getRandomId('webchatButtonTemplateButton')
|
|
46
|
+
|
|
47
|
+
// Auto-focus first button on mount if enabled
|
|
48
|
+
onMounted(() => {
|
|
49
|
+
if (!props.config?.settings?.widgetSettings?.enableAutoFocus) return
|
|
50
|
+
|
|
51
|
+
const firstButton = document.getElementById(`${webchatButtonTemplateButtonId}-0`)
|
|
52
|
+
const chatHistory = document.getElementById('webchatChatHistoryWrapperLiveLogPanel')
|
|
53
|
+
|
|
54
|
+
if (!chatHistory?.contains(document.activeElement)) return
|
|
55
|
+
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
firstButton?.focus()
|
|
58
|
+
}, 200)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Filter valid buttons
|
|
62
|
+
const buttons = computed(() => {
|
|
63
|
+
if (!props.payload || props.payload?.length === 0) return []
|
|
64
|
+
|
|
65
|
+
return props.payload.filter((button: Props['payload'][number]) => {
|
|
66
|
+
// Filter by type
|
|
67
|
+
if ('type' in button && !['postback', 'web_url', 'phone_number', 'openXApp'].includes(button.type)) {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Filter text content_type buttons without title
|
|
72
|
+
if ('content_type' in button && button.content_type === 'text' && !button.title) {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return true
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Determine container component type
|
|
81
|
+
const componentTag = computed(() => {
|
|
82
|
+
return buttons.value.length > 1 ? 'ul' : 'div'
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Container classes
|
|
86
|
+
const containerClasses = computed(() => {
|
|
87
|
+
const classes = [props.className, styles.buttons, props.containerClassName]
|
|
88
|
+
return classes.filter(Boolean).join(' ')
|
|
89
|
+
})
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<component
|
|
94
|
+
:is="componentTag"
|
|
95
|
+
v-if="buttons.length > 0"
|
|
96
|
+
:class="containerClasses"
|
|
97
|
+
:style="containerStyle || {}"
|
|
98
|
+
:aria-labelledby="templateTextId"
|
|
99
|
+
data-testid="action-buttons"
|
|
100
|
+
>
|
|
101
|
+
<template v-if="buttons.length > 1">
|
|
102
|
+
<li
|
|
103
|
+
v-for="(button, index) in buttons"
|
|
104
|
+
:key="`${webchatButtonTemplateButtonId}-${index}`"
|
|
105
|
+
:class="buttonListItemClassName"
|
|
106
|
+
:aria-posinset="index + 1"
|
|
107
|
+
:aria-setsize="buttons.length"
|
|
108
|
+
>
|
|
109
|
+
<ActionButton
|
|
110
|
+
:id="`${webchatButtonTemplateButtonId}-${index}`"
|
|
111
|
+
:button="button"
|
|
112
|
+
:action="action"
|
|
113
|
+
:disabled="action === undefined"
|
|
114
|
+
:position="index + 1"
|
|
115
|
+
:total="buttons.length"
|
|
116
|
+
:custom-icon="customIcon"
|
|
117
|
+
:show-url-icon="showUrlIcon"
|
|
118
|
+
:config="config"
|
|
119
|
+
:on-emit-analytics="onEmitAnalytics"
|
|
120
|
+
:size="size"
|
|
121
|
+
:data-message-id="dataMessageId"
|
|
122
|
+
:class-name="buttonClassName"
|
|
123
|
+
:open-x-app-overlay="openXAppOverlay"
|
|
124
|
+
/>
|
|
125
|
+
</li>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<template v-else>
|
|
129
|
+
<ActionButton
|
|
130
|
+
v-for="(button, index) in buttons"
|
|
131
|
+
:id="`${webchatButtonTemplateButtonId}-${index}`"
|
|
132
|
+
:key="`${webchatButtonTemplateButtonId}-${index}`"
|
|
133
|
+
:button="button"
|
|
134
|
+
:action="action"
|
|
135
|
+
:disabled="action === undefined"
|
|
136
|
+
:position="index + 1"
|
|
137
|
+
:total="buttons.length"
|
|
138
|
+
:custom-icon="customIcon"
|
|
139
|
+
:show-url-icon="showUrlIcon"
|
|
140
|
+
:config="config"
|
|
141
|
+
:on-emit-analytics="onEmitAnalytics"
|
|
142
|
+
:size="size"
|
|
143
|
+
:data-message-id="dataMessageId"
|
|
144
|
+
:class-name="buttonClassName"
|
|
145
|
+
:open-x-app-overlay="openXAppOverlay"
|
|
146
|
+
/>
|
|
147
|
+
</template>
|
|
148
|
+
</component>
|
|
149
|
+
</template>
|
|
150
|
+
|
|
151
|
+
<style module>
|
|
152
|
+
.buttons {
|
|
153
|
+
align-items: flex-start;
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-direction: row;
|
|
156
|
+
flex-wrap: wrap;
|
|
157
|
+
gap: 8px;
|
|
158
|
+
max-width: 295px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
ul.buttons {
|
|
162
|
+
list-style: none;
|
|
163
|
+
padding-inline-start: 0;
|
|
164
|
+
margin-block: 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
ul.buttons li {
|
|
168
|
+
list-style: none;
|
|
169
|
+
}
|
|
170
|
+
</style>
|