mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	[Feature] Website live chat (#187)
Co-authored-by: Nithin David Thomas <webofnithin@gmail.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
		| @@ -1,3 +1,7 @@ | ||||
| linters: | ||||
|   LeadingZero: | ||||
|     enabled: false | ||||
|  | ||||
| exclude: | ||||
|   - 'app/javascript/widget/assets/scss/_reset.scss' | ||||
|   - 'app/javascript/widget/assets/scss/sdk.css' | ||||
|   | ||||
							
								
								
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -53,6 +53,8 @@ gem 'telegram-bot-ruby' | ||||
| gem 'twitter' | ||||
| # facebook client | ||||
| gem 'koala' | ||||
| # Random name generator | ||||
| gem 'haikunator' | ||||
|  | ||||
| ##--- gems for debugging and error reporting ---## | ||||
| # static analysis | ||||
|   | ||||
| @@ -192,6 +192,7 @@ GEM | ||||
|     foreman (0.86.0) | ||||
|     globalid (0.4.2) | ||||
|       activesupport (>= 4.2.0) | ||||
|     haikunator (1.1.0) | ||||
|     hashie (3.6.0) | ||||
|     http (3.3.0) | ||||
|       addressable (~> 2.3) | ||||
| @@ -455,6 +456,7 @@ DEPENDENCIES | ||||
|   faker | ||||
|   figaro | ||||
|   foreman | ||||
|   haikunator | ||||
|   hashie | ||||
|   jbuilder (~> 2.5) | ||||
|   kaminari | ||||
|   | ||||
							
								
								
									
										26
									
								
								app/controllers/api/v1/widget/inboxes_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/controllers/api/v1/widget/inboxes_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| class Api::V1::Widget::InboxesController < ApplicationController | ||||
|   def create | ||||
|     ActiveRecord::Base.transaction do | ||||
|       channel = web_widgets.create!( | ||||
|         website_name: permitted_params[:website_name], | ||||
|         website_url: permitted_params[:website_url] | ||||
|       ) | ||||
|       inbox = inboxes.create!(name: permitted_params[:website_name], channel: channel) | ||||
|       render json: inbox | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def inboxes | ||||
|     current_account.inboxes | ||||
|   end | ||||
|  | ||||
|   def web_widgets | ||||
|     current_account.web_widgets | ||||
|   end | ||||
|  | ||||
|   def permitted_params | ||||
|     params.fetch(:website).permit(:website_name, :website_url) | ||||
|   end | ||||
| end | ||||
| @@ -1,28 +1,68 @@ | ||||
| class Api::V1::Widget::MessagesController < ApplicationController | ||||
|   # TODO: move widget apis to different controller. | ||||
|   skip_before_action :set_current_user, only: [:create_incoming] | ||||
|   skip_before_action :check_subscription, only: [:create_incoming] | ||||
|   skip_around_action :handle_with_exception, only: [:create_incoming] | ||||
| class Api::V1::Widget::MessagesController < ActionController::Base | ||||
|   before_action :set_conversation, only: [:create] | ||||
|  | ||||
|   def create_incoming | ||||
|     builder = Integrations::Widget::IncomingMessageBuilder.new(incoming_message_params) | ||||
|     builder.perform | ||||
|     render json: builder.message | ||||
|   def index | ||||
|     @messages = conversation.nil? ? [] : message_finder.perform | ||||
|   end | ||||
|  | ||||
|   def create_outgoing | ||||
|     builder = Integrations::Widget::OutgoingMessageBuilder.new(outgoing_message_params) | ||||
|     builder.perform | ||||
|     render json: builder.message | ||||
|   def create | ||||
|     @message = conversation.messages.new(message_params) | ||||
|     @message.save! | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def incoming_message_params | ||||
|     params.require(:message).permit(:contact_id, :inbox_id, :content) | ||||
|   def conversation | ||||
|     @conversation ||= ::Conversation.find_by( | ||||
|       contact_id: cookie_params[:contact_id], | ||||
|       inbox_id: cookie_params[:inbox_id] | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def outgoing_message_params | ||||
|     params.require(:message).permit(:user_id, :inbox_id, :content, :conversation_id) | ||||
|   def set_conversation | ||||
|     @conversation = ::Conversation.create!(conversation_params) if conversation.nil? | ||||
|   end | ||||
|  | ||||
|   def message_params | ||||
|     { | ||||
|       account_id: conversation.account_id, | ||||
|       inbox_id: conversation.inbox_id, | ||||
|       message_type: :incoming, | ||||
|       content: permitted_params[:content] | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def conversation_params | ||||
|     { | ||||
|       account_id: inbox.account_id, | ||||
|       inbox_id: inbox.id, | ||||
|       contact_id: cookie_params[:contact_id] | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def inbox | ||||
|     @inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id]) | ||||
|   end | ||||
|  | ||||
|   def cookie_params | ||||
|     JSON.parse(cookies.signed[cookie_name]).symbolize_keys | ||||
|   end | ||||
|  | ||||
|   def message_finder_params | ||||
|     { | ||||
|       filter_internal_messages: true | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   def message_finder | ||||
|     @message_finder ||= MessageFinder.new(conversation, message_finder_params) | ||||
|   end | ||||
|  | ||||
|   def cookie_name | ||||
|     'cw_conversation_' + params[:website_token] | ||||
|   end | ||||
|  | ||||
|   def permitted_params | ||||
|     params.fetch(:message).permit(:content) | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										47
									
								
								app/controllers/widgets_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								app/controllers/widgets_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| class WidgetsController < ActionController::Base | ||||
|   before_action :set_web_widget | ||||
|   before_action :set_contact | ||||
|   before_action :build_contact | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_web_widget | ||||
|     @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) | ||||
|   end | ||||
|  | ||||
|   def set_contact | ||||
|     return if cookie_params[:source_id].nil? | ||||
|  | ||||
|     contact_inbox = ::ContactInbox.find_by( | ||||
|       inbox_id: @web_widget.inbox.id, | ||||
|       source_id: cookie_params[:source_id] | ||||
|     ) | ||||
|  | ||||
|     @contact = contact_inbox.contact | ||||
|   end | ||||
|  | ||||
|   def build_contact | ||||
|     return if @contact.present? | ||||
|  | ||||
|     contact_inbox = @web_widget.create_contact_inbox | ||||
|     @contact = contact_inbox.contact | ||||
|  | ||||
|     cookies.signed[cookie_name] = JSON.generate( | ||||
|       source_id: contact_inbox.source_id, | ||||
|       contact_id: @contact.id, | ||||
|       inbox_id: @web_widget.inbox.id | ||||
|     ).to_s | ||||
|   end | ||||
|  | ||||
|   def cookie_params | ||||
|     cookies.signed[cookie_name] ? JSON.parse(cookies.signed[cookie_name]).symbolize_keys : {} | ||||
|   end | ||||
|  | ||||
|   def permitted_params | ||||
|     params.permit(:website_token) | ||||
|   end | ||||
|  | ||||
|   def cookie_name | ||||
|     'cw_conversation_' + permitted_params[:website_token] | ||||
|   end | ||||
| end | ||||
| @@ -10,11 +10,17 @@ class MessageFinder | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def messages | ||||
|     return @conversation.messages if @params[:filter_internal_messages].blank? | ||||
|  | ||||
|     @conversation.messages.where.not('private = ? OR message_type = ?', true, 2) | ||||
|   end | ||||
|  | ||||
|   def current_messages | ||||
|     if @params[:before].present? | ||||
|       @conversation.messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse | ||||
|       messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse | ||||
|     else | ||||
|       @conversation.messages.reorder('created_at desc').limit(20).reverse | ||||
|       messages.reorder('created_at desc').limit(20).reverse | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										9
									
								
								app/javascript/dashboard/api/channel/webChannel.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/javascript/dashboard/api/channel/webChannel.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import ApiClient from '../ApiClient'; | ||||
|  | ||||
| class WebChannel extends ApiClient { | ||||
|   constructor() { | ||||
|     super('widget/inboxes'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default new WebChannel(); | ||||
							
								
								
									
										
											BIN
										
									
								
								app/javascript/dashboard/assets/images/channels/website.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/javascript/dashboard/assets/images/channels/website.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 5.9 KiB | 
| @@ -25,3 +25,14 @@ | ||||
|   border-radius: $space-smaller; | ||||
|   font-size: $font-size-mini; | ||||
| } | ||||
|  | ||||
| code { | ||||
|   border: 0; | ||||
|   font-family: 'Monaco'; | ||||
|   font-size: $font-size-mini; | ||||
|  | ||||
|   &.hljs { | ||||
|     background: $color-background; | ||||
|     padding: $space-two; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -121,7 +121,7 @@ export default { | ||||
|     fetchData() { | ||||
|       if (this.chatLists.length === 0) { | ||||
|         this.$store.dispatch('fetchAllConversations', { | ||||
|           inbox: this.conversationInbox, | ||||
|           inboxId: this.conversationInbox ? this.conversationInbox : undefined, | ||||
|           assigneeStatus: this.allMessageType, | ||||
|           convStatus: this.activeStatusTab, | ||||
|         }); | ||||
|   | ||||
| @@ -22,7 +22,7 @@ | ||||
|     <ul v-if="menuItem.hasSubMenu" class="nested vertical menu"> | ||||
|       <router-link | ||||
|         v-for="child in menuItem.children" | ||||
|         :key="child.label" | ||||
|         :key="child.id" | ||||
|         active-class="active flex-container" | ||||
|         :class="computedInboxClass(child)" | ||||
|         tag="li" | ||||
|   | ||||
| @@ -1,22 +1,50 @@ | ||||
| <template> | ||||
|   <div class="small-3 columns channel" :class="{ inactive: channel !== 'facebook' }" @click.capture="itemClick"> | ||||
|     <img src="~dashboard/assets/images/channels/facebook.png" v-if="channel === 'facebook'"> | ||||
|     <img src="~dashboard/assets/images/channels/twitter.png" v-if="channel === 'twitter'"> | ||||
|     <img src="~dashboard/assets/images/channels/telegram.png" v-if="channel === 'telegram'"> | ||||
|     <img src="~dashboard/assets/images/channels/line.png" v-if="channel === 'line'"> | ||||
|     <h3 class="channel__title">{{channel}}</h3> | ||||
|     <!-- <p>This is the most sexiest integration to begin </p> --> | ||||
|   <div | ||||
|     class="small-3 columns channel" | ||||
|     :class="{ inactive: !isActive(channel) }" | ||||
|     @click="onItemClick" | ||||
|   > | ||||
|     <img | ||||
|       v-if="channel === 'facebook'" | ||||
|       src="~dashboard/assets/images/channels/facebook.png" | ||||
|     /> | ||||
|     <img | ||||
|       v-if="channel === 'twitter'" | ||||
|       src="~dashboard/assets/images/channels/twitter.png" | ||||
|     /> | ||||
|     <img | ||||
|       v-if="channel === 'telegram'" | ||||
|       src="~dashboard/assets/images/channels/telegram.png" | ||||
|     /> | ||||
|     <img | ||||
|       v-if="channel === 'line'" | ||||
|       src="~dashboard/assets/images/channels/line.png" | ||||
|     /> | ||||
|     <img | ||||
|       v-if="channel === 'website'" | ||||
|       src="~dashboard/assets/images/channels/website.png" | ||||
|     /> | ||||
|     <h3 class="channel__title"> | ||||
|       {{ channel }} | ||||
|     </h3> | ||||
|   </div> | ||||
| </template> | ||||
| <script> | ||||
| /* global bus */ | ||||
| export default { | ||||
|   props: ['channel'], | ||||
|   created() { | ||||
|   props: { | ||||
|     channel: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     itemClick() { | ||||
|       bus.$emit('channelItemClick', this.channel); | ||||
|     isActive(channel) { | ||||
|       return ['facebook', 'website'].includes(channel); | ||||
|     }, | ||||
|     onItemClick() { | ||||
|       if (this.isActive(this.channel)) { | ||||
|         this.$emit('channel-item-click', this.channel); | ||||
|       } | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -4,9 +4,6 @@ export default { | ||||
|     return `${this.APP_BASE_URL}/`; | ||||
|   }, | ||||
|   GRAVATAR_URL: 'https://www.gravatar.com/avatar/', | ||||
|   CHANNELS: { | ||||
|     FACEBOOK: 'facebook', | ||||
|   }, | ||||
|   ASSIGNEE_TYPE_SLUG: { | ||||
|     MINE: 0, | ||||
|     UNASSIGNED: 1, | ||||
|   | ||||
| @@ -1,20 +1,9 @@ | ||||
| import { createConsumer } from '@rails/actioncable'; | ||||
|  | ||||
| import AuthAPI from '../api/auth'; | ||||
| import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; | ||||
|  | ||||
| class ActionCableConnector { | ||||
| class ActionCableConnector extends BaseActionCableConnector { | ||||
|   constructor(app, pubsubToken) { | ||||
|     const consumer = createConsumer(); | ||||
|     consumer.subscriptions.create( | ||||
|       { | ||||
|         channel: 'RoomChannel', | ||||
|         pubsub_token: pubsubToken, | ||||
|       }, | ||||
|       { | ||||
|         received: this.onReceived, | ||||
|       } | ||||
|     ); | ||||
|     this.app = app; | ||||
|     super(app, pubsubToken); | ||||
|     this.events = { | ||||
|       'message.created': this.onMessageCreated, | ||||
|       'conversation.created': this.onConversationCreated, | ||||
| @@ -43,12 +32,6 @@ class ActionCableConnector { | ||||
|     this.app.$store.dispatch('addMessage', data); | ||||
|   }; | ||||
|  | ||||
|   onReceived = ({ event, data } = {}) => { | ||||
|     if (this.events[event]) { | ||||
|       this.events[event](data); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   onReload = () => window.location.reload(); | ||||
|  | ||||
|   onStatusChange = data => { | ||||
|   | ||||
| @@ -15,6 +15,19 @@ | ||||
|       "FB": { | ||||
|         "HELP": "PS: By signing in, we only get access to your Page's messages. Your private messages can never be accessed by Chatwoot." | ||||
|       }, | ||||
|       "WEBSITE_CHANNEL": { | ||||
|         "TITLE": "Website channel", | ||||
|         "DESC": "Create a channel for your website and start supporting your customers via our website widget.", | ||||
|         "CHANNEL_NAME": { | ||||
|           "LABEL": "Website Name", | ||||
|           "PLACEHOLDER": "Enter your website name (eg: Acme Inc)" | ||||
|         }, | ||||
|         "CHANNEL_DOMAIN": { | ||||
|           "LABEL": "Website Domain", | ||||
|           "PLACEHOLDER": "Enter your website domain (eg: acme.com)" | ||||
|         }, | ||||
|         "SUBMIT_BUTTON":"Create inbox" | ||||
|       }, | ||||
|       "AUTH": { | ||||
|         "TITLE": "Channels", | ||||
|         "DESC": "Currently we support only Facebook Pages as a platform. We have more platforms like Twitter, Telegram and Line in the works, which will be out soon." | ||||
|   | ||||
| @@ -9,14 +9,14 @@ | ||||
|         v-for="channel in channelList" | ||||
|         :key="channel" | ||||
|         :channel="channel" | ||||
|         @channel-item-click="initChannelAuth" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| /* global bus */ | ||||
| import ChannelItem from '../../../../components/widgets/ChannelItem'; | ||||
| import ChannelItem from 'dashboard/components/widgets/ChannelItem'; | ||||
| import router from '../../../index'; | ||||
| import PageHeader from '../SettingsSubPageHeader'; | ||||
|  | ||||
| @@ -27,22 +27,16 @@ export default { | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       channelList: ['facebook', 'twitter', 'telegram', 'line'], | ||||
|       channelList: ['website', 'facebook', 'twitter', 'telegram', 'line'], | ||||
|     }; | ||||
|   }, | ||||
|   created() { | ||||
|     bus.$on('channelItemClick', channel => { | ||||
|       this.initChannelAuth(channel); | ||||
|     }); | ||||
|   }, | ||||
|   methods: { | ||||
|     initChannelAuth(channel) { | ||||
|       if (channel === 'facebook') { | ||||
|         router.push({ | ||||
|           name: 'settings_inboxes_page_channel', | ||||
|           params: { page: 'new', sub_page: 'facebook' }, | ||||
|         }); | ||||
|       } | ||||
|       const params = { | ||||
|         page: 'new', | ||||
|         sub_page: channel, | ||||
|       }; | ||||
|       router.push({ name: 'settings_inboxes_page_channel', params }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
|   | ||||
| @@ -15,7 +15,7 @@ | ||||
|  | ||||
|         <table v-if="inboxesList.length" class="woot-table"> | ||||
|           <tbody> | ||||
|             <tr v-for="item in inboxesList" :key="item.label"> | ||||
|             <tr v-for="item in inboxesList" :key="item.id"> | ||||
|               <td> | ||||
|                 <img | ||||
|                   class="woot-thumbnail" | ||||
| @@ -26,7 +26,12 @@ | ||||
|               <!-- Short Code  --> | ||||
|               <td> | ||||
|                 <span class="agent-name">{{ item.label }}</span> | ||||
|                 <span>Facebook</span> | ||||
|                 <span v-if="item.channelType === 'Channel::FacebookPage'"> | ||||
|                   Facebook | ||||
|                 </span> | ||||
|                 <span v-if="item.channelType === 'Channel::WebWidget'"> | ||||
|                   Website | ||||
|                 </span> | ||||
|               </td> | ||||
|  | ||||
|               <!-- Action Buttons --> | ||||
|   | ||||
| @@ -5,7 +5,10 @@ | ||||
|         :header-image="inbox.avatarUrl" | ||||
|         :header-title="inbox.label" | ||||
|       /> | ||||
|       <div class="code-wrapper"> | ||||
|       <div | ||||
|         v-if="inbox.channelType === 'Channel::FacebookPage'" | ||||
|         class="code-wrapper" | ||||
|       > | ||||
|         <p class="title"> | ||||
|           {{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING') }} | ||||
|         </p> | ||||
| @@ -18,6 +21,20 @@ | ||||
|           </code> | ||||
|         </p> | ||||
|       </div> | ||||
|       <div | ||||
|         v-else-if="inbox.channelType === 'Channel::WebWidget'" | ||||
|         class="code-wrapper" | ||||
|       > | ||||
|         <p class="title"> | ||||
|           {{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING') }} | ||||
|         </p> | ||||
|         <p class="sub-head"> | ||||
|           {{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_SUB_HEAD') }} | ||||
|         </p> | ||||
|         <highlight-code lang="javascript"> | ||||
|           {{ webWidgetScript }} | ||||
|         </highlight-code> | ||||
|       </div> | ||||
|       <div class="agent-wrapper"> | ||||
|         <p class="title"> | ||||
|           {{ $t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS') }} | ||||
| @@ -53,6 +70,7 @@ | ||||
| /* eslint-disable no-useless-escape */ | ||||
| /* global bus */ | ||||
| import { mapGetters } from 'vuex'; | ||||
| import 'highlight.js/styles/default.css'; | ||||
|  | ||||
| export default { | ||||
|   props: ['onClose', 'inbox', 'show'], | ||||
| @@ -83,6 +101,20 @@ export default { | ||||
|         color="blue" | ||||
|         size="standard" > | ||||
|       </div>`, | ||||
|       webWidgetScript: ` | ||||
|         (function(d,t) { | ||||
|           var BASE_URL = '${window.location.origin}'; | ||||
|           var g=d.createElement(t),s=d.getElementsByTagName(t)[0]; | ||||
|           g.src= BASE_URL + "/packs/js/sdk.js"; | ||||
|           s.parentNode.insertBefore(g,s); | ||||
|           g.onload=function(){ | ||||
|             window.chatwootSDK.run({ | ||||
|               websiteToken: '${this.inbox.websiteToken}', | ||||
|               baseUrl: BASE_URL | ||||
|             }) | ||||
|           } | ||||
|         })(document,"script"); | ||||
|       `, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|   | ||||
| @@ -1,19 +1,23 @@ | ||||
| import CONSTANTS from '../../../../constants'; | ||||
| import FacebookView from './Facebook'; | ||||
| import Facebook from './channels/Facebook'; | ||||
| import Website from './channels/Website'; | ||||
|  | ||||
| const channelViewList = { | ||||
|   facebook: Facebook, | ||||
|   website: Website, | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   create() { | ||||
|     return { | ||||
|       name: 'new-channel-view', | ||||
|  | ||||
|       render(h) { | ||||
|         if (this.channel_name === CONSTANTS.CHANNELS.FACEBOOK) { | ||||
|           return h(FacebookView); | ||||
|         } | ||||
|         return null; | ||||
|       }, | ||||
|       props: { | ||||
|         channel_name: String, | ||||
|         channel_name: { | ||||
|           type: String, | ||||
|           required: true, | ||||
|         }, | ||||
|       }, | ||||
|       name: 'new-channel-view', | ||||
|       render(h) { | ||||
|         return h(channelViewList[this.channel_name] || null); | ||||
|       }, | ||||
|     }; | ||||
|   }, | ||||
|   | ||||
| @@ -64,14 +64,13 @@ | ||||
|   </div> | ||||
| </template> | ||||
| <script> | ||||
| /* eslint no-console: 0 */ | ||||
| /* eslint-env browser */ | ||||
| /* global FB */ | ||||
| import { required } from 'vuelidate/lib/validators'; | ||||
| import ChannelApi from '../../../../api/channels'; | ||||
| import LoadingState from '../../../../components/widgets/LoadingState'; | ||||
| import PageHeader from '../SettingsSubPageHeader'; | ||||
| import router from '../../../index'; | ||||
| import LoadingState from 'dashboard/components/widgets/LoadingState'; | ||||
| import ChannelApi from '../../../../../api/channels'; | ||||
| import PageHeader from '../../SettingsSubPageHeader'; | ||||
| import router from '../../../../index'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
| @@ -0,0 +1,83 @@ | ||||
| <template> | ||||
|   <div class="wizard-body small-9 columns"> | ||||
|     <page-header | ||||
|       :header-title="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.TITLE')" | ||||
|       :header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')" | ||||
|     /> | ||||
|     <loading-state | ||||
|       v-if="isCreating" | ||||
|       message="Creating Website Support Channel" | ||||
|     ></loading-state> | ||||
|     <form v-if="!isCreating" class="row" @submit.prevent="createChannel()"> | ||||
|       <div class="medium-12 columns"> | ||||
|         <label> | ||||
|           {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL') }} | ||||
|           <input | ||||
|             v-model.trim="websiteName" | ||||
|             type="text" | ||||
|             :placeholder=" | ||||
|               $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.PLACEHOLDER') | ||||
|             " | ||||
|           /> | ||||
|         </label> | ||||
|       </div> | ||||
|       <div class="medium-12 columns"> | ||||
|         <label> | ||||
|           {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL') }} | ||||
|           <input | ||||
|             v-model.trim="websiteUrl" | ||||
|             type="text" | ||||
|             :placeholder=" | ||||
|               $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER') | ||||
|             " | ||||
|           /> | ||||
|         </label> | ||||
|       </div> | ||||
|       <div class="modal-footer"> | ||||
|         <div class="medium-12 columns"> | ||||
|           <woot-submit-button | ||||
|             :button-text="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.SUBMIT_BUTTON')" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| /* global bus */ | ||||
| import router from '../../../../index'; | ||||
| import PageHeader from '../../SettingsSubPageHeader'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     PageHeader, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       websiteName: '', | ||||
|       websiteUrl: '', | ||||
|       isCreating: false, | ||||
|     }; | ||||
|   }, | ||||
|   mounted() { | ||||
|     bus.$on('new_website_channel', ({ inboxId }) => { | ||||
|       router.replace({ | ||||
|         name: 'settings_inboxes_add_agents', | ||||
|         params: { page: 'new', inbox_id: inboxId }, | ||||
|       }); | ||||
|     }); | ||||
|   }, | ||||
|   methods: { | ||||
|     createChannel() { | ||||
|       this.isCreating = true; | ||||
|       this.$store.dispatch('addWebsiteChannel', { | ||||
|         website: { | ||||
|           website_name: this.websiteName, | ||||
|           website_url: this.websiteUrl, | ||||
|         }, | ||||
|       }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| @@ -1,13 +1,14 @@ | ||||
| /* eslint no-console: 0 */ | ||||
| /* eslint-env browser */ | ||||
| /* eslint no-param-reassign: 0 */ | ||||
|  | ||||
| /* global bus */ | ||||
| // import * as types from '../mutation-types'; | ||||
| import defaultState from '../../i18n/default-sidebar'; | ||||
| import * as types from '../mutation-types'; | ||||
| import Account from '../../api/account'; | ||||
| import ChannelApi from '../../api/channels'; | ||||
| import { frontendURL } from '../../helper/URLHelper'; | ||||
| import WebChannel from '../../api/channel/webChannel'; | ||||
|  | ||||
| const state = defaultState; | ||||
| // inboxes fetch flag | ||||
| @@ -66,6 +67,15 @@ const actions = { | ||||
|         }); | ||||
|     }); | ||||
|   }, | ||||
|   addWebsiteChannel: async ({ commit }, params) => { | ||||
|     try { | ||||
|       const response = await WebChannel.create(params); | ||||
|       commit(types.default.SET_INBOX_ITEM, response); | ||||
|       bus.$emit('new_website_channel', { inboxId: response.data.id }); | ||||
|     } catch (error) { | ||||
|       // Handle error | ||||
|     } | ||||
|   }, | ||||
|   addInboxItem({ commit }, { channel, params }) { | ||||
|     const donePromise = new Promise(resolve => { | ||||
|       ChannelApi.createChannel(channel, params) | ||||
| @@ -137,9 +147,10 @@ const mutations = { | ||||
|       channel_id: item.id, | ||||
|       label: item.name, | ||||
|       toState: frontendURL(`inbox/${item.id}`), | ||||
|       channelType: item.channelType, | ||||
|       channelType: item.channel_type, | ||||
|       avatarUrl: item.avatar_url, | ||||
|       pageId: item.page_id, | ||||
|       websiteToken: item.website_token, | ||||
|     })); | ||||
|     // Identify menuItem to update | ||||
|     // May have more than one object to update | ||||
| @@ -156,7 +167,7 @@ const mutations = { | ||||
|       channel_id: data.id, | ||||
|       label: data.name, | ||||
|       toState: frontendURL(`inbox/${data.id}`), | ||||
|       channelType: data.channelType, | ||||
|       channelType: data.channel_type, | ||||
|       avatarUrl: data.avatar_url === undefined ? null : data.avatar_url, | ||||
|       pageId: data.page_id, | ||||
|     }); | ||||
|   | ||||
| @@ -16,6 +16,8 @@ import WootWizard from 'components/ui/Wizard'; | ||||
| import { sync } from 'vuex-router-sync'; | ||||
| import Vuelidate from 'vuelidate'; | ||||
| import VTooltip from 'v-tooltip'; | ||||
| import VueHighlightJS from 'vue-highlight.js'; | ||||
| import javascript from 'highlight.js/lib/languages/javascript'; | ||||
|  | ||||
| import WootUiKit from '../dashboard/components'; | ||||
| import App from '../dashboard/App'; | ||||
| @@ -34,6 +36,11 @@ Vue.use(VueI18n); | ||||
| Vue.use(WootUiKit); | ||||
| Vue.use(Vuelidate); | ||||
| Vue.use(VTooltip); | ||||
| Vue.use(VueHighlightJS, { | ||||
|   languages: { | ||||
|     javascript, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| Vue.component('multiselect', Multiselect); | ||||
| Vue.component('woot-switch', WootSwitch); | ||||
|   | ||||
							
								
								
									
										160
									
								
								app/javascript/packs/sdk.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										160
									
								
								app/javascript/packs/sdk.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| import sdkStyles from '../widget/assets/scss/sdk.css'; | ||||
| /* eslint-disable no-param-reassign */ | ||||
| const bubbleImg = | ||||
|   'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAUVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////8IN+deAAAAGnRSTlMAAwgJEBk0TVheY2R5eo+ut8jb5OXs8fX2+cjRDTIAAADsSURBVHgBldZbkoMgFIThRgQv8SKKgGf/C51UnJqaRI30/9zfe+NQUQ3TvG7bOk9DVeCmshmj/CuOTYnrdBfkUOg0zlOtl9OWVuEk4+QyZ3DIevmSt/ioTvK1VH/s5bY3YdM9SBZ/mUUyWgx+U06ycgp7D8msxSvtc4HXL9BLdj2elSEfhBJAI0QNgJEBI1BEBsQClVBVGDgwYOLAhJkDM1YOrNg4sLFAsLJgZsHEgoEFFQt0JAFGFjQsKAMJ0LFAexKgZYFyJIDxJIBNJEDNAtSJBLCeBDCOBFAPzwFA94ED+zmhwDO9358r8ANtIsMXi7qVAwAAAABJRU5ErkJggg=='; | ||||
| const closeImg = | ||||
|   'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEUAAAD///////////////////////////////////////////////////////////////////////////////9Du/pqAAAAFHRSTlMACBstLi8wMVB+mcbT2err7O3w8n+sjtQAAAEuSURBVHgBtNLdcoMgGITh1SCGH9DId//X2mnTg7hYxj0oh8w8r+MqgDnmlsIE6UwhtRxnAHge9n2KV7wvP+h4AvPbm73W+359/aJjRjQTCuTNIrJJBfKW0UwqkLeGZJ8Ff2O/T28JwZQCewuYilJgX6buavdDv188br1RIE+jc2H5yy+9VwrXXij0nsflwth7YFRw7N3Y88BcYL+z7wubO/lt6AcFwQMLF9irP8r2eF8/ei8VLrxUkDzguMDejX03WK3dsGJB9lxgrxd0T8PTRxUL5OUCealQz76KXg/or/CvI36VXgcEAAAgCMP6t16IZVDg3zPuI+0rb5g2zlsoW2lbqlvrOyw7bTuuO+8LGIs4C1mLeQuai7oL2437LRytPC1drX0tnq2+Ld+r/wDPIIIJkfdlbQAAAABJRU5ErkJggg=='; | ||||
|  | ||||
| const body = document.getElementsByTagName('body')[0]; | ||||
| const iframe = document.createElement('iframe'); | ||||
| const holder = document.createElement('div'); | ||||
|  | ||||
| const bubbleHolder = document.createElement('div'); | ||||
| const chatBubble = document.createElement('div'); | ||||
| const closeBubble = document.createElement('div'); | ||||
|  | ||||
| const notification_bubble = document.createElement('span'); | ||||
| const bodyOverFlowStyle = document.body.style.overflow; | ||||
|  | ||||
| function addClass(elm, classes) { | ||||
|   if (classes) { | ||||
|     elm.className += ` ${classes}`; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function loadCSS() { | ||||
|   const css = document.createElement('style'); | ||||
|   css.type = 'text/css'; | ||||
|   css.innerHTML = sdkStyles; | ||||
|   document.body.appendChild(css); | ||||
| } | ||||
|  | ||||
| function wootOn(elm, event, fn) { | ||||
|   if (document.addEventListener) { | ||||
|     elm.addEventListener(event, fn, false); | ||||
|   } else if (document.attachEvent) { | ||||
|     // <= IE 8 loses scope so need to apply, we add this to object so we | ||||
|     // can detach later (can't detach anonymous functions) | ||||
|     // eslint-disable-next-line | ||||
|     elm[event + fn] = function() { | ||||
|       // eslint-disable-next-line | ||||
|       return fn.apply(elm, arguments); | ||||
|     }; | ||||
|     elm.attachEvent(`on${event}`, elm[event + fn]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| function classHelper(classes, action, elm) { | ||||
|   let classarray; | ||||
|   let search; | ||||
|   let replace; | ||||
|   let i; | ||||
|   let has = false; | ||||
|   if (classes) { | ||||
|     // Trim any whitespace | ||||
|     classarray = classes.split(/\s+/); | ||||
|     for (i = 0; i < classarray.length; i += 1) { | ||||
|       search = new RegExp(`\\b${classarray[i]}\\b`, 'g'); | ||||
|       replace = new RegExp(` *${classarray[i]}\\b`, 'g'); | ||||
|       if (action === 'remove') { | ||||
|         // eslint-disable-next-line | ||||
|         elm.className = elm.className.replace(replace, ''); | ||||
|       } else if (action === 'toggle') { | ||||
|         // eslint-disable-next-line | ||||
|         elm.className = elm.className.match(search) | ||||
|           ? elm.className.replace(replace, '') | ||||
|           : `${elm.className} ${classarray[i]}`; | ||||
|       } else if (action === 'has') { | ||||
|         if (elm.className.match(search)) { | ||||
|           has = true; | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   return has; | ||||
| } | ||||
|  | ||||
| // Toggle class | ||||
| function toggleClass(elm, classes) { | ||||
|   classHelper(classes, 'toggle', elm); | ||||
| } | ||||
|  | ||||
| const createBubbleIcon = ({ className, src, target }) => { | ||||
|   target.className = className; | ||||
|   const bubbleIcon = document.createElement('img'); | ||||
|   bubbleIcon.src = src; | ||||
|   target.appendChild(bubbleIcon); | ||||
|   return target; | ||||
| }; | ||||
|  | ||||
| function createBubbleHolder() { | ||||
|   addClass(bubbleHolder, 'woot--bubble-holder'); | ||||
|   body.appendChild(bubbleHolder); | ||||
| } | ||||
|  | ||||
| function createNotificationBubble() { | ||||
|   addClass(notification_bubble, 'woot--notification'); | ||||
|   return notification_bubble; | ||||
| } | ||||
|  | ||||
| function bubbleClickCallback() { | ||||
|   toggleClass(chatBubble, 'woot--hide'); | ||||
|   toggleClass(closeBubble, 'woot--hide'); | ||||
|   toggleClass(holder, 'woot--hide'); | ||||
| } | ||||
|  | ||||
| function onClickChatBubble() { | ||||
|   wootOn(chatBubble, 'click', bubbleClickCallback); | ||||
|   wootOn(closeBubble, 'click', bubbleClickCallback); | ||||
| } | ||||
|  | ||||
| function disableScroll() { | ||||
|   document.body.style.overflow = 'hidden'; | ||||
| } | ||||
|  | ||||
| function enableScroll() { | ||||
|   document.body.style.overflow = bodyOverFlowStyle; | ||||
| } | ||||
|  | ||||
| function loadCallback() { | ||||
|   iframe.style.visibility = ''; | ||||
|   iframe.setAttribute('id', `chatwoot_live_chat_widget`); | ||||
|   iframe.onmouseenter = disableScroll; | ||||
|   iframe.onmouseleave = enableScroll; | ||||
|  | ||||
|   loadCSS(); | ||||
|   createBubbleHolder(); | ||||
|  | ||||
|   bubbleHolder.appendChild( | ||||
|     createBubbleIcon({ | ||||
|       className: 'woot-widget-bubble', | ||||
|       src: bubbleImg, | ||||
|       target: chatBubble, | ||||
|     }) | ||||
|   ); | ||||
|   bubbleHolder.appendChild( | ||||
|     createBubbleIcon({ | ||||
|       className: 'woot-widget-bubble woot--close woot--hide', | ||||
|       src: closeImg, | ||||
|       target: closeBubble, | ||||
|     }) | ||||
|   ); | ||||
|   bubbleHolder.appendChild(createNotificationBubble()); | ||||
|   onClickChatBubble(); | ||||
| } | ||||
|  | ||||
| function loadIframe({ websiteToken, baseUrl }) { | ||||
|   iframe.style.visibility = 'hidden'; | ||||
|   iframe.src = `${baseUrl}/widgets?website_token=${websiteToken}`; | ||||
|   iframe.onload = loadCallback; | ||||
|  | ||||
|   holder.className = 'woot-widget-holder woot--hide'; | ||||
|   holder.appendChild(iframe); | ||||
|  | ||||
|   body.appendChild(holder); | ||||
| } | ||||
|  | ||||
| window.chatwootSDK = { | ||||
|   run: loadIframe, | ||||
| }; | ||||
							
								
								
									
										19
									
								
								app/javascript/packs/widget.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/javascript/packs/widget.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import Vue from 'vue'; | ||||
| import store from '../widget/store'; | ||||
| import App from '../widget/App.vue'; | ||||
| import router from '../widget/router'; | ||||
| import ActionCableConnector from '../widget/helpers/actionCable'; | ||||
|  | ||||
| Vue.config.productionTip = false; | ||||
| window.onload = () => { | ||||
|   window.WOOT_WIDGET = new Vue({ | ||||
|     router, | ||||
|     store, | ||||
|     render: h => h(App), | ||||
|   }).$mount('#app'); | ||||
|  | ||||
|   window.actionCable = new ActionCableConnector( | ||||
|     window.WOOT_WIDGET, | ||||
|     window.chatwootPubsubToken | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										26
									
								
								app/javascript/shared/helpers/BaseActionCableConnector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/javascript/shared/helpers/BaseActionCableConnector.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| import { createConsumer } from '@rails/actioncable'; | ||||
|  | ||||
| class BaseActionCableConnector { | ||||
|   constructor(app, pubsubToken) { | ||||
|     const consumer = createConsumer(); | ||||
|     consumer.subscriptions.create( | ||||
|       { | ||||
|         channel: 'RoomChannel', | ||||
|         pubsub_token: pubsubToken, | ||||
|       }, | ||||
|       { | ||||
|         received: this.onReceived, | ||||
|       } | ||||
|     ); | ||||
|     this.app = app; | ||||
|     this.events = {}; | ||||
|   } | ||||
|  | ||||
|   onReceived = ({ event, data } = {}) => { | ||||
|     if (this.events[event] && typeof this.events[event] === 'function') { | ||||
|       this.events[event](data); | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export default BaseActionCableConnector; | ||||
							
								
								
									
										25
									
								
								app/javascript/widget/App.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										25
									
								
								app/javascript/widget/App.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| <template> | ||||
|   <div id="app" class="woot-widget-wrap"> | ||||
|     <router-view /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions } from 'vuex'; | ||||
|  | ||||
| export default { | ||||
|   name: 'App', | ||||
|  | ||||
|   methods: { | ||||
|     ...mapActions('conversation', ['fetchOldConversations']), | ||||
|   }, | ||||
|  | ||||
|   mounted() { | ||||
|     this.fetchOldConversations(); | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @import '~widget/assets/scss/woot.scss'; | ||||
| </style> | ||||
							
								
								
									
										12
									
								
								app/javascript/widget/api/auth.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/widget/api/auth.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import authEndPoint from 'widget/api/endPoints'; | ||||
| import { API } from 'widget/helpers/axios'; | ||||
|  | ||||
| const createContact = async (inboxId, accountId) => { | ||||
|   const urlData = authEndPoint.createContact(inboxId, accountId); | ||||
|   const result = await API.post(urlData.url, urlData.params); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   createContact, | ||||
| }; | ||||
							
								
								
									
										16
									
								
								app/javascript/widget/api/conversation.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										16
									
								
								app/javascript/widget/api/conversation.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import endPoints from 'widget/api/endPoints'; | ||||
| import { API } from 'widget/helpers/axios'; | ||||
|  | ||||
| const sendMessageAPI = async content => { | ||||
|   const urlData = endPoints.sendMessage(content); | ||||
|   const result = await API.post(urlData.url, urlData.params); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| const getConversationAPI = async conversationId => { | ||||
|   const urlData = endPoints.getConversation(conversationId); | ||||
|   const result = await API.get(urlData.url); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| export { sendMessageAPI, getConversationAPI }; | ||||
							
								
								
									
										17
									
								
								app/javascript/widget/api/endPoints.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										17
									
								
								app/javascript/widget/api/endPoints.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| const sendMessage = content => ({ | ||||
|   url: `/api/v1/widget/messages${window.location.search}`, | ||||
|   params: { | ||||
|     message: { | ||||
|       content, | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const getConversation = () => ({ | ||||
|   url: `/api/v1/widget/messages${window.location.search}`, | ||||
| }); | ||||
|  | ||||
| export default { | ||||
|   sendMessage, | ||||
|   getConversation, | ||||
| }; | ||||
							
								
								
									
										
											BIN
										
									
								
								app/javascript/widget/assets/images/defaultUser.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/javascript/widget/assets/images/defaultUser.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 3.8 KiB | 
							
								
								
									
										11
									
								
								app/javascript/widget/assets/images/message-send.svg
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								app/javascript/widget/assets/images/message-send.svg
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg width="21px" height="21px" viewBox="0 0 21 21" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | ||||
|     <!-- Generator: Sketch 53 (72520) - https://sketchapp.com --> | ||||
|     <title>Untitled</title> | ||||
|     <desc>Created with Sketch.</desc> | ||||
|     <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | ||||
|         <g id="message-send" fill="#FFFFFF" fill-rule="nonzero"> | ||||
|             <path d="M18.34,7.32 L4.34,0.32 C3.20803579,-0.243393454 1.84434515,-0.0365739638 0.930331262,0.837115781 C0.0163173744,1.71080553 -0.251780361,3.06378375 0.26,4.22 L2.66,9.59 L2.66,9.59 C2.77000426,9.8522654 2.77000426,10.1477346 2.66,10.41 L0.26,15.78 C-0.153051509,16.7079201 -0.0685371519,17.7818234 0.48458191,18.6337075 C1.03770097,19.4855916 1.98429967,19.9997529 3,20 C3.46823099,19.9953274 3.9294892,19.8859921 4.35,19.68 L18.35,12.68 C19.3627539,12.1705304 20.001816,11.1336797 20.001816,10 C20.001816,8.86632027 19.3627539,7.82946961 18.35,7.32 L18.34,7.32 Z M17.45,10.89 L3.45,17.89 C3.07351737,18.0707705 2.62434212,17.9985396 2.32351279,17.7088521 C2.02268345,17.4191646 1.93356002,16.9730338 2.1,16.59 L4.49,11.22 C4.5209392,11.1482915 4.54765161,11.0748324 4.57,11 L11.46,11 C12.0122847,11 12.46,10.5522847 12.46,10 C12.46,9.44771525 12.0122847,9 11.46,9 L4.57,9 C4.54765161,8.9251676 4.5209392,8.85170847 4.49,8.78 L2.1,3.41 C1.93356002,3.02696622 2.02268345,2.5808354 2.32351279,2.2911479 C2.62434212,2.00146039 3.07351737,1.92922952 3.45,2.11 L17.45,9.11 C17.7839662,9.28109597 17.9940395,9.62475706 17.9940395,10 C17.9940395,10.3752429 17.7839662,10.718904 17.45,10.89 Z" id="Shape"></path> | ||||
|         </g> | ||||
|     </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/javascript/widget/assets/images/send.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/javascript/widget/assets/images/send.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										57
									
								
								app/javascript/widget/assets/scss/_buttons.scss
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										57
									
								
								app/javascript/widget/assets/scss/_buttons.scss
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| $button-border-width: 1px; | ||||
| // Buttons | ||||
| .button { | ||||
|   appearance: none; | ||||
|   background: $color-primary; | ||||
|   border: $button-border-width solid $color-primary; | ||||
|   border-radius: $border-radius; | ||||
|   color: $color-white; | ||||
|   cursor: pointer; | ||||
|   display: inline-block; | ||||
|   font-size: $font-size-default; | ||||
|   height: $space-two * 2; | ||||
|   line-height: $line-height; | ||||
|   outline: none; | ||||
|   padding: $space-smaller $space-normal; | ||||
|   text-align: center; | ||||
|   text-decoration: none; | ||||
|   transition: background .2s, border .2s, box-shadow .2s, color .2s; | ||||
|   user-select: none; | ||||
|   vertical-align: middle; | ||||
|   white-space: nowrap; | ||||
|  | ||||
|   &:focus, | ||||
|   &:hover { | ||||
|     background: lighten($color-primary, 7%); | ||||
|     border-color: $color-primary; | ||||
|     text-decoration: none; | ||||
|   } | ||||
|  | ||||
|   &:active, | ||||
|   &.active { | ||||
|     background: $color-primary; | ||||
|     border-color: darken($color-primary, 5%); | ||||
|     color: lighten($color-primary, 20%); | ||||
|     text-decoration: none; | ||||
|   } | ||||
|  | ||||
|   &[disabled], | ||||
|   &:disabled, | ||||
|   &.disabled { | ||||
|     cursor: default; | ||||
|     opacity: .5; | ||||
|     pointer-events: none; | ||||
|   } | ||||
|  | ||||
|   &.small { | ||||
|     font-size: $font-size-small; | ||||
|     height: $space-medium; | ||||
|     padding: $space-smaller $space-slab; | ||||
|   } | ||||
|  | ||||
|   &.large { | ||||
|     font-size: $font-size-medium; | ||||
|     height: $space-larger; | ||||
|     padding: $space-small $space-medium; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										71
									
								
								app/javascript/widget/assets/scss/_forms.scss
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										71
									
								
								app/javascript/widget/assets/scss/_forms.scss
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| // scss-lint:disable PropertySortOrder DeclarationOrder QualifyingElement | ||||
| $form-border-width: 1px; | ||||
| $input-height: $space-two * 2; | ||||
|  | ||||
| .form-input { | ||||
|   @include placeholder { | ||||
|     color: $color-gray; | ||||
|   } | ||||
|  | ||||
|   appearance: none; | ||||
|   background: $color-white; | ||||
|   border: $form-border-width solid $color-border; | ||||
|   border-radius: $border-radius; | ||||
|   box-sizing: border-box; | ||||
|   color: $color-body; | ||||
|   display: block; | ||||
|   font-size: $font-size-default; | ||||
|   height: $input-height; | ||||
|   line-height: 1.3; | ||||
|   max-width: 100%; | ||||
|   outline: none; | ||||
|   padding: $space-small $space-slab; | ||||
|   position: relative; | ||||
|   transition: background .2s, border .2s, box-shadow .2s, color .2s; | ||||
|   width: 100%; | ||||
|  | ||||
|   &:focus { | ||||
|     border-color: $color-primary; | ||||
|   } | ||||
|  | ||||
|   &::placeholder { | ||||
|     color: $color-gray; | ||||
|   } | ||||
|  | ||||
|   // Input sizes | ||||
|   &.small { | ||||
|     font-size: $font-size-small; | ||||
|     height: $space-large; | ||||
|     padding: $space-small $space-slab; | ||||
|   } | ||||
|  | ||||
|   &.large { | ||||
|     font-size: $font-size-medium; | ||||
|     height: $space-larger; | ||||
|     padding: $space-slab $space-two; | ||||
|   } | ||||
|  | ||||
|   &.input-inline { | ||||
|     display: inline-block; | ||||
|     vertical-align: middle; | ||||
|     width: auto; | ||||
|   } | ||||
|  | ||||
|   // Input types | ||||
|   &[type="file"] { | ||||
|     height: auto; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Form element: Textarea | ||||
| textarea.form-input { | ||||
|   @include placeholder { | ||||
|     color: $color-light-gray; | ||||
|   } | ||||
|  | ||||
|   &, | ||||
|   &.large, | ||||
|   &.small { | ||||
|     height: auto; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										20
									
								
								app/javascript/widget/assets/scss/_mixins.scss
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										20
									
								
								app/javascript/widget/assets/scss/_mixins.scss
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| // scss-lint:disable PseudoElement SpaceBeforeBrace VendorPrefix | ||||
| $shadow-color-1: rgba(50, 50, 93, 0.2); | ||||
| $shadow-color-2: rgba(0, 0, 0, 0.07); | ||||
| $shadow-color-3: rgba(50, 50, 93, .08); | ||||
| $shadow-color-4: rgba(0, 0, 0, .05); | ||||
|  | ||||
| @mixin normal-shadow { | ||||
|   box-shadow: 0 $space-small $space-normal $shadow-color-1, 0 $space-smaller $space-slab $shadow-color-2; | ||||
| } | ||||
|  | ||||
| @mixin light-shadow { | ||||
|   box-shadow: 0 $space-smaller 6px $shadow-color-3, 0 1px 3px $shadow-color-4; | ||||
| } | ||||
|  | ||||
| @mixin placeholder { | ||||
|   &::-webkit-input-placeholder {@content} | ||||
|   &:-moz-placeholder           {@content} | ||||
|   &::-moz-placeholder          {@content} | ||||
|   &:-ms-input-placeholder      {@content} | ||||
| } | ||||
							
								
								
									
										54
									
								
								app/javascript/widget/assets/scss/_reset.scss
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										54
									
								
								app/javascript/widget/assets/scss/_reset.scss
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| // scss-lint:disable | ||||
| /* http://meyerweb.com/eric/tools/css/reset/ | ||||
|    v2.0 | 20110126 | ||||
|    License: none (public domain) | ||||
| */ | ||||
|  | ||||
| html, body, div, span, applet, object, iframe, | ||||
| h1, h2, h3, h4, h5, h6, p, blockquote, pre, | ||||
| a, abbr, acronym, address, big, cite, code, | ||||
| del, dfn, em, img, ins, kbd, q, s, samp, | ||||
| small, strike, strong, sub, sup, tt, var, | ||||
| b, u, i, center, | ||||
| dl, dt, dd, ol, ul, li, | ||||
| fieldset, form, label, legend, | ||||
| table, caption, tbody, tfoot, thead, tr, th, td, | ||||
| article, aside, canvas, details, embed, | ||||
| figure, figcaption, footer, header, hgroup, | ||||
| menu, nav, output, ruby, section, summary, | ||||
| time, mark, audio, video { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   border: 0; | ||||
|   font-size: 100%; | ||||
|   font: inherit; | ||||
|   vertical-align: baseline; | ||||
| } | ||||
|  | ||||
| /* HTML5 display-role reset for older browsers */ | ||||
| article, aside, details, figcaption, figure, | ||||
| footer, header, hgroup, menu, nav, section { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   line-height: 1; | ||||
| } | ||||
|  | ||||
| ol, ul { | ||||
|   list-style: none; | ||||
| } | ||||
|  | ||||
| blockquote, q { | ||||
|   quotes: none; | ||||
| } | ||||
|  | ||||
| blockquote:before, blockquote:after, | ||||
| q:before, q:after { | ||||
|   content: none; | ||||
| } | ||||
|  | ||||
| table { | ||||
|   border-collapse: collapse; | ||||
|   border-spacing: 0; | ||||
| } | ||||
							
								
								
									
										90
									
								
								app/javascript/widget/assets/scss/_variables.scss
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										90
									
								
								app/javascript/widget/assets/scss/_variables.scss
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| // Font sizes | ||||
| $font-size-nano: 0.8rem; | ||||
| $font-size-micro: 0.8rem; | ||||
| $font-size-mini: 1rem; | ||||
| $font-size-small: 1.2rem; | ||||
| $font-size-default: 1.4rem; | ||||
| $font-size-medium: 1.6rem; | ||||
| $font-size-large: 2rem; | ||||
| $font-size-big: 2.4rem; | ||||
| $font-size-bigger: 3.2rem; | ||||
| $font-size-mega: 4rem; | ||||
| $font-size-giga: 5.6rem; | ||||
|  | ||||
| // spaces | ||||
| $zero: 0; | ||||
| $space-micro: 0.2rem; | ||||
| $space-smaller: 0.4rem; | ||||
| $space-small: 0.8rem; | ||||
| $space-one: 1rem; | ||||
| $space-slab: 1.2rem; | ||||
| $space-normal: 1.6rem; | ||||
| $space-two: 2rem; | ||||
| $space-medium: 2.4rem; | ||||
| $space-large: 3.2rem; | ||||
| $space-larger: 4.8rem; | ||||
| $space-big: 6.4rem; | ||||
| $space-jumbo: 8rem; | ||||
| $space-mega: 10rem; | ||||
|  | ||||
| // font-weight | ||||
| $font-weight-feather: 100; | ||||
| $font-weight-light: 300; | ||||
| $font-weight-normal: 400; | ||||
| $font-weight-medium: 500; | ||||
| $font-weight-bold: 600; | ||||
| $font-weight-black: 700; | ||||
|  | ||||
| //Navbar | ||||
| $nav-bar-width: 23rem; | ||||
| $header-height: 5.6rem; | ||||
|  | ||||
| // Woot Logo | ||||
| $woot-logo-width: 20rem; | ||||
| $woot-logo-height: 8rem; | ||||
| $woot-logo-padding: $space-large $space-large $space-large $space-large; | ||||
|  | ||||
| // Colors | ||||
| $color-woot: #1f93ff; | ||||
| $color-primary: $color-woot; | ||||
| $color-gray: #6e6f73; | ||||
| $color-light-gray: #999a9b; | ||||
| $color-border: #e0e6ed; | ||||
| $color-border-transparent: rgba(224, 230, 237, 0.5); | ||||
| $color-border-light: #f0f4f5; | ||||
| $color-background: #ecf3f9; | ||||
| $color-background-light: #fafafa; | ||||
| $color-white: #fff; | ||||
| $color-body: #3c4858; | ||||
| $color-heading: #1f2d3d; | ||||
| $color-modal-header: #f1f1f1; | ||||
| // Thumbnail | ||||
| $thumbnail-radius: 4rem; | ||||
|  | ||||
| // chat-header | ||||
| $conv-header-height: 4rem; | ||||
|  | ||||
| // login | ||||
|  | ||||
| // Inbox List | ||||
|  | ||||
| $inbox-thumb-size: 4.8rem; | ||||
|  | ||||
|  | ||||
| // Spinner | ||||
| $spinkit-spinner-color: $color-white !default; | ||||
| $spinkit-spinner-margin: 0 0 0 1.6rem !default; | ||||
| $spinkit-size: 1.6rem !default; | ||||
|  | ||||
| // Snackbar default | ||||
| $woot-snackbar-bg: #323232; | ||||
| $woot-snackbar-button: #ffeb3b; | ||||
|  | ||||
| $swift-ease-out-duration: .4s !default; | ||||
| $swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default; | ||||
| $swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default; | ||||
|  | ||||
| $border-radius: 3px; | ||||
| $line-height: 1; | ||||
| $footer-height: 11.2rem; | ||||
| $header-expanded-height: $space-medium * 10; | ||||
							
								
								
									
										65
									
								
								app/javascript/widget/assets/scss/sdk.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								app/javascript/widget/assets/scss/sdk.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| .woot-widget-holder { | ||||
|   z-index: 2147483000!important; | ||||
|   position: fixed!important; | ||||
|   bottom: 104px; | ||||
|   right: 20px; | ||||
|   height: calc(85% - 64px - 20px); | ||||
|   width: 370px!important; | ||||
|   min-height: 250px!important; | ||||
|   max-height: 590px!important; | ||||
|   -moz-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important; | ||||
|   -o-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important; | ||||
|   -webkit-box-shadow: 0 5px 40px rgba(0,0,0,.16)!important; | ||||
|   box-shadow: 0 5px 40px rgba(0,0,0,.16)!important; | ||||
|   -o-border-radius: 8px!important; | ||||
|   -moz-border-radius: 8px!important; | ||||
|   -webkit-border-radius: 8px!important; | ||||
|   border-radius: 8px!important; | ||||
|   overflow: hidden!important; | ||||
|   opacity: 1!important; | ||||
| } | ||||
|  | ||||
| .woot-widget-holder iframe { width: 100% !important; height: 100% !important; border: 0; } | ||||
|  | ||||
| .woot-widget-bubble { | ||||
|   z-index: 2147483000!important; | ||||
|   -moz-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important; | ||||
|   -o-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important; | ||||
|   -webkit-box-shadow: 0 8px 24px rgba(0,0,0,.16)!important; | ||||
|   box-shadow: 0 8px 24px rgba(0,0,0,.16)!important; | ||||
|   -o-border-radius: 100px!important; | ||||
|   -moz-border-radius: 100px!important; | ||||
|   -webkit-border-radius: 100px!important; | ||||
|   border-radius: 100px!important; | ||||
|   background: #1f93ff; | ||||
|   position: fixed; | ||||
|   cursor: pointer; | ||||
|   right: 20px; | ||||
|   bottom: 20px; | ||||
|   width: 64px!important; | ||||
|   height: 64px!important; | ||||
| } | ||||
|  | ||||
|   .woot-widget-bubble:hover { | ||||
|   background: #1f93ff; | ||||
|   -moz-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important; | ||||
|   -o-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important; | ||||
|   -webkit-box-shadow: 0 8px 32px rgba(0,0,0,.4)!important; | ||||
|   box-shadow: 0 8px 32px rgba(0,0,0,.4)!important; | ||||
| } | ||||
|  | ||||
| .woot-widget-bubble img { | ||||
|   width: 24px; | ||||
|   height: 24px; | ||||
|   margin: 20px; | ||||
| } | ||||
|  | ||||
| .woot-widget-bubble.woot--close img { | ||||
|   width: 16px; | ||||
|   height: 16px; | ||||
|   margin: 24px; | ||||
| } | ||||
|  | ||||
| .woot--hide { | ||||
|   display: none !important; | ||||
| } | ||||
							
								
								
									
										16
									
								
								app/javascript/widget/assets/scss/woot.scss
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										16
									
								
								app/javascript/widget/assets/scss/woot.scss
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| @import 'variables'; | ||||
| @import 'buttons'; | ||||
| @import 'mixins'; | ||||
| @import 'forms'; | ||||
| @import 'reset'; | ||||
|  | ||||
| html, | ||||
| body { | ||||
|   font-family: -apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; | ||||
|   font-size: 10px; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| .woot-widget-wrap { | ||||
|   height: 100%; | ||||
| } | ||||
							
								
								
									
										83
									
								
								app/javascript/widget/components/AgentMessage.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										83
									
								
								app/javascript/widget/components/AgentMessage.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| <template> | ||||
|   <div class="agent-message"> | ||||
|     <div class="avatar-wrap"> | ||||
|       <UserAvatar size="small" :src="avatarUrl" /> | ||||
|     </div> | ||||
|     <div class="message-wrap"> | ||||
|       <h5 class="agent-name"> | ||||
|         {{ agentName }} | ||||
|       </h5> | ||||
|       <AgentMessageBubble :message="message" /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import UserAvatar from 'widget/components/UserAvatar.vue'; | ||||
| import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue'; | ||||
|  | ||||
| export default { | ||||
|   name: 'AgentMessage', | ||||
|   components: { | ||||
|     UserAvatar, | ||||
|     AgentMessageBubble, | ||||
|   }, | ||||
|   props: { | ||||
|     message: String, | ||||
|     avatarUrl: String, | ||||
|     agentName: String, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .agent-message { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   justify-content: flex-start; | ||||
|   align-items: flex-end; | ||||
|   margin: 0 $space-smaller $space-micro auto; | ||||
|  | ||||
|   & + .agent-message { | ||||
|     margin-bottom: $space-micro; | ||||
|  | ||||
|     .chat-bubble { | ||||
|       border-top-left-radius: $space-smaller; | ||||
|     } | ||||
|  | ||||
|     .user-avatar { | ||||
|       visibility: hidden; | ||||
|     } | ||||
|  | ||||
|     .agent-name { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   & + .user-message { | ||||
|     margin-bottom: $space-normal; | ||||
|   } | ||||
|  | ||||
|   .avatar-wrap { | ||||
|     flex-shrink: 1; | ||||
|     flex-grow: 0; | ||||
|   } | ||||
|  | ||||
|   .message-wrap { | ||||
|     max-width: 90%; | ||||
|     flex-shrink: 0; | ||||
|     flex-grow: 1; | ||||
|     margin-left: $space-small; | ||||
|  | ||||
|     .agent-name { | ||||
|       font-weight: $font-weight-medium; | ||||
|       margin-bottom: $space-smaller; | ||||
|       margin-left: $space-two; | ||||
|       color: $color-body; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										27
									
								
								app/javascript/widget/components/AgentMessageBubble.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										27
									
								
								app/javascript/widget/components/AgentMessageBubble.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| <template> | ||||
|   <div class="chat-bubble agent"> | ||||
|     {{ message }} | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   name: 'AgentMessageBubble', | ||||
|   props: { | ||||
|     message: String, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .chat-bubble { | ||||
|   &.agent { | ||||
|     background: $color-white; | ||||
|     border-bottom-left-radius: $space-smaller; | ||||
|     color: $color-body; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										33
									
								
								app/javascript/widget/components/ChatFooter.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										33
									
								
								app/javascript/widget/components/ChatFooter.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| <template> | ||||
|   <footer class="footer"> | ||||
|     <ChatInputWrap :on-send-message="onSendMessage" /> | ||||
|   </footer> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ChatInputWrap from 'widget/components/ChatInputWrap.vue'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     ChatInputWrap, | ||||
|   }, | ||||
|   props: { | ||||
|     msg: String, | ||||
|     onSendMessage: Function, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .footer { | ||||
|   background: $color-white; | ||||
|   box-shadow: 0 -$space-micro 3px rgba(50, 50, 93, 0.04), | ||||
|     0 -1px 2px rgba(0, 0, 0, 0.03); | ||||
|   box-sizing: border-box; | ||||
|   padding: $space-small; | ||||
|   width: 100%; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										51
									
								
								app/javascript/widget/components/ChatHeaderExpanded.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										51
									
								
								app/javascript/widget/components/ChatHeaderExpanded.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| <template> | ||||
|   <header class="header-expanded"> | ||||
|     <div> | ||||
|       <h2 class="title"> | ||||
|         {{ introHeading }} | ||||
|       </h2> | ||||
|       <p class="body"> | ||||
|         {{ introBody }} | ||||
|       </p> | ||||
|     </div> | ||||
|   </header> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   name: 'ChatHeaderExpanded', | ||||
|   props: { | ||||
|     introHeading: { | ||||
|       type: String, | ||||
|       default: 'Hi there ! 🙌🏼', | ||||
|     }, | ||||
|     introBody: { | ||||
|       type: String, | ||||
|       default: | ||||
|         'We make it simple to connect with us. Ask us anything, or share your feedback.', | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .header-expanded { | ||||
|   background: $color-woot; | ||||
|   padding: $space-large; | ||||
|   width: 100%; | ||||
|   box-sizing: border-box; | ||||
|   color: $color-white; | ||||
|  | ||||
|   .title { | ||||
|     font-size: $font-size-mega; | ||||
|     margin-bottom: $space-two; | ||||
|   } | ||||
|  | ||||
|   .body { | ||||
|     font-size: $font-size-medium; | ||||
|     line-height: 1.5; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										29
									
								
								app/javascript/widget/components/ChatInputArea.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										29
									
								
								app/javascript/widget/components/ChatInputArea.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| <template> | ||||
|   <textarea | ||||
|     class="form-input user-message-input" | ||||
|     :placeholder="placeholder" | ||||
|     :value="value" | ||||
|     @input="$emit('input', $event.target.value)" | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     placeholder: String, | ||||
|     value: String, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .user-message-input { | ||||
|   border-color: $color-white; | ||||
|   border-bottom-color: $color-border-light; | ||||
|   height: $space-big; | ||||
|   resize: none; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										65
									
								
								app/javascript/widget/components/ChatInputWrap.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										65
									
								
								app/javascript/widget/components/ChatInputWrap.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| <template> | ||||
|   <div class="input-wrap"> | ||||
|     <div> | ||||
|       <ChatInputArea v-model="userInput" :placeholder="placeholder" /> | ||||
|     </div> | ||||
|     <div class="message-button-wrap"> | ||||
|       <ChatSendButton | ||||
|         :on-click="handleButtonClick" | ||||
|         :disabled="!userInput.length" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ChatSendButton from 'widget/components/ChatSendButton.vue'; | ||||
| import ChatInputArea from 'widget/components/ChatInputArea.vue'; | ||||
|  | ||||
| export default { | ||||
|   name: 'ChatInputWrap', | ||||
|   components: { | ||||
|     ChatSendButton, | ||||
|     ChatInputArea, | ||||
|   }, | ||||
|  | ||||
|   props: { | ||||
|     placeholder: { | ||||
|       type: String, | ||||
|       default: 'Type your message', | ||||
|     }, | ||||
|     onSendMessage: { | ||||
|       type: Function, | ||||
|       default: () => {}, | ||||
|     }, | ||||
|   }, | ||||
|  | ||||
|   data() { | ||||
|     return { | ||||
|       userInput: '', | ||||
|     }; | ||||
|   }, | ||||
|   methods: { | ||||
|     handleButtonClick() { | ||||
|       if (this.userInput) { | ||||
|         this.onSendMessage(this.userInput); | ||||
|       } | ||||
|       this.userInput = ''; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .input-wrap { | ||||
|   .message-button-wrap { | ||||
|     align-items: center; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: flex-end; | ||||
|     margin-top: $space-small; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										38
									
								
								app/javascript/widget/components/ChatMessage.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										38
									
								
								app/javascript/widget/components/ChatMessage.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| <template> | ||||
|   <UserMessage v-if="isUserMessage" :message="message.content" /> | ||||
|   <AgentMessage | ||||
|     v-else | ||||
|     :agent-name="message.sender_name" | ||||
|     :message="message.content" | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import AgentMessage from 'widget/components/AgentMessage.vue'; | ||||
| import UserMessage from 'widget/components/UserMessage.vue'; | ||||
| import { MESSAGE_TYPE } from 'widget/helpers/constants'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     AgentMessage, | ||||
|     UserMessage, | ||||
|   }, | ||||
|   props: { | ||||
|     message: Object, | ||||
|   }, | ||||
|   computed: { | ||||
|     isUserMessage() { | ||||
|       return this.message.message_type === MESSAGE_TYPE.INCOMING; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| .message-wrap { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   align-items: flex-end; | ||||
|   max-width: 90%; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										63
									
								
								app/javascript/widget/components/ChatSendButton.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										63
									
								
								app/javascript/widget/components/ChatSendButton.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| <template> | ||||
|   <button | ||||
|     type="submit" | ||||
|     :disabled="disabled" | ||||
|     class="button  send-button" | ||||
|     @click="onClick" | ||||
|   > | ||||
|     <span v-if="!loading" class="icon-holder"> | ||||
|       <img src="~widget/assets/images/message-send.svg" /> | ||||
|       <span>Send</span> | ||||
|     </span> | ||||
|     <spinner v-else size="small" /> | ||||
|   </button> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Spinner from 'widget/components/Spinner.vue'; | ||||
|  | ||||
| export default { | ||||
|   components: { | ||||
|     Spinner, | ||||
|   }, | ||||
|   props: { | ||||
|     loading: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     disabled: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     onClick: { | ||||
|       type: Function, | ||||
|       default: () => {}, | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .send-button { | ||||
|   align-items: center; | ||||
|   display: flex; | ||||
|   justify-content: space-around; | ||||
|   min-width: $space-big; | ||||
|   position: relative; | ||||
|  | ||||
|   .icon-holder { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     fill: $color-white; | ||||
|     font-weight: $font-weight-medium; | ||||
|  | ||||
|     img { | ||||
|       margin-right: $space-small; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										33
									
								
								app/javascript/widget/components/ConversationWrap.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										33
									
								
								app/javascript/widget/components/ConversationWrap.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| <template> | ||||
|   <section class="conversation"> | ||||
|     <ChatMessage | ||||
|       v-for="message in messages" | ||||
|       :key="message.id" | ||||
|       :message="message" | ||||
|     /> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import ChatMessage from 'widget/components/ChatMessage.vue'; | ||||
|  | ||||
| export default { | ||||
|   name: 'ConversationWrap', | ||||
|   components: { | ||||
|     ChatMessage, | ||||
|   }, | ||||
|   props: { | ||||
|     messages: Object, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .conversation { | ||||
|   height: 100%; | ||||
|   padding: $space-large $space-small $space-large $space-normal; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										31
									
								
								app/javascript/widget/components/HelloWorld.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										31
									
								
								app/javascript/widget/components/HelloWorld.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| <template> | ||||
|   <div class="hello"> | ||||
|     <h1>{{ msg }}</h1> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     msg: String, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| h3 { | ||||
|   margin: 40px 0 0; | ||||
| } | ||||
| ul { | ||||
|   list-style-type: none; | ||||
|   padding: 0; | ||||
| } | ||||
| li { | ||||
|   display: inline-block; | ||||
|   margin: 0 10px; | ||||
| } | ||||
| a { | ||||
|   color: #42b983; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										52
									
								
								app/javascript/widget/components/Spinner.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										52
									
								
								app/javascript/widget/components/Spinner.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| <template> | ||||
|   <span class="spinner" :class="size"></span> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| const SIZES = ['small', 'medium', 'large']; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
|     size: { | ||||
|       validator: value => SIZES.includes(value), | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .spinner { | ||||
|   @keyframes spinner { | ||||
|     to { | ||||
|       transform: rotate(360deg); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:before { | ||||
|     animation: spinner 0.7s linear infinite; | ||||
|     border-radius: 50%; | ||||
|     border-top-color: lighten($color-woot, 10%); | ||||
|     border: 2px solid rgba(255, 255, 255, 0.7); | ||||
|     box-sizing: border-box; | ||||
|     content: ''; | ||||
|     height: $space-medium; | ||||
|     left: 50%; | ||||
|     margin-left: -$space-slab; | ||||
|     margin-top: -$space-slab; | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     width: $space-medium; | ||||
|   } | ||||
|  | ||||
|   &.small:before { | ||||
|     border-width: 1px; | ||||
|     height: $space-slab; | ||||
|     margin-left: -$space-slab/2; | ||||
|     margin-top: -$space-slab/2; | ||||
|     width: $space-slab; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										46
									
								
								app/javascript/widget/components/UserAvatar.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										46
									
								
								app/javascript/widget/components/UserAvatar.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| <template> | ||||
|   <div class="user-avatar" :class="size" :style="getBgImage"></div> | ||||
| </template> | ||||
| <script> | ||||
| /** | ||||
|  * Thumbnail Component | ||||
|  * Src - source for round image | ||||
|  */ | ||||
| export default { | ||||
|   name: 'UserAvatar', | ||||
|   props: { | ||||
|     src: { | ||||
|       type: String, | ||||
|     }, | ||||
|     size: { | ||||
|       type: String, | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     getBgImage() { | ||||
|       if (this.src) return { 'background-image': `url(${this.src})` }; | ||||
|       return {}; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
| @import '~widget/assets/scss/mixins.scss'; | ||||
|  | ||||
| .user-avatar { | ||||
|   @include light-shadow; | ||||
|   background: url('~widget/assets/images/defaultUser.png') center center | ||||
|     no-repeat; | ||||
|   background-size: cover; | ||||
|   border-radius: 50%; | ||||
|   height: 40px; | ||||
|   width: 40px; | ||||
|  | ||||
|   &.small { | ||||
|     width: $space-medium; | ||||
|     height: $space-medium; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										55
									
								
								app/javascript/widget/components/UserMessage.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										55
									
								
								app/javascript/widget/components/UserMessage.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| <template> | ||||
|   <div class="user-message"> | ||||
|     <div class="message-wrap"> | ||||
|       <UserMessageBubble :message="message" /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import UserMessageBubble from 'widget/components/UserMessageBubble.vue'; | ||||
|  | ||||
| export default { | ||||
|   name: 'UserMessage', | ||||
|   components: { | ||||
|     UserMessageBubble, | ||||
|   }, | ||||
|   props: { | ||||
|     message: String, | ||||
|     avatarUrl: String, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
|  | ||||
| .user-message { | ||||
|   align-items: flex-end; | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   justify-content: flex-end; | ||||
|   margin: 0 $space-smaller $space-micro auto; | ||||
|   text-align: right; | ||||
|  | ||||
|   & + .user-message { | ||||
|     margin-bottom: $space-micro; | ||||
|     .chat-bubble { | ||||
|       border-top-right-radius: $space-smaller; | ||||
|     } | ||||
|     .user-avatar { | ||||
|       visibility: hidden; | ||||
|     } | ||||
|     .agent-name { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
|   & + .agent-message { | ||||
|     margin-bottom: $space-normal; | ||||
|   } | ||||
|   .message-wrap { | ||||
|     margin-right: $space-small; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										36
									
								
								app/javascript/widget/components/UserMessageBubble.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										36
									
								
								app/javascript/widget/components/UserMessageBubble.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| <template> | ||||
|   <div class="chat-bubble user"> | ||||
|     {{ message }} | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   name: 'UserMessageBubble', | ||||
|   props: { | ||||
|     message: String, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <!-- Add "scoped" attribute to limit CSS to this component only --> | ||||
| <style lang="scss"> | ||||
| @import '~widget/assets/scss/variables.scss'; | ||||
| @import '~widget/assets/scss/mixins.scss'; | ||||
|  | ||||
| .chat-bubble { | ||||
|   @include light-shadow; | ||||
|   background: $color-woot; | ||||
|   border-radius: $space-two; | ||||
|   color: $color-white; | ||||
|   display: inline-block; | ||||
|   font-size: $font-size-default; | ||||
|   line-height: 1.5; | ||||
|   max-width: 80%; | ||||
|   padding: $space-small $space-two; | ||||
|  | ||||
|   &.user { | ||||
|     border-bottom-right-radius: $space-smaller; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										16
									
								
								app/javascript/widget/helpers/actionCable.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/javascript/widget/helpers/actionCable.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; | ||||
|  | ||||
| class ActionCableConnector extends BaseActionCableConnector { | ||||
|   constructor(app, pubsubToken) { | ||||
|     super(app, pubsubToken); | ||||
|     this.events = { | ||||
|       'message.created': this.onMessageCreated, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   onMessageCreated = data => { | ||||
|     this.app.$store.dispatch('conversation/addMessage', data); | ||||
|   }; | ||||
| } | ||||
|  | ||||
| export default ActionCableConnector; | ||||
							
								
								
									
										15
									
								
								app/javascript/widget/helpers/axios.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										15
									
								
								app/javascript/widget/helpers/axios.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import axios from 'axios'; | ||||
| import { APP_BASE_URL } from 'widget/helpers/constants'; | ||||
|  | ||||
| export const API = axios.create({ | ||||
|   baseURL: APP_BASE_URL, | ||||
|   withCredentials: false, | ||||
| }); | ||||
|  | ||||
| export const setHeader = (key, value) => { | ||||
|   API.defaults.headers.common[key] = value; | ||||
| }; | ||||
|  | ||||
| export const removeHeader = key => { | ||||
|   delete API.defaults.headers.common[key]; | ||||
| }; | ||||
							
								
								
									
										12
									
								
								app/javascript/widget/helpers/constants.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										12
									
								
								app/javascript/widget/helpers/constants.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| export const APP_BASE_URL = ''; | ||||
|  | ||||
| export const MESSAGE_STATUS = { | ||||
|   FAILED: 'failed', | ||||
|   SUCCESS: 'success', | ||||
|   PROGRESS: 'progress', | ||||
| }; | ||||
|  | ||||
| export const MESSAGE_TYPE = { | ||||
|   INCOMING: 0, | ||||
|   OUTGOING: 1, | ||||
| }; | ||||
							
								
								
									
										10
									
								
								app/javascript/widget/helpers/utils.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										10
									
								
								app/javascript/widget/helpers/utils.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| /* eslint-disable import/prefer-default-export */ | ||||
| export const isEmptyObject = obj => | ||||
|   Object.keys(obj).length === 0 && obj.constructor === Object; | ||||
|  | ||||
| export const arrayToHashById = array => | ||||
|   array.reduce((map, obj) => { | ||||
|     const newMap = map; | ||||
|     newMap[obj.id] = obj; | ||||
|     return newMap; | ||||
|   }, {}); | ||||
							
								
								
									
										24
									
								
								app/javascript/widget/router.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										24
									
								
								app/javascript/widget/router.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import Vue from 'vue'; | ||||
| import Router from 'vue-router'; | ||||
| import Home from './views/Home.vue'; | ||||
|  | ||||
| Vue.use(Router); | ||||
|  | ||||
| export default new Router({ | ||||
|   routes: [ | ||||
|     { | ||||
|       path: '/', | ||||
|       name: 'home', | ||||
|       component: Home, | ||||
|     }, | ||||
|     // { | ||||
|     //   path: '/about', | ||||
|     //   name: 'about', | ||||
|     //   // route level code-splitting | ||||
|     //   // this generates a separate chunk (about.[hash].js) for this route | ||||
|     //   // which is lazy-loaded when the route is visited. | ||||
|     //   component: () => | ||||
|     //     import(/* webpackChunkName: "about" */ './views/About.vue'), | ||||
|     // }, | ||||
|   ], | ||||
| }); | ||||
							
								
								
									
										11
									
								
								app/javascript/widget/store/index.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								app/javascript/widget/store/index.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import Vue from 'vue'; | ||||
| import Vuex from 'vuex'; | ||||
| import conversation from 'widget/store/modules/conversation'; | ||||
|  | ||||
| Vue.use(Vuex); | ||||
|  | ||||
| export default new Vuex.Store({ | ||||
|   modules: { | ||||
|     conversation, | ||||
|   }, | ||||
| }); | ||||
							
								
								
									
										60
									
								
								app/javascript/widget/store/modules/conversation.js
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										60
									
								
								app/javascript/widget/store/modules/conversation.js
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| /* eslint-disable no-param-reassign */ | ||||
| import Vue from 'vue'; | ||||
| import { sendMessageAPI, getConversationAPI } from 'widget/api/conversation'; | ||||
|  | ||||
| export const DEFAULT_CONVERSATION = 'default'; | ||||
| const state = { | ||||
|   conversations: {}, | ||||
| }; | ||||
|  | ||||
| const getters = { | ||||
|   getConversation: _state => _state.conversations, | ||||
| }; | ||||
|  | ||||
| const actions = { | ||||
|   sendMessage: async (_, params) => { | ||||
|     const { content } = params; | ||||
|     await sendMessageAPI(content); | ||||
|   }, | ||||
|  | ||||
|   fetchOldConversations: async ({ commit }) => { | ||||
|     try { | ||||
|       const { data } = await getConversationAPI(); | ||||
|       commit('initMessagesInConversation', data); | ||||
|     } catch (error) { | ||||
|       // Handle error | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   addMessage({ commit }, data) { | ||||
|     commit('pushMessageToConversations', data); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| const mutations = { | ||||
|   initInboxInConversations($state, lastConversation) { | ||||
|     Vue.set($state.conversations, lastConversation, {}); | ||||
|   }, | ||||
|  | ||||
|   pushMessageToConversations($state, message) { | ||||
|     const { id } = message; | ||||
|     const messagesInbox = $state.conversations; | ||||
|     Vue.set(messagesInbox, id, message); | ||||
|   }, | ||||
|  | ||||
|   initMessagesInConversation(_state, payload) { | ||||
|     if (!payload.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     payload.map(message => Vue.set(_state.conversations, message.id, message)); | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|   namespaced: true, | ||||
|   state, | ||||
|   getters, | ||||
|   actions, | ||||
|   mutations, | ||||
| }; | ||||
							
								
								
									
										5
									
								
								app/javascript/widget/views/About.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										5
									
								
								app/javascript/widget/views/About.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| <template> | ||||
|   <div class="about"> | ||||
|     <h1>Chatwoot</h1> | ||||
|   </div> | ||||
| </template> | ||||
							
								
								
									
										78
									
								
								app/javascript/widget/views/Home.vue
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										78
									
								
								app/javascript/widget/views/Home.vue
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| <template> | ||||
|   <div class="home"> | ||||
|     <div class="header-wrap"> | ||||
|       <ChatHeaderExpanded /> | ||||
|     </div> | ||||
|     <div class="conversation-wrap"> | ||||
|       <ConversationWrap :messages="getConversation" /> | ||||
|     </div> | ||||
|     <div class="footer-wrap"> | ||||
|       <ChatFooter :on-send-message="handleSendMessage" /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { mapActions, mapGetters } from 'vuex'; | ||||
|  | ||||
| // import { DEFAULT_CONVERSATION } from 'widget/store/modules/conversation'; | ||||
| import ChatFooter from 'widget/components/ChatFooter.vue'; | ||||
| import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue'; | ||||
| import ConversationWrap from 'widget/components/ConversationWrap.vue'; | ||||
|  | ||||
| export default { | ||||
|   name: 'Home', | ||||
|   components: { | ||||
|     ChatFooter, | ||||
|     ChatHeaderExpanded, | ||||
|     ConversationWrap, | ||||
|   }, | ||||
|   methods: { | ||||
|     ...mapActions('conversation', ['sendMessage']), | ||||
|     handleSendMessage(content) { | ||||
|       this.sendMessage({ | ||||
|         content, | ||||
|       }); | ||||
|     }, | ||||
|     scrollToBottom() { | ||||
|       const container = this.$el.querySelector('.conversation-wrap'); | ||||
|       container.scrollTop = container.scrollHeight; | ||||
|     }, | ||||
|   }, | ||||
|   computed: { | ||||
|     ...mapGetters('conversation', ['getConversation']), | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.scrollToBottom(); | ||||
|   }, | ||||
|   updated() { | ||||
|     this.scrollToBottom(); | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| @import '~widget/assets/scss/woot.scss'; | ||||
|  | ||||
| .home { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   flex-wrap: nowrap; | ||||
|   background: $color-background; | ||||
|  | ||||
|   .header-wrap { | ||||
|     flex-shrink: 0; | ||||
|   } | ||||
|  | ||||
|   .conversation-wrap { | ||||
|     flex: 1; | ||||
|     overflow-y: auto; | ||||
|   } | ||||
|  | ||||
|   .footer-wrap { | ||||
|     flex-shrink: 0; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -16,8 +16,10 @@ class ActionCableListener < BaseListener | ||||
|   def message_created(event) | ||||
|     message, account, timestamp = extract_message_and_account(event) | ||||
|     conversation = message.conversation | ||||
|     contact = conversation.contact | ||||
|     members = conversation.inbox.members.pluck(:pubsub_token) | ||||
|     send_to_members(members, MESSAGE_CREATED, message.push_event_data) | ||||
|     send_to_contact(contact, MESSAGE_CREATED, message.push_event_data) | ||||
|   end | ||||
|  | ||||
|   def conversation_reopened(event) | ||||
| @@ -48,6 +50,12 @@ class ActionCableListener < BaseListener | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def send_to_contact(contact, event_name, data) | ||||
|     return if contact.nil? | ||||
|  | ||||
|     ActionCable.server.broadcast(contact.pubsub_token, event: event_name, data: data) | ||||
|   end | ||||
|  | ||||
|   def push(pubsub_token, data) | ||||
|     # Enqueue sidekiq job to push event to corresponding channel | ||||
|   end | ||||
|   | ||||
| @@ -6,6 +6,7 @@ class Account < ApplicationRecord | ||||
|   has_many :conversations, dependent: :destroy | ||||
|   has_many :contacts, dependent: :destroy | ||||
|   has_many :facebook_pages, dependent: :destroy, class_name: '::Channel::FacebookPage' | ||||
|   has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' | ||||
|   has_many :telegram_bots, dependent: :destroy | ||||
|   has_many :canned_responses, dependent: :destroy | ||||
|   has_one :subscription, dependent: :destroy | ||||
|   | ||||
| @@ -2,7 +2,24 @@ module Channel | ||||
|   class WebWidget < ApplicationRecord | ||||
|     self.table_name = 'channel_web_widgets' | ||||
|  | ||||
|     validates :website_name, presence: true | ||||
|     validates :website_url, presence: true | ||||
|  | ||||
|     belongs_to :account | ||||
|     has_one :inbox, as: :channel, dependent: :destroy | ||||
|     has_secure_token :website_token | ||||
|  | ||||
|     def create_contact_inbox | ||||
|       ActiveRecord::Base.transaction do | ||||
|         contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000)) | ||||
|         ::ContactInbox.create!( | ||||
|           contact_id: contact.id, | ||||
|           inbox_id: inbox.id, | ||||
|           source_id: SecureRandom.uuid | ||||
|         ) | ||||
|       rescue StandardError => e | ||||
|         Rails.logger e | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -10,6 +10,7 @@ json.data do | ||||
|       json.channel_type inbox.channel_type | ||||
|       json.avatar_url inbox.channel.try(:avatar).try(:url) | ||||
|       json.page_id inbox.channel.try(:page_id) | ||||
|       json.website_token inbox.channel.try(:website_token) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										9
									
								
								app/views/api/v1/widget/messages/index.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/views/api/v1/widget/messages/index.json.jbuilder
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| json.array! @messages do |message| | ||||
|   json.id message.id | ||||
|   json.content message.content | ||||
|   json.message_type message.message_type_before_type_cast | ||||
|   json.created_at message.created_at.to_i | ||||
|   json.conversation_id message. conversation_id | ||||
|   json.attachment message.attachment.push_event_data if message.attachment | ||||
|   json.sender_name message.user.name if message.user | ||||
| end | ||||
| @@ -31,7 +31,7 @@ | ||||
|     <%= yield %> | ||||
|     <script> | ||||
|       window.chatwootConfig = { | ||||
|         fbAppId: <%= ENV['fb_app_id'] %> | ||||
|         fbAppId: '<%= ENV['fb_app_id'] %>' | ||||
|       } | ||||
|     </script> | ||||
|   </body> | ||||
|   | ||||
							
								
								
									
										18
									
								
								app/views/widgets/index.html.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/views/widgets/index.html.erb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|   <head> | ||||
|     <title>Chatwoot</title> | ||||
|     <%= csrf_meta_tags %> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> | ||||
|     <%= javascript_pack_tag 'widget' %> | ||||
|     <%= stylesheet_pack_tag 'widget' %> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <%= yield %> | ||||
|     <script> | ||||
|       window.chatwootWebChannel = '<%= @web_widget.website_name %>' | ||||
|       window.chatwootPubsubToken = '<%= @contact.pubsub_token %>' | ||||
|     </script> | ||||
|   </body> | ||||
| </html> | ||||
| @@ -22,5 +22,9 @@ module Chatwoot | ||||
|     # the framework and any gems in your application. | ||||
|     config.generators.javascripts = false | ||||
|     config.generators.stylesheets = false | ||||
|  | ||||
|     config.action_dispatch.default_headers = { | ||||
|       'X-Frame-Options' => 'ALLOWALL' | ||||
|     } | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -11,6 +11,8 @@ Rails.application.routes.draw do | ||||
|  | ||||
|   match '/status', to: 'home#status', via: [:get] | ||||
|  | ||||
|   resources :widgets, only: [:index] | ||||
|  | ||||
|   namespace :api, :defaults => { :format => 'json' } do | ||||
|     namespace :v1 do | ||||
|       resources :callbacks, only: [] do | ||||
| @@ -23,12 +25,8 @@ Rails.application.routes.draw do | ||||
|       end | ||||
|  | ||||
|       namespace :widget do | ||||
|         resources :messages, only: [] do | ||||
|           collection do | ||||
|             post :create_incoming | ||||
|             post :create_outgoing | ||||
|           end | ||||
|         end | ||||
|         resources :messages, only: [:index, :create] | ||||
|         resources :inboxes, only: [:create] | ||||
|       end | ||||
|  | ||||
|       resources :accounts, only: [:create] | ||||
|   | ||||
| @@ -1,9 +1,5 @@ | ||||
| process.env.NODE_ENV = process.env.NODE_ENV || 'development'; | ||||
|  | ||||
| const dotenv = require('dotenv'); | ||||
|  | ||||
| dotenv.config({ path: '.env', silent: true }); | ||||
|  | ||||
| const environment = require('./environment'); | ||||
|  | ||||
| module.exports = environment.toWebpackConfig(); | ||||
|   | ||||
| @@ -15,5 +15,10 @@ environment.loaders.append('audio', { | ||||
| }); | ||||
|  | ||||
| environment.config.merge({ resolve }); | ||||
| environment.config.set('output.filename', chunkData => { | ||||
|   return chunkData.chunk.name === 'sdk' | ||||
|     ? 'js/[name].js' | ||||
|     : 'js/[name]-[hash].js'; | ||||
| }); | ||||
|  | ||||
| module.exports = environment; | ||||
|   | ||||
| @@ -5,6 +5,7 @@ const resolve = { | ||||
|   alias: { | ||||
|     vue$: 'vue/dist/vue.common.js', | ||||
|     dashboard: path.resolve('./app/javascript/dashboard'), | ||||
|     widget: path.resolve('./app/javascript/widget'), | ||||
|     assets: path.resolve('./app/javascript/dashboard/assets'), | ||||
|     components: path.resolve('./app/javascript/dashboard/components'), | ||||
|   }, | ||||
|   | ||||
| @@ -0,0 +1,6 @@ | ||||
| class AddWebsiteTokenToWebWidget < ActiveRecord::Migration[6.1] | ||||
|   def change | ||||
|     add_column :channel_web_widgets, :website_token, :string | ||||
|     add_index :channel_web_widgets, :website_token, unique: true | ||||
|   end | ||||
| end | ||||
| @@ -11,7 +11,6 @@ | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema.define(version: 2019_10_27_054756) do | ||||
|  | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "plpgsql" | ||||
|  | ||||
| @@ -61,6 +60,8 @@ ActiveRecord::Schema.define(version: 2019_10_27_054756) do | ||||
|     t.integer "account_id" | ||||
|     t.datetime "created_at", null: false | ||||
|     t.datetime "updated_at", null: false | ||||
|     t.string "website_token" | ||||
|     t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true | ||||
|   end | ||||
|  | ||||
|   create_table "contact_inboxes", force: :cascade do |t| | ||||
|   | ||||
| @@ -12,8 +12,8 @@ | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@babel/polyfill": "^7.6.0", | ||||
|     "@rails/actioncable": "^6.0.0", | ||||
|     "@babel/preset-env": "~7.3.4", | ||||
|     "@rails/actioncable": "^6.0.0", | ||||
|     "@rails/webpacker": "^4.0.7", | ||||
|     "axios": "^0.19.0", | ||||
|     "babel-helper-vue-jsx-merge-props": "^2.0.3", | ||||
| @@ -24,6 +24,7 @@ | ||||
|     "dotenv": "^8.0.0", | ||||
|     "emojione": "~2.2.7", | ||||
|     "foundation-sites": "6.3.0", | ||||
|     "highlight.js": "^9.15.10", | ||||
|     "ionicons": "~2.0.1", | ||||
|     "js-cookie": "~2.1.3", | ||||
|     "md5": "~2.2.1", | ||||
| @@ -39,6 +40,7 @@ | ||||
|     "vue-axios": "~1.2.2", | ||||
|     "vue-chartjs": "^3.4.2", | ||||
|     "vue-clickaway": "~2.1.0", | ||||
|     "vue-highlight.js": "^3.1.0", | ||||
|     "vue-i18n": "~5.0.3", | ||||
|     "vue-loader": "^15.7.0", | ||||
|     "vue-multiselect": "~2.1.6", | ||||
|   | ||||
							
								
								
									
										34
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								yarn.lock
									
									
									
									
									
								
							| @@ -3276,6 +3276,11 @@ detect-file@^1.0.0: | ||||
|   resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" | ||||
|   integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= | ||||
|  | ||||
| detect-indent@^5.0.0: | ||||
|   version "5.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" | ||||
|   integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= | ||||
|  | ||||
| detect-libc@^1.0.2: | ||||
|   version "1.0.3" | ||||
|   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" | ||||
| @@ -4665,6 +4670,11 @@ hex-color-regex@^1.1.0: | ||||
|   resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" | ||||
|   integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== | ||||
|  | ||||
| highlight.js@^9.15.10: | ||||
|   version "9.15.10" | ||||
|   resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" | ||||
|   integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== | ||||
|  | ||||
| hmac-drbg@^1.0.0: | ||||
|   version "1.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" | ||||
| @@ -8558,6 +8568,14 @@ redent@^1.0.0: | ||||
|     indent-string "^2.1.0" | ||||
|     strip-indent "^1.0.1" | ||||
|  | ||||
| redent@^2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa" | ||||
|   integrity sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo= | ||||
|   dependencies: | ||||
|     indent-string "^3.0.0" | ||||
|     strip-indent "^2.0.0" | ||||
|  | ||||
| regenerate-unicode-properties@^8.1.0: | ||||
|   version "8.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" | ||||
| @@ -9531,6 +9549,11 @@ strip-indent@^1.0.1: | ||||
|   dependencies: | ||||
|     get-stdin "^4.0.1" | ||||
|  | ||||
| strip-indent@^2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" | ||||
|   integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= | ||||
|  | ||||
| strip-json-comments@^2.0.0, strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: | ||||
|   version "2.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" | ||||
| @@ -9836,7 +9859,7 @@ tsconfig@^7.0.0: | ||||
|     strip-bom "^3.0.0" | ||||
|     strip-json-comments "^2.0.0" | ||||
|  | ||||
| tslib@^1.9.0: | ||||
| tslib@^1.9.0, tslib@^1.9.3: | ||||
|   version "1.10.0" | ||||
|   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" | ||||
|   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== | ||||
| @@ -10144,6 +10167,15 @@ vue-eslint-parser@^5.0.0: | ||||
|     esquery "^1.0.1" | ||||
|     lodash "^4.17.11" | ||||
|  | ||||
| vue-highlight.js@^3.1.0: | ||||
|   version "3.1.0" | ||||
|   resolved "https://registry.yarnpkg.com/vue-highlight.js/-/vue-highlight.js-3.1.0.tgz#87b60b4931fd310b318f2b2c9116fe71b69dd053" | ||||
|   integrity sha512-i55SERtdV0CYQppGo29iT6NOq+oOenOKVwkLWZRt7bSynbsQoj/e8GJy/5xL1s5OOYObC/CxA39bRadVyPQt1A== | ||||
|   dependencies: | ||||
|     detect-indent "^5.0.0" | ||||
|     redent "^2.0.0" | ||||
|     tslib "^1.9.3" | ||||
|  | ||||
| vue-hot-reload-api@^2.3.0: | ||||
|   version "2.3.4" | ||||
|   resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Pranav Raj S
					Pranav Raj S