polish widget UI and fix proactive message delete

Widget:
- home background stays pinned while the content scrolls, fade sits on it
- one radius and shadow scale across cards, buttons and inputs
- live search in the help tab with a "no results" state
- attachment-only messages show "Image" in the conversation preview
- launcher and panel use the same shadows as the header

Admin:
- preview column is a fixed width so zoom does not resize it, hidden on small screens
- preview matches the real widget again
- search spinner replaces the magnifier instead of covering the page

Fixes:
- deleting a proactive message did nothing, the dialog cleared the pending
  id before the handler read it
- an inbox created through the API could not be saved from the form, it
  jumped to the pre-chat tab with no error

Adds livechatWidgetTabs.cy.js covering the proactive message and help
center tabs.
This commit is contained in:
Abhinav Raut
2026-09-20 18:48:51 +05:30
parent a074bc4d55
commit 08e6801fc4
52 changed files with 923 additions and 426 deletions
+5 -3
View File
@@ -97,6 +97,7 @@ type handoffFormReq struct {
type chatSettingsResponse struct {
Campaigns *struct{} `json:"campaigns,omitempty"`
HasCampaigns bool `json:"has_campaigns"`
CampaignDelaySeconds int `json:"campaign_delay_seconds"`
livechat.Config
// Hide server-side fields from the public widget response.
TrustedDomains *struct{} `json:"trusted_domains,omitempty"`
@@ -149,9 +150,10 @@ func handleGetChatSettings(r *fastglue.Request) error {
}
response := chatSettingsResponse{
Config: config,
HasCampaigns: slices.ContainsFunc(config.Campaigns, func(c proactive.Campaign) bool { return c.Enabled }),
CustomAttributes: customAttributes,
Config: config,
HasCampaigns: slices.ContainsFunc(config.Campaigns, func(c proactive.Campaign) bool { return c.Enabled }),
CampaignDelaySeconds: earliestCampaignDelay(config.Campaigns),
CustomAttributes: customAttributes,
}
// Get business hours data if office hours feature is enabled.
+13
View File
@@ -213,6 +213,19 @@ func campaignSender(app *App, id int) (umodels.User, error) {
return app.user.GetAgent(id, "")
}
func earliestCampaignDelay(campaigns []proactive.Campaign) int {
delay := 0
for _, campaign := range campaigns {
if !campaign.Enabled {
continue
}
if delay == 0 || campaign.DelaySeconds < delay {
delay = campaign.DelaySeconds
}
}
return delay
}
func validCampaignKey(key string) bool {
id, err := uuid.Parse(key)
return err == nil && id != uuid.Nil
@@ -1,13 +1,13 @@
<template>
<div class="w-full space-y-3">
<div v-if="searchable" class="relative max-w-xs">
<Search class="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="globalFilter"
:placeholder="searchPlaceholder || t('globals.terms.search')"
class="pl-8"
/>
</div>
<SearchInput
v-if="searchable"
v-model="globalFilter"
class="max-w-xs"
:placeholder="searchPlaceholder || t('globals.terms.search')"
:clear-label="t('globals.messages.clearSearch')"
:loading="loading"
/>
<div
ref="scrollContainer"
@@ -112,7 +112,8 @@ import {
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import { ArrowUpDown, ChevronDown, ChevronUp, Ghost, Search } from 'lucide-vue-next'
import { ArrowUpDown, ChevronDown, ChevronUp, Ghost } from 'lucide-vue-next'
import SearchInput from '@shared-ui/components/SearchInput.vue'
import {
TableBody,
TableCell,
@@ -120,7 +121,6 @@ import {
TableHeader,
TableRow
} from '@shared-ui/components/ui/table'
import { Input } from '@shared-ui/components/ui/input'
const { t } = useI18n()
@@ -100,7 +100,7 @@
<div v-if="loadedArticle && helpCenterLocales.length > 1" class="space-y-3">
<h3 class="font-medium text-sm text-muted-foreground">
{{ t('globals.terms.language', 2) }}
{{ t('helpCenter.translations') }}
</h3>
<div class="overflow-hidden rounded-md border bg-card">
@@ -198,10 +198,7 @@
variant="outline"
size="sm"
@click="
emit('create-translation-collection', {
article: translationSource,
locale: form.values.locale
})
emit('create-translation-collection', { locale: form.values.locale })
"
>
<Plus class="mr-2 size-4" aria-hidden="true" />
@@ -453,6 +450,10 @@ const props = defineProps({
type: Object,
default: null
},
createdCollection: {
type: Object,
default: null
},
submitForm: {
type: Function,
required: true
@@ -614,6 +615,15 @@ watch(
{ immediate: true }
)
watch(
() => props.createdCollection,
async (collection) => {
if (!collection || !props.isOpen) return
await fetchAvailableCollections()
form.setFieldValue('collection_id', String(collection.id), false)
}
)
const fetchAvailableCollections = async () => {
try {
const { data } = await api.getCollections(props.helpCenterId)
@@ -494,7 +494,7 @@
</div>
<div
v-else-if="item.type === 'external_link'"
class="grid grid-cols-3 gap-2"
class="grid grid-cols-2 gap-2"
>
<Input
v-model="item.text"
@@ -506,12 +506,6 @@
placeholder="https://example.com"
@change="updateHomeApps"
/>
<Input
v-model="item.image_url"
type="url"
:placeholder="$t('globals.terms.logoUrl')"
@change="updateHomeApps"
/>
</div>
</div>
<Button
@@ -921,6 +915,7 @@
ref="campaignsRef"
:model-value="form.values.config.campaigns || []"
:inbox-id="initialValues.id || 0"
:brand-name="form.values.config.brand_name || ''"
:cooldown="form.values.config.campaign_cooldown_hours ?? 24"
:show-errors="showCampaignErrors"
@update:model-value="updateCampaigns"
@@ -1759,7 +1754,7 @@ const addHomeApp = (type) => {
} else if (type === 'help') {
homeApps.value.push({ type: 'help' })
} else {
homeApps.value.push({ type: 'external_link', text: '', url: '', image_url: '' })
homeApps.value.push({ type: 'external_link', text: '', url: '' })
}
updateHomeApps()
}
@@ -1787,7 +1782,7 @@ const isHomeAppEmpty = (item) => {
if (item.type === 'help') return false
return item.type === 'announcement'
? !item.title && !item.description && !item.image_url && !item.url
: !item.text && !item.url && !item.image_url
: !item.text && !item.url
}
const isHomeAppComplete = (item) => {
@@ -1907,24 +1902,21 @@ watch(
homeApps.value = newValues.config.home_apps.map((app) => ({ ...app }))
}
let pc = newValues.config?.prechat_form
if (pc) {
pc = normalizePrechatConfig(pc)
for (const audience of ['visitors', 'users']) {
const existingFields = pc[audience].fields
if (existingFields.length === 0) {
pc[audience].fields = getDefaultPrechatFields()
continue
}
const existingKeys = new Set(existingFields.map((field) => field.key))
let nextOrder = existingFields.reduce((max, field) => Math.max(max, field.order || 0), 0)
const missing = getDefaultPrechatFields()
.filter((field) => !existingKeys.has(field.key))
.map((field) => ({ ...field, order: ++nextOrder }))
pc[audience].fields = [...existingFields, ...missing]
const pc = normalizePrechatConfig(newValues.config?.prechat_form)
for (const audience of ['visitors', 'users']) {
const existingFields = pc[audience].fields
if (existingFields.length === 0) {
pc[audience].fields = getDefaultPrechatFields()
continue
}
prechatConfig.value = pc
const existingKeys = new Set(existingFields.map((field) => field.key))
let nextOrder = existingFields.reduce((max, field) => Math.max(max, field.order || 0), 0)
const missing = getDefaultPrechatFields()
.filter((field) => !existingKeys.has(field.key))
.map((field) => ({ ...field, order: ++nextOrder }))
pc[audience].fields = [...existingFields, ...missing]
}
prechatConfig.value = pc
form.setValues(
{
@@ -4,13 +4,13 @@
<transition name="ld-preview-window">
<div
v-if="open"
class="absolute top-4 left-3 right-3 mx-auto max-w-[360px]"
class="absolute top-4 inset-x-0 mx-auto max-w-[400px]"
:style="{ bottom: windowBottom + 'px' }"
>
<div
class="libredesk-widget-preview flex flex-col h-full bg-background text-foreground rounded-2xl overflow-hidden shadow-2xl border border-border"
class="libredesk-widget-preview flex flex-col h-full bg-background text-foreground rounded-2xl overflow-hidden border border-border"
:style="[primaryStyle, { boxShadow: IFRAME_BOX_SHADOW }]"
:class="isDark ? 'dark' : 'light'"
:style="primaryStyle"
>
<!-- Chat view -->
<template v-if="view === 'chat'">
@@ -134,11 +134,14 @@
v-if="!openedExisting && quickReplies.length"
class="flex flex-wrap justify-end gap-2 mt-auto"
>
<span
<Button
v-for="reply in quickReplies"
:key="reply"
class="rounded-full border border-primary text-primary px-3 py-1.5 text-sm"
>{{ reply }}</span
type="button"
variant="outline"
size="sm"
class="h-auto max-w-full rounded-full py-1.5 text-left whitespace-normal shadow-md"
>{{ reply }}</Button
>
</div>
<template v-if="openedExisting">
@@ -168,7 +171,7 @@
<!-- Message input -->
<div class="border-t border-border shrink-0">
<div class="p-2">
<div class="border border-input rounded-lg bg-background">
<div class="border border-input rounded-md bg-background">
<div class="p-2">
<textarea
:placeholder="$t('globals.terms.typeMessage')"
@@ -205,23 +208,30 @@
<template v-else>
<div class="flex-1 min-h-0 relative">
<div v-if="view === 'home'" class="h-full overflow-y-auto flex flex-col">
<div class="relative" :style="headerStyle">
<div class="p-6">
<div v-if="view === 'home'" class="relative h-full bg-background">
<div
class="pointer-events-none absolute inset-x-0 top-0"
:style="[headerStyle, { height: `${headerHeight}px` }]"
>
<div v-if="showFade" class="absolute inset-x-0 bottom-0 h-20" :style="fadeStyle"></div>
</div>
<div class="relative h-full overflow-y-auto">
<div ref="headerRef">
<div class="px-7 pb-7 pt-7">
<img
v-if="config.logo_url"
:src="config.logo_url"
:alt="config.brand_name"
class="max-h-7 max-w-full"
/>
<div class="mt-16" :class="textColorClass">
<h2 class="text-3xl font-bold leading-none tracking-tight break-words">
{{ parsedGreeting }}
</h2>
<p
class="mt-2 max-w-64 text-xl font-medium leading-tight"
<div class="mt-20" :class="textColorClass">
<h2
class="text-3xl font-semibold leading-tight tracking-tight break-words"
:class="subTextColorClass"
>
{{ parsedGreeting }}
</h2>
<p class="mt-2 text-3xl font-semibold leading-tight tracking-tight break-words">
{{ parsedIntroduction }}
</p>
</div>
@@ -230,53 +240,44 @@
<Button
type="button"
size="lg"
class="w-full rounded-xl font-semibold shadow-md"
class="w-full font-semibold shadow-md"
@click="startNew"
>
{{ startButtonText }}
<ArrowRight :size="16" aria-hidden="true" />
</Button>
</div>
<div
v-if="showFade"
class="absolute bottom-0 left-0 right-0 h-20 pointer-events-none"
:style="fadeStyle"
></div>
</div>
<div
v-if="homeItems.length"
class="flex flex-col gap-3 bg-background px-4 pt-1 pb-5"
>
<div v-if="homeItems.length" class="flex flex-col gap-3 px-4 pt-1 pb-5">
<template v-for="(item, index) in homeItems" :key="index">
<section
v-if="item.type === 'help'"
class="space-y-2 rounded-xl border border-border/80 bg-card p-2 shadow-sm"
class="space-y-1 rounded-xl border border-border/80 bg-card p-2 shadow-sm"
>
<Button
type="button"
variant="outline"
class="h-10 w-full justify-start rounded-lg"
class="h-10 w-full justify-between px-2"
@click="view = 'help'"
>
<Search class="size-4" aria-hidden="true" />
{{ $t('widget.searchArticles') }}
<Search class="size-4" aria-hidden="true" />
</Button>
<Button
v-for="article in featuredArticles"
:key="article.id"
type="button"
variant="ghost"
class="w-full h-auto justify-start text-left whitespace-normal"
class="w-full h-auto justify-start px-2 py-1.5 text-left whitespace-normal"
@click="view = 'help'"
>
<FileText class="size-4 shrink-0" aria-hidden="true" />
{{ article.title }}
</Button>
</section>
<Card
v-else-if="item.type === 'announcement'"
class="overflow-hidden rounded-xl border-border/80 transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
class="overflow-hidden rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<img
v-if="item.image_url"
@@ -298,40 +299,22 @@
</Card>
<Card
v-else
class="rounded-xl border-border/80 transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
class="rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<CardContent class="p-4">
<div class="flex items-center gap-3">
<span
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted"
>
<img
v-if="item.image_url"
:src="item.image_url"
alt=""
class="size-5 object-contain"
/>
<Globe2
v-else
:size="16"
class="text-muted-foreground"
aria-hidden="true"
/>
</span>
<span
class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground"
>{{ item.text || item.url }}</span
>
<ExternalLink
:size="15"
class="shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</div>
<CardContent class="flex items-center gap-3 p-4">
<span class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground"
>{{ item.text || item.url }}</span
>
<ExternalLink
:size="15"
class="shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</CardContent>
</Card>
</template>
</div>
</div>
</div>
<!-- Help -->
@@ -348,11 +331,12 @@
</button>
<h3 class="font-medium truncate">{{ $t('globals.terms.help') }}</h3>
</header>
<div class="flex gap-2 p-3 border-b border-border shrink-0">
<Input :placeholder="$t('widget.searchArticles')" readonly />
<Button type="button" variant="outline" :aria-label="$t('widget.searchArticles')">
<Search class="size-4" aria-hidden="true" />
</Button>
<div class="relative p-3 border-b border-border shrink-0">
<Search
class="absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input class="pl-10" :placeholder="$t('widget.searchArticles')" readonly />
</div>
<div class="flex-1 min-h-0 overflow-y-auto">
<p
@@ -480,8 +464,8 @@
<!-- Launcher -->
<button
type="button"
:aria-label="open ? $t('globals.messages.closeChat') : $t('widget.openChat')"
class="absolute flex items-center justify-center rounded-full shadow-lg transition-transform hover:scale-105"
:aria-label="open ? $t('globals.messages.closeChat') : $t('globals.messages.openChat')"
class="absolute flex items-center justify-center rounded-full transition-transform hover:scale-105"
:style="launcherStyle"
@click="open = !open"
>
@@ -497,11 +481,15 @@
</template>
<script setup>
const IFRAME_BOX_SHADOW = 'rgba(9, 14, 21, 0.9) 0px 5px 40px 0px'
const LAUNCHER_DROP_SHADOW =
'drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px)'
const BACK_BUTTON_CLASS =
'flex items-center justify-center size-8 rounded-md hover:bg-accent text-foreground'
const PREVIEW_NAV_TAB_CLASS = 'flex w-full flex-col items-center justify-center gap-1 py-4'
import { computed, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { Card, CardContent } from '@shared-ui/components/ui/card'
@@ -515,7 +503,6 @@ import {
ArrowLeft,
ArrowUp,
ExternalLink,
Globe2,
ChevronDown,
ChevronRight,
AlertTriangle,
@@ -523,7 +510,6 @@ import {
Smile,
Maximize2,
Search,
FileText,
X
} from 'lucide-vue-next'
import HelpCollectionList from '@shared-ui/components/HelpCollectionList.vue'
@@ -549,6 +535,9 @@ const props = defineProps({
const { t } = useI18n()
const open = ref(true)
const headerRef = ref(null)
const { height: headerHeight } = useElementSize(headerRef)
const view = ref('home')
// The preview can't send/receive, so a sample conversation stands in for a real one.
const openedExisting = ref(false)
@@ -744,6 +733,7 @@ const campaignStyle = computed(() => ({
width: `min(280px, calc(100% - ${clampedSide.value * 2}px))`
}))
const launcherStyle = computed(() => ({
filter: LAUNCHER_DROP_SHADOW,
width: LAUNCHER_SIZE + 'px',
height: LAUNCHER_SIZE + 'px',
backgroundColor: launcherColor.value,
@@ -29,7 +29,6 @@
<div class="mt-4 space-y-6">
<SwitchField
:title="$t('admin.inbox.livechat.prechatForm.audienceEnabled')"
:description="$t('admin.inbox.livechat.prechatForm.audienceEnabled.description')"
:checked="audienceConfig.enabled"
@update:checked="audienceConfig.enabled = $event"
/>
@@ -72,7 +72,7 @@ const AUDIENCE_TRANSLATION_KEYS = {
const SELECT_FIELDS = [
{
key: 'audience',
labelKey: 'widget.campaign.audience',
labelKey: 'globals.terms.audience',
values: ['all', 'visitors', 'users']
}
]
@@ -81,6 +81,7 @@ const DEVICES = ['desktop', 'mobile']
const props = defineProps({
modelValue: { type: Array, default: () => [] },
inboxId: { type: Number, default: 0 },
brandName: { type: String, default: '' },
cooldown: { type: Number, default: 24 },
showErrors: { type: Boolean, default: false }
})
@@ -97,9 +98,10 @@ const pendingDelete = ref(null)
const stats = ref([])
const from = ref(new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10))
const to = ref(new Date().toISOString().slice(0, 10))
const brandSenderName = computed(() => props.brandName.trim() || t('widget.brandBot'))
const senderName = (id) => {
const agent = usersStore.options.find((item) => String(item.value) === String(id))
return agent?.label || t('widget.brandBot')
return agent?.label || brandSenderName.value
}
const campaignErrors = computed(() => {
@@ -187,12 +189,14 @@ const add = (source) => {
selected.value = item.id
}
// Radix clears pendingDelete as the dialog closes, so the id comes from the open editor.
const remove = () => {
const id = selected.value
emit(
'update:modelValue',
props.modelValue.filter((item) => item.id !== pendingDelete.value)
props.modelValue.filter((item) => item.id !== id)
)
if (selected.value === pendingDelete.value) selected.value = ''
selected.value = ''
pendingDelete.value = null
}
@@ -422,7 +426,7 @@ onMounted(async () => {
<!-- Campaign editor -->
<template v-else>
<div class="flex items-center justify-between gap-2">
<div data-campaign-editor class="flex items-center justify-between gap-2">
<Button type="button" variant="ghost" size="sm" class="-ml-2" @click="selected = ''">
<ChevronRight class="size-4 rotate-180" />
{{ t('widget.backToCampaigns') }}
@@ -489,11 +493,11 @@ onMounted(async () => {
<div class="grid sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="campaign-sender">{{ t('widget.campaignSender') }}</Label>
<Label for="campaign-sender">{{ t('globals.terms.sender') }}</Label>
<SelectAgentCombobox
id="campaign-sender"
:model-value="campaign.sender_id === 0 ? 'brand' : String(campaign.sender_id)"
:prepend-items="[{ value: 'brand', label: t('widget.brandBot') }]"
:prepend-items="[{ value: 'brand', label: brandSenderName }]"
@update:model-value="update('sender_id', $event === 'brand' ? 0 : Number($event))"
/>
</div>
@@ -532,7 +536,7 @@ onMounted(async () => {
</h4>
<div data-campaign-devices class="space-y-3">
<p class="text-sm font-medium text-foreground">{{ t('widget.campaign.devices') }}</p>
<p class="text-sm font-medium text-foreground">{{ t('globals.terms.device', 2) }}</p>
<SwitchField
v-for="device in DEVICES"
:key="device"
@@ -88,6 +88,7 @@ const unusedArticles = computed(() =>
<div
v-for="audience in AUDIENCES"
:key="audience"
:data-help-audience="audience"
class="space-y-4 border rounded-md p-4"
>
<p class="text-sm font-medium text-foreground">
@@ -104,7 +105,7 @@ const unusedArticles = computed(() =>
<p class="text-sm text-muted-foreground">{{ t('widget.helpOnHomeHint') }}</p>
</div>
<div class="space-y-4">
<div data-featured-articles class="space-y-4">
<h4 class="text-base font-semibold text-foreground">{{ t('widget.featuredArticles') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('widget.featuredArticlesHint') }}</p>
@@ -151,7 +152,7 @@ const unusedArticles = computed(() =>
@update:model-value="update('featured_ids', [...modelValue.featured_ids, Number($event)])"
>
<SelectTrigger class="max-w-md">
<SelectValue :placeholder="t('widget.addArticle')" />
<SelectValue :placeholder="t('widget.selectArticle')" />
</SelectTrigger>
<SelectContent>
<SelectItem
@@ -151,6 +151,8 @@ export const normalizePrechatConfig = (config = {}) => {
}
return {
...config,
enabled: config.enabled ?? false,
fields: config.fields ?? [],
handoff_only: config.handoff_only ?? false,
visitors: normalizeAudience(config.visitors),
users: normalizeAudience(config.users)
@@ -359,6 +359,19 @@ describe('Livechat Inbox Form Schema', () => {
expect(config.users).toEqual({ enabled: false, title: 'What is the issue?', fields: [] })
})
test('an inbox saved without a prechat form still parses', () => {
const config = normalizePrechatConfig(undefined)
expect(config.enabled).toBe(false)
expect(config.fields).toEqual([])
expect(() =>
schema.parse({
...validForm,
config: { ...validForm.config, prechat_form: config }
})
).not.toThrow()
})
test('campaign priority follows saved array order', () => {
const campaigns = [{ id: 'first' }, { id: 'second' }, { id: 'third' }]
expect(moveCampaign(campaigns, 2, -1).map(({ id }) => id)).toEqual(['first', 'third', 'second'])
@@ -1,10 +1,19 @@
<template>
<div class="flex justify-between flex-1 min-h-0">
<div class="w-full xl:w-8/12 pr-6 relative">
<div class="relative pr-6" :class="fixedHelp ? 'flex-1 min-w-0' : 'w-full xl:w-8/12'">
<slot name="content" />
</div>
<div class="hidden lg:block rounded-md w-3/12 p-2 space-y-2 self-stretch text-sm text-foreground/70">
<div
class="rounded-md p-2 space-y-2 self-stretch text-sm text-foreground/70"
:class="fixedHelp ? 'hidden xl:block w-[432px] shrink-0' : 'hidden lg:block w-3/12'"
>
<slot name="help" />
</div>
</div>
</template>
<script setup>
defineProps({
fixedHelp: { type: Boolean, default: false }
})
</script>
@@ -127,7 +127,7 @@
<ArticleEditSheet
:is-open="showArticleEditSheet"
@update:open="$event ? (showArticleEditSheet = true) : closeEditSheet()"
@update:open="$event ? (showArticleEditSheet = true) : closeArticleSheet()"
:article="editingArticle"
:collection-id="editingArticle?.collection_id || createArticleCollectionId"
:help-center-id="parseInt(id)"
@@ -135,9 +135,10 @@
:help-center-locales="helpCenter?.allowed_locales || ['en']"
:default-locale="props.locale"
:translation-source="translationSource"
:created-collection="createdCollection"
:submit-form="handleArticleSave"
:is-loading="isSubmittingArticle"
@cancel="closeEditSheet"
@cancel="closeArticleSheet"
@create-translation="openCreateTranslation"
@create-translation-collection="openCreateTranslationCollection"
@open-translation="openTranslation"
@@ -146,7 +147,7 @@
<CollectionEditSheet
:is-open="showCollectionEditSheet"
@update:open="$event ? (showCollectionEditSheet = true) : closeEditSheet()"
@update:open="$event ? (showCollectionEditSheet = true) : closeCollectionSheet()"
:collection="editingCollection"
:help-center-id="parseInt(id)"
:parent-id="createCollectionParentId"
@@ -154,7 +155,7 @@
:default-locale="createCollectionLocale || props.locale"
:submit-form="handleCollectionSave"
:is-loading="isSubmittingCollection"
@cancel="closeEditSheet"
@cancel="closeCollectionSheet"
/>
<Sheet :open="showInsights" @update:open="showInsights = $event">
@@ -332,7 +333,7 @@ const editingCollection = ref(null)
const createCollectionParentId = ref(null)
const createCollectionLocale = ref('')
const createArticleCollectionId = ref(null)
const pendingTranslation = ref(null)
const createdCollection = ref(null)
const deletingItem = ref(null)
const breadcrumbLinks = computed(() => [
@@ -427,17 +428,26 @@ const openEditSheet = (item) => {
}
}
const closeEditSheet = () => {
const closeArticleSheet = () => {
showArticleEditSheet.value = false
showCollectionEditSheet.value = false
editingArticle.value = null
translationSource.value = null
editingCollection.value = null
selectedItem.value = null
createArticleCollectionId.value = null
createdCollection.value = null
}
const closeCollectionSheet = () => {
showCollectionEditSheet.value = false
editingCollection.value = null
createCollectionParentId.value = null
createCollectionLocale.value = ''
createArticleCollectionId.value = null
pendingTranslation.value = null
}
const closeEditSheet = () => {
closeArticleSheet()
closeCollectionSheet()
selectedItem.value = null
}
const visitSite = () => {
@@ -472,7 +482,6 @@ const toggleActive = async () => {
}
const openCreateCollectionModal = (parentId = null) => {
pendingTranslation.value = null
editingCollection.value = null
createCollectionParentId.value = typeof parentId === 'number' ? parentId : null
createCollectionLocale.value = props.locale
@@ -482,7 +491,6 @@ const openCreateCollectionModal = (parentId = null) => {
const handleCollectionSave = async (formData) => {
isSubmittingCollection.value = true
try {
const resumeTranslation = !editingCollection.value ? pendingTranslation.value : null
let savedCollection = null
if (editingCollection.value) {
await api.updateCollection(props.id, editingCollection.value.id, formData)
@@ -493,11 +501,10 @@ const handleCollectionSave = async (formData) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
closeEditSheet()
if (resumeTranslation) {
createArticleCollectionId.value = savedCollection.id
translationSource.value = resumeTranslation
showArticleEditSheet.value = true
const keepArticleDraft = savedCollection && showArticleEditSheet.value
closeCollectionSheet()
if (keepArticleDraft) {
createdCollection.value = savedCollection
return
}
if (!followSavedLocale(formData.locale)) fetchTree({ silent: true })
@@ -541,11 +548,8 @@ const openCreateTranslation = ({ article, locale }) => {
translationSource.value = { ...article, locale }
}
const openCreateTranslationCollection = ({ article, locale }) => {
pendingTranslation.value = { ...article, locale }
showArticleEditSheet.value = false
editingArticle.value = null
translationSource.value = null
const openCreateTranslationCollection = ({ locale }) => {
createdCollection.value = null
editingCollection.value = null
createCollectionParentId.value = null
createCollectionLocale.value = locale
@@ -1,5 +1,5 @@
<template>
<AdminSplitLayout>
<AdminSplitLayout :fixed-help="!!livechatPreview">
<template #content>
<router-view />
</template>
@@ -1,45 +1,21 @@
<template>
<div class="flex flex-col h-screen">
<SearchHeader />
<div class="flex-1 overflow-y-auto">
<div ref="scrollRef" class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-6xl space-y-4 px-4 py-6">
<div class="relative">
<SearchIcon
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
aria-hidden="true"
/>
<Input
ref="inputRef"
v-model="term"
:placeholder="$t('search.searchBy')"
:aria-label="$t('globals.terms.search')"
class="h-12 pl-10 pr-20 text-base"
/>
<Spinner
v-if="loading"
size="sm"
variant="muted"
:absolute="false"
:center="false"
class="absolute right-10 top-1/2 -translate-y-1/2"
/>
<button
v-if="term"
type="button"
:aria-label="$t('globals.messages.clearSearch')"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
@click="term = ''"
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<SearchInput
ref="inputRef"
v-model="term"
:placeholder="$t('search.searchBy')"
:aria-label="$t('globals.terms.search')"
:clear-label="$t('globals.messages.clearSearch')"
input-class="h-12 text-base"
:loading="loading"
/>
<SearchFilters :filters="filters" @update:filters="filters = $event" />
<div v-if="loading && resultCount === 0" class="flex justify-center items-center h-64">
<Spinner :absolute="false" />
</div>
<div v-else-if="error" class="py-16 text-center space-y-4">
<div v-if="error" class="py-16 text-center space-y-4">
<p class="text-destructive">{{ error }}</p>
<Button type="button" @click="search"> {{ $t('globals.terms.tryAgain') }} </Button>
</div>
@@ -76,10 +52,9 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { Search as SearchIcon, X } from 'lucide-vue-next'
import { Search as SearchIcon } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import Spinner from '@shared-ui/components/ui/spinner/Spinner.vue'
import SearchInput from '@shared-ui/components/SearchInput.vue'
import SearchHeader from '@main/features/search/SearchHeader.vue'
import SearchFilters from '@main/features/search/SearchFilters.vue'
import SearchResults from '@main/features/search/SearchResults.vue'
@@ -201,6 +176,8 @@ const search = async () => {
}
}
const scrollRef = ref(null)
const fetchPage = async (type, page, perPage) => {
if (perPage !== results.value[type].per_page) {
cursorsByPage[type] = new Map([[1, '']])
@@ -229,7 +206,10 @@ const fetchPage = async (type, page, perPage) => {
}
}
const changePage = ({ type, page, perPage }) => fetchPage(type, page, perPage)
const changePage = async ({ type, page, perPage }) => {
await fetchPage(type, page, perPage)
scrollRef.value?.scrollTo({ top: 0 })
}
const changeSort = ({ type, sort }) => {
if (sorts.value[type] === sort) return
@@ -280,7 +260,7 @@ watch(activeTab, syncRoute)
if (term.value.length >= MIN_SEARCH_LENGTH) search()
onMounted(() => inputRef.value?.$el?.focus?.())
onMounted(() => inputRef.value?.input?.$el?.focus?.())
onBeforeUnmount(() => {
clearTimeout(debounceTimer)
+8 -1
View File
@@ -64,7 +64,14 @@ onMounted(() => {
const signalWidgetLoaded = async () => {
if (widgetStore.config.help?.help_center_id) await help.load(locale.value)
window.parent.postMessage({ type: 'WIDGET_LOADED', campaigns: widgetStore.config.has_campaigns }, '*')
window.parent.postMessage(
{
type: 'WIDGET_LOADED',
campaigns: widgetStore.config.has_campaigns,
campaignDelay: widgetStore.config.campaign_delay_seconds || 0
},
'*'
)
}
const fetchInitialConversations = async () => {
@@ -1,7 +1,7 @@
<template>
<a :href="announcement.url" target="_blank" rel="noopener noreferrer" class="block no-underline">
<Card
class="overflow-hidden rounded-xl border-border/80 transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
class="overflow-hidden rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<img :src="announcement.image_url" :alt="announcement.title" class="w-full h-auto" />
<CardContent class="p-4 text-sm">
@@ -30,6 +30,9 @@ const onMessage = (event) => {
frame.value?.parentElement?.scrollTo({ top: 0 })
} else if (event.data.type === 'height' && Number.isFinite(event.data.height)) {
height.value = `${event.data.height}px`
} else if (event.data.type === 'scroll' && Number.isFinite(event.data.top)) {
const scroller = frame.value?.parentElement
if (scroller) scroller.scrollTo({ top: frame.value.offsetTop + event.data.top, behavior: 'smooth' })
}
}
@@ -9,7 +9,7 @@
:key="rating.value"
@click="selectedRating = rating.value"
:aria-label="rating.text"
class="flex flex-col items-center p-2 rounded-lg cursor-pointer hover:bg-muted transition-all"
class="flex flex-col items-center p-2 rounded-md cursor-pointer hover:bg-muted transition-all"
:class="{ 'scale-125 bg-muted': selectedRating === rating.value }"
>
<span class="text-xl mb-1">{{ rating.emoji }}</span>
@@ -21,25 +21,24 @@
<label class="text-xs text-muted-foreground mb-2 block">
{{ t('globals.messages.additionalFeedback') }}
</label>
<textarea
<Textarea
v-model="feedback"
:placeholder="$t('globals.terms.tellUsMore')"
class="w-full p-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground"
rows="2"
class="min-h-[60px]"
maxlength="500"
></textarea>
/>
<div class="text-xs text-muted-foreground text-right mt-1">{{ feedback.length }}/500</div>
</div>
<button
@click="submitRating"
<Button
type="button"
class="w-full"
:disabled="(!selectedRating && !feedback.trim()) || isSubmitting"
class="w-full py-2 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer"
@click="submitRating"
>
<div v-if="isSubmitting" class="w-4 h-4 border border-primary-foreground border-t-transparent rounded-full animate-spin"></div>
<span v-if="isSubmitting">{{ t('globals.messages.submitting') }}</span>
<span v-else>{{ t('globals.messages.submitFeedback') }}</span>
</button>
<Spinner v-if="isSubmitting" size="sm" :absolute="false" :center="false" />
{{ isSubmitting ? t('globals.messages.submitting') : t('globals.messages.submitFeedback') }}
</Button>
</div>
<div v-else class="text-center py-2">
@@ -63,6 +62,9 @@
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@widget/api/index.js'
import { Button } from '@shared-ui/components/ui/button'
import { Textarea } from '@shared-ui/components/ui/textarea'
import { Spinner } from '@shared-ui/components/ui/spinner'
const props = defineProps({
message: { type: Object, required: true }
@@ -12,7 +12,7 @@
</Button>
<ChatTitle />
</div>
<div class="flex items-center gap-2 ml-auto" :class="{ 'mr-12': widgetStore.isMobileFullScreen }">
<div class="flex items-center gap-2 ml-auto">
<DropdownMenu v-if="!widgetStore.isMobileFullScreen || canDownloadTranscript">
<DropdownMenuTrigger as-child>
<Button type="button" variant="ghost" size="sm" :aria-label="$t('globals.terms.more')">
@@ -38,6 +38,7 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CloseWidgetButton />
</div>
</div>
</template>
@@ -57,6 +58,7 @@ import api from '@widget/api/index.js'
import { downloadBlobResponse, parseBlobError } from '@shared-ui/utils/file'
import { handleHTTPError } from '@shared-ui/utils/http'
import ChatTitle from './ChatTitle.vue'
import CloseWidgetButton from './CloseWidgetButton.vue'
import { useWidgetStore } from '@widget/store/widget.js'
const widgetStore = useWidgetStore()
@@ -330,14 +330,16 @@ watch(
nextTick(() => {
const id = chatStore.previewUnreadUUID
chatStore.previewUnreadUUID = null
const container = messagesContainer.value
const target = id && contentEl.value?.querySelector(`[data-message-uuid="${id}"]`)
if (target) {
const offset = target
? target.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop
: 0
if (target && offset > 0 && container.scrollHeight - container.clientHeight - offset > 1) {
hasUserScrolled.value = true
scrollToOffset(
target.getBoundingClientRect().top -
messagesContainer.value.getBoundingClientRect().top +
messagesContainer.value.scrollTop
)
scrollToOffset(offset)
} else scrollToBottom()
})
if (widgetStore.isOpen && !chatStore.isLoadingConversation) {
@@ -3,6 +3,7 @@
v-if="widgetStore.isMobileFullScreen"
@click="closeWidget"
variant="ghost"
size="sm"
:aria-label="$t('globals.messages.closeChat')"
>
<X class="w-4 h-4" />
@@ -15,10 +16,5 @@ import { X } from 'lucide-vue-next'
import { useWidgetStore } from '@widget/store/widget.js'
const widgetStore = useWidgetStore()
// Send message to parent window (widget.js) to close the widget
const closeWidget = () => {
widgetStore.setOpen(false)
window.parent.postMessage({ type: 'CLOSE_WIDGET' }, '*')
}
const closeWidget = () => widgetStore.closeWidget()
</script>
@@ -28,7 +28,7 @@
</div>
<div class="flex items-center gap-2">
<p class="text-sm text-muted-foreground truncate flex-1 min-w-0">
{{ conversation.last_message.content }}
{{ lastMessagePreview(conversation.last_message, t) }}
</p>
<UnreadCountBadge :count="conversation.unread_message_count" class="flex-shrink-0" />
</div>
@@ -50,6 +50,7 @@ import { getRelativeTime } from '@shared-ui/utils/datetime.js'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Spinner } from '@shared-ui/components/ui/spinner'
import UnreadCountBadge from './UnreadCountBadge.vue'
import { lastMessagePreview } from '@widget/utils/messagePreview.js'
const chatStore = useChatStore()
const widgetStore = useWidgetStore()
@@ -1,19 +1,13 @@
<template>
<a :href="link.url" target="_blank" rel="noopener noreferrer" class="block no-underline">
<Card
class="rounded-xl border-border/80 transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
class="rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<CardContent class="p-4">
<div class="flex items-center gap-3">
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<img v-if="link.image_url" :src="link.image_url" alt="" class="size-5 object-contain" />
<Globe2 v-else size="16" class="text-muted-foreground" aria-hidden="true" />
</span>
<span class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground">
{{ link.text }}
</span>
<ExternalLink size="15" class="shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
<CardContent class="flex items-center gap-3 p-4">
<span class="min-w-0 flex-1 text-sm font-medium leading-snug text-foreground">
{{ link.text }}
</span>
<ExternalLink size="15" class="shrink-0 text-muted-foreground" aria-hidden="true" />
</CardContent>
</Card>
</a>
@@ -21,7 +15,7 @@
<script setup>
import { Card, CardContent } from '@shared-ui/components/ui/card'
import { ExternalLink, Globe2 } from 'lucide-vue-next'
import { ExternalLink } from 'lucide-vue-next'
defineProps({
link: {
@@ -1,6 +1,9 @@
<template>
<div class="relative" :style="headerStyle">
<div class="p-7">
<div class="relative">
<div
class="px-7 pb-7"
:class="widgetStore.isMobileFullScreen ? 'pt-[max(3.5rem,env(safe-area-inset-top))]' : 'pt-7'"
>
<img
v-if="config.logo_url"
:src="config.logo_url"
@@ -8,10 +11,13 @@
class="max-h-7 max-w-full"
/>
<div class="mt-20" :class="textColorClass">
<h2 class="text-4xl font-bold leading-none tracking-tight break-words">
<h2
class="text-3xl font-semibold leading-tight tracking-tight break-words"
:class="subTextColorClass"
>
{{ parsedGreeting }}
</h2>
<p class="mt-3 max-w-80 text-2xl font-medium leading-tight" :class="subTextColorClass">
<p class="mt-2 text-3xl font-semibold leading-tight tracking-tight break-words">
{{ parsedIntroduction }}
</p>
</div>
@@ -19,17 +25,14 @@
<div class="relative z-10 px-4 pb-5">
<slot />
</div>
<div
v-if="config.home_screen?.background?.type"
class="absolute bottom-0 left-0 right-0 h-20 pointer-events-none"
:style="fadeStyle"
></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@widget/store/user.js'
import { useWidgetStore } from '@widget/store/widget.js'
import { useHeaderTheme } from '@widget/composables/useHeaderTheme.js'
import { renderTemplate } from '@shared-ui/utils/string.js'
const props = defineProps({
@@ -40,6 +43,7 @@ const props = defineProps({
})
const userStore = useUserStore()
const widgetStore = useWidgetStore()
const userData = computed(() => ({
firstName: userStore.firstName,
@@ -52,47 +56,5 @@ const parsedIntroduction = computed(() =>
renderTemplate(props.config.introduction_message, userData.value)
)
const headerStyle = computed(() => {
const hs = props.config.home_screen
if (!hs?.background?.type) return {}
const style = {}
switch (hs.background.type) {
case 'solid':
if (hs.background.color) style.backgroundColor = hs.background.color
break
case 'gradient':
if (hs.background.gradient_start && hs.background.gradient_end) {
style.background = `linear-gradient(to bottom, ${hs.background.gradient_start}, ${hs.background.gradient_end})`
}
break
case 'image':
if (hs.background.image_url) {
style.backgroundImage = `url(${hs.background.image_url})`
style.backgroundSize = 'cover'
style.backgroundPosition = 'center'
}
break
}
return style
})
const headerTextColor = computed(() => props.config.home_screen?.header_text_color)
const textColorClass = computed(() => {
if (headerTextColor.value === 'black') return 'text-black'
if (headerTextColor.value === 'white') return 'text-white'
return ''
})
const subTextColorClass = computed(() => {
if (headerTextColor.value === 'black') return 'text-black/70'
if (headerTextColor.value === 'white') return 'text-white/70'
return 'text-muted-foreground'
})
const fadeStyle = {
background:
'linear-gradient(to bottom, transparent 0%, hsl(var(--background) / 0.08) 20%, hsl(var(--background) / 0.32) 45%, hsl(var(--background) / 0.72) 72%, hsl(var(--background)) 100%)'
}
const { textColorClass, subTextColorClass } = useHeaderTheme(computed(() => props.config))
</script>
@@ -23,11 +23,11 @@ const open = (article) => {
<Button
type="button"
variant="outline"
class="h-10 w-full justify-start rounded-lg"
class="h-10 w-full justify-between px-2"
@click="open()"
>
<Search class="size-4" aria-hidden="true" />
{{ $t('widget.searchArticles') }}
<Search class="size-4" aria-hidden="true" />
</Button>
<Button
v-for="article in help.featured"
@@ -10,7 +10,7 @@
<img
:src="getThumbnailUrl(attachment)"
:alt="attachment.name"
class="max-w-48 max-h-32 rounded-lg object-cover"
class="max-w-48 max-h-32 rounded-md object-cover"
@error="fallbackToOriginal($event, attachment.url)"
@click="openImage(attachment.url)"
/>
@@ -19,7 +19,7 @@
<!-- File attachment -->
<div
v-else
class="flex items-center gap-2 p-2 bg-muted rounded-lg border border-border hover:bg-muted/80 transition-colors"
class="flex items-center gap-2 p-2 bg-muted rounded-md border border-border hover:bg-muted/80 transition-colors"
@click="downloadFile(attachment)"
>
<div class="flex-shrink-0">
@@ -1,16 +1,17 @@
<template>
<div class="border-t focus:ring-0 focus:outline-none">
<div class="focus:ring-0 focus:outline-none">
<ReplyButtons
v-if="quickReplies.length"
:replies="quickReplies"
:disabled="isSending"
class="px-2 pt-2"
align="end"
class="px-2 pb-2"
@select="sendQuickReply"
/>
<!-- Message Input -->
<div class="p-2">
<div class="p-2 border-t">
<!-- Unified Input Container -->
<div class="border border-input rounded-lg bg-background focus-within:border-secondary">
<div class="border border-input rounded-md bg-background focus-within:border-secondary">
<!-- Textarea Container -->
<div class="p-2">
<Textarea
@@ -1,5 +1,5 @@
<template>
<div class="bg-secondary/30 border border-secondary rounded-lg p-4" v-if="noticeText" role="status">
<div class="bg-secondary/30 border border-secondary rounded-md p-4" v-if="noticeText" role="status">
<div class="flex items-center justify-center gap-3">
<AlertTriangle class="flex-shrink-0 text-secondary-foreground" size="16" />
<p class="text-sm text-secondary-foreground">{{ noticeText }}</p>
@@ -2,7 +2,7 @@
<div class="space-y-3">
<Card
@click="continueConversation"
class="cursor-pointer rounded-xl border-border/70 bg-card/95 shadow-lg transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-xl"
class="cursor-pointer rounded-xl border-border/80 shadow-sm transition-[background-color,box-shadow] can-hover:hover:bg-accent can-hover:hover:shadow-md"
>
<CardContent class="p-4">
<div class="flex items-start justify-between">
@@ -14,7 +14,7 @@
</div>
<div class="flex gap-2 items-start">
<div class="text-sm text-foreground line-clamp-2 flex-1 min-w-0">
{{ conversation.last_message.content }}
{{ lastMessagePreview(conversation.last_message, t) }}
</div>
<UnreadCountBadge :count="conversation.unread_message_count" class="flex-shrink-0" />
</div>
@@ -39,6 +39,7 @@ import UnreadCountBadge from '@widget/components/UnreadCountBadge.vue'
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
import { useChatStore } from '@widget/store/chat.js'
import { useWidgetStore } from '@widget/store/widget.js'
import { lastMessagePreview } from '@widget/utils/messagePreview.js'
import { useI18n } from 'vue-i18n'
const props = defineProps({
@@ -1,12 +1,12 @@
<template>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2" :class="align === 'end' ? 'justify-end' : 'justify-start'">
<Button
v-for="(reply, index) in replies"
:key="`${index}-${reply}`"
type="button"
variant="outline"
size="sm"
class="h-auto max-w-full rounded-full py-1.5 text-left whitespace-normal"
class="h-auto max-w-full rounded-full py-1.5 text-left whitespace-normal shadow-md"
:disabled="disabled"
@click="emit('select', reply)"
>
@@ -20,7 +20,8 @@ import { Button } from '@shared-ui/components/ui/button'
defineProps({
replies: { type: Array, required: true },
disabled: { type: Boolean, default: false }
disabled: { type: Boolean, default: false },
align: { type: String, default: 'start' }
})
const emit = defineEmits(['select'])
@@ -0,0 +1,23 @@
<template>
<Button v-if="canStartNewConversation" type="button" @click="startNewConversation">
{{ label }}
<ArrowRight v-if="arrow" size="16" aria-hidden="true" />
</Button>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowRight } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { useStartConversation } from '@widget/composables/useStartConversation.js'
const props = defineProps({
fallbackLabel: { type: String, default: 'globals.messages.startNewConversation' },
arrow: { type: Boolean, default: false }
})
const { t } = useI18n()
const { configuredLabel, canStartNewConversation, startNewConversation } = useStartConversation()
const label = computed(() => configuredLabel.value || t(props.fallbackLabel))
</script>
@@ -0,0 +1,50 @@
import { computed } from 'vue'
export function useHeaderTheme (config) {
const homeScreen = computed(() => config.value?.home_screen)
const choice = computed(() => homeScreen.value?.header_text_color)
const textColorClass = computed(() => {
if (choice.value === 'black') return 'text-black'
if (choice.value === 'white') return 'text-white'
return ''
})
const subTextColorClass = computed(() => {
if (choice.value === 'black') return 'text-black/70'
if (choice.value === 'white') return 'text-white/70'
return 'text-muted-foreground'
})
const backgroundStyle = computed(() => {
const background = homeScreen.value?.background
if (!background?.type) return {}
if (background.type === 'solid' && background.color) {
return { backgroundColor: background.color }
}
if (background.type === 'gradient' && background.gradient_start && background.gradient_end) {
return {
background: `linear-gradient(to bottom, ${background.gradient_start}, ${background.gradient_end})`
}
}
if (background.type === 'image' && background.image_url) {
return {
backgroundImage: `url(${background.image_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}
}
return {}
})
const showFade = computed(
() => Boolean(homeScreen.value?.background?.type) && Boolean(homeScreen.value?.fade_background)
)
const fadeStyle = {
background:
'linear-gradient(to bottom, transparent 0%, hsl(var(--background) / 0.08) 20%, hsl(var(--background) / 0.32) 45%, hsl(var(--background) / 0.72) 72%, hsl(var(--background)) 100%)'
}
return { textColorClass, subTextColorClass, backgroundStyle, showFade, fadeStyle }
}
@@ -69,7 +69,7 @@ export function useReplyPreviews() {
labels: {
dismiss: t('globals.terms.dismiss'),
dismissAll: t('widget.dismissPreviews'),
open: t('widget.openConversation')
open: t('globals.messages.openConversation')
},
theme: {
background: style.backgroundColor,
@@ -0,0 +1,32 @@
import { computed } from 'vue'
import { useChatStore } from '@widget/store/chat.js'
import { useWidgetStore } from '@widget/store/widget.js'
import { useUserStore } from '@widget/store/user.js'
export function useStartConversation () {
const chatStore = useChatStore()
const widgetStore = useWidgetStore()
const userStore = useUserStore()
const audienceConfig = computed(() =>
userStore.isVisitor ? widgetStore.config?.visitors : widgetStore.config?.users
)
const configuredLabel = computed(() => audienceConfig.value?.start_conversation_button_text || '')
// Mirrors the server check, else the button is shown and the send fails.
const canStartNewConversation = computed(() => {
if (!audienceConfig.value?.allow_start_conversation) return false
return (
audienceConfig.value?.prevent_multiple_conversations !== true || !chatStore.hasConversations
)
})
const startNewConversation = () => {
chatStore.setCurrentConversation(null)
chatStore.clearMessages()
widgetStore.navigateToChat()
}
return { configuredLabel, canStartNewConversation, startNewConversation }
}
@@ -1,7 +1,11 @@
<template>
<div class="flex flex-col h-full relative">
<div class="absolute top-2 right-2 z-20">
<CloseWidgetButton />
<div
v-if="widgetStore.currentView === 'home'"
class="absolute right-2 z-20"
:class="widgetStore.isMobileFullScreen ? 'top-[max(1rem,env(safe-area-inset-top))]' : 'top-2'"
>
<CloseWidgetButton :class="textColorClass" />
</div>
<Tabs
:modelValue="widgetStore.currentView"
@@ -54,6 +58,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@shared-ui/components/ui/tabs'
import HomeView from '@widget/views/HomeView.vue'
import { House, MessagesSquare, CircleQuestionMark } from 'lucide-vue-next'
@@ -63,10 +68,12 @@ import ChatView from '@widget/views/ChatView.vue'
import ConversationsView from '@widget/views/ConversationsView.vue'
import ConnectionBanner from '@widget/components/ConnectionBanner.vue'
import CloseWidgetButton from '@widget/components/CloseWidgetButton.vue'
import { useHeaderTheme } from '@widget/composables/useHeaderTheme.js'
import { useWidgetStore } from '@widget/store/widget.js'
const widgetStore = useWidgetStore()
const help = useHelpStore()
const { textColorClass } = useHeaderTheme(computed(() => widgetStore.config))
const handleTabChange = (value) => {
if (value === 'home') {
@@ -1,8 +1,11 @@
<template>
<div class="flex items-center justify-center p-4 border-b">
<div class="relative flex items-center justify-center p-4 border-b">
<h3 class="text-base font-semibold text-foreground">{{ title }}</h3>
<CloseWidgetButton class="absolute right-2 inset-y-0 my-auto" />
</div>
</template>
<script setup>
import CloseWidgetButton from '@widget/components/CloseWidgetButton.vue'
defineProps(['title'])
</script>
@@ -0,0 +1,14 @@
const attachmentTerm = (contentType = '') => {
if (contentType.startsWith('image/')) return 'globals.terms.image'
if (contentType.startsWith('video/')) return 'globals.terms.video'
if (contentType.startsWith('audio/')) return 'globals.terms.audio'
return 'globals.terms.file'
}
export const lastMessagePreview = (lastMessage, t) => {
const content = lastMessage?.content?.trim()
if (content) return content
const attachment = lastMessage?.attachments?.[0]
if (!attachment) return ''
return t(attachmentTerm(attachment.content_type), 1)
}
@@ -9,7 +9,7 @@
</div>
<!-- Floating button with gradient fade -->
<div v-if="canStartNewConversation" class="absolute bottom-0 inset-x-0">
<div class="absolute bottom-0 inset-x-0">
<!-- Gradient fade overlay -->
<div
class="h-20 bg-gradient-to-t from-background via-background/80 to-transparent pointer-events-none"
@@ -17,47 +17,14 @@
<!-- Floating button -->
<div class="absolute bottom-4 inset-x-0 mx-auto w-fit z-10">
<Button @click="startNewConversation">
{{ startButtonText }}
</Button>
<StartConversationButton />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@shared-ui/components/ui/button'
import { useChatStore } from '@widget/store/chat.js'
import { useWidgetStore } from '@widget/store/widget.js'
import { useUserStore } from '@widget/store/user.js'
import ConversationsList from '@widget/components/ConversationsList.vue'
import WidgetHeader from '@widget/layouts/WidgetHeader.vue'
const { t } = useI18n()
const chatStore = useChatStore()
const widgetStore = useWidgetStore()
const userStore = useUserStore()
const startButtonText = computed(() => {
const userConfig = userStore.isVisitor ? widgetStore.config?.visitors : widgetStore.config?.users
return userConfig?.start_conversation_button_text || t('globals.messages.startNewConversation')
})
const canStartNewConversation = computed(() => {
const userConfig = userStore.isVisitor ? widgetStore.config?.visitors : widgetStore.config?.users
// Mirrors the server check, else the button is offered and the send fails.
if (!userConfig?.allow_start_conversation) return false
return userConfig?.prevent_multiple_conversations !== true || !chatStore.hasConversations
})
const startNewConversation = () => {
// Clear current conversation
chatStore.setCurrentConversation(null)
chatStore.clearMessages()
// Navigate directly to chat view
widgetStore.navigateToChat()
}
import StartConversationButton from '@widget/components/StartConversationButton.vue'
</script>
+31 -17
View File
@@ -1,13 +1,16 @@
<script setup>
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue'
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Search, Maximize2, Minimize2, FileQuestionMark } from 'lucide-vue-next'
import { ArrowLeft, Maximize2, Minimize2, FileQuestionMark } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import SearchInput from '@shared-ui/components/SearchInput.vue'
import { useHelpStore } from '@widget/store/help.js'
import { useWidgetStore } from '@widget/store/widget.js'
import HelpCollectionList from '@shared-ui/components/HelpCollectionList.vue'
import ArticleReader from '@widget/components/ArticleReader.vue'
import CloseWidgetButton from '@widget/components/CloseWidgetButton.vue'
import StartConversationButton from '@widget/components/StartConversationButton.vue'
import { Spinner } from '@shared-ui/components/ui/spinner'
import api from '@widget/api/index.js'
@@ -20,6 +23,7 @@ onUnmounted(() => {
})
const valid = (identity) => mounted && identity === help.identity
const busy = ref(false)
const searching = ref(false)
const error = ref('')
const scroller = ref(null)
const backButton = ref(null)
@@ -34,6 +38,18 @@ const toggleExpand = () => {
widget.toggleExpand()
help.setExpandArticles(widget.isExpanded)
}
const runSearch = useDebounceFn(() => search(), 300)
watch(
() => help.query,
(query) => {
if (!query.trim()) {
help.results = null
return
}
runSearch()
}
)
const retry = () => {
error.value = ''
help.load(locale.value)
@@ -44,7 +60,7 @@ const search = async () => {
help.results = null
return
}
busy.value = true
searching.value = true
error.value = ''
const identity = help.identity
try {
@@ -54,7 +70,7 @@ const search = async () => {
} catch {
if (valid(identity) && help.query.trim() === query) error.value = t('widget.helpLoadError')
} finally {
busy.value = false
searching.value = false
}
}
const openArticle = async ({ id, slug, locale: articleLocale }) => {
@@ -122,7 +138,7 @@ onMounted(async () => {
if (help.focusSearch && mounted) {
help.focusSearch = false
await nextTick()
requestAnimationFrame(() => searchInput.value?.$el?.focus())
requestAnimationFrame(() => searchInput.value?.input?.$el?.focus())
}
})
</script>
@@ -156,6 +172,7 @@ onMounted(async () => {
<Minimize2 v-if="widget.isExpanded" class="size-4" aria-hidden="true" />
<Maximize2 v-else class="size-4" aria-hidden="true" />
</Button>
<CloseWidgetButton class="absolute right-2 inset-y-0 my-auto" />
</header>
<div class="relative flex flex-col flex-1 min-h-0">
<div
@@ -177,20 +194,14 @@ onMounted(async () => {
</div>
</template>
<template v-else>
<form class="flex gap-2 p-3 border-b" @submit.prevent="search">
<Input
<form class="p-3 border-b" @submit.prevent="search">
<SearchInput
ref="searchInput"
v-model="help.query"
:aria-label="t('widget.searchArticles')"
:placeholder="t('widget.searchArticles')"
:clear-label="t('globals.messages.clearSearch')"
:loading="searching"
/>
<Button
type="submit"
variant="outline"
:disabled="busy || !help.available"
:aria-label="t('globals.terms.search')"
><Search class="size-4" aria-hidden="true"
/></Button>
</form>
<div
ref="scroller"
@@ -211,7 +222,10 @@ onMounted(async () => {
class="flex flex-col items-center justify-center px-4 py-12 text-center"
>
<FileQuestionMark class="w-10 h-10 text-muted-foreground mb-4" aria-hidden="true" />
<p class="text-sm text-muted-foreground">{{ t('widget.noArticles') }}</p>
<p class="text-sm text-muted-foreground">
{{ t('widget.noResultsFor', { query: help.query.trim() }) }}
</p>
<StartConversationButton class="mt-4" />
</div>
</div>
</template>
+35 -51
View File
@@ -1,47 +1,48 @@
<template>
<div class="flex flex-col h-full">
<div class="relative flex flex-col h-full bg-background">
<div
class="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50"
class="pointer-events-none absolute inset-x-0 top-0"
:style="[backgroundStyle, { height: `${headerHeight}px` }]"
>
<div class="flex flex-col">
<HomeHeader :config="config">
<RecentConversationCard
v-if="mostRecentConversation"
:conversation="mostRecentConversation"
/>
<div v-else-if="canStartConversation">
<Button
size="lg"
class="w-full rounded-xl font-semibold shadow-md"
@click="startConversation"
>
{{ startButtonText }}
<ArrowRight size="16" aria-hidden="true" />
</Button>
</div>
</HomeHeader>
<div v-if="showFade" class="absolute inset-x-0 bottom-0 h-20" :style="fadeStyle"></div>
</div>
<div v-if="homeItems.length" class="space-y-3 bg-background px-4 pt-1 pb-5">
<template v-for="(item, index) in homeItems" :key="index">
<HomeHelp v-if="item.type === 'help'" />
<AnnouncementCard v-else-if="item.type === 'announcement'" :announcement="item" />
<HomeExternalLink v-else-if="item.type === 'external_link'" :link="item" />
</template>
</div>
<div
class="relative flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50"
>
<HomeHeader ref="headerRef" :config="config">
<RecentConversationCard
v-if="mostRecentConversation"
:conversation="mostRecentConversation"
/>
<StartConversationButton
v-else
arrow
size="lg"
fallback-label="globals.messages.sendUsMessage"
class="w-full font-semibold shadow-md"
/>
</HomeHeader>
<div v-if="homeItems.length" class="space-y-3 px-4 pt-1 pb-5">
<template v-for="(item, index) in homeItems" :key="index">
<HomeHelp v-if="item.type === 'help'" />
<AnnouncementCard v-else-if="item.type === 'announcement'" :announcement="item" />
<HomeExternalLink v-else-if="item.type === 'external_link'" :link="item" />
</template>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ArrowRight } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { computed, ref } from 'vue'
import { useElementSize } from '@vueuse/core'
import { useWidgetStore } from '@widget/store/widget.js'
import { useHeaderTheme } from '@widget/composables/useHeaderTheme.js'
import { useChatStore } from '@widget/store/chat.js'
import { useUserStore } from '@widget/store/user.js'
import { useI18n } from 'vue-i18n'
import HomeHeader from '@widget/components/HomeHeader.vue'
import StartConversationButton from '@widget/components/StartConversationButton.vue'
import HomeExternalLink from '@widget/components/HomeExternalLink.vue'
import AnnouncementCard from '@widget/components/AnnouncementCard.vue'
import RecentConversationCard from '@widget/components/RecentConversationCard.vue'
@@ -50,9 +51,10 @@ import { useHelpStore } from '@widget/store/help.js'
const widgetStore = useWidgetStore()
const chatStore = useChatStore()
const userStore = useUserStore()
const { t } = useI18n()
const config = computed(() => widgetStore.config)
const { backgroundStyle, showFade, fadeStyle } = useHeaderTheme(config)
const headerRef = ref(null)
const { height: headerHeight } = useElementSize(computed(() => headerRef.value?.$el))
const help = useHelpStore()
const homeItems = computed(() =>
(config.value.home_apps || []).filter((item) => item.type !== 'help' || help.available)
@@ -65,22 +67,4 @@ const mostRecentConversation = computed(() => {
return conversations[0]
})
const canStartConversation = computed(() => {
const userConfig = userStore.isVisitor ? config.value.visitors : config.value.users
// Mirrors the server check, else the button is offered and the send fails.
if (!userConfig?.allow_start_conversation) return false
return userConfig?.prevent_multiple_conversations !== true || !chatStore.hasConversations
})
const startButtonText = computed(() => {
const isVisitor = userStore.isVisitor
return isVisitor
? config.value.visitors?.start_conversation_button_text || t('globals.messages.sendUsMessage')
: config.value.users?.start_conversation_button_text || t('globals.messages.sendUsMessage')
})
const startConversation = () => {
chatStore.setCurrentConversation(null)
widgetStore.navigateToChat()
}
</script>
@@ -30,6 +30,8 @@ describe('Live chat inbox form', () => {
let inboxId
beforeEach(() => {
// radix-vue's select focuses a node that is already gone in headless runs.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
cy.viewport(1280, 800)
cy.login()
})
@@ -0,0 +1,314 @@
// The steps run in order and share the inbox created by the first one.
const stamp = Date.now()
const inboxName = `Cypress Widget Tabs ${stamp}`
const brandName = `Cypress Tabs Brand ${stamp}`
const campaignName = `Cypress Campaign ${stamp}`
const campaignMessage = `Need a hand with pricing? ${stamp}`
const secondCampaignName = `Cypress Second ${stamp}`
const hcName = `Cypress Widget HC ${stamp}`
const hcSlug = `cypress-widget-hc-${stamp}`
const collectionName = `Cypress Collection ${stamp}`
const articleTitle = `Cypress Article ${stamp}`
const listPath = '/admin/inboxes'
const openTab = (label) => cy.get('[role="tab"]').contains(label).click()
const selectOption = (trigger, option) => {
cy.get(trigger).click()
cy.get('[role="option"]').contains(option).click()
// The listbox keeps a focus guard over the form until it has finished closing.
cy.get('[role="option"]').should('not.exist')
}
const editInbox = (inboxId) => cy.visit(`${listPath}/${inboxId}/edit`)
const openCampaign = (name) => cy.contains('button', name).click()
describe('Live chat inbox form: proactive messages and help center tabs', () => {
let inboxId
let helpCenterId
let articleId
before(() => {
cy.login()
cy.api('POST', '/api/v1/help-centers', {
name: hcName,
slug: hcSlug,
page_title: `Help ${stamp}`,
default_locale: 'en',
allowed_locales: ['en'],
template: 'docs'
})
.then(({ body }) => {
helpCenterId = body.data.id
return cy.api('POST', `/api/v1/help-centers/${helpCenterId}/collections`, {
name: collectionName,
locale: 'en',
sort_order: 1,
is_published: true
})
})
.then(({ body }) =>
cy.api('POST', `/api/v1/collections/${body.data.id}/articles`, {
title: articleTitle,
content: '<p>Cypress body.</p>',
locale: 'en',
status: 'published'
})
)
.then(({ body }) => {
articleId = body.data.id
expect(articleId).to.be.a('number')
})
})
beforeEach(() => {
// radix-vue's select focuses a node that is already gone in headless runs.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
cy.viewport(1280, 900)
cy.login()
})
after(() => {
if (inboxId) cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false })
if (helpCenterId) {
cy.api('DELETE', `/api/v1/help-centers/${helpCenterId}`, null, { failOnStatusCode: false })
}
})
it('creates the inbox the other steps edit', () => {
cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
cy.visit('/admin/inboxes/new')
cy.contains('Create a live chat inbox').click()
cy.get('input[name="name"]').type(inboxName)
cy.get('input[name="config.brand_name"]').type(brandName)
cy.get('button[type="submit"]').click()
cy.wait('@createInbox').then(({ response }) => {
expect(response.statusCode).to.eq(200)
inboxId = response.body.data.id
})
})
it('saves an inbox created outside the form, which carries no prechat config', () => {
const apiInboxName = `${inboxName} api`
cy.api('POST', '/api/v1/inboxes', {
name: apiInboxName,
channel: 'livechat',
enabled: true,
config: {
brand_name: brandName,
colors: { primary: '#112233' },
launcher: { position: 'right' }
}
}).then(({ body }) => {
const apiInboxId = body.data.id
cy.intercept('PUT', `**/api/v1/inboxes/${apiInboxId}`).as('updateApiInbox')
editInbox(apiInboxId)
cy.get('input[name="config.website_url"]').type('https://cypress.test/api-inbox')
cy.get('button[type="submit"]').click()
cy.wait('@updateApiInbox').its('response.statusCode').should('eq', 200)
cy.api('DELETE', `/api/v1/inboxes/${apiInboxId}`, null, { failOnStatusCode: false })
})
})
it('saves a proactive message and loads every field back', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Proactive messages')
cy.contains('button', 'Add proactive message').click()
cy.get('#campaign-name').clear().type(campaignName)
cy.get('#campaign-message').clear().type(campaignMessage)
cy.get('#campaign-include_urls').clear().type('/pricing\nhttps://example.com/docs/*')
cy.get('#campaign-exclude_urls').clear().type('/checkout/*')
cy.get('#campaign-delay_seconds').type('{selectall}25')
cy.get('#campaign-event').clear().type('pricing_viewed')
selectOption('#campaign-repeat', 'After an interval')
cy.get('#campaign-repeat_hours').type('{selectall}12')
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
editInbox(inboxId)
openTab('Proactive messages')
openCampaign(campaignName)
cy.get('#campaign-name').should('have.value', campaignName)
cy.get('#campaign-message').should('have.value', campaignMessage)
cy.get('#campaign-include_urls').should(
'have.value',
'/pricing\nhttps://example.com/docs/*'
)
cy.get('#campaign-exclude_urls').should('have.value', '/checkout/*')
cy.get('#campaign-delay_seconds').should('have.value', '25')
cy.get('#campaign-event').should('have.value', 'pricing_viewed')
cy.get('#campaign-repeat_hours').should('have.value', '12')
})
it('rejects a proactive message with an empty message', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Proactive messages')
openCampaign(campaignName)
cy.get('#campaign-message').clear()
cy.get('button[type="submit"]').click()
cy.contains('Message cannot be empty').should('be.visible')
cy.get('@updateInbox.all').should('have.length', 0)
})
it('rejects a repeat interval outside the allowed range', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Proactive messages')
openCampaign(campaignName)
cy.get('#campaign-repeat_hours').type('{selectall}9000')
cy.get('button[type="submit"]').click()
cy.get('#campaign-repeat_hours').should('have.attr', 'aria-invalid', 'true')
cy.get('@updateInbox.all').should('have.length', 0)
})
it('rejects a proactive message with both devices turned off', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Proactive messages')
openCampaign(campaignName)
cy.get('[data-campaign-devices] button[role="switch"]').each(($switch) => {
cy.wrap($switch).click()
})
cy.get('button[type="submit"]').click()
cy.contains('Select at least one device').should('be.visible')
cy.get('@updateInbox.all').should('have.length', 0)
})
it('persists the enabled toggle and the order of two proactive messages', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Proactive messages')
cy.contains('button', 'Add proactive message').click()
cy.get('#campaign-name').clear().type(secondCampaignName)
cy.get('#campaign-message').clear().type(`Second message ${stamp}`)
cy.contains('button', 'Back to proactive messages').click()
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
editInbox(inboxId)
openTab('Proactive messages')
// A new proactive message starts paused, so one click enables it.
cy.contains('button', campaignName)
.parents('.border')
.first()
.find('button[role="switch"][aria-label="Enabled"]')
.should('have.attr', 'data-state', 'unchecked')
.click()
cy.contains('button', secondCampaignName)
.parents('.border')
.first()
.find('button[aria-label="Move up"]')
.click()
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
cy.api('GET', `/api/v1/inboxes/${inboxId}`).then(({ body }) => {
const campaigns = body.data.config.campaigns
expect(campaigns.map((item) => item.name)).to.deep.eq([secondCampaignName, campaignName])
expect(campaigns.find((item) => item.name === campaignName).enabled).to.eq(true)
})
})
it('deletes a proactive message', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Proactive messages')
openCampaign(secondCampaignName)
cy.get('[data-campaign-editor]').contains('button', 'Delete').click()
cy.get('[role="alertdialog"]').contains('button', 'Delete').click()
cy.get('[data-campaign-editor]').should('not.exist')
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
editInbox(inboxId)
openTab('Proactive messages')
cy.contains(secondCampaignName).should('not.exist')
cy.contains(campaignName).should('exist')
})
it('links a help center, shows the help tab and features an article', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Help center')
selectOption('#widget-help-center', hcName)
cy.get('[data-help-audience="visitors"]').contains('Show Help tab').should('be.visible')
cy.contains('Featured articles').should('be.visible')
cy.get('[data-featured-articles] button[role="combobox"]').should('not.be.disabled').click()
cy.get('[role="option"]').contains(articleTitle).click()
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
editInbox(inboxId)
openTab('Help center')
cy.get('#widget-help-center').should('contain', hcName)
cy.contains(articleTitle).should('be.visible')
cy.api('GET', `/api/v1/inboxes/${inboxId}`).then(({ body }) => {
const help = body.data.config.help
expect(help.help_center_id).to.eq(helpCenterId)
expect(help.featured_ids).to.deep.eq([articleId])
})
})
it('removes a featured article', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Help center')
cy.get('[data-featured-articles] button[aria-label="Remove"]').click()
cy.get('[data-featured-articles] button[aria-label="Remove"]').should('not.exist')
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
cy.api('GET', `/api/v1/inboxes/${inboxId}`).then(({ body }) => {
expect(body.data.config.help.featured_ids).to.deep.eq([])
})
})
it('unlinks the help center and hides its settings', () => {
cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox')
editInbox(inboxId)
openTab('Help center')
selectOption('#widget-help-center', 'None')
cy.contains('Featured articles').should('not.exist')
cy.get('button[type="submit"]').click()
cy.wait('@updateInbox').its('response.statusCode').should('eq', 200)
cy.api('GET', `/api/v1/inboxes/${inboxId}`).then(({ body }) => {
expect(body.data.config.help.help_center_id).to.eq(0)
})
})
})
@@ -0,0 +1,62 @@
<template>
<div class="relative">
<Spinner
v-if="loading"
size="sm"
variant="muted"
:absolute="false"
:center="false"
class="absolute left-3 top-1/2 size-4 -translate-y-1/2 pointer-events-none"
/>
<Search
v-else
class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none"
aria-hidden="true"
/>
<Input
ref="input"
:model-value="modelValue"
:placeholder="placeholder"
:aria-label="ariaLabel || placeholder"
:class="['pl-10', modelValue ? 'pr-10' : 'pr-3', inputClass]"
@update:model-value="emit('update:modelValue', $event)"
/>
<slot name="trailing" />
<button
v-if="modelValue"
type="button"
:aria-label="clearLabel"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
@click="clear"
>
<X class="size-4" aria-hidden="true" />
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Search, X } from 'lucide-vue-next'
import { Input } from '@shared-ui/components/ui/input'
import { Spinner } from '@shared-ui/components/ui/spinner'
defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
ariaLabel: { type: String, default: '' },
clearLabel: { type: String, default: '' },
inputClass: { type: [String, Array, Object], default: '' },
loading: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'clear'])
const input = ref(null)
const clear = () => {
emit('update:modelValue', '')
emit('clear')
input.value?.$el?.focus?.()
}
defineExpose({ input })
</script>
+10 -10
View File
@@ -392,7 +392,6 @@
"admin.inbox.livechat.prechatForm.enabled": "Enable pre-chat form",
"admin.inbox.livechat.prechatForm.enabled.description": "Show a form to collect information before chat starts",
"admin.inbox.livechat.prechatForm.audienceEnabled": "Show form to this audience",
"admin.inbox.livechat.prechatForm.audienceEnabled.description": "Ask the selected audience to fill this form",
"admin.inbox.livechat.prechatForm.fields": "Form fields",
"admin.inbox.livechat.prechatForm.handoffOnly": "Show only during AI handoff",
"admin.inbox.livechat.prechatForm.handoffOnly.description": "Skip this form when a chat starts. Show it when the AI needs to transfer the conversation to a human.",
@@ -873,6 +872,8 @@
"globals.messages.clearSearch": "Clear search",
"globals.messages.close": "Close",
"globals.messages.closeChat": "Close chat",
"globals.messages.openChat": "Open chat",
"globals.messages.openConversation": "Open conversation",
"globals.messages.connected": "Connected",
"globals.messages.connecting": "Connecting...",
"globals.messages.connectionFailedRefresh": "Connection failed. Please refresh.",
@@ -998,6 +999,7 @@
"globals.terms.article": "Article | Articles",
"globals.terms.ascending": "Ascending",
"globals.terms.assignedTeam": "Assigned team",
"globals.terms.audience": "Audience",
"globals.terms.assignee": "Assignee",
"globals.terms.attachment": "Attachment | Attachments",
"globals.terms.audio": "Audio",
@@ -1074,6 +1076,7 @@
"globals.terms.general": "General",
"globals.terms.appearance": "Appearance",
"globals.terms.desktop": "Desktop",
"globals.terms.device": "Device | Devices",
"globals.terms.tablet": "Tablet",
"globals.terms.mobile": "Mobile",
"globals.terms.good": "Good",
@@ -1177,6 +1180,7 @@
"globals.terms.secret": "Secret | Secrets",
"globals.terms.security": "Security",
"globals.terms.select": "Select",
"globals.terms.sender": "Sender",
"globals.terms.sendWelcomeEmail": "Send welcome email? | Send welcome emails?",
"globals.terms.sent": "Sent",
"globals.terms.setPassword": "Set password | Set passwords",
@@ -1245,6 +1249,7 @@
"helpCenter.aiEnabledHint": "Takes effect only when the article is published. Enable it in one language only, not in every translation.",
"helpCenter.addTranslation": "Add translation",
"helpCenter.translationOf": "Translation of “{title}”",
"helpCenter.translations": "Translations",
"helpCenter.saveBeforeSwitchingTranslation": "Unsaved changes would be lost. Save the article before switching languages.",
"helpCenter.saveBeforeTranslation": "Unsaved changes would be lost. Save the article before adding a translation.",
"helpCenter.customCSS": "Custom CSS",
@@ -1643,7 +1648,7 @@
"webhook.new": "New webhook",
"webhook.sendTest": "Send test",
"webhook.sentSuccessfully": "Webhook sent successfully",
"widget.addArticle": "Add article",
"widget.selectArticle": "Select article",
"widget.addCampaign": "Add proactive message",
"widget.addHelpHomeApp": "Add help articles",
"widget.articleUnavailable": "Article unavailable",
@@ -1652,10 +1657,8 @@
"widget.brandBot": "Brand bot",
"widget.campaign.all": "Everyone",
"widget.campaign.any": "Any time",
"widget.campaign.audience": "Audience",
"widget.campaign.business_hours": "Business hours rule",
"widget.campaign.delay_seconds": "Active time on page (seconds)",
"widget.campaign.devices": "Devices",
"widget.campaign.dismissed": "Dismissed",
"widget.campaign.displayed": "Displayed",
"widget.campaign.event": "Optional event name",
@@ -1678,7 +1681,6 @@
"widget.campaignDeviceRequired": "Select at least one device. This proactive message cannot appear until desktop or mobile is enabled.",
"widget.campaignLimitReached": "You have reached the limit of 50 proactive messages. Delete one to add another.",
"widget.campaignPriorityHint": "Messages are checked from top to bottom. The first matching message is shown.",
"widget.campaignSender": "Sender",
"widget.campaignsHint": "Show a message on matching pages before the chat is opened. A conversation is created only when someone replies to it.",
"widget.campaignStats": "Proactive message results",
"widget.campaignUrlLimit": "Add up to 20 page patterns.",
@@ -1690,7 +1692,6 @@
"widget.featuredArticlesHint": "Choose up to 10 articles. Leave empty to show popular articles.",
"widget.genericNotice": "Generic notification",
"widget.helpFeedbackError": "Feedback could not be saved. Try again.",
"widget.helpful": "Was this helpful?",
"widget.helpHomeAppHint": "Shows a search button and the featured articles set in the Help center tab.",
"widget.helpHomeAppRequiresCenter": "Choose a help center in the Help center tab before adding this card.",
"widget.helpLoadError": "Help could not be loaded. Try again or send us a message.",
@@ -1702,9 +1703,8 @@
"widget.newCampaign": "New proactive message",
"widget.newReply": "You have a new reply",
"widget.openInHelpCenter": "Open in help center",
"widget.noArticles": "No articles found. Try different words or send us a message.",
"widget.openConversation": "Open conversation",
"widget.openChat": "Open chat",
"widget.noArticles": "No published articles yet.",
"widget.noResultsFor": "No results for “{query}”",
"widget.prechatForm.startChat": "Start chat",
"widget.prechatForm.continueToHuman": "Continue to a human",
"conversation.sidebarCounts.all": "{count} open conversation in all | {count} open conversations in all",
@@ -1716,7 +1716,7 @@
"widget.proactiveMessages": "Proactive messages",
"widget.replyPreviews": "Replies above the launcher",
"widget.replyTeam": "Route replies to team",
"widget.searchArticles": "Search help articles",
"widget.searchArticles": "Search for help",
"widget.showHelpTab": "Show Help tab",
"widget.urlPatternsHint": "One URL or path per line. Use * to match any characters. Empty includes match every page."
}
+5 -2
View File
@@ -5,8 +5,11 @@ It holds two fake accounts and looks them up by the contact's external user id,
which libredesk sends in the `X-Libredesk-Contact-External-Id` header. Add your
own test users by editing the `users` map in `main.go`.
Custom tools are only called by AI assistants. Copilot gets the built-in tools
and never touches these.
AI assistants call every enabled custom tool. Copilot and Generate Reply call the
ones with "Available in Copilot" or "Available in Generate Reply" turned on, and a
tool with "Require agent approval" on waits for the agent to approve the call there.
"Require verified contact" applies to assistant runs only, since an agent is the one
asking on the Copilot and Generate Reply surfaces.
## Run
+1 -1
View File
@@ -16,7 +16,7 @@ Open `http://localhost:8005`, then fill in:
- **baseURL**: your Libredesk instance (default `http://localhost:8001` - Default points to the widget dev server, not the API server)
- **inboxID**: the livechat inbox's UUID (from the inbox's installation snippet)
- **Signing secret**: the "Secret Key" from that inbox's Security tab
- **Signing secret**: the "Secret Key" from the inbox form
`exp` is set to now + 1 hour on every generate. Use "Clear session" to drop the
widget's cookies and start over as a brand-new visitor.
+10 -3
View File
@@ -2,9 +2,14 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Libredesk widget - JWT test</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; color: #222; }
@media (max-width: 640px) {
body { margin: 16px auto; }
.row { flex-direction: column; gap: 0; }
}
h1 { font-size: 18px; }
label { display: block; margin-top: 14px; font-size: 13px; font-weight: 600; color: #444; }
input, textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; font-size: 13px; padding: 8px; margin-top: 4px; border: 1px solid #ccc; border-radius: 6px; }
@@ -16,12 +21,13 @@
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.hint { font-size: 12px; color: #777; font-weight: 400; }
.field-hint { display: block; margin-top: 4px; }
</style>
</head>
<body>
<h1>Libredesk widget - JWT auth test</h1>
<p class="hint">Edit the payload below, click "Sign JWT &amp; load widget". <code>exp</code> is auto-set to now + 1 hour on every generate so the token never shows up expired. The signing secret must match the "Secret Key" configured on this inbox's Security tab in Libredesk admin.</p>
<p class="hint">Edit the payload below, then load the widget. The signing secret must match the "Secret Key" configured in the inbox form.</p>
<div class="row">
<div>
@@ -29,8 +35,9 @@
<input id="baseURL" value="http://localhost:8001">
</div>
<div>
<label>inboxID</label>
<input id="inboxID" placeholder="livechat inbox UUID from inbox form" value="">
<label for="inboxID">Inbox UUID</label>
<input id="inboxID" aria-describedby="inboxIDHint" value="">
<span id="inboxIDHint" class="hint field-hint">Copy the UUID from your inbox form.</span>
</div>
</div>
@@ -75,6 +75,7 @@
</script>
<script src="/static/public/static/help-center-search.js?v={{ AssetVer }}" defer></script>
{{ if .Data.HelpCenter.CustomJS }}<script>{{ .Data.HelpCenter.CustomJS }}</script>{{ end }}
{{ template "hc-livechat-widget" . }}
</body>
</html>
{{ end }}
@@ -191,6 +191,7 @@
</script>
<script src="/static/public/static/help-center-search.js?v={{ AssetVer }}" defer></script>
{{ if .Data.HelpCenter.CustomJS }}<script>{{ .Data.HelpCenter.CustomJS }}</script>{{ end }}
{{ template "hc-livechat-widget" . }}
</body>
</html>
{{ end }}
@@ -138,6 +138,19 @@
keepEmbedded();
new MutationObserver(keepEmbedded).observe(document.body, { childList: true, subtree: true });
// The parent sizes this iframe to the document height, so the widget panel owns scrolling.
document.addEventListener('click', function (event) {
var link = event.target.closest('a[href^="#"]');
if (!link) return;
var id = decodeURIComponent(link.getAttribute('href').slice(1));
var target = id && document.getElementById(id);
if (!target) return;
event.preventDefault();
var top = target.getBoundingClientRect().top + window.scrollY;
parent.postMessage({ source: 'libredesk-hc-embed', type: 'scroll', top: Math.max(top - 12, 0) }, '*');
if (history.replaceState) history.replaceState(null, '', '#' + id);
});
var last = 0;
var report = function () {
var height = Math.ceil(document.body.getBoundingClientRect().height);
+15 -6
View File
@@ -21,7 +21,8 @@
}
this.IFRAME_BORDER_RADIUS = '20px';
this.IFRAME_BOX_SHADOW = '0 1px 6px rgba(9, 14, 21, 0.5), 0 4px 32px rgba(9, 14, 21, 0.65)';
this.IFRAME_BOX_SHADOW = 'rgba(9, 14, 21, 0.9) 0px 5px 40px 0px';
this.LAUNCHER_DROP_SHADOW = 'drop-shadow(rgba(9, 14, 21, 0.54) 0px 1px 6px) drop-shadow(rgba(9, 14, 21, 0.9) 0px 2px 32px)';
this.IFRAME_WIDTH = '400px';
this.IFRAME_HEIGHT = '700px';
this.EXPANDED_WIDTH = '750px';
@@ -49,6 +50,7 @@
this.campaignActiveSeconds = 0;
this.campaignURL = location.href;
this.campaignEvent = "";
this.campaignDelay = 0;
this.campaignBrowserKey = this.getCookie(this.getCookieName('campaign')) || this.randomKey();
this.setCookie(this.getCookieName('campaign'), this.campaignBrowserKey);
this.campaignSessionKey = this.getCookie(this.getCookieName('campaign-session')) || this.randomKey();
@@ -229,7 +231,7 @@
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.14);
filter: ${this.LAUNCHER_DROP_SHADOW};
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
`;
@@ -449,7 +451,10 @@
break;
case 'WIDGET_LOADED':
this.handleWidgetLoaded();
if (event.data.campaigns) this.startCampaignTracking();
if (event.data.campaigns) {
this.campaignDelay = Math.min(Math.max(Number(event.data.campaignDelay) || 0, 0), 86400);
this.startCampaignTracking();
}
break;
case 'EXPAND_WIDGET':
this.expandWidget();
@@ -802,7 +807,9 @@
if (this.campaignInterval) return;
let last = performance.now();
let elapsed = 0;
let gap = this.CAMPAIGN_POLL_MIN;
let asked = false;
const firstGap = () => Math.max(this.CAMPAIGN_POLL_MIN, this.campaignDelay);
let gap = firstGap();
this.campaignInterval = setInterval(() => {
const now = performance.now();
const delta = Math.min((now - last) / 1000, 2);
@@ -812,14 +819,16 @@
this.campaignActiveSeconds = 0;
this.campaignEvent = '';
elapsed = 0;
gap = this.CAMPAIGN_POLL_MIN;
asked = false;
gap = firstGap();
}
if (document.hidden || this.isChatVisible || this.hideLauncher) return;
this.campaignActiveSeconds += delta;
elapsed += delta;
if (elapsed < gap || this.unreadCount > 0 || this.campaignData) return;
elapsed = 0;
gap = Math.min(gap * 2, this.CAMPAIGN_POLL_MAX);
gap = asked ? Math.min(gap * 2, this.CAMPAIGN_POLL_MAX) : this.CAMPAIGN_POLL_MIN;
asked = true;
this.postToIframe({ type: 'CAMPAIGN_CONTEXT', context: { url: location.href, mobile: this.isMobile, active_seconds: Math.floor(this.campaignActiveSeconds), event: this.campaignEvent } });
}, 1000);
}