fixes to pre chat form

This commit is contained in:
Abhinav Raut
2025-09-25 02:20:00 +05:30
parent c7291b1d1a
commit 3a5990174b
7 changed files with 499 additions and 417 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

@@ -91,10 +91,14 @@
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue :placeholder="$t('admin.inbox.livechat.emailFallbackInbox.placeholder')" />
<SelectValue
:placeholder="$t('admin.inbox.livechat.emailFallbackInbox.placeholder')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem :value="0">{{ $t('admin.inbox.livechat.emailFallbackInbox.none') }}</SelectItem>
<SelectItem :value="0">{{
$t('admin.inbox.livechat.emailFallbackInbox.none')
}}</SelectItem>
<SelectItem v-for="inbox in emailInboxes" :key="inbox.id" :value="inbox.id">
{{ inbox.name }}
</SelectItem>
@@ -163,8 +167,12 @@
<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>
<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" />
@@ -543,11 +551,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 -->
@@ -715,7 +719,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: {
@@ -740,7 +743,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(() =>
@@ -846,24 +849,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()
@@ -895,6 +880,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) => {
@@ -911,6 +905,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

@@ -40,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,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

@@ -23,6 +23,18 @@ 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"`
@@ -71,19 +83,9 @@ type Config struct {
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"`
Enabled bool `json:"enabled"`
Title string `json:"title"`
Fields []PreChatFormField `json:"fields"`
} `json:"prechat_form"`
}