From d0611cb7f2dd39c8df98a7054d05848e4d54eb61 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 16 May 2025 14:18:52 +0530
Subject: [PATCH] feat: Improve CSAT responses (#11485)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
This PR introduces basic customization options for the CSAT survey:
* **Display Type**: Option to use star ratings instead of emojis.
* **Message Text**: Customize the survey message (up to 200 characters).
* **Survey Rules**: Send surveys based on labels — trigger when a
conversation has or doesn't have a specific label.
Fixes
https://linear.app/chatwoot/document/improve-csat-responses-a61cf30e054e
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Loom videos
**Website Channel (Widget)**
https://www.loom.com/share/7f47836cde7940ae9d17b7997d060a18?sid=aad2ad0a-140a-4a09-8829-e01fa2e102c5
**Email Channel (Survey link)**
https://www.loom.com/share/e92f4c4c0f73417ba300a25885e093ce?sid=4bb006f0-1c2a-4352-a232-8bf684e3d757
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Pranav
---
.../api/v1/accounts/inboxes_controller.rb | 18 +-
.../filter/inputs/FilterSelect.vue | 6 +-
.../components-next/message/bubbles/CSAT.vue | 33 ++-
.../dashboard/i18n/locale/en/inboxMgmt.json | 31 ++-
.../dashboard/settings/inbox/Settings.vue | 27 +-
.../settingsPage/CustomerSatisfactionPage.vue | 233 ++++++++++++++++++
.../components/CSATDisplayTypeSelector.vue | 26 ++
.../components/CSATEmojiInput.vue | 43 ++++
.../settingsPage/components/CSATStarInput.vue | 36 +++
.../components/CustomerSatisfaction.vue | 33 ++-
.../shared/components/StarRating.vue | 65 +++++
app/javascript/shared/constants/messages.js | 5 +
app/javascript/survey/App.vue | 2 +-
app/javascript/survey/assets/scss/woot.scss | 1 +
app/javascript/survey/components/Banner.vue | 2 +-
app/javascript/survey/components/Feedback.vue | 2 +-
app/javascript/survey/views/Response.vue | 44 +++-
.../widget/components/AgentMessageBubble.vue | 2 +
app/models/inbox.rb | 1 +
.../message_templates/template/csat_survey.rb | 58 ++++-
app/views/api/v1/models/_inbox.json.jbuilder | 1 +
.../api/v1/models/_csat_survey.json.jbuilder | 2 +
...250514045638_add_csat_config_to_inboxes.rb | 5 +
db/schema.rb | 3 +-
.../v1/accounts/inboxes_controller_spec.rb | 88 +++++++
.../template/csat_survey_spec.rb | 99 +++++++-
26 files changed, 812 insertions(+), 54 deletions(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/CSATDisplayTypeSelector.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/CSATEmojiInput.vue
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/components/CSATStarInput.vue
create mode 100644 app/javascript/shared/components/StarRating.vue
create mode 100644 db/migrate/20250514045638_add_csat_config_to_inboxes.rb
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 011faaf28..61d16b2ca 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -42,7 +42,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
- @inbox.update!(permitted_params.except(:channel))
+ inbox_params = permitted_params.except(:channel, :csat_config)
+ inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
+ @inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
@@ -121,10 +123,22 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save!
end
+ def format_csat_config(config)
+ {
+ display_type: config['display_type'] || 'emoji',
+ message: config['message'] || '',
+ survey_rules: {
+ operator: config.dig('survey_rules', 'operator') || 'contains',
+ values: config.dig('survey_rules', 'values') || []
+ }
+ }
+ end
+
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
- :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name]
+ :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
+ { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
end
def permitted_params(channel_attributes = [])
diff --git a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
index db420920b..56bce8f9d 100644
--- a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
+++ b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
@@ -25,6 +25,10 @@ const props = defineProps({
type: String,
default: 'faded',
},
+ label: {
+ type: String,
+ default: null,
+ },
});
const selected = defineModel({
@@ -56,7 +60,7 @@ const updateSelected = newValue => {
:variant
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
- :label="hideLabel ? null : selectedOption.label"
+ :label="label || (hideLabel ? null : selectedOption.label)"
@click="toggle"
/>
diff --git a/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue b/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue
index 211840944..6f86bd868 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/CSAT.vue
@@ -2,10 +2,10 @@
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
-import { CSAT_RATINGS } from 'shared/constants/messages';
+import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
-const { contentAttributes } = useMessageContext();
+const { contentAttributes, content } = useMessageContext();
const { t } = useI18n();
const response = computed(() => {
@@ -16,6 +16,14 @@ const isRatingSubmitted = computed(() => {
return !!response.value.rating;
});
+const displayType = computed(() => {
+ return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
+});
+
+const isStarRating = computed(() => {
+ return displayType.value === CSAT_DISPLAY_TYPES.STAR;
+});
+
const rating = computed(() => {
if (isRatingSubmitted.value) {
return CSAT_RATINGS.find(
@@ -25,16 +33,33 @@ const rating = computed(() => {
return null;
});
+
+const starRatingValue = computed(() => {
+ return response.value.rating || 0;
+});
- {{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}
+ {{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}
-
{{ t('CONVERSATION.RATING_TITLE') }}
- - {{ t(rating.translationKey) }}
+ -
+ {{ t(rating.translationKey) }}
+
+ -
+
+
+
+
-
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index 5716e050c..cc0fe4b33 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -481,7 +481,8 @@
"PRE_CHAT_FORM": "Pre Chat Form",
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
- "BOT_CONFIGURATION": "Bot Configuration"
+ "BOT_CONFIGURATION": "Bot Configuration",
+ "CSAT": "CSAT"
},
"SETTINGS": "Settings",
"FEATURES": {
@@ -502,9 +503,7 @@
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "Enable auto assignment",
- "ENABLE_CSAT": "Enable CSAT",
"SENDER_NAME_SECTION": "Enable Agent Name in Email",
- "ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
@@ -578,6 +577,32 @@
"LABEL": "Visitors should provide their name and email address before starting the chat"
}
},
+ "CSAT": {
+ "TITLE": "Enable CSAT",
+ "SUBTITLE": "Automatically trigger CSAT surveys at the end of conversations to understand how customers feel about their support experience. Track satisfaction trends and identify areas for improvement over time.",
+ "DISPLAY_TYPE": {
+ "LABEL": "Display type"
+ },
+ "MESSAGE": {
+ "LABEL": "Message",
+ "PLACEHOLDER": "Please enter a message to show users with the form"
+ },
+ "SURVEY_RULE": {
+ "LABEL": "Survey rule",
+ "DESCRIPTION_PREFIX": "Send the survey if the conversation",
+ "DESCRIPTION_SUFFIX": "any of the labels",
+ "OPERATOR": {
+ "CONTAINS": "contains",
+ "DOES_NOT_CONTAINS": "does not contain"
+ },
+ "SELECT_PLACEHOLDER": "select labels"
+ },
+ "NOTE": "Note: CSAT surveys are sent only once per conversation",
+ "API": {
+ "SUCCESS_MESSAGE": "CSAT settings updated successfully",
+ "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
+ }
+ },
"BUSINESS_HOURS": {
"TITLE": "Set your availability",
"SUBTITLE": "Set your availability on your livechat widget",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
index 73d9963bd..d9551c427 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue
@@ -15,6 +15,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
+import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -28,6 +29,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
+ CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
PreChatFormSettings,
@@ -53,7 +55,6 @@ export default {
greetingEnabled: true,
greetingMessage: '',
emailCollectEnabled: false,
- csatSurveyEnabled: false,
senderNameType: 'friendly',
businessName: '',
locktoSingleConversation: false,
@@ -107,6 +108,10 @@ export default {
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
+ {
+ key: 'csat',
+ name: this.$t('INBOX_MGMT.TABS.CSAT'),
+ },
];
if (this.isAWebWidgetInbox) {
@@ -277,7 +282,6 @@ export default {
this.greetingEnabled = this.inbox.greeting_enabled || false;
this.greetingMessage = this.inbox.greeting_message || '';
this.emailCollectEnabled = this.inbox.enable_email_collect;
- this.csatSurveyEnabled = this.inbox.csat_survey_enabled;
this.senderNameType = this.inbox.sender_name_type;
this.businessName = this.inbox.business_name;
this.allowMessagesAfterResolved =
@@ -300,7 +304,6 @@ export default {
id: this.currentInboxId,
name: this.selectedInboxName,
enable_email_collect: this.emailCollectEnabled,
- csat_survey_enabled: this.csatSurveyEnabled,
allow_messages_after_resolved: this.allowMessagesAfterResolved,
greeting_enabled: this.greetingEnabled,
greeting_message: this.greetingMessage || '',
@@ -589,21 +592,6 @@ export default {
-
-