mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-19 04:24:55 +00:00
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:
@@ -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 = [])
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
65
app/javascript/shared/components/StarRating.vue
Normal file
65
app/javascript/shared/components/StarRating.vue
Normal 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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
5
db/migrate/20250514045638_add_csat_config_to_inboxes.rb
Normal file
5
db/migrate/20250514045638_add_csat_config_to_inboxes.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AddCsatConfigToInboxes < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :inboxes, :csat_config, :jsonb, default: {}, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'when no survey rules are configured' do
|
||||||
|
it 'creates a CSAT survey message' do
|
||||||
|
inbox.update(csat_config: {})
|
||||||
|
|
||||||
|
service.perform
|
||||||
|
|
||||||
it 'creates the out of office messages' do
|
|
||||||
described_class.new(conversation: conversation).perform
|
|
||||||
expect(conversation.messages.template.count).to eq(1)
|
expect(conversation.messages.template.count).to eq(1)
|
||||||
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
||||||
end
|
end
|
||||||
end
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user