Compare commits

...

5 Commits

Author SHA1 Message Date
Abhinav Raut
54e614422d refactor and fixes to convo continuity 2025-09-25 02:49:23 +05:30
Abhinav Raut
1deeaf6df3 remove unnecessary descriptions for fields and translations 2025-09-25 02:32:00 +05:30
Abhinav Raut
3a5990174b fixes to pre chat form 2025-09-25 02:20:00 +05:30
Abhinav Raut
c7291b1d1a fix layout for live chat inbox tabs for smaller devices
hide admin help text on smaller devices
2025-09-24 23:54:26 +05:30
Abhinav Raut
5de870c446 Config option to show or hide ‘Powered by Libredesk’ in the live chat widget.
Make the start conversation button a floating button and add a gradient fade overlay
2025-09-24 23:34:39 +05:30
13 changed files with 610 additions and 493 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"maps"
"math"
"path/filepath"
"slices"
"strconv"
@@ -162,29 +163,11 @@ func handleGetChatSettings(r *fastglue.Request) error {
}
}
// Collect custom attribute IDs from pre-chat form fields
// Filter out pre-chat form fields for which custom attributes don't exist anymore
if config.PreChatForm.Enabled && len(config.PreChatForm.Fields) > 0 {
customAttrIDs := make(map[int]bool)
for _, field := range config.PreChatForm.Fields {
if field.Enabled && field.CustomAttributeID > 0 {
customAttrIDs[field.CustomAttributeID] = true
}
}
// Fetch custom attributes if any are referenced
if len(customAttrIDs) > 0 {
customAttributes := make(map[int]customAttributeWidget)
for id := range customAttrIDs {
attr, err := app.customAttribute.Get(id)
if err != nil {
app.lo.Error("failed to fetch custom attribute for widget", "id", id, "error", err)
continue
}
customAttributes[id] = customAttributeWidget{
ID: attr.ID,
Values: attr.Values,
}
}
filteredFields, customAttributes := filterPreChatFormFields(config.PreChatForm.Fields, app)
response.PreChatForm.Fields = filteredFields
if len(customAttributes) > 0 {
response.CustomAttributes = customAttributes
}
}
@@ -253,8 +236,8 @@ func handleChatInit(r *fastglue.Request) error {
lastName := claims.LastName
email := claims.Email
// Process form custom attributes
formCustomAttributes := processFormCustomAttributes(req.FormData, config, app)
// Validate custom attribute
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Merge JWT and form custom attributes (form takes precedence)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
@@ -284,14 +267,13 @@ func handleChatInit(r *fastglue.Request) error {
// User exists, update custom attributes from both JWT and form
// Don't override existing name and email.
// Process form custom attributes
formCustomAttributes := processFormCustomAttributes(req.FormData, config, app)
// Validate custom attribute
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Merge JWT and form custom attributes (form takes precedence)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
if len(mergedAttributes) > 0 {
fmt.Println("Updating custom attributes for user:", user.ID, "with attributes:", mergedAttributes)
if err := app.user.SaveCustomAttributes(user.ID, mergedAttributes, false); err != nil {
app.lo.Error("error updating contact custom attributes", "contact_id", user.ID, "error", err)
// Don't fail the request for custom attributes update failure
@@ -309,8 +291,8 @@ func handleChatInit(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
// Process form custom attributes
formCustomAttributes := processFormCustomAttributes(req.FormData, config, app)
// Validate custom attribute
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Merge JWT and form custom attributes (form takes precedence)
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
@@ -334,7 +316,7 @@ func handleChatInit(r *fastglue.Request) error {
}
// Process custom attributes from form data
formCustomAttributes := processFormCustomAttributes(req.FormData, config, app)
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
// Marshal custom attributes for storage
var customAttribJSON []byte
@@ -378,7 +360,7 @@ func handleChatInit(r *fastglue.Request) error {
}
if !allowStartConversation {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError)
}
if preventMultipleConversations {
@@ -397,7 +379,7 @@ func handleChatInit(r *fastglue.Request) error {
userType = "user"
}
app.lo.Info(userType+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations))
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError)
}
}
@@ -956,9 +938,8 @@ func mergeCustomAttributes(jwtAttributes, formAttributes map[string]interface{})
return merged
}
// processFormCustomAttributes processes form data and extracts custom attributes
// based on the pre-chat form configuration, skipping default fields like name and email
func processFormCustomAttributes(formData map[string]interface{}, config livechat.Config, app *App) map[string]interface{} {
// validateCustomAttributes validates and processes custom attributes from form data
func validateCustomAttributes(formData map[string]interface{}, config livechat.Config, app *App) map[string]interface{} {
customAttributes := make(map[string]interface{})
if !config.PreChatForm.Enabled || len(formData) == 0 {
@@ -973,17 +954,7 @@ func processFormCustomAttributes(formData map[string]interface{}, config livecha
}
// Create a map of valid field keys for quick lookup
validFields := make(map[string]struct {
Key string `json:"key"`
Type string `json:"type"`
Label string `json:"label"`
Placeholder string `json:"placeholder"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
Order int `json:"order"`
IsDefault bool `json:"is_default"`
CustomAttributeID int `json:"custom_attribute_id,omitempty"`
})
validFields := make(map[string]livechat.PreChatFormField)
for _, field := range config.PreChatForm.Fields {
if field.Enabled {
validFields[field.Key] = field
@@ -1026,6 +997,24 @@ func processFormCustomAttributes(formData map[string]interface{}, config livecha
}
customAttributes[field.Key] = strValue
}
// Numbers
if numValue, ok := value.(float64); ok {
if math.IsNaN(numValue) || math.IsInf(numValue, 0) {
app.lo.Warn("form field contains invalid numeric value", "key", key, "value", numValue)
continue
}
if numValue > 1e12 || numValue < -1e12 {
app.lo.Warn("form field numeric value out of acceptable range", "key", key, "value", numValue)
continue
}
customAttributes[field.Key] = numValue
}
// Set rest as is
customAttributes[field.Key] = value
}
return customAttributes
@@ -1087,3 +1076,54 @@ func validateFormData(formData map[string]interface{}, config livechat.Config, e
return finalName, finalEmail, nil
}
// filterPreChatFormFields filters out pre-chat form fields that reference non-existent custom attributes while retaining the default fields
func filterPreChatFormFields(fields []livechat.PreChatFormField, app *App) ([]livechat.PreChatFormField, map[int]customAttributeWidget) {
if len(fields) == 0 {
return fields, nil
}
// Collect custom attribute IDs and enabled fields
customAttrIDs := make(map[int]bool)
enabledFields := make([]livechat.PreChatFormField, 0, len(fields))
for _, field := range fields {
if field.Enabled {
enabledFields = append(enabledFields, field)
if field.CustomAttributeID > 0 {
customAttrIDs[field.CustomAttributeID] = true
}
}
}
// Fetch existing custom attributes
existingCustomAttrs := make(map[int]customAttributeWidget)
for id := range customAttrIDs {
attr, err := app.customAttribute.Get(id)
if err != nil {
app.lo.Warn("custom attribute referenced in pre-chat form no longer exists", "custom_attribute_id", id, "error", err)
continue
}
existingCustomAttrs[id] = customAttributeWidget{
ID: attr.ID,
Values: attr.Values,
}
}
// Filter out fields with non-existent custom attributes
filteredFields := make([]livechat.PreChatFormField, 0, len(enabledFields))
for _, field := range enabledFields {
// Keep default fields
if field.IsDefault {
filteredFields = append(filteredFields, field)
continue
}
// Only keep custom fields if their custom attribute exists
if _, exists := existingCustomAttrs[field.CustomAttributeID]; exists {
filteredFields = append(filteredFields, field)
}
}
return filteredFields, existingCustomAttrs
}

View File

@@ -2,7 +2,7 @@
<form @submit="onSubmit" class="space-y-6 w-full">
<!-- Main Tabs -->
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-7">
<TabsList class="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-7 gap-2 h-auto">
<TabsTrigger value="general">{{ $t('admin.inbox.livechat.tabs.general') }}</TabsTrigger>
<TabsTrigger value="appearance">{{
$t('admin.inbox.livechat.tabs.appearance')
@@ -14,7 +14,7 @@
<TabsTrigger value="users">{{ $t('admin.inbox.livechat.tabs.users') }}</TabsTrigger>
</TabsList>
<div class="mt-6">
<div class="mt-8">
<!-- General Tab -->
<div v-show="activeTab === 'general'" class="space-y-6">
<FormField v-slot="{ componentField }" name="name">
@@ -87,14 +87,20 @@
<!-- Email Fallback Inbox -->
<FormField v-slot="{ componentField }" name="linked_email_inbox_id">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.emailFallbackInbox') }}</FormLabel>
<FormLabel>{{ $t('admin.inbox.livechat.conversationContinuity') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="$t('admin.inbox.livechat.emailFallbackInbox.placeholder')" />
<SelectValue
:placeholder="
$t('globals.messages.select', {
name: $t('globals.terms.inbox').toLowerCase()
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('admin.inbox.livechat.emailFallbackInbox.none') }}</SelectItem>
<SelectItem :value="0"> None </SelectItem>
<SelectItem v-for="inbox in emailInboxes" :key="inbox.id" :value="inbox.id">
{{ inbox.name }}
</SelectItem>
@@ -102,7 +108,7 @@
</Select>
</FormControl>
<FormDescription>
{{ $t('admin.inbox.livechat.emailFallbackInbox.description') }}
{{ $t('admin.inbox.livechat.conversationContinuity.description') }}
</FormDescription>
</FormItem>
</FormField>
@@ -110,6 +116,38 @@
<!-- Appearance Tab -->
<div v-show="activeTab === 'appearance'" class="space-y-6">
<!-- Dark mode -->
<FormField v-slot="{ componentField, handleChange }" name="config.dark_mode">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.livechat.darkMode') }}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.darkMode.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Show Powered By -->
<FormField v-slot="{ componentField, handleChange }" name="config.show_powered_by">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.showPoweredBy')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.showPoweredBy.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Logo URL -->
<FormField v-slot="{ componentField }" name="config.logo_url">
<FormItem>
@@ -144,21 +182,6 @@
</div>
</div>
<!-- Dark mode -->
<FormField v-slot="{ componentField, handleChange }" name="config.dark_mode">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.livechat.darkMode') }}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.darkMode.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<!-- Launcher Configuration -->
<div class="space-y-4">
<h4 class="font-medium text-foreground">{{ $t('admin.inbox.livechat.launcher') }}</h4>
@@ -342,9 +365,6 @@
<FormLabel class="text-base">{{
$t('admin.inbox.livechat.noticeBanner.enabled')
}}</FormLabel>
<FormDescription>{{
$t('admin.inbox.livechat.noticeBanner.enabled.description')
}}</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
@@ -366,9 +386,6 @@
rows="2"
/>
</FormControl>
<FormDescription>{{
$t('admin.inbox.livechat.noticeBanner.text.description')
}}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -530,11 +547,7 @@
<!-- Pre-Chat Form Tab -->
<div v-show="activeTab === 'prechat'" class="space-y-6">
<PreChatFormConfig
:form="form"
:custom-attributes="customAttributes"
@fetch-custom-attributes="fetchCustomAttributes"
/>
<PreChatFormConfig v-model="prechatConfig" />
</div>
<!-- Users Tab -->
@@ -702,7 +715,6 @@ import { Tabs, TabsList, TabsTrigger } from '@shared-ui/components/ui/tabs'
import { Plus, X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import PreChatFormConfig from './PreChatFormConfig.vue'
import api from '@/api'
const props = defineProps({
initialValues: {
@@ -727,7 +739,7 @@ const { t } = useI18n()
const activeTab = ref('general')
const selectedUserTab = ref('visitors')
const externalLinks = ref([])
const customAttributes = ref([])
const prechatConfig = ref({})
const inboxStore = useInboxStore()
const emailInboxes = computed(() =>
@@ -745,6 +757,7 @@ const form = useForm({
config: {
brand_name: '',
dark_mode: false,
show_powered_by: true,
language: 'en',
logo_url: '',
launcher: {
@@ -832,24 +845,6 @@ const updateExternalLinks = () => {
form.setFieldValue('config.external_links', externalLinks.value)
}
const fetchCustomAttributes = async () => {
try {
// Fetch both contact and conversation custom attributes
const [contactAttrs, conversationAttrs] = await Promise.all([
api.getCustomAttributes('contact'),
api.getCustomAttributes('conversation')
])
customAttributes.value = [
...(contactAttrs.data?.data || []),
...(conversationAttrs.data?.data || [])
]
} catch (error) {
console.error('Error fetching custom attributes:', error)
customAttributes.value = []
}
}
// Fetch inboxes on mount for the linked email inbox dropdown
onMounted(() => {
inboxStore.fetchInboxes()
@@ -881,6 +876,15 @@ const onSubmit = form.handleSubmit(async (values) => {
await props.submitForm(values)
})
// Watch for prechat config changes and sync with form
watch(
prechatConfig,
(newConfig) => {
form.setFieldValue('config.prechat_form', newConfig)
},
{ deep: true }
)
watch(
() => props.initialValues,
(newValues) => {
@@ -897,6 +901,12 @@ watch(
if (newValues.config?.external_links) {
externalLinks.value = [...newValues.config.external_links]
}
// Set prechat config
if (newValues.config?.prechat_form) {
prechatConfig.value = { ...newValues.config.prechat_form }
}
form.setValues(newValues)
},
{ deep: true, immediate: true }

View File

@@ -1,35 +1,35 @@
<template>
<div class="space-y-6">
<!-- Master Toggle -->
<FormField v-slot="{ componentField, handleChange }" name="config.prechat_form.enabled">
<FormItem class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<FormLabel class="text-base">{{ $t('admin.inbox.livechat.prechatForm.enabled') }}</FormLabel>
<FormDescription>
{{ $t('admin.inbox.livechat.prechatForm.enabled.description') }}
</FormDescription>
</div>
<FormControl>
<Switch :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
</FormItem>
</FormField>
<div class="flex flex-row items-center justify-between box p-4">
<div class="space-y-0.5">
<label class="text-base font-medium">
{{ $t('admin.inbox.livechat.prechatForm.enabled') }}
</label>
<p class="text-sm text-muted-foreground">
{{ $t('admin.inbox.livechat.prechatForm.enabled.description') }}
</p>
</div>
<Switch v-model:checked="prechatConfig.enabled" />
</div>
<!-- Form Configuration -->
<div v-if="form.values.config?.prechat_form?.enabled" class="space-y-6">
<div v-if="prechatConfig.enabled" class="space-y-6">
<!-- Form Title -->
<FormField v-slot="{ componentField }" name="config.prechat_form.title">
<FormItem>
<FormLabel>{{ $t('admin.inbox.livechat.prechatForm.title') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" placeholder="Tell us about yourself" />
</FormControl>
<FormDescription>
{{ $t('admin.inbox.livechat.prechatForm.title.description') }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div>
<label class="text-sm font-medium">
{{ $t('admin.inbox.livechat.prechatForm.title') }}
</label>
<Input
type="text"
v-model="prechatConfig.title"
placeholder="Tell us about yourself"
class="mt-1"
/>
<p class="text-sm text-muted-foreground mt-1">
{{ $t('admin.inbox.livechat.prechatForm.title.description') }}
</p>
</div>
<!-- Fields Configuration -->
<div class="space-y-4">
@@ -37,11 +37,10 @@
<h4 class="font-medium text-foreground">
{{ $t('admin.inbox.livechat.prechatForm.fields') }}
</h4>
<Button
type="button"
variant="outline"
size="sm"
@click="$emit('fetch-custom-attributes')"
<Button
variant="outline"
size="sm"
@click="fetchCustomAttributes"
:disabled="availableCustomAttributes.length === 0"
>
<Plus class="w-4 h-4 mr-2" />
@@ -53,105 +52,66 @@
<div class="space-y-3">
<Draggable
v-model="draggableFields"
item-key="key"
:item-key="(field) => field.key || `field_${field.custom_attribute_id || 'unknown'}`"
:animation="200"
class="space-y-3"
>
<template #item="{ element: field, index }">
<div class="border rounded-lg p-4 space-y-4">
<!-- Field Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="cursor-move text-muted-foreground">
<GripVertical class="w-4 h-4" />
</div>
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">
{{ field.type }} {{ field.is_default ? '(Default)' : '(Custom)' }}
<div :key="field.key || `field-${index}`" class="border rounded-lg p-4 space-y-4">
<!-- Field Header -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="cursor-move text-muted-foreground">
<GripVertical class="w-4 h-4" />
</div>
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-sm text-muted-foreground">
{{ field.type }} {{ field.is_default ? '(Default)' : '(Custom)' }}
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<FormField
:name="`config.prechat_form.fields.${index}.enabled`"
v-slot="{ componentField, handleChange }"
>
<FormControl>
<Switch
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormControl>
</FormField>
<Button
v-if="!field.is_default"
type="button"
variant="ghost"
size="sm"
@click="removeField(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
</div>
<!-- Field Configuration -->
<div v-if="field.enabled" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Label -->
<FormField
:name="`config.prechat_form.fields.${index}.label`"
v-slot="{ componentField }"
>
<FormItem>
<FormLabel class="text-sm font-medium">{{ $t('globals.terms.label') }}</FormLabel>
<FormControl>
<Input
v-bind="componentField"
placeholder="Field label"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Placeholder -->
<FormField
:name="`config.prechat_form.fields.${index}.placeholder`"
v-slot="{ componentField }"
>
<FormItem>
<FormLabel class="text-sm font-medium">{{ $t('globals.terms.placeholder') }}</FormLabel>
<FormControl>
<Input
v-bind="componentField"
placeholder="Field placeholder"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Required -->
<FormField
:name="`config.prechat_form.fields.${index}.required`"
v-slot="{ componentField, handleChange }"
>
<FormItem>
<div class="flex items-center space-x-2">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="handleChange"
/>
</FormControl>
<FormLabel class="text-sm">{{ $t('globals.terms.required') }}</FormLabel>
<Switch v-model:checked="field.enabled" />
<Button
v-if="!field.is_default"
variant="ghost"
size="sm"
@click="removeField(index)"
>
<X class="w-4 h-4" />
</Button>
</div>
</FormItem>
</FormField>
</div>
</div>
<!-- Field Configuration -->
<div v-if="field.enabled" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Label -->
<div>
<label class="text-sm font-medium">{{ $t('globals.terms.label') }}</label>
<Input v-model="field.label" placeholder="Field label" class="mt-1" />
</div>
<!-- Placeholder -->
<div>
<label class="text-sm font-medium">
{{ $t('globals.terms.placeholder') }}
</label>
<Input
v-model="field.placeholder"
placeholder="Field placeholder"
class="mt-1"
/>
</div>
</div>
<!-- Required -->
<div class="flex items-center space-x-2">
<Checkbox v-model:checked="field.required" />
<label class="text-sm">{{ $t('globals.terms.required') }}</label>
</div>
</div>
</div>
</template>
</Draggable>
@@ -164,7 +124,9 @@
<!-- Custom Attributes Selection -->
<div v-if="availableCustomAttributes.length > 0" class="space-y-3">
<h5 class="font-medium text-sm">{{ $t('admin.inbox.livechat.prechatForm.availableFields') }}</h5>
<h5 class="font-medium text-sm">
{{ $t('admin.inbox.livechat.prechatForm.availableFields') }}
</h5>
<div class="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
<div
v-for="attr in availableCustomAttributes"
@@ -186,68 +148,79 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import { computed, onMounted, ref } from 'vue'
import { Input } from '@shared-ui/components/ui/input'
import { Button } from '@shared-ui/components/ui/button'
import { Switch } from '@shared-ui/components/ui/switch'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { Plus, X, GripVertical } from 'lucide-vue-next'
import Draggable from 'vuedraggable'
import api from '@/api'
const props = defineProps({
form: {
type: Object,
required: true
},
customAttributes: {
type: Array,
default: () => []
}
const prechatConfig = defineModel({
default: () => ({
enabled: false,
title: '',
fields: [
{
key: 'name',
type: 'text',
label: 'Full name',
placeholder: 'Enter your name',
required: true,
enabled: true,
order: 1,
is_default: true
},
{
key: 'email',
type: 'email',
label: 'Email address',
placeholder: 'your@email.com',
required: true,
enabled: true,
order: 2,
is_default: true
}
]
})
})
const emit = defineEmits(['fetch-custom-attributes'])
const customAttributes = ref([])
const formFields = computed(() => {
return props.form.values.config?.prechat_form?.fields || []
return prechatConfig.value.fields || []
})
const availableCustomAttributes = computed(() => {
const usedIds = formFields.value
.filter(field => field.custom_attribute_id)
.map(field => field.custom_attribute_id)
return props.customAttributes.filter(attr => !usedIds.includes(attr.id))
.filter((field) => field.custom_attribute_id)
.map((field) => field.custom_attribute_id)
return customAttributes.value.filter((attr) => !usedIds.includes(attr.id))
})
const draggableFields = computed({
get() {
return formFields.value
return prechatConfig.value.fields || []
},
set(newValue) {
const fieldsWithUpdatedOrder = newValue.map((field, index) => ({
...field,
order: index + 1
}))
props.form.setFieldValue('config.prechat_form.fields', fieldsWithUpdatedOrder)
prechatConfig.value.fields = fieldsWithUpdatedOrder
}
})
const removeField = (index) => {
const fields = formFields.value.filter((_, i) => i !== index)
props.form.setFieldValue('config.prechat_form.fields', fields)
prechatConfig.value.fields = fields
}
const addCustomAttributeToForm = (attribute) => {
const newField = {
key: attribute.key,
key: attribute.key || `custom_attr_${attribute.id || Date.now()}`,
type: attribute.data_type,
label: attribute.name,
placeholder: '',
@@ -257,12 +230,49 @@ const addCustomAttributeToForm = (attribute) => {
is_default: false,
custom_attribute_id: attribute.id
}
const fields = [...formFields.value, newField]
props.form.setFieldValue('config.prechat_form.fields', fields)
prechatConfig.value.fields = fields
}
const fetchCustomAttributes = async () => {
try {
// Fetch both contact and conversation custom attributes
const [contactAttrs, conversationAttrs] = await Promise.all([
api.getCustomAttributes('contact'),
api.getCustomAttributes('conversation')
])
customAttributes.value = [
...(contactAttrs.data?.data || []),
...(conversationAttrs.data?.data || [])
]
// Clean up orphaned custom attribute fields
const availableCustomAttrIds = customAttributes.value.map((attr) => attr.id)
const cleanedFields = (prechatConfig.value.fields || []).filter((field) => {
// Keep default fields
if (field.is_default) return true
// Keep custom fields that still exist
if (field.custom_attribute_id && availableCustomAttrIds.includes(field.custom_attribute_id))
return true
// Remove orphaned custom fields
return false
})
// Update fields if any were removed
if (cleanedFields.length !== (prechatConfig.value.fields || []).length) {
prechatConfig.value.fields = cleanedFields
}
} catch (error) {
console.error('Error fetching custom attributes:', error)
customAttributes.value = []
}
}
onMounted(() => {
emit('fetch-custom-attributes')
fetchCustomAttributes()
})
</script>
</script>

View File

@@ -9,6 +9,7 @@ export const createFormSchema = (t) => z.object({
config: z.object({
brand_name: z.string().min(1, { message: t('globals.messages.required') }),
dark_mode: z.boolean(),
show_powered_by: z.boolean(),
language: z.string().min(1, { message: t('globals.messages.required') }),
logo_url: z.string().url({
message: t('globals.messages.invalid', {
@@ -39,7 +40,7 @@ export const createFormSchema = (t) => z.object({
colors: z.object({
primary: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, {
message: t('globals.messages.invalid', {
name: t('globals.terms.colors').toLowerCase()
name: t('admin.inbox.livechat.colors').toLowerCase()
})
}),
}),

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex justify-between h-full">
<div class="w-8/12">
<div class="w-full lg:w-8/12">
<slot name="content" />
</div>
<div class="rounded w-3/12 p-2 space-y-2 self-start">
<div class="hidden lg:block rounded w-3/12 p-2 space-y-2 self-start">
<slot name="help" />
</div>
</div>

View File

@@ -1,12 +1,14 @@
<template>
<div class="bg-background flex-1 flex flex-col">
<div v-if="showForm" class="flex-1 flex flex-col max-h-full">
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50 p-4 space-y-4">
<div
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 hover:scrollbar-thumb-muted-foreground/50 p-4 space-y-4"
>
<!-- Form title -->
<div v-if="formTitle" class="text-xl text-foreground mb-2 text-center">
{{ formTitle }}
</div>
<!-- Default message -->
<div v-else class="text-lg font-semibold text-foreground mb-2">
{{ $t('globals.terms.helpUsServeYouBetter') }}
@@ -15,152 +17,169 @@
<form @submit.prevent="submitForm" class="space-y-4">
<!-- Dynamic fields -->
<div v-for="field in sortedFields" :key="field.key" class="space-y-2">
<!-- Text input -->
<FormField v-if="field.type === 'text'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="text"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Email input -->
<FormField v-else-if="field.type === 'email'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="email"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Number input -->
<FormField v-else-if="field.type === 'number'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="number"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Date input -->
<FormField v-else-if="field.type === 'date'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="date"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Link/URL input -->
<FormField v-else-if="field.type === 'link'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="url"
:placeholder="field.placeholder || 'https://'"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Checkbox input -->
<FormField v-else-if="field.type === 'checkbox'" v-slot="{ componentField }" :name="field.key">
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
:checked="componentField.modelValue"
@update:checked="componentField.handleChange"
/>
</FormControl>
<div class="space-y-1 leading-none">
<!-- Text input -->
<FormField v-if="field.type === 'text'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="text"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</div>
</FormItem>
</FormField>
</FormItem>
</FormField>
<!-- List/Select input -->
<FormField v-else-if="field.type === 'list'" v-slot="{ componentField }" :name="field.key">
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="field.placeholder || $t('globals.terms.select')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in getFieldOptions(field)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Email input -->
<FormField
v-else-if="field.type === 'email'"
v-slot="{ componentField }"
:name="field.key"
>
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="email"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Number input -->
<FormField
v-else-if="field.type === 'number'"
v-slot="{ componentField }"
:name="field.key"
>
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="number"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Date input -->
<FormField
v-else-if="field.type === 'date'"
v-slot="{ componentField }"
:name="field.key"
>
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="date"
:placeholder="field.placeholder || ''"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Link/URL input -->
<FormField
v-else-if="field.type === 'link'"
v-slot="{ componentField }"
:name="field.key"
>
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
v-bind="componentField"
type="url"
:placeholder="field.placeholder || 'https://'"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Checkbox input -->
<FormField
v-else-if="field.type === 'checkbox'"
v-slot="{ componentField, handleChange }"
:name="field.key"
>
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox :checked="componentField.modelValue" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormMessage />
</div>
</FormItem>
</FormField>
<!-- List/Select input -->
<FormField
v-else-if="field.type === 'list'"
v-slot="{ componentField }"
:name="field.key"
>
<FormItem>
<FormLabel class="text-sm font-medium">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="field.placeholder || $t('globals.terms.select')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in getFieldOptions(field)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>
</div>
<!-- Submit button - fixed at bottom -->
<div class="p-4 border-t">
<Button
@click="submitForm"
class="w-full"
:disabled="!meta.valid"
>
<Button @click="submitForm" class="w-full" :disabled="!requiredFieldsFilled || !meta.valid">
{{ $t('globals.terms.continue') }}
</Button>
</div>
@@ -175,14 +194,20 @@ import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import { useWidgetStore } from '../store/widget.js'
import { useI18n } from 'vue-i18n'
import { createPreChatFormSchema } from './preChatFormSchema.js'
@@ -205,27 +230,25 @@ const formFields = computed(() => config.value.fields || [])
// Sort and filter enabled fields, excluding default fields if user has session token
const sortedFields = computed(() => {
let fields = formFields.value.filter(field => field.enabled)
let fields = formFields.value.filter((field) => field.enabled)
// If user has session token, exclude default name and email fields
if (props.excludeDefaultFields) {
fields = fields.filter(field => !['name', 'email'].includes(field.key))
fields = fields.filter((field) => !['name', 'email'].includes(field.key))
}
return fields.sort((a, b) => (a.order || 0) - (b.order || 0))
})
const showForm = computed(() => preChatFormEnabled.value && sortedFields.value.length > 0)
// Create form with dynamic schema based on fields
const formSchema = computed(() =>
toTypedSchema(createPreChatFormSchema(t, sortedFields.value))
)
const formSchema = computed(() => toTypedSchema(createPreChatFormSchema(t, sortedFields.value)))
// Generate initial values dynamically
const initialValues = computed(() => {
const values = {}
sortedFields.value.forEach(field => {
sortedFields.value.forEach((field) => {
if (field.type === 'checkbox') {
values[field.key] = false
} else {
@@ -235,21 +258,31 @@ const initialValues = computed(() => {
return values
})
const { handleSubmit, meta, resetForm } = useForm({
const { handleSubmit, meta, values } = useForm({
validationSchema: formSchema,
initialValues
})
const requiredFieldsFilled = computed(() => {
return sortedFields.value
.filter((field) => field.required)
.every((field) => {
const value = values[field.key]
if (field.type === 'checkbox') return true
return value && String(value).trim() !== ''
})
})
const submitForm = handleSubmit((values) => {
// Filter out empty values (except for checkboxes)
const filteredValues = {}
Object.keys(values).forEach(key => {
const field = sortedFields.value.find(f => f.key === key)
Object.keys(values).forEach((key) => {
const field = sortedFields.value.find((f) => f.key === key)
if (field?.type === 'checkbox' || (values[key] && String(values[key]).trim())) {
filteredValues[key] = values[key]
}
})
emit('submit', filteredValues)
})
@@ -258,7 +291,7 @@ const getFieldOptions = (field) => {
if (field.type === 'list' && field.custom_attribute_id) {
const customAttr = widgetStore.config?.custom_attributes?.[field.custom_attribute_id]
if (customAttr?.values) {
return customAttr.values.map(value => ({
return customAttr.values.map((value) => ({
value: value,
label: value
}))
@@ -268,16 +301,13 @@ const getFieldOptions = (field) => {
}
// Auto-submit for disabled mode
watch(showForm, (newValue) => {
if (!newValue) {
emit('submit', {})
}
}, { immediate: true })
// Reset form when pre-chat form config changes
watch([preChatFormEnabled, sortedFields], () => {
resetForm({
values: initialValues.value
})
}, { deep: true })
</script>
watch(
showForm,
(newValue) => {
if (!newValue) {
emit('submit', {})
}
},
{ immediate: true }
)
</script>

View File

@@ -26,13 +26,13 @@ export const createPreChatFormSchema = (t, fields = []) => {
break
case 'date':
fieldSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
fieldSchema = z.string().regex(/^(\d{4}-\d{2}-\d{2}|)$/, {
message: t('globals.messages.invalid', { name: field.label })
})
break
case 'link':
fieldSchema = z.string().url({
fieldSchema = z.string().refine((val) => val === '' || z.string().url().safeParse(val).success, {
message: t('globals.messages.invalid', { name: t('globals.terms.url').toLowerCase() })
})
break

View File

@@ -20,7 +20,10 @@
<span class="text-xs">{{ $t('globals.terms.message', 2) }}</span>
</TabsTrigger>
</TabsList>
<div class="text-center flex items-center justify-center py-1">
<div
v-if="widgetStore.config?.show_powered_by !== false"
class="text-center flex items-center justify-center"
>
<span class="text-[10px] text-muted-foreground"
>Powered by <a href="https://libredesk.io" target="_blank">Libredesk</a></span
>

View File

@@ -1,18 +1,29 @@
<template>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full relative">
<!-- Header -->
<WidgetHeader :title="$t('globals.terms.message', 2)" />
<!-- Messages List -->
<div class="flex-1 overflow-y-auto">
<div class="flex-1 overflow-y-auto pb-20">
<MessagesList />
</div>
<!-- New conversation button -->
<div class="p-4 border-border mx-auto" v-if="canStartNewConversation">
<Button @click="startNewConversation">
{{ widgetStore.config?.users?.start_conversation_button_text || $t('globals.messages.startNewConversation') }}
</Button>
<!-- Floating button with gradient fade -->
<div v-if="canStartNewConversation" 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"
></div>
<!-- Floating button -->
<div class="absolute bottom-4 inset-x-0 mx-auto w-fit z-10">
<Button @click="startNewConversation">
{{
widgetStore.config?.users?.start_conversation_button_text ||
$t('globals.messages.startNewConversation')
}}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -539,14 +539,16 @@
"admin.inbox.livechat.showOfficeHoursInChat.description": "Show when the team will be next available",
"admin.inbox.livechat.showOfficeHoursAfterAssignment": "Show Office Hours After Team Assignment",
"admin.inbox.livechat.showOfficeHoursAfterAssignment.description": "Show office hours after conversation is assigned to a team",
"admin.inbox.livechat.showPoweredBy": "Show Powered By",
"admin.inbox.livechat.showPoweredBy.description": "Show \"Powered by Libredesk\" in the chat widget",
"admin.inbox.livechat.noticeBanner": "Notice Banner",
"admin.inbox.livechat.noticeBanner.enabled": "Enable Notice Banner",
"admin.inbox.livechat.noticeBanner.enabled.description": "Show a notice banner to visitors",
"admin.inbox.livechat.noticeBanner.text": "Notice Banner Text",
"admin.inbox.livechat.noticeBanner.text.description": "Default: Our response times are slower than usual. We're working hard to get to your message.",
"admin.inbox.livechat.colors": "Colors",
"admin.inbox.livechat.colors.primary": "Primary Color",
"admin.inbox.livechat.colors.background": "Background Color",
"admin.inbox.livechat.darkMode": "Dark Mode",
"admin.inbox.livechat.darkMode.description": "Enable dark mode for the chat widget",
"admin.inbox.livechat.features": "Features",
"admin.inbox.livechat.features.fileUpload": "File Upload",
"admin.inbox.livechat.features.fileUpload.description": "Allow users to upload files in chat",
@@ -581,10 +583,8 @@
"admin.inbox.livechat.prechatForm.addField": "Add Field",
"admin.inbox.livechat.prechatForm.noFields": "No fields configured. Add fields from custom attributes.",
"admin.inbox.livechat.prechatForm.availableFields": "Available Custom Attributes",
"admin.inbox.livechat.emailFallbackInbox": "Email fallback inbox",
"admin.inbox.livechat.emailFallbackInbox.placeholder": "Select an email inbox",
"admin.inbox.livechat.emailFallbackInbox.description": "When contacts go offline, replies will be sent from this email inbox. The contacts can continue the same conversation by replying to the email or in the chat widget when they return to your site.",
"admin.inbox.livechat.emailFallbackInbox.none": "None",
"admin.inbox.livechat.conversationContinuity": "Conversation continuity email inbox",
"admin.inbox.livechat.conversationContinuity.description": "When contacts go offline, replies will be sent from this email inbox. The contacts can continue the same conversation by replying to the email or in the chat widget when they return to your site.",
"admin.agent.deleteConfirmation": "This will permanently delete the agent. Consider disabling the account instead.",
"admin.agent.apiKey.description": "Generate API keys for this agent to access libredesk programmatically.",
"admin.agent.apiKey.noKey": "No API key has been generated for this agent.",

View File

@@ -702,35 +702,39 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
err error
)
// If ConversationUUID is already set (from reply-to header), match it with existing conversation
// If ConversationUUID is already set, match it with existing conversation
if in.Message.ConversationUUID != "" && in.Channel == inbox.ChannelEmail {
var conversation models.Conversation
if err := m.q.GetConversation.Get(&conversation, 0, in.Message.ConversationUUID); err != nil {
m.lo.Error("error fetching conversation", "uuid", in.Message.ConversationUUID, "error", err)
return models.Message{}, fmt.Errorf("fetching conversation: %w", err)
}
// Validate sender matches conversation contact for security
contact, err := m.userStore.Get(conversation.ContactID, "", "")
err := m.q.GetConversation.Get(&conversation, 0, in.Message.ConversationUUID)
if err != nil {
m.lo.Error("error fetching conversation contact", "contact_id", conversation.ContactID, "error", err)
return models.Message{}, fmt.Errorf("fetching conversation contact: %w", err)
}
// Check if sender email matches contact email
if !strings.EqualFold(in.Contact.Email.String, contact.Email.String) {
m.lo.Warn("sender email mismatch for conversation, creating new conversation instead",
"sender_email", in.Contact.Email.String,
"expected_email", contact.Email.String,
"conversation_uuid", in.Message.ConversationUUID)
// Clear UUID to let normal flow create new conversation
in.Message.ConversationUUID = ""
conversationFound = false
if err == sql.ErrNoRows {
// No conversation found - continue with normal flow to create new conversation
m.lo.Info("no conversation found with matching email conversation UUID, creating new conversation instead", "conversation_uuid", in.Message.ConversationUUID)
} else {
m.lo.Error("error fetching conversation", "uuid", in.Message.ConversationUUID, "error", err)
return models.Message{}, fmt.Errorf("fetching conversation: %w", err)
}
} else {
// Valid sender - use existing conversation
in.Message.SenderID = conversation.ContactID
in.Message.ConversationID = conversation.ID
conversationFound = true
// Conversation found validate sender email matches contact email
contact, err := m.userStore.Get(conversation.ContactID, "", "")
if err != nil {
m.lo.Error("error fetching conversation contact", "contact_id", conversation.ContactID, "error", err)
return models.Message{}, fmt.Errorf("fetching conversation contact: %w", err)
}
if strings.EqualFold(in.Contact.Email.String, contact.Email.String) {
// Valid sender - use existing conversation
in.Message.SenderID = conversation.ContactID
in.Message.ConversationID = conversation.ID
conversationFound = true
} else {
// Email mismatch - create new conversation instead
m.lo.Warn("sender email mismatch for conversation, creating new conversation instead",
"sender_email", in.Contact.Email.String,
"expected_email", contact.Email.String,
"conversation_uuid", in.Message.ConversationUUID)
in.Message.ConversationUUID = ""
}
}
}
@@ -1121,7 +1125,7 @@ func (m *Manager) updateMessageSourceID(id int, sourceID string) error {
return err
}
// sendConversationContinuityEmail sends an email to continue the conversation over email when a live chat contact is offline.
// sendConversationContinuityEmail sends an email to the contact for conversation continuity, supposed to be called after contacts go offline in live chat.
func (m *Manager) sendConversationContinuityEmail(message models.Message) error {
// Get contact and make sure it has a valid email
contact, err := m.userStore.Get(message.MessageReceiverID, "", "")
@@ -1131,7 +1135,7 @@ func (m *Manager) sendConversationContinuityEmail(message models.Message) error
if contact.Email.String == "" {
m.lo.Info("contact has no email for conversation continuity", "contact_id", contact.ID, "conversation_uuid", message.ConversationUUID)
return nil
return fmt.Errorf("contact has no email for conversation continuity")
}
// Get the original livechat inbox to check for linked email inbox
@@ -1143,7 +1147,7 @@ func (m *Manager) sendConversationContinuityEmail(message models.Message) error
// Check if livechat inbox has a linked email inbox
if !originalInbox.LinkedEmailInboxID.Valid {
m.lo.Info("no linked email inbox configured for livechat inbox", "inbox_id", message.InboxID)
return nil // No fallback configured.
return fmt.Errorf("no linked email inbox configured for livechat inbox")
}
// Get the linked email inbox
@@ -1177,7 +1181,10 @@ func (m *Manager) sendConversationContinuityEmail(message models.Message) error
emailUserPart := strings.Split(emailAddress, "@")
if len(emailUserPart) == 2 {
emailMessage.SourceID = null.StringFrom(fmt.Sprintf("%s.%d@%s", message.ConversationUUID, time.Now().UnixNano(), emailUserPart[1]))
m.updateMessageSourceID(message.ID, emailMessage.SourceID.String)
if err := m.updateMessageSourceID(message.ID, emailMessage.SourceID.String); err != nil {
m.lo.Error("error updating message source ID", "error", err)
return fmt.Errorf("error updating message source ID: %w", err)
}
}
// Get all message source IDs for References header

View File

@@ -476,7 +476,11 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
e.lo.Debug("enqueuing incoming email message", "message_id", incomingMsg.Message.SourceID.String,
"attachments", len(envelope.Attachments), "inline_attachments", len(envelope.Inlines))
// Multi-layer UUID extraction for conversation continuity
// Extract conversation UUID from the email using multiple fallback methods.
// 1. Try Reply-To/To address extraction (primary method)
// 2. Try In-Reply-To header
// 3. Try References header chain
// If none of these yield a UUID, the message will be treated as a new conversation.
conversationUUID := e.extractConversationUUID(envelope)
if conversationUUID != "" {
incomingMsg.Message.ConversationUUID = conversationUUID

View File

@@ -23,12 +23,25 @@ const (
MaxConnectionsPerUser = 10
)
type PreChatFormField struct {
Key string `json:"key"`
Type string `json:"type"`
Label string `json:"label"`
Placeholder string `json:"placeholder"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
Order int `json:"order"`
IsDefault bool `json:"is_default"`
CustomAttributeID int `json:"custom_attribute_id"`
}
// Config holds the live chat inbox configuration.
type Config struct {
BrandName string `json:"brand_name"`
DarkMode bool `json:"dark_mode"`
Language string `json:"language"`
Users struct {
BrandName string `json:"brand_name"`
DarkMode bool `json:"dark_mode"`
ShowPoweredBy bool `json:"show_powered_by"`
Language string `json:"language"`
Users struct {
AllowStartConversation bool `json:"allow_start_conversation"`
PreventMultipleConversations bool `json:"prevent_multiple_conversations"`
StartConversationButtonText string `json:"start_conversation_button_text"`
@@ -69,20 +82,10 @@ type Config struct {
ShowOfficeHoursInChat bool `json:"show_office_hours_in_chat"`
ShowOfficeHoursAfterAssignment bool `json:"show_office_hours_after_assignment"`
ChatReplyExpectationMessage string `json:"chat_reply_expectation_message"`
PreChatForm struct {
Enabled bool `json:"enabled"`
Title string `json:"title"`
Fields []struct {
Key string `json:"key"`
Type string `json:"type"`
Label string `json:"label"`
Placeholder string `json:"placeholder"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
Order int `json:"order"`
IsDefault bool `json:"is_default"`
CustomAttributeID int `json:"custom_attribute_id,omitempty"`
} `json:"fields"`
PreChatForm struct {
Enabled bool `json:"enabled"`
Title string `json:"title"`
Fields []PreChatFormField `json:"fields"`
} `json:"prechat_form"`
}
@@ -90,7 +93,6 @@ type Config struct {
type Client struct {
ID string
Channel chan []byte
mutex sync.RWMutex
}
// LiveChat represents the live chat inbox.
@@ -103,7 +105,6 @@ type LiveChat struct {
userStore inbox.UserStore
clients map[string][]*Client // Maps user IDs to slices of clients (to handle multiple devices)
clientsMutex sync.RWMutex
wg sync.WaitGroup
}
// Opts holds the options required for the live chat inbox.