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
|
||||
|
||||
def update
|
||||
@inbox.update!(permitted_params.except(:channel))
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
@@ -121,10 +123,22 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@inbox.channel.save!
|
||||
end
|
||||
|
||||
def format_csat_config(config)
|
||||
{
|
||||
display_type: config['display_type'] || 'emoji',
|
||||
message: config['message'] || '',
|
||||
survey_rules: {
|
||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||
values: config.dig('survey_rules', 'values') || []
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name]
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'faded',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
@@ -56,7 +60,7 @@ const updateSelected = newValue => {
|
||||
:variant
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="hideLabel ? null : selectedOption.label"
|
||||
:label="label || (hideLabel ? null : selectedOption.label)"
|
||||
@click="toggle"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
const { contentAttributes, content } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const response = computed(() => {
|
||||
@@ -16,6 +16,14 @@ const isRatingSubmitted = computed(() => {
|
||||
return !!response.value.rating;
|
||||
});
|
||||
|
||||
const displayType = computed(() => {
|
||||
return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
|
||||
});
|
||||
|
||||
const isStarRating = computed(() => {
|
||||
return displayType.value === CSAT_DISPLAY_TYPES.STAR;
|
||||
});
|
||||
|
||||
const rating = computed(() => {
|
||||
if (isRatingSubmitted.value) {
|
||||
return CSAT_RATINGS.find(
|
||||
@@ -25,16 +33,33 @@ const rating = computed(() => {
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const starRatingValue = computed(() => {
|
||||
return response.value.rating || 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<dt class="text-n-slate-11 italic">
|
||||
{{ t('CONVERSATION.RATING_TITLE') }}
|
||||
</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">
|
||||
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
|
||||
|
||||
@@ -481,7 +481,8 @@
|
||||
"PRE_CHAT_FORM": "Pre Chat Form",
|
||||
"BUSINESS_HOURS": "Business Hours",
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration"
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
"FEATURES": {
|
||||
@@ -502,9 +503,7 @@
|
||||
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
|
||||
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
|
||||
"AUTO_ASSIGNMENT": "Enable auto assignment",
|
||||
"ENABLE_CSAT": "Enable CSAT",
|
||||
"SENDER_NAME_SECTION": "Enable Agent Name in Email",
|
||||
"ENABLE_CSAT_SUB_TEXT": "Enable/Disable CSAT(Customer satisfaction) survey after resolving a conversation",
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
@@ -578,6 +577,32 @@
|
||||
"LABEL": "Visitors should provide their name and email address before starting the chat"
|
||||
}
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Enable CSAT",
|
||||
"SUBTITLE": "Automatically trigger CSAT surveys at the end of conversations to understand how customers feel about their support experience. Track satisfaction trends and identify areas for improvement over time.",
|
||||
"DISPLAY_TYPE": {
|
||||
"LABEL": "Display type"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
"DESCRIPTION_SUFFIX": "any of the labels",
|
||||
"OPERATOR": {
|
||||
"CONTAINS": "contains",
|
||||
"DOES_NOT_CONTAINS": "does not contain"
|
||||
},
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
}
|
||||
},
|
||||
"BUSINESS_HOURS": {
|
||||
"TITLE": "Set your availability",
|
||||
"SUBTITLE": "Set your availability on your livechat widget",
|
||||
|
||||
@@ -15,6 +15,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import WidgetBuilder from './WidgetBuilder.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
@@ -28,6 +29,7 @@ export default {
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
CustomerSatisfactionPage,
|
||||
FacebookReauthorize,
|
||||
GreetingsEditor,
|
||||
PreChatFormSettings,
|
||||
@@ -53,7 +55,6 @@ export default {
|
||||
greetingEnabled: true,
|
||||
greetingMessage: '',
|
||||
emailCollectEnabled: false,
|
||||
csatSurveyEnabled: false,
|
||||
senderNameType: 'friendly',
|
||||
businessName: '',
|
||||
locktoSingleConversation: false,
|
||||
@@ -107,6 +108,10 @@ export default {
|
||||
key: 'businesshours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
@@ -277,7 +282,6 @@ export default {
|
||||
this.greetingEnabled = this.inbox.greeting_enabled || false;
|
||||
this.greetingMessage = this.inbox.greeting_message || '';
|
||||
this.emailCollectEnabled = this.inbox.enable_email_collect;
|
||||
this.csatSurveyEnabled = this.inbox.csat_survey_enabled;
|
||||
this.senderNameType = this.inbox.sender_name_type;
|
||||
this.businessName = this.inbox.business_name;
|
||||
this.allowMessagesAfterResolved =
|
||||
@@ -300,7 +304,6 @@ export default {
|
||||
id: this.currentInboxId,
|
||||
name: this.selectedInboxName,
|
||||
enable_email_collect: this.emailCollectEnabled,
|
||||
csat_survey_enabled: this.csatSurveyEnabled,
|
||||
allow_messages_after_resolved: this.allowMessagesAfterResolved,
|
||||
greeting_enabled: this.greetingEnabled,
|
||||
greeting_message: this.greetingMessage || '',
|
||||
@@ -589,21 +592,6 @@ export default {
|
||||
</p>
|
||||
</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">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }}
|
||||
<select v-model="allowMessagesAfterResolved">
|
||||
@@ -802,6 +790,9 @@ export default {
|
||||
<div v-if="selectedTabKey === 'configuration'">
|
||||
<ConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
<CustomerSatisfactionPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'preChatForm'">
|
||||
<PreChatFormSettings :inbox="inbox" />
|
||||
</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>
|
||||
import { mapGetters } from 'vuex';
|
||||
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 StarRating from 'shared/components/StarRating.vue';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
FluentIcon,
|
||||
StarRating,
|
||||
},
|
||||
props: {
|
||||
messageContentAttributes: {
|
||||
@@ -19,6 +21,14 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
displayType: {
|
||||
type: String,
|
||||
default: CSAT_DISPLAY_TYPES.EMOJI,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -47,7 +57,13 @@ export default {
|
||||
title() {
|
||||
return this.isRatingSubmitted
|
||||
? 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;
|
||||
}
|
||||
},
|
||||
|
||||
selectRating(rating) {
|
||||
this.selectedRating = rating.value;
|
||||
this.onSubmit();
|
||||
},
|
||||
selectStarRating(value) {
|
||||
this.selectedRating = value;
|
||||
this.onSubmit();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -104,7 +125,7 @@ export default {
|
||||
<h6 class="text-n-slate-12 text-sm font-medium pt-5 px-2.5 text-center">
|
||||
{{ title }}
|
||||
</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
|
||||
v-for="rating in ratings"
|
||||
:key="rating.key"
|
||||
@@ -114,6 +135,12 @@ export default {
|
||||
{{ rating.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
<StarRating
|
||||
v-else-if="isStarType"
|
||||
:selected-rating="selectedRating"
|
||||
:is-disabled="isRatingSubmitted"
|
||||
@select-rating="selectStarRating"
|
||||
/>
|
||||
<form
|
||||
v-if="!isFeedbackSubmitted"
|
||||
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 = {
|
||||
WEBM: 'audio/webm',
|
||||
OGG: 'audio/ogg',
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" class="woot-survey-wrap min-h-screen">
|
||||
<div id="app" dir="ltr" class="woot-survey-wrap min-h-screen">
|
||||
<Response />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'widget/assets/scss/reset';
|
||||
@import 'shared/assets/fonts/widget_fonts';
|
||||
@import 'dashboard/assets/scss/next-colors';
|
||||
|
||||
html,
|
||||
body {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
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" />
|
||||
<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 }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<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') }}
|
||||
</label>
|
||||
<TextArea
|
||||
|
||||
@@ -5,8 +5,11 @@ import Spinner from 'shared/components/Spinner.vue';
|
||||
import Rating from 'survey/components/Rating.vue';
|
||||
import Feedback from 'survey/components/Feedback.vue';
|
||||
import Banner from 'survey/components/Banner.vue';
|
||||
import StarRating from 'shared/components/StarRating.vue';
|
||||
import { getSurveyDetails, updateSurvey } from 'survey/api/survey';
|
||||
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'Response',
|
||||
components: {
|
||||
@@ -15,6 +18,7 @@ export default {
|
||||
Spinner,
|
||||
Banner,
|
||||
Feedback,
|
||||
StarRating,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -26,6 +30,8 @@ export default {
|
||||
isUpdating: false,
|
||||
logo: '',
|
||||
inboxName: '',
|
||||
displayType: CSAT_DISPLAY_TYPES.EMOJI,
|
||||
messageContent: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -42,16 +48,22 @@ export default {
|
||||
isButtonDisabled() {
|
||||
return !(this.selectedRating && this.feedback);
|
||||
},
|
||||
isEmojiType() {
|
||||
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
|
||||
},
|
||||
isStarType() {
|
||||
return this.displayType === CSAT_DISPLAY_TYPES.STAR;
|
||||
},
|
||||
shouldShowBanner() {
|
||||
return this.isRatingSubmitted || this.errorMessage;
|
||||
},
|
||||
enableFeedbackForm() {
|
||||
return !this.isFeedbackSubmitted && this.isRatingSubmitted;
|
||||
},
|
||||
shouldShowErrorMesage() {
|
||||
shouldShowErrorMessage() {
|
||||
return !!this.errorMessage;
|
||||
},
|
||||
shouldShowSuccessMesage() {
|
||||
shouldShowSuccessMessage() {
|
||||
return !!this.isRatingSubmitted;
|
||||
},
|
||||
message() {
|
||||
@@ -82,6 +94,10 @@ export default {
|
||||
this.surveyDetails = result?.data?.csat_survey_response;
|
||||
this.selectedRating = this.surveyDetails?.rating;
|
||||
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);
|
||||
} catch (error) {
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
@@ -129,41 +145,49 @@ export default {
|
||||
<template>
|
||||
<div
|
||||
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="" />
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
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">
|
||||
<img v-if="logo" :src="logo" alt="Chatwoot logo" class="mb-6 logo" />
|
||||
<p
|
||||
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>
|
||||
<Banner
|
||||
v-if="shouldShowBanner"
|
||||
:show-success="shouldShowSuccessMesage"
|
||||
:show-error="shouldShowErrorMesage"
|
||||
:show-success="shouldShowSuccessMessage"
|
||||
:show-error="shouldShowErrorMessage"
|
||||
:message="message"
|
||||
/>
|
||||
<label
|
||||
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') }}
|
||||
</label>
|
||||
<Rating
|
||||
v-if="isEmojiType"
|
||||
:selected-rating="selectedRating"
|
||||
@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
|
||||
v-if="enableFeedbackForm"
|
||||
:is-updating="isUpdating"
|
||||
|
||||
@@ -144,6 +144,8 @@ export default {
|
||||
<CustomerSatisfaction
|
||||
v-if="isCSAT"
|
||||
:message-content-attributes="messageContentAttributes.submitted_values"
|
||||
:display-type="messageContentAttributes.display_type"
|
||||
:message="message"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# auto_assignment_config :jsonb
|
||||
# business_name :string
|
||||
# channel_type :string
|
||||
# csat_config :jsonb not null
|
||||
# csat_survey_enabled :boolean default(FALSE)
|
||||
# email_address :string
|
||||
# enable_auto_assignment :boolean default(TRUE)
|
||||
|
||||
@@ -2,6 +2,8 @@ class MessageTemplates::Template::CsatSurvey
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def perform
|
||||
return unless should_send_csat_survey?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(csat_survey_message_params)
|
||||
end
|
||||
@@ -9,8 +11,47 @@ class MessageTemplates::Template::CsatSurvey
|
||||
|
||||
private
|
||||
|
||||
delegate :contact, :account, to: :conversation
|
||||
delegate :inbox, to: :message
|
||||
delegate :contact, :account, :inbox, to: :conversation
|
||||
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
|
||||
{
|
||||
@@ -18,7 +59,18 @@ class MessageTemplates::Template::CsatSurvey
|
||||
inbox_id: @conversation.inbox_id,
|
||||
message_type: :template,
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ json.greeting_message resource.greeting_message
|
||||
json.working_hours_enabled resource.working_hours_enabled
|
||||
json.enable_email_collect resource.enable_email_collect
|
||||
json.csat_survey_enabled resource.csat_survey_enabled
|
||||
json.csat_config resource.csat_config
|
||||
json.enable_auto_assignment resource.enable_auto_assignment
|
||||
json.auto_assignment_config resource.auto_assignment_config
|
||||
json.out_of_office_message resource.out_of_office_message
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
json.id resource.id
|
||||
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_name resource.inbox.name
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -729,6 +729,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_12_231037) do
|
||||
t.bigint "portal_id"
|
||||
t.integer "sender_name_type", default: 0, null: false
|
||||
t.string "business_name"
|
||||
t.jsonb "csat_config", default: {}, null: false
|
||||
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 ["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')
|
||||
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
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/agent_bot' do
|
||||
|
||||
@@ -1,13 +1,100 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe MessageTemplates::Template::CsatSurvey do
|
||||
context 'when this hook is called' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:account) { create(:account) }
|
||||
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
|
||||
described_class.new(conversation: conversation).perform
|
||||
expect(conversation.messages.template.count).to eq(1)
|
||||
expect(conversation.messages.template.first.content_type).to eq('input_csat')
|
||||
describe '#perform' do
|
||||
context 'when no survey rules are configured' do
|
||||
it 'creates a CSAT survey message' do
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user