feat: CSAT response collection public page (#2685)
This commit is contained in:
parent
9b01b82cc7
commit
92c14fa87d
18 changed files with 371 additions and 10 deletions
|
@ -25,7 +25,7 @@ Style/FrozenStringLiteralComment:
|
|||
Style/SymbolArray:
|
||||
Enabled: false
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
- 'app/dispatchers/dispatcher.rb'
|
||||
Style/GlobalVars:
|
||||
|
@ -57,6 +57,7 @@ Rails/ApplicationController:
|
|||
- 'app/controllers/widgets_controller.rb'
|
||||
- 'app/controllers/platform_controller.rb'
|
||||
- 'app/controllers/public_controller.rb'
|
||||
- 'app/controllers/survey/responses_controller.rb'
|
||||
Style/ClassAndModuleChildren:
|
||||
EnforcedStyle: compact
|
||||
Exclude:
|
||||
|
@ -79,7 +80,7 @@ Style/GuardClause:
|
|||
- 'app/models/message.rb'
|
||||
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
||||
Metrics/AbcSize:
|
||||
Exclude:
|
||||
Exclude:
|
||||
- 'app/controllers/concerns/auth_helper.rb'
|
||||
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
|
||||
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
|
||||
|
@ -108,7 +109,7 @@ Rails/BulkChangeTable:
|
|||
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
|
||||
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
|
||||
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb'
|
||||
Rails/UniqueValidationWithoutIndex:
|
||||
Rails/UniqueValidationWithoutIndex:
|
||||
Exclude:
|
||||
- 'app/models/channel/twitter_profile.rb'
|
||||
- 'app/models/webhook.rb'
|
||||
|
|
10
app/controllers/survey/responses_controller.rb
Normal file
10
app/controllers/survey/responses_controller.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
class Survey::ResponsesController < ActionController::Base
|
||||
before_action :set_global_config
|
||||
def show; end
|
||||
|
||||
private
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL')
|
||||
end
|
||||
end
|
25
app/javascript/packs/survey.js
Normal file
25
app/javascript/packs/survey.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import Vue from 'vue';
|
||||
import Vuelidate from 'vuelidate';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import App from '../survey/App.vue';
|
||||
import i18n from '../survey/i18n';
|
||||
|
||||
Vue.use(VueI18n);
|
||||
Vue.use(Vuelidate);
|
||||
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
// Event Bus
|
||||
window.bus = new Vue();
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
window.onload = () => {
|
||||
window.WOOT_SURVEY = new Vue({
|
||||
i18n: i18nConfig,
|
||||
render: h => h(App),
|
||||
}).$mount('#app');
|
||||
};
|
|
@ -16,21 +16,28 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const {
|
||||
LOGO_THUMBNAIL: logoThumbnail,
|
||||
BRAND_NAME: brandName,
|
||||
WIDGET_BRAND_URL: widgetBrandURL,
|
||||
} = window.globalConfig || {};
|
||||
|
||||
export default {
|
||||
mixins: [globalConfigMixin],
|
||||
data() {
|
||||
return {
|
||||
referrerHost: '',
|
||||
globalConfig: {
|
||||
brandName,
|
||||
logoThumbnail,
|
||||
widgetBrandURL,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
brandRedirectURL() {
|
||||
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=widget_branding`;
|
||||
if (this.referrerHost) {
|
||||
|
@ -47,7 +54,6 @@ export default {
|
|||
};
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<button :class="buttonClassName" :style="buttonStyles" @click="onClick">
|
||||
<button
|
||||
:class="buttonClassName"
|
||||
:style="buttonStyles"
|
||||
:disabled="disabled"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -22,6 +27,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
buttonClassName() {
|
||||
|
|
74
app/javascript/shared/components/TextArea.vue
Normal file
74
app/javascript/shared/components/TextArea.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<label class="block">
|
||||
<div
|
||||
v-if="label"
|
||||
class="mb-2 text-xs font-medium"
|
||||
:class="{
|
||||
'text-black-800': !error,
|
||||
'text-red-400': error,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
<textarea
|
||||
class="
|
||||
resize-none
|
||||
border
|
||||
rounded
|
||||
w-full
|
||||
py-2
|
||||
px-3
|
||||
text-slate-700
|
||||
leading-tight
|
||||
outline-none
|
||||
"
|
||||
:class="{
|
||||
'border-black-200 hover:border-black-300 focus:border-black-300':
|
||||
!error,
|
||||
'border-red-200 hover:border-red-300 focus:border-red-300': error,
|
||||
}"
|
||||
:placeholder="placeholder"
|
||||
:value="value"
|
||||
@change="onChange"
|
||||
/>
|
||||
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
|
||||
{{ error }}
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onChange(event) {
|
||||
this.$emit('input', event.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
textarea {
|
||||
min-height: 8rem;
|
||||
}
|
||||
</style>
|
20
app/javascript/survey/App.vue
Executable file
20
app/javascript/survey/App.vue
Executable file
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<div id="app" class="woot-survey-wrap">
|
||||
<response />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Response from './views/Response.vue';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Response,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '~survey/assets/scss/woot.scss';
|
||||
</style>
|
21
app/javascript/survey/assets/scss/woot.scss
Executable file
21
app/javascript/survey/assets/scss/woot.scss
Executable file
|
@ -0,0 +1,21 @@
|
|||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'widget/assets/scss/reset';
|
||||
@import 'widget/assets/scss/variables';
|
||||
@import 'widget/assets/scss/buttons';
|
||||
@import 'widget/assets/scss/mixins';
|
||||
@import 'widget/assets/scss/forms';
|
||||
@import 'shared/assets/fonts/widget_fonts';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: $font-family;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.woot-survey-wrap {
|
||||
height: 100%;
|
||||
}
|
73
app/javascript/survey/components/Rating.vue
Normal file
73
app/javascript/survey/components/Rating.vue
Normal file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="customer-satisfcation mb-2">
|
||||
<div class="ratings flex py-5 px-0">
|
||||
<button
|
||||
v-for="rating in ratings"
|
||||
:key="rating.key"
|
||||
:class="buttonClass(rating)"
|
||||
@click="selectRating(rating)"
|
||||
>
|
||||
{{ rating.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CSAT_RATINGS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
messageContentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
ratings: CSAT_RATINGS,
|
||||
selectedRating: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isRatingSubmitted() {
|
||||
return this.messageContentAttributes?.csat_survey_response?.rating;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
buttonClass(rating) {
|
||||
return [
|
||||
{ selected: rating.value === this.selectedRating },
|
||||
{ disabled: this.isRatingSubmitted },
|
||||
{ hover: this.isRatingSubmitted },
|
||||
'emoji-button shadow-none text-4xl outline-none mr-8',
|
||||
];
|
||||
},
|
||||
selectRating(rating) {
|
||||
this.selectedRating = rating.value;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.emoji-button {
|
||||
filter: grayscale(100%);
|
||||
&.selected,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
filter: grayscale(0%);
|
||||
transform: scale(1.32);
|
||||
transition: transform 300ms;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
5
app/javascript/survey/i18n/index.js
Normal file
5
app/javascript/survey/i18n/index.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { default as en } from './locale/en.json';
|
||||
|
||||
export default {
|
||||
en,
|
||||
};
|
15
app/javascript/survey/i18n/locale/en.json
Normal file
15
app/javascript/survey/i18n/locale/en.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"SURVEY": {
|
||||
"DESCRIPTION": "Dear customer 👋 , please take a few moments to complete the feedback about the conversation.",
|
||||
"RATING": {
|
||||
"LABEL": "Rate your conversation",
|
||||
"SUCCESS_MESSAGE": "Thank you for submitting the rating"
|
||||
},
|
||||
"FEEDBACK": {
|
||||
"LABEL": "Do you have any thoughts you'd like to share?",
|
||||
"PLACEHOLDER": "Your feedback (optional)",
|
||||
"BUTTON_TEXT": "Submit feedback"
|
||||
}
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot"
|
||||
}
|
64
app/javascript/survey/views/Response.vue
Normal file
64
app/javascript/survey/views/Response.vue
Normal file
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div
|
||||
class="w-full h-full flex flex-col flex-no-wrap overflow-hidden bg-white"
|
||||
>
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<div class="max-w-screen-sm w-full my-0 m-auto px-8 py-12">
|
||||
<img src="/brand-assets/logo.svg" alt="Chatwoot logo" class="logo" />
|
||||
<p class="text-black-700 text-lg leading-relaxed mt-4 mb-4">
|
||||
{{ $t('SURVEY.DESCRIPTION') }}
|
||||
</p>
|
||||
<label class="text-base font-medium text-black-800">
|
||||
{{ $t('SURVEY.RATING.LABEL') }}
|
||||
</label>
|
||||
<rating />
|
||||
<label class="text-base font-medium text-black-800">
|
||||
{{ $t('SURVEY.FEEDBACK.LABEL') }}
|
||||
</label>
|
||||
<text-area
|
||||
v-model="message"
|
||||
class="my-5"
|
||||
:placeholder="$t('SURVEY.FEEDBACK.PLACEHOLDER')"
|
||||
/>
|
||||
<custom-button class="font-medium float-right">
|
||||
<spinner v-if="isSubmitted" class="p-0" />
|
||||
{{ $t('SURVEY.FEEDBACK.BUTTON_TEXT') }}
|
||||
</custom-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-wrap flex-shrink-0 w-full flex flex-col relative">
|
||||
<branding></branding>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Branding from 'shared/components/Branding.vue';
|
||||
import Rating from 'survey/components/Rating.vue';
|
||||
import CustomButton from 'shared/components/Button';
|
||||
import TextArea from 'shared/components/TextArea.vue';
|
||||
import configMixin from 'shared/mixins/configMixin';
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
Branding,
|
||||
Rating,
|
||||
CustomButton,
|
||||
TextArea,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
data() {
|
||||
return {
|
||||
message: '',
|
||||
isSubmitted: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
.logo {
|
||||
max-height: $space-large;
|
||||
}
|
||||
</style>
|
|
@ -69,7 +69,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Branding from 'widget/components/Branding.vue';
|
||||
import Branding from 'shared/components/Branding.vue';
|
||||
import ChatFooter from 'widget/components/ChatFooter.vue';
|
||||
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
|
||||
import ChatHeader from 'widget/components/ChatHeader.vue';
|
||||
|
|
17
app/views/survey/responses/show.html.erb
Normal file
17
app/views/survey/responses/show.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= @global_config['INSTALLATION_NAME'] %></title>
|
||||
<%= csrf_meta_tags %>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
<script>
|
||||
window.globalConfig = <%= raw @global_config.to_json %>
|
||||
</script>
|
||||
<%= javascript_pack_tag 'survey' %>
|
||||
<%= stylesheet_pack_tag 'survey' %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
|
@ -20,6 +20,9 @@ Rails.application.routes.draw do
|
|||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
|
||||
|
||||
resource :widget, only: [:show]
|
||||
namespace :survey do
|
||||
resources :responses, only: [:show]
|
||||
end
|
||||
end
|
||||
|
||||
get '/api', to: 'api#index'
|
||||
|
|
|
@ -6,6 +6,7 @@ const resolve = {
|
|||
vue$: 'vue/dist/vue.common.js',
|
||||
dashboard: path.resolve('./app/javascript/dashboard'),
|
||||
widget: path.resolve('./app/javascript/widget'),
|
||||
survey: path.resolve('./app/javascript/survey'),
|
||||
assets: path.resolve('./app/javascript/dashboard/assets'),
|
||||
components: path.resolve('./app/javascript/dashboard/components'),
|
||||
'./iconfont.eot': 'vue-easytable/libs/font/iconfont.eot',
|
||||
|
|
16
spec/controllers/service/responses_controller_spec.rb
Normal file
16
spec/controllers/service/responses_controller_spec.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe '/survey/response', type: :request do
|
||||
describe 'GET survey/responses/{uuid}' do
|
||||
it 'renders the page correctly when called' do
|
||||
conversation = create(:conversation)
|
||||
get survey_response_url(id: conversation.uuid)
|
||||
expect(response).to be_successful
|
||||
end
|
||||
|
||||
it 'returns 404 when called with invalid conversation uuid' do
|
||||
get survey_response_url(id: '')
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,6 +4,7 @@ module.exports = {
|
|||
purge: [
|
||||
'./app/javascript/widget/**/*.vue',
|
||||
'./app/javascript/shared/**/*.vue',
|
||||
'./app/javascript/survey/**/*.vue',
|
||||
],
|
||||
future: {
|
||||
removeDeprecatedGapUtilities: true,
|
||||
|
|
Loading…
Reference in a new issue