@@ -58,6 +62,7 @@ import { sendWidgetTyping } from '../websocket.js'
import { convertTextToHtml } from '@shared-ui/utils/string.js'
import { useTypingIndicator } from '@shared-ui/composables/useTypingIndicator.js'
import MessageInputActions from './MessageInputActions.vue'
+import VisitorInfoForm from './VisitorInfoForm.vue'
import api from '@widget/api/index.js'
const emit = defineEmits(['error'])
@@ -68,8 +73,23 @@ const messageInput = ref(null)
const newMessage = ref('')
const isUploading = ref(false)
const isSending = ref(false)
+const visitorInfo = ref({ name: '', email: '' })
+const visitorInfoSubmitted = ref(false)
const config = computed(() => widgetStore.config)
+// Determine if visitor form should be shown
+const showVisitorForm = computed(() => {
+ if (!userStore.isVisitor || userStore.userSessionToken) return false
+
+ const requireContactInfo = config.value?.visitors?.require_contact_info || 'disabled'
+
+ if (requireContactInfo !== 'disabled' && !visitorInfoSubmitted.value) {
+ return true
+ }
+
+ return false
+})
+
// Setup typing indicator
const { startTyping, stopTyping } = useTypingIndicator((isTyping) => {
if (chatStore.currentConversation?.uuid) {
@@ -77,10 +97,24 @@ const { startTyping, stopTyping } = useTypingIndicator((isTyping) => {
}
})
+// Handle visitor info form submission
+const handleVisitorInfoSubmit = (info) => {
+ visitorInfo.value = info
+ visitorInfoSubmitted.value = true
+}
+
const initChatConversation = async (messageText) => {
- const resp = await api.initChatConversation({
+ const payload = {
message: messageText
- })
+ }
+
+ // Add visitor info if user is a visitor
+ if (userStore.isVisitor) {
+ payload.visitor_name = visitorInfo.value.name
+ payload.visitor_email = visitorInfo.value.email
+ }
+
+ const resp = await api.initChatConversation(payload)
const { conversation, jwt, messages } = resp.data.data
// Set user session token if not already set.
diff --git a/frontend/apps/widget/src/components/VisitorInfoForm.vue b/frontend/apps/widget/src/components/VisitorInfoForm.vue
new file mode 100644
index 0000000..98cabd5
--- /dev/null
+++ b/frontend/apps/widget/src/components/VisitorInfoForm.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+ {{ contactInfoMessage }}
+
+
+
+
+ {{ $t('globals.placeholders.helpUsServeYouBetter') }}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/apps/widget/src/components/visitorInfoFormSchema.js b/frontend/apps/widget/src/components/visitorInfoFormSchema.js
new file mode 100644
index 0000000..8531dd0
--- /dev/null
+++ b/frontend/apps/widget/src/components/visitorInfoFormSchema.js
@@ -0,0 +1,43 @@
+import { z } from 'zod'
+
+export const createVisitorInfoSchema = (t, requireContactInfo) => {
+ const baseSchema = {
+ name: z.string().optional(),
+ email: z.string().optional()
+ }
+
+ if (requireContactInfo === 'required') {
+ return z.object({
+ name: z
+ .string({
+ required_error: t('globals.messages.required', { name: t('globals.terms.name') }),
+ })
+ .min(1, {
+ message: t('globals.messages.required', { name: t('globals.terms.name') }),
+ }),
+ email: z
+ .string({
+ required_error: t('globals.messages.required', { name: t('globals.terms.email') }),
+ })
+ .min(1, {
+ message: t('globals.messages.required', { name: t('globals.terms.email') }),
+ })
+ .email({
+ message: t('globals.messages.invalidEmail'),
+ }),
+ })
+ } else if (requireContactInfo === 'optional') {
+ return z.object({
+ name: z.string().optional(),
+ email: z
+ .string()
+ .optional()
+ .refine(val => !val || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val), {
+ message: t('globals.messages.invalidEmail'),
+ }),
+ })
+ }
+
+ // Disabled mode - no validation
+ return z.object(baseSchema)
+}
\ No newline at end of file
diff --git a/i18n/en.json b/i18n/en.json
index 445c14f..06acd4a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -147,6 +147,7 @@
"globals.terms.test": "Test | Tests",
"globals.terms.confirmation": "Confirmation | Confirmations",
"globals.terms.dialog": "Dialog | Dialogs",
+ "globals.terms.continue": "Continue",
"globals.terms.modal": "Modal | Modals",
"globals.terms.timezone": "Timezone | Timezones",
"globals.terms.language": "Language | Languages",
@@ -276,6 +277,7 @@
"globals.messages.type": "{name} type",
"globals.messages.typeOf": "Type of {name}",
"globals.messages.invalidEmailAddress": "Invalid email address",
+ "globals.messages.invalidEmail": "Invalid email address",
"globals.messages.selectAtLeastOne": "Please select at least one {name}",
"globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
"globals.messages.couldNotReload": "Could not reload {name}",
@@ -561,6 +563,15 @@
"admin.inbox.livechat.preventReplyingToClosedConversations": "Prevent Replying to Closed Conversations",
"admin.inbox.livechat.preventReplyingToClosedConversations.visitors.description": "Prevent visitors from replying to closed conversations",
"admin.inbox.livechat.preventReplyingToClosedConversations.users.description": "Prevent users users from replying to closed conversations",
+ "admin.inbox.livechat.requireContactInfo": "Require Contact Information",
+ "admin.inbox.livechat.requireContactInfo.disabled": "Disabled",
+ "admin.inbox.livechat.requireContactInfo.optional": "Optional",
+ "admin.inbox.livechat.requireContactInfo.required": "Required",
+ "admin.inbox.livechat.requireContactInfo.visitors.description": "Require visitors to provide contact information before starting a conversation",
+ "admin.inbox.livechat.requireContactInfo.users.description": "Require users to provide contact information before starting a conversation",
+ "admin.inbox.livechat.contactInfoMessage": "Contact Information Message",
+ "admin.inbox.livechat.contactInfoMessage.visitors.description": "Custom message shown to visitors when collecting contact information",
+ "admin.inbox.livechat.contactInfoMessage.users.description": "Custom message shown to users when collecting contact information",
"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.",
@@ -721,6 +732,9 @@
"contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
"globals.placeholders.typeMessage": "Type your message...",
"globals.placeholders.tellUsMore": "Tell us more...",
+ "globals.placeholders.name": "Enter your name",
+ "globals.placeholders.email": "Enter your email address",
+ "globals.placeholders.helpUsServeYouBetter": "Help us serve you better by providing your contact information.",
"globals.days.sunday": "Sunday",
"globals.days.monday": "Monday",
"globals.days.tuesday": "Tuesday",
diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go
index e7f154f..6234673 100644
--- a/internal/inbox/channel/livechat/livechat.go
+++ b/internal/inbox/channel/livechat/livechat.go
@@ -53,6 +53,8 @@ type Config struct {
AllowStartConversation bool `json:"allow_start_conversation"`
PreventMultipleConversations bool `json:"prevent_multiple_conversations"`
StartConversationButtonText string `json:"start_conversation_button_text"`
+ RequireContactInfo string `json:"require_contact_info"` // "disabled", "optional", "required"
+ ContactInfoMessage string `json:"contact_info_message"` // Custom message for the form
} `json:"visitors"`
NoticeBanner struct {
Text string `json:"text"`
diff --git a/internal/user/contact.go b/internal/user/contact.go
index 83d9da4..b2d2c07 100644
--- a/internal/user/contact.go
+++ b/internal/user/contact.go
@@ -22,7 +22,7 @@ func (u *Manager) CreateContact(user *models.User) error {
// If external_user_id is provided, insert with it.
if user.ExternalUserID.Valid {
- if err := u.q.InsertContactWithExtID.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, user.ExternalUserID).Scan(&user.ID); err != nil {
+ if err := u.q.InsertContactWithExtID.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, user.ExternalUserID, user.CustomAttributes).Scan(&user.ID); err != nil {
u.lo.Error("error inserting contact with external ID", "error", err)
return fmt.Errorf("insert contact with external ID: %w", err)
}
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index 88c2193..83dd100 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -107,6 +107,12 @@ SET custom_attributes = $2,
updated_at = now()
WHERE id = $1;
+-- name: upsert-custom-attributes
+UPDATE users
+SET custom_attributes = COALESCE(custom_attributes, '{}'::jsonb) || $2,
+updated_at = now()
+WHERE id = $1
+
-- name: update-avatar
UPDATE users
SET avatar_url = $2, updated_at = now()
@@ -154,8 +160,8 @@ JOIN roles r ON r.name = role_name
RETURNING user_id;
-- name: insert-contact-with-external-id
-INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id)
-VALUES ($1, 'contact', $2, $3, $4, $5, $6)
+INSERT INTO users (email, type, first_name, last_name, "password", avatar_url, external_user_id, custom_attributes)
+VALUES ($1, 'contact', $2, $3, $4, $5, $6, $7)
ON CONFLICT (external_user_id) WHERE type = 'contact' AND deleted_at IS NULL AND external_user_id IS NOT NULL
DO UPDATE SET updated_at = now()
RETURNING id;
diff --git a/internal/user/user.go b/internal/user/user.go
index a96faf0..de056b3 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -69,6 +69,7 @@ type queries struct {
UpdateContact *sqlx.Stmt `query:"update-contact"`
UpdateAgent *sqlx.Stmt `query:"update-agent"`
UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
+ UpsertCustomAttributes *sqlx.Stmt `query:"upsert-custom-attributes"`
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
@@ -258,19 +259,23 @@ func (u *Manager) UpdateLastActive(id int) error {
return nil
}
-// UpdateCustomAttributes updates the custom attributes of an user.
-func (u *Manager) UpdateCustomAttributes(id int, customAttributes map[string]any) error {
- // Convert custom attributes to JSON.
+// SaveCustomAttributes sets or merges custom attributes for a user.
+// If replace is true, existing attributes are overwritten. Otherwise, attributes are merged.
+func (u *Manager) SaveCustomAttributes(id int, customAttributes map[string]any, replace bool) error {
jsonb, err := json.Marshal(customAttributes)
if err != nil {
- u.lo.Error("error marshalling custom attributes to JSON", "error", err)
+ u.lo.Error("error marshalling custom attributes", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
}
- // Update custom attributes in the database.
- if _, err := u.q.UpdateCustomAttributes.Exec(id, jsonb); err != nil {
- u.lo.Error("error updating user custom attributes", "error", err)
+ var execErr error
+ if replace {
+ _, execErr = u.q.UpdateCustomAttributes.Exec(id, jsonb)
+ } else {
+ _, execErr = u.q.UpsertCustomAttributes.Exec(id, jsonb)
+ }
+ if execErr != nil {
+ u.lo.Error("error saving custom attributes", "error", execErr)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
-
}
return nil
}