mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
Compare commits
5 Commits
d7067bce7d
...
54e614422d
Author | SHA1 | Date | |
---|---|---|---|
|
54e614422d | ||
|
1deeaf6df3 | ||
|
3a5990174b | ||
|
c7291b1d1a | ||
|
5de870c446 |
132
cmd/chat.go
132
cmd/chat.go
@@ -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
|
||||
}
|
||||
|
@@ -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 }
|
||||
|
@@ -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>
|
||||
|
@@ -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()
|
||||
})
|
||||
}),
|
||||
}),
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
>
|
||||
|
@@ -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>
|
||||
|
12
i18n/en.json
12
i18n/en.json
@@ -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.",
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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.
|
||||
|
Reference in New Issue
Block a user