feat: Improve CSAT responses (#11485)

# 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 <pranavrajs@gmail.com>
This commit is contained in:
Sivin Varghese
2025-05-16 14:18:52 +05:30
committed by GitHub
parent e9cda40b71
commit d0611cb7f2
26 changed files with 812 additions and 54 deletions

View File

@@ -42,7 +42,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update 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_inbox_working_hours
update_channel if channel_update_required? update_channel if channel_update_required?
end end
@@ -121,10 +123,22 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save! @inbox.channel.save!
end 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 def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [: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, :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 end
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])

View File

@@ -25,6 +25,10 @@ const props = defineProps({
type: String, type: String,
default: 'faded', default: 'faded',
}, },
label: {
type: String,
default: null,
},
}); });
const selected = defineModel({ const selected = defineModel({
@@ -56,7 +60,7 @@ const updateSelected = newValue => {
:variant :variant
:icon="iconToRender" :icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true" :trailing-icon="selectedOption.icon ? false : true"
:label="hideLabel ? null : selectedOption.label" :label="label || (hideLabel ? null : selectedOption.label)"
@click="toggle" @click="toggle"
/> />
</slot> </slot>

View File

@@ -2,10 +2,10 @@
import { computed } from 'vue'; import { computed } from 'vue';
import BaseBubble from './Base.vue'; import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n'; 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'; import { useMessageContext } from '../provider.js';
const { contentAttributes } = useMessageContext(); const { contentAttributes, content } = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const response = computed(() => { const response = computed(() => {
@@ -16,6 +16,14 @@ const isRatingSubmitted = computed(() => {
return !!response.value.rating; 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(() => { const rating = computed(() => {
if (isRatingSubmitted.value) { if (isRatingSubmitted.value) {
return CSAT_RATINGS.find( return CSAT_RATINGS.find(
@@ -25,16 +33,33 @@ const rating = computed(() => {
return null; return null;
}); });
const starRatingValue = computed(() => {
return response.value.rating || 0;
});
</script> </script>
<template> <template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat"> <BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4> <h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4"> <dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic"> <dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }} {{ t('CONVERSATION.RATING_TITLE') }}
</dt> </dt>
<dd>{{ t(rating.translationKey) }}</dd> <dd v-if="!isStarRating">
{{ t(rating.translationKey) }}
</dd>
<dd v-else class="flex mt-1">
<span v-for="n in 5" :key="n" class="text-2xl mr-1">
<i
:class="[
n <= starRatingValue
? 'i-ri-star-fill text-n-amber-9'
: 'i-ri-star-line text-n-slate-10',
]"
/>
</span>
</dd>
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2"> <dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
{{ t('CONVERSATION.FEEDBACK_TITLE') }} {{ t('CONVERSATION.FEEDBACK_TITLE') }}

View File

@@ -481,7 +481,8 @@
"PRE_CHAT_FORM": "Pre Chat Form", "PRE_CHAT_FORM": "Pre Chat Form",
"BUSINESS_HOURS": "Business Hours", "BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder", "WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration" "BOT_CONFIGURATION": "Bot Configuration",
"CSAT": "CSAT"
}, },
"SETTINGS": "Settings", "SETTINGS": "Settings",
"FEATURES": { "FEATURES": {
@@ -502,9 +503,7 @@
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box", "ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation", "ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "Enable auto assignment", "AUTO_ASSIGNMENT": "Enable auto assignment",
"ENABLE_CSAT": "Enable CSAT",
"SENDER_NAME_SECTION": "Enable Agent Name in Email", "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", "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": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.", "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" "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": { "BUSINESS_HOURS": {
"TITLE": "Set your availability", "TITLE": "Set your availability",
"SUBTITLE": "Set your availability on your livechat widget", "SUBTITLE": "Set your availability on your livechat widget",

View File

@@ -15,6 +15,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue'; import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import WidgetBuilder from './WidgetBuilder.vue'; import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue'; import BotConfiguration from './components/BotConfiguration.vue';
@@ -28,6 +29,7 @@ export default {
BotConfiguration, BotConfiguration,
CollaboratorsPage, CollaboratorsPage,
ConfigurationPage, ConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize, FacebookReauthorize,
GreetingsEditor, GreetingsEditor,
PreChatFormSettings, PreChatFormSettings,
@@ -53,7 +55,6 @@ export default {
greetingEnabled: true, greetingEnabled: true,
greetingMessage: '', greetingMessage: '',
emailCollectEnabled: false, emailCollectEnabled: false,
csatSurveyEnabled: false,
senderNameType: 'friendly', senderNameType: 'friendly',
businessName: '', businessName: '',
locktoSingleConversation: false, locktoSingleConversation: false,
@@ -107,6 +108,10 @@ export default {
key: 'businesshours', key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
}, },
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
]; ];
if (this.isAWebWidgetInbox) { if (this.isAWebWidgetInbox) {
@@ -277,7 +282,6 @@ export default {
this.greetingEnabled = this.inbox.greeting_enabled || false; this.greetingEnabled = this.inbox.greeting_enabled || false;
this.greetingMessage = this.inbox.greeting_message || ''; this.greetingMessage = this.inbox.greeting_message || '';
this.emailCollectEnabled = this.inbox.enable_email_collect; this.emailCollectEnabled = this.inbox.enable_email_collect;
this.csatSurveyEnabled = this.inbox.csat_survey_enabled;
this.senderNameType = this.inbox.sender_name_type; this.senderNameType = this.inbox.sender_name_type;
this.businessName = this.inbox.business_name; this.businessName = this.inbox.business_name;
this.allowMessagesAfterResolved = this.allowMessagesAfterResolved =
@@ -300,7 +304,6 @@ export default {
id: this.currentInboxId, id: this.currentInboxId,
name: this.selectedInboxName, name: this.selectedInboxName,
enable_email_collect: this.emailCollectEnabled, enable_email_collect: this.emailCollectEnabled,
csat_survey_enabled: this.csatSurveyEnabled,
allow_messages_after_resolved: this.allowMessagesAfterResolved, allow_messages_after_resolved: this.allowMessagesAfterResolved,
greeting_enabled: this.greetingEnabled, greeting_enabled: this.greetingEnabled,
greeting_message: this.greetingMessage || '', greeting_message: this.greetingMessage || '',
@@ -589,21 +592,6 @@ export default {
</p> </p>
</label> </label>
<label class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT') }}
<select v-model="csatSurveyEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="pb-4"> <label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }} {{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }}
<select v-model="allowMessagesAfterResolved"> <select v-model="allowMessagesAfterResolved">
@@ -802,6 +790,9 @@ export default {
<div v-if="selectedTabKey === 'configuration'"> <div v-if="selectedTabKey === 'configuration'">
<ConfigurationPage :inbox="inbox" /> <ConfigurationPage :inbox="inbox" />
</div> </div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'preChatForm'"> <div v-if="selectedTabKey === 'preChatForm'">
<PreChatFormSettings :inbox="inbox" /> <PreChatFormSettings :inbox="inbox" />
</div> </div>

View File

@@ -0,0 +1,233 @@
<script setup>
import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
const props = defineProps({
inbox: { type: Object, required: true },
});
const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
surveyRuleOperator: 'contains',
});
const filterTypes = [
{
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.CONTAINS'),
value: 'contains',
},
{
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.DOES_NOT_CONTAINS'),
value: 'does_not_contain',
},
];
const labelOptions = computed(() =>
labels.value?.length
? labels.value
.map(label => ({ label: label.title, value: label.title }))
.filter(label => !selectedLabelValues.value.includes(label.value))
: []
);
const initializeState = () => {
if (!props.inbox) return;
const { csat_survey_enabled, csat_config } = props.inbox;
state.csatSurveyEnabled = csat_survey_enabled || false;
if (!csat_config) return;
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
});
watch(() => props.inbox, initializeState, { immediate: true });
const handleLabelSelect = value => {
if (!value || selectedLabelValues.value.includes(value)) {
return;
}
selectedLabelValues.value.push(value);
};
const updateDisplayType = type => {
state.displayType = type;
};
const updateSurveyRuleOperator = operator => {
state.surveyRuleOperator = operator;
};
const removeLabel = label => {
const index = selectedLabelValues.value.indexOf(label);
if (index !== -1) {
selectedLabelValues.value.splice(index, 1);
}
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
formData: false,
...attributes,
};
return store.dispatch('inboxes/updateInbox', payload);
};
const saveSettings = async () => {
try {
isUpdating.value = true;
const csatConfig = {
display_type: state.displayType,
message: state.message,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
</script>
<template>
<div class="mx-8">
<SectionLayout
:title="$t('INBOX_MGMT.CSAT.TITLE')"
:description="$t('INBOX_MGMT.CSAT.SUBTITLE')"
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="state.csatSurveyEnabled" />
</div>
</template>
<div class="grid gap-5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
<CSATDisplayTypeSelector
:selected-type="state.displayType"
@update="updateDisplayType"
/>
</WithLabel>
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
name="survey_rule"
>
<div class="mb-4">
<span
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
v-model="state.surveyRuleOperator"
variant="faded"
:options="filterTypes"
class="inline-flex shrink-0"
@update:model-value="updateSurveyRuleOperator"
/>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_SUFFIX') }}
<NextButton
v-for="label in selectedLabelValues"
:key="label"
sm
faded
slate
trailing-icon
:label="label"
icon="i-lucide-x"
class="inline-flex shrink-0"
@click="removeLabel(label)"
/>
<FilterSelect
v-model="currentLabel"
:options="labelOptions"
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.SELECT_PLACEHOLDER')"
hide-label
variant="faded"
class="inline-flex shrink-0"
@update:model-value="handleLabelSelect"
/>
</span>
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
</p>
<div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdating"
@click="saveSettings"
/>
</div>
</div>
</SectionLayout>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import CSATEmojiInput from './CSATEmojiInput.vue';
import CSATStarInput from './CSATStarInput.vue';
const props = defineProps({
selectedType: {
type: String,
default: CSAT_DISPLAY_TYPES.EMOJI,
},
});
const emit = defineEmits(['update']);
</script>
<template>
<div class="flex flex-wrap gap-6 mt-2">
<CSATEmojiInput
:selected="props.selectedType === CSAT_DISPLAY_TYPES.EMOJI"
@update="emit('update', $event)"
/>
<CSATStarInput
:selected="props.selectedType === CSAT_DISPLAY_TYPES.STAR"
@update="emit('update', $event)"
/>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref, computed } from 'vue';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
const props = defineProps({
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const selectionClass = computed(() => {
return props.selected
? 'outline-n-brand bg-n-brand/5'
: 'outline-n-weak bg-n-alpha-black2';
});
const emojis = CSAT_RATINGS;
const selectedEmoji = ref(5);
</script>
<template>
<button
class="flex items-center rounded-lg transition-all duration-500 cursor-pointer outline outline-1 px-4 py-2 gap-2 min-w-56"
:class="selectionClass"
@click="emit('update', CSAT_DISPLAY_TYPES.EMOJI)"
>
<div
v-for="emoji in emojis"
:key="emoji.key"
class="rounded-full p-1 transition-transform duration-150 focus:outline-none flex items-center flex-shrink-0"
>
<span
class="text-2xl"
:class="selectedEmoji === emoji.value ? '' : 'grayscale opacity-60'"
>
{{ emoji.emoji }}
</span>
</div>
</button>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { computed } from 'vue';
const props = defineProps({
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const selectionClass = computed(() => {
return props.selected
? 'bg-n-brand/5 outline-n-brand'
: 'bg-n-alpha-black2 outline-n-weak';
});
</script>
<template>
<button
class="flex items-center rounded-lg transition-all duration-300 cursor-pointer outline outline-1 px-4 py-2 gap-2 min-w-56"
:class="selectionClass"
@click="emit('update', CSAT_DISPLAY_TYPES.STAR)"
>
<div
v-for="n in 5"
:key="'star-' + n"
class="rounded-full p-1 transition-transform duration-150 focus:outline-none flex items-center flex-shrink-0"
:aria-label="`Star ${n}`"
>
<i class="i-ri-star-fill text-n-amber-9 text-2xl" />
</div>
</button>
</template>

View File

@@ -1,14 +1,16 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS } from 'shared/constants/messages'; import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue'; import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import StarRating from 'shared/components/StarRating.vue';
import { getContrastingTextColor } from '@chatwoot/utils'; import { getContrastingTextColor } from '@chatwoot/utils';
export default { export default {
components: { components: {
Spinner, Spinner,
FluentIcon, FluentIcon,
StarRating,
}, },
props: { props: {
messageContentAttributes: { messageContentAttributes: {
@@ -19,6 +21,14 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
displayType: {
type: String,
default: CSAT_DISPLAY_TYPES.EMOJI,
},
message: {
type: String,
default: '',
},
}, },
data() { data() {
return { return {
@@ -47,7 +57,13 @@ export default {
title() { title() {
return this.isRatingSubmitted return this.isRatingSubmitted
? this.$t('CSAT.SUBMITTED_TITLE') ? this.$t('CSAT.SUBMITTED_TITLE')
: this.$t('CSAT.TITLE'); : this.message || this.$t('CSAT.TITLE');
},
isEmojiType() {
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
},
isStarType() {
return this.displayType === CSAT_DISPLAY_TYPES.STAR;
}, },
}, },
@@ -88,10 +104,15 @@ export default {
this.isUpdating = false; this.isUpdating = false;
} }
}, },
selectRating(rating) { selectRating(rating) {
this.selectedRating = rating.value; this.selectedRating = rating.value;
this.onSubmit(); this.onSubmit();
}, },
selectStarRating(value) {
this.selectedRating = value;
this.onSubmit();
},
}, },
}; };
</script> </script>
@@ -104,7 +125,7 @@ export default {
<h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center"> <h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center">
{{ title }} {{ title }}
</h6> </h6>
<div class="ratings flex justify-around py-5 px-4"> <div v-if="isEmojiType" class="ratings flex justify-around py-5 px-4">
<button <button
v-for="rating in ratings" v-for="rating in ratings"
:key="rating.key" :key="rating.key"
@@ -114,6 +135,12 @@ export default {
{{ rating.emoji }} {{ rating.emoji }}
</button> </button>
</div> </div>
<StarRating
v-else-if="isStarType"
:selected-rating="selectedRating"
:is-disabled="isRatingSubmitted"
@select-rating="selectStarRating"
/>
<form <form
v-if="!isFeedbackSubmitted" v-if="!isFeedbackSubmitted"
class="feedback-form flex" class="feedback-form flex"

View File

@@ -0,0 +1,65 @@
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({
selectedRating: {
type: Number,
default: null,
},
isDisabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['selectRating']);
const starRatings = [1, 2, 3, 4, 5];
const hoveredRating = ref(0);
const onHoverRating = value => {
if (props.isDisabled) return;
hoveredRating.value = value;
};
const selectRating = value => {
if (props.isDisabled) return;
emit('selectRating', value);
};
const getStarClass = value => {
const isStarActive =
(hoveredRating.value > 0 &&
!props.isDisabled &&
hoveredRating.value >= value) ||
props.selectedRating >= value;
const starTypeClass = isStarActive
? 'i-ri-star-fill text-n-amber-9'
: 'i-ri-star-line text-n-slate-10';
return starTypeClass;
};
</script>
<template>
<div class="flex justify-center py-5 px-4 gap-3">
<button
v-for="value in starRatings"
:key="value"
type="button"
class="rounded-full p-1 transition-all duration-200 focus:enabled:scale-[1.2] focus-within:enabled:scale-[1.2] hover:enabled:scale-[1.2] focus:outline-none flex items-center flex-shrink-0"
:class="{ 'cursor-not-allowed opacity-50': isDisabled }"
:disabled="isDisabled"
:aria-label="'Star ' + value"
@click="selectRating(value)"
@mouseenter="onHoverRating(value)"
@mouseleave="onHoverRating(0)"
>
<span
:class="getStarClass(value)"
class="transition-all duration-500 text-2xl"
/>
</button>
</div>
</template>

View File

@@ -100,6 +100,11 @@ export const CSAT_RATINGS = [
}, },
]; ];
export const CSAT_DISPLAY_TYPES = {
EMOJI: 'emoji',
STAR: 'star',
};
export const AUDIO_FORMATS = { export const AUDIO_FORMATS = {
WEBM: 'audio/webm', WEBM: 'audio/webm',
OGG: 'audio/ogg', OGG: 'audio/ogg',

View File

@@ -10,7 +10,7 @@ export default {
</script> </script>
<template> <template>
<div id="app" class="woot-survey-wrap min-h-screen"> <div id="app" dir="ltr" class="woot-survey-wrap min-h-screen">
<Response /> <Response />
</div> </div>
</template> </template>

View File

@@ -3,6 +3,7 @@
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import 'widget/assets/scss/reset'; @import 'widget/assets/scss/reset';
@import 'shared/assets/fonts/widget_fonts'; @import 'shared/assets/fonts/widget_fonts';
@import 'dashboard/assets/scss/next-colors';
html, html,
body { body {

View File

@@ -24,7 +24,7 @@ export default {
class="ion-checkmark-circled text-3xl text-green-500 mr-1" class="ion-checkmark-circled text-3xl text-green-500 mr-1"
/> />
<i v-if="showError" class="ion-android-alert text-3xl text-red-600 mr-1" /> <i v-if="showError" class="ion-android-alert text-3xl text-red-600 mr-1" />
<label class="text-base font-medium text-black-800 mt-4 mb-4"> <label class="text-base font-medium text-n-slate-12 mt-4 mb-4">
{{ message }} {{ message }}
</label> </label>
</div> </div>

View File

@@ -32,7 +32,7 @@ export default {
<template> <template>
<div class="mt-6"> <div class="mt-6">
<label class="text-base font-medium text-black-800"> <label class="text-base font-medium text-n-slate-12">
{{ $t('SURVEY.FEEDBACK.LABEL') }} {{ $t('SURVEY.FEEDBACK.LABEL') }}
</label> </label>
<TextArea <TextArea

View File

@@ -5,8 +5,11 @@ import Spinner from 'shared/components/Spinner.vue';
import Rating from 'survey/components/Rating.vue'; import Rating from 'survey/components/Rating.vue';
import Feedback from 'survey/components/Feedback.vue'; import Feedback from 'survey/components/Feedback.vue';
import Banner from 'survey/components/Banner.vue'; import Banner from 'survey/components/Banner.vue';
import StarRating from 'shared/components/StarRating.vue';
import { getSurveyDetails, updateSurvey } from 'survey/api/survey'; import { getSurveyDetails, updateSurvey } from 'survey/api/survey';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
export default { export default {
name: 'Response', name: 'Response',
components: { components: {
@@ -15,6 +18,7 @@ export default {
Spinner, Spinner,
Banner, Banner,
Feedback, Feedback,
StarRating,
}, },
data() { data() {
return { return {
@@ -26,6 +30,8 @@ export default {
isUpdating: false, isUpdating: false,
logo: '', logo: '',
inboxName: '', inboxName: '',
displayType: CSAT_DISPLAY_TYPES.EMOJI,
messageContent: '',
}; };
}, },
computed: { computed: {
@@ -42,16 +48,22 @@ export default {
isButtonDisabled() { isButtonDisabled() {
return !(this.selectedRating && this.feedback); return !(this.selectedRating && this.feedback);
}, },
isEmojiType() {
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
},
isStarType() {
return this.displayType === CSAT_DISPLAY_TYPES.STAR;
},
shouldShowBanner() { shouldShowBanner() {
return this.isRatingSubmitted || this.errorMessage; return this.isRatingSubmitted || this.errorMessage;
}, },
enableFeedbackForm() { enableFeedbackForm() {
return !this.isFeedbackSubmitted && this.isRatingSubmitted; return !this.isFeedbackSubmitted && this.isRatingSubmitted;
}, },
shouldShowErrorMesage() { shouldShowErrorMessage() {
return !!this.errorMessage; return !!this.errorMessage;
}, },
shouldShowSuccessMesage() { shouldShowSuccessMessage() {
return !!this.isRatingSubmitted; return !!this.isRatingSubmitted;
}, },
message() { message() {
@@ -82,6 +94,10 @@ export default {
this.surveyDetails = result?.data?.csat_survey_response; this.surveyDetails = result?.data?.csat_survey_response;
this.selectedRating = this.surveyDetails?.rating; this.selectedRating = this.surveyDetails?.rating;
this.feedbackMessage = this.surveyDetails?.feedback_message || ''; this.feedbackMessage = this.surveyDetails?.feedback_message || '';
this.displayType = result.data.display_type || CSAT_DISPLAY_TYPES.EMOJI;
this.messageContent =
result.data.content ||
this.$t('SURVEY.DESCRIPTION', { inboxName: this.inboxName });
this.setLocale(result.data.locale); this.setLocale(result.data.locale);
} catch (error) { } catch (error) {
const errorMessage = error?.response?.data?.message; const errorMessage = error?.response?.data?.message;
@@ -129,41 +145,49 @@ export default {
<template> <template>
<div <div
v-if="isLoading" v-if="isLoading"
class="flex items-center justify-center flex-1 h-full min-h-screen bg-black-25" class="flex items-center justify-center flex-1 h-full min-h-screen bg-n-background"
> >
<Spinner size="" /> <Spinner size="" />
</div> </div>
<div <div
v-else v-else
class="flex items-center justify-center w-full h-full min-h-screen overflow-auto bg-slate-50" class="flex items-center justify-center w-full h-full min-h-screen overflow-auto bg-n-background"
> >
<div <div
class="flex flex-col w-full h-full bg-white rounded-lg shadow-lg lg:w-2/5 lg:h-auto" class="flex flex-col w-full h-full bg-n-solid-1 rounded-lg border border-solid border-n-weak shadow-md lg:w-2/5 lg:h-auto"
> >
<div class="w-full px-12 pt-12 pb-6 m-auto my-0"> <div class="w-full px-12 pt-12 pb-6 m-auto my-0">
<img v-if="logo" :src="logo" alt="Chatwoot logo" class="mb-6 logo" /> <img v-if="logo" :src="logo" alt="Chatwoot logo" class="mb-6 logo" />
<p <p
v-if="!isRatingSubmitted" v-if="!isRatingSubmitted"
class="mb-8 text-lg leading-relaxed text-black-700" class="mb-8 text-lg leading-relaxed text-n-slate-12"
> >
{{ $t('SURVEY.DESCRIPTION', { inboxName }) }} {{ messageContent }}
</p> </p>
<Banner <Banner
v-if="shouldShowBanner" v-if="shouldShowBanner"
:show-success="shouldShowSuccessMesage" :show-success="shouldShowSuccessMessage"
:show-error="shouldShowErrorMesage" :show-error="shouldShowErrorMessage"
:message="message" :message="message"
/> />
<label <label
v-if="!isRatingSubmitted" v-if="!isRatingSubmitted"
class="mb-4 text-base font-medium text-black-800" class="mb-4 text-base font-medium text-n-slate-11"
> >
{{ $t('SURVEY.RATING.LABEL') }} {{ $t('SURVEY.RATING.LABEL') }}
</label> </label>
<Rating <Rating
v-if="isEmojiType"
:selected-rating="selectedRating" :selected-rating="selectedRating"
@select-rating="selectRating" @select-rating="selectRating"
/> />
<StarRating
v-if="isStarType"
:selected-rating="selectedRating"
:is-disabled="isRatingSubmitted"
class="[&>button>span]:text-4xl !justify-start !px-0"
@select-rating="selectRating"
/>
<Feedback <Feedback
v-if="enableFeedbackForm" v-if="enableFeedbackForm"
:is-updating="isUpdating" :is-updating="isUpdating"

View File

@@ -144,6 +144,8 @@ export default {
<CustomerSatisfaction <CustomerSatisfaction
v-if="isCSAT" v-if="isCSAT"
:message-content-attributes="messageContentAttributes.submitted_values" :message-content-attributes="messageContentAttributes.submitted_values"
:display-type="messageContentAttributes.display_type"
:message="message"
:message-id="messageId" :message-id="messageId"
/> />
</div> </div>

View File

@@ -9,6 +9,7 @@
# auto_assignment_config :jsonb # auto_assignment_config :jsonb
# business_name :string # business_name :string
# channel_type :string # channel_type :string
# csat_config :jsonb not null
# csat_survey_enabled :boolean default(FALSE) # csat_survey_enabled :boolean default(FALSE)
# email_address :string # email_address :string
# enable_auto_assignment :boolean default(TRUE) # enable_auto_assignment :boolean default(TRUE)

View File

@@ -2,6 +2,8 @@ class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!] pattr_initialize [:conversation!]
def perform def perform
return unless should_send_csat_survey?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params) conversation.messages.create!(csat_survey_message_params)
end end
@@ -9,8 +11,47 @@ class MessageTemplates::Template::CsatSurvey
private private
delegate :contact, :account, to: :conversation delegate :contact, :account, :inbox, to: :conversation
delegate :inbox, to: :message delegate :csat_config, to: :inbox
def should_send_csat_survey?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
return false if rule_values.empty?
true
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def message_content
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
csat_config['message']
end
def csat_survey_message_params def csat_survey_message_params
{ {
@@ -18,7 +59,18 @@ class MessageTemplates::Template::CsatSurvey
inbox_id: @conversation.inbox_id, inbox_id: @conversation.inbox_id,
message_type: :template, message_type: :template,
content_type: :input_csat, content_type: :input_csat,
content: I18n.t('conversations.templates.csat_input_message_body') content: message_content,
content_attributes: content_attributes
}
end
def csat_config
inbox.csat_config || {}
end
def content_attributes
{
display_type: csat_config['display_type'] || 'emoji'
} }
end end
end end

View File

@@ -8,6 +8,7 @@ json.greeting_message resource.greeting_message
json.working_hours_enabled resource.working_hours_enabled json.working_hours_enabled resource.working_hours_enabled
json.enable_email_collect resource.enable_email_collect json.enable_email_collect resource.enable_email_collect
json.csat_survey_enabled resource.csat_survey_enabled json.csat_survey_enabled resource.csat_survey_enabled
json.csat_config resource.csat_config
json.enable_auto_assignment resource.enable_auto_assignment json.enable_auto_assignment resource.enable_auto_assignment
json.auto_assignment_config resource.auto_assignment_config json.auto_assignment_config resource.auto_assignment_config
json.out_of_office_message resource.out_of_office_message json.out_of_office_message resource.out_of_office_message

View File

@@ -1,5 +1,7 @@
json.id resource.id json.id resource.id
json.csat_survey_response resource.csat_survey_response json.csat_survey_response resource.csat_survey_response
json.display_type resource.inbox.csat_config.try(:[], 'display_type') || 'emoji'
json.content resource.inbox.csat_config.try(:[], 'message')
json.inbox_avatar_url resource.inbox.avatar_url json.inbox_avatar_url resource.inbox.avatar_url
json.inbox_name resource.inbox.name json.inbox_name resource.inbox.name
json.locale resource.account.locale json.locale resource.account.locale

View File

@@ -0,0 +1,5 @@
class AddCsatConfigToInboxes < ActiveRecord::Migration[7.0]
def change
add_column :inboxes, :csat_config, :jsonb, default: {}, null: false
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_05_12_231037) do ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -729,6 +729,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_12_231037) do
t.bigint "portal_id" t.bigint "portal_id"
t.integer "sender_name_type", default: 0, null: false t.integer "sender_name_type", default: 0, null: false
t.string "business_name" t.string "business_name"
t.jsonb "csat_config", default: {}, null: false
t.index ["account_id"], name: "index_inboxes_on_account_id" t.index ["account_id"], name: "index_inboxes_on_account_id"
t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type" t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
t.index ["portal_id"], name: "index_inboxes_on_portal_id" t.index ["portal_id"], name: "index_inboxes_on_portal_id"

View File

@@ -717,6 +717,94 @@ RSpec.describe 'Inboxes API', type: :request do
expect(email_channel.reload.smtp_authentication).to eq('plain') expect(email_channel.reload.smtp_authentication).to eq('plain')
end end
end end
context 'when handling CSAT configuration' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:inbox) { create(:inbox, account: account) }
let(:csat_config) do
{
'display_type' => 'emoji',
'message' => 'How would you rate your experience?',
'survey_rules' => {
'operator' => 'contains',
'values' => %w[support help]
}
}
end
it 'successfully updates the inbox with CSAT configuration' do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
context 'when CSAT is configured' do
before do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config
},
headers: admin.create_new_auth_token,
as: :json
end
it 'returns configured CSAT settings in inbox details' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['csat_survey_enabled']).to be true
saved_config = json_response['csat_config']
expect(saved_config).to be_present
expect(saved_config['display_type']).to eq('emoji')
end
it 'returns configured CSAT message' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
json_response = response.parsed_body
saved_config = json_response['csat_config']
expect(saved_config['message']).to eq('How would you rate your experience?')
end
it 'returns configured CSAT survey rules' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
json_response = response.parsed_body
saved_config = json_response['csat_config']
expect(saved_config['survey_rules']['operator']).to eq('contains')
expect(saved_config['survey_rules']['values']).to match_array(%w[support help])
end
it 'includes CSAT configuration in inbox list' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox_list = response.parsed_body
found_inbox = inbox_list['payload'].find { |i| i['id'] == inbox.id }
expect(found_inbox['csat_survey_enabled']).to be true
expect(found_inbox['csat_config']).to be_present
expect(found_inbox['csat_config']['display_type']).to eq('emoji')
end
end
end
end end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/agent_bot' do describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/agent_bot' do

View File

@@ -1,13 +1,100 @@
require 'rails_helper' require 'rails_helper'
describe MessageTemplates::Template::CsatSurvey do describe MessageTemplates::Template::CsatSurvey do
context 'when this hook is called' do let(:account) { create(:account) }
let(:conversation) { create(:conversation) } let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:service) { described_class.new(conversation: conversation) }
it 'creates the out of office messages' do describe '#perform' do
described_class.new(conversation: conversation).perform context 'when no survey rules are configured' do
expect(conversation.messages.template.count).to eq(1) it 'creates a CSAT survey message' do
expect(conversation.messages.template.first.content_type).to eq('input_csat') inbox.update(csat_config: {})
service.perform
expect(conversation.messages.template.count).to eq(1)
expect(conversation.messages.template.first.content_type).to eq('input_csat')
end
end
end
describe '#perform with contains operator' do
let(:csat_config) do
{
'display_type' => 'emoji',
'message' => 'Please rate your experience',
'survey_rules' => {
'operator' => 'contains',
'values' => %w[support help]
}
}
end
before do
inbox.update(csat_config: csat_config)
end
context 'when conversation has matching labels' do
it 'creates a CSAT survey message' do
conversation.update(label_list: %w[support urgent])
service.perform
expect(conversation.messages.template.count).to eq(1)
message = conversation.messages.template.first
expect(message.content_type).to eq('input_csat')
expect(message.content).to eq('Please rate your experience')
expect(message.content_attributes['display_type']).to eq('emoji')
end
end
context 'when conversation has no matching labels' do
it 'does not create a CSAT survey message' do
conversation.update(label_list: %w[billing-support payment])
service.perform
expect(conversation.messages.template.count).to eq(0)
end
end
end
describe '#perform with does_not_contain operator' do
let(:csat_config) do
{
'display_type' => 'emoji',
'message' => 'Please rate your experience',
'survey_rules' => {
'operator' => 'does_not_contain',
'values' => %w[support help]
}
}
end
before do
inbox.update(csat_config: csat_config)
end
context 'when conversation does not have matching labels' do
it 'creates a CSAT survey message' do
conversation.update(label_list: %w[billing payment])
service.perform
expect(conversation.messages.template.count).to eq(1)
expect(conversation.messages.template.first.content_type).to eq('input_csat')
end
end
context 'when conversation has matching labels' do
it 'does not create a CSAT survey message' do
conversation.update(label_list: %w[support urgent])
service.perform
expect(conversation.messages.template.count).to eq(0)
end
end end
end end
end end