mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-25 13:59:36 +00:00
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:
+5
-3
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user