Merge branch 'release/2.8.0'
This commit is contained in:
@@ -53,3 +53,4 @@ exclude_patterns:
|
||||
- 'app/javascript/dashboard/i18n/index.js'
|
||||
- 'app/javascript/widget/i18n/index.js'
|
||||
- 'app/javascript/survey/i18n/index.js'
|
||||
- 'app/javascript/shared/constants/locales.js'
|
||||
|
||||
22
.env.example
22
.env.example
@@ -38,6 +38,9 @@ REDIS_SENTINEL_MASTER_NAME=
|
||||
# REDIS_OPENSSL_VERIFY_MODE=none
|
||||
|
||||
# Postgres Database config variables
|
||||
# You can leave POSTGRES_DATABASE blank. The default name of
|
||||
# the database in the production environment is chatwoot_production
|
||||
POSTGRES_DATABASE=
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_USERNAME=postgres
|
||||
POSTGRES_PASSWORD=
|
||||
@@ -48,7 +51,6 @@ RAILS_MAX_THREADS=5
|
||||
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
|
||||
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
|
||||
|
||||
|
||||
#SMTP domain key is set up for HELO checking
|
||||
SMTP_DOMAIN=chatwoot.com
|
||||
# the default value is set "mailhog" and is used by docker-compose for development environments,
|
||||
@@ -93,7 +95,6 @@ AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=
|
||||
|
||||
|
||||
# Log settings
|
||||
# Disable if you want to write logs to a file
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
@@ -130,7 +131,6 @@ ANDROID_BUNDLE_ID=com.chatwoot.app
|
||||
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
|
||||
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
|
||||
|
||||
|
||||
### Smart App Banner
|
||||
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
|
||||
# You can find your app-id in https://itunesconnect.apple.com
|
||||
@@ -147,8 +147,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## Bot Customizations
|
||||
USE_INBOX_AVATAR_FOR_BOT=true
|
||||
|
||||
|
||||
### APM and Error Monitoring configurations
|
||||
## Elastic APM
|
||||
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
|
||||
# ELASTIC_APM_SERVER_URL=
|
||||
# ELASTIC_APM_SECRET_TOKEN=
|
||||
|
||||
## Sentry
|
||||
# SENTRY_DSN=
|
||||
|
||||
@@ -169,7 +173,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||
# DD_TRACE_AGENT_URL=
|
||||
|
||||
|
||||
## IP look up configuration
|
||||
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
|
||||
## works only on accounts with ip look up feature enabled
|
||||
@@ -181,7 +184,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||
## To prevent and throttle abusive requests
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
# CW_API_ONLY_SERVER=false
|
||||
@@ -195,3 +197,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||
# If you want to use official mobile app,
|
||||
# the notifications would be relayed via a Chatwoot server
|
||||
ENABLE_PUSH_RELAY_SERVER=true
|
||||
|
||||
# Stripe API key
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# Set to true if you want to upload files to cloud storage using the signed url
|
||||
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
|
||||
DIRECT_UPLOADS_ENABLED=
|
||||
|
||||
36
.github/workflows/lock.yml
vendored
Normal file
36
.github/workflows/lock.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# We often have cases where users would comment over stale closed Github Issues.
|
||||
# This creates unnecessary noise for the original reporter and makes it harder for triaging.
|
||||
# This action locks the closed threads once it is inactive for over a month.
|
||||
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
with:
|
||||
issue-inactive-days: '30'
|
||||
issue-lock-reason: 'resolved'
|
||||
issue-comment: >
|
||||
This issue has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
pr-inactive-days: '30'
|
||||
pr-lock-reason: 'resolved'
|
||||
pr-comment: >
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
58
.github/workflows/nightly_installer.yml
vendored
Normal file
58
.github/workflows/nightly_installer.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
# #
|
||||
# #
|
||||
# # Linux nightly installer action
|
||||
# # This action will try to install and setup
|
||||
# # chatwoot on an Ubuntu 20.04 machine using
|
||||
# # the linux installer script.
|
||||
# #
|
||||
# # This is set to run daily at midnight.
|
||||
# #
|
||||
|
||||
name: Run Linux nightly installer
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
run: |
|
||||
wget https://get.chatwoot.app/linux/install.sh
|
||||
chmod +x install.sh
|
||||
|
||||
- name: create input file
|
||||
run: |
|
||||
echo "no" > input
|
||||
echo "yes" >> input
|
||||
|
||||
- name: Run the installer
|
||||
run: |
|
||||
sudo ./install.sh --install < input
|
||||
|
||||
# temp fix for postgresql not starting
|
||||
# automatically in gh action env
|
||||
- name: start postgresql service
|
||||
if: always()
|
||||
run: |
|
||||
sudo service postgresql start
|
||||
|
||||
#re-running the installer again
|
||||
- name: Run the installer again
|
||||
if: always()
|
||||
run: |
|
||||
sudo ./install.sh --install < input
|
||||
|
||||
|
||||
# disabling http verify for now as http
|
||||
# access to port 3000 fails in gh action env
|
||||
# - name: Verify
|
||||
# if: always()
|
||||
# run: |
|
||||
# sudo netstat -ntlp | grep 3000
|
||||
# sudo systemctl restart chatwoot.target
|
||||
# curl http://localhost:3000/api
|
||||
|
||||
@@ -183,3 +183,4 @@ AllCops:
|
||||
- db/migrate/20200503151130_add_account_feature_flag.rb
|
||||
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
|
||||
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
|
||||
- db/migrate/20220809104508_revert_cascading_indexes.rb
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -91,6 +91,7 @@ gem 'google-cloud-dialogflow'
|
||||
|
||||
##-- apm and error monitoring ---#
|
||||
gem 'ddtrace'
|
||||
gem 'elastic-apm'
|
||||
gem 'newrelic_rpm'
|
||||
gem 'scout_apm'
|
||||
gem 'sentry-rails', '~> 5.3'
|
||||
@@ -127,6 +128,9 @@ gem 'working_hours'
|
||||
# full text search for articles
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe'
|
||||
|
||||
group :production, :staging do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
|
||||
20
Gemfile.lock
20
Gemfile.lock
@@ -182,6 +182,9 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
ecma-re-validator (0.4.0)
|
||||
regexp_parser (~> 2.2)
|
||||
elastic-apm (4.5.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
http (>= 3.0)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.10.0)
|
||||
et-orbi (1.2.7)
|
||||
@@ -226,6 +229,9 @@ GEM
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
ffi (1.15.5)
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.5.3)
|
||||
@@ -318,9 +324,15 @@ GEM
|
||||
hkdf (0.3.0)
|
||||
html2text (0.2.1)
|
||||
nokogiri (~> 1.6)
|
||||
http (5.1.0)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
http-form_data (~> 2.2)
|
||||
llhttp-ffi (~> 0.4.0)
|
||||
http-accept (1.7.0)
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
httparty (0.20.0)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
@@ -383,6 +395,9 @@ GEM
|
||||
listen (3.7.1)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
llhttp-ffi (0.4.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
loofah (2.18.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
@@ -616,6 +631,7 @@ GEM
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.6.2)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (6.5.0)
|
||||
telephone_number (1.4.16)
|
||||
thor (1.2.1)
|
||||
tilt (2.0.10)
|
||||
@@ -708,6 +724,7 @@ DEPENDENCIES
|
||||
devise_token_auth
|
||||
dotenv-rails
|
||||
down (~> 5.0)
|
||||
elastic-apm
|
||||
email_reply_trimmer
|
||||
facebook-messenger
|
||||
factory_bot_rails
|
||||
@@ -769,6 +786,7 @@ DEPENDENCIES
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stripe
|
||||
telephone_number
|
||||
time_diff
|
||||
twilio-ruby (~> 5.66)
|
||||
@@ -787,4 +805,4 @@ RUBY VERSION
|
||||
ruby 3.0.4p208
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.16
|
||||
2.3.17
|
||||
|
||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||
end
|
||||
|
||||
def merge_contact(base_contact, merge_contact)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class AccountBuilder
|
||||
include CustomExceptions::Account
|
||||
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin]
|
||||
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
|
||||
|
||||
def perform
|
||||
if @user.nil?
|
||||
|
||||
@@ -23,7 +23,7 @@ class ContactBuilder
|
||||
end
|
||||
|
||||
def update_contact_avatar(contact)
|
||||
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
|
||||
::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
|
||||
end
|
||||
|
||||
def create_contact
|
||||
|
||||
@@ -58,7 +58,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
return if contact_params[:remote_avatar_url].blank?
|
||||
return if @contact.avatar.attached?
|
||||
|
||||
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url])
|
||||
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
|
||||
end
|
||||
|
||||
def conversation
|
||||
|
||||
@@ -15,6 +15,9 @@ class NotificationBuilder
|
||||
|
||||
def user_subscribed_to_notification?
|
||||
notification_setting = user.notification_settings.find_by(account_id: account.id)
|
||||
# added for the case where an assignee might be removed from the account but remains in conversation
|
||||
return if notification_setting.blank?
|
||||
|
||||
return true if notification_setting.public_send("email_#{notification_type}?")
|
||||
return true if notification_setting.public_send("push_#{notification_type}?")
|
||||
|
||||
|
||||
@@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,10 +2,12 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_article, except: [:index, :create]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@articles_count = @portal.articles.count
|
||||
@articles = @portal.articles
|
||||
@articles = @articles.search(list_params) if params[:payload].present?
|
||||
@articles = @articles.search(list_params) if list_params.present?
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -45,8 +47,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:payload).permit(
|
||||
:category_slug, :locale, :query
|
||||
)
|
||||
params.permit(:locale, :query, :page, :category_slug, :status, :author_id)
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -90,9 +90,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def set_avatar(facebook_inbox, page_id)
|
||||
avatar_file = Down.download(
|
||||
"http://graph.facebook.com/#{page_id}/picture?type=large"
|
||||
)
|
||||
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
|
||||
avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large"
|
||||
Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_category, except: [:index, :create]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@categories = @portal.categories.search(params)
|
||||
@@ -49,4 +50,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
|
||||
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
|
||||
)
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
contacts = resolved_contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
search: "%#{params[:q]}%"
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts_with_conversation_count(contacts)
|
||||
@@ -166,13 +166,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def process_avatar
|
||||
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present?
|
||||
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
|
||||
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
|
||||
hash = Digest::MD5.hexdigest(params[:email])
|
||||
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
|
||||
::ContactAvatarJob.perform_later(@contact, gravatar_url)
|
||||
end
|
||||
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
|
||||
end
|
||||
|
||||
def render_error(error, error_status)
|
||||
|
||||
57
app/controllers/api/v1/accounts/macros_controller.rb
Normal file
57
app/controllers/api/v1/accounts/macros_controller.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||
|
||||
def index
|
||||
@macros = Macro.with_visibility(current_user, params)
|
||||
end
|
||||
|
||||
def create
|
||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.actions = params[:actions]
|
||||
|
||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||
|
||||
@macro.save!
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def destroy
|
||||
@macro.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.save!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def execute
|
||||
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(
|
||||
:name, :account_id, :visibility,
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
def macros_with_user
|
||||
permitted_params.merge(updated_by_id: current_user.id)
|
||||
end
|
||||
|
||||
def fetch_macro
|
||||
@macro = Current.account.macros.find_by(id: params[:id])
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
before_action :fetch_portal, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@portals = Current.account.portals
|
||||
@@ -17,8 +18,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def create
|
||||
@portal = Current.account.portals.build(portal_params)
|
||||
render json: { error: @portal.errors.messages }, status: :unprocessable_entity and return unless @portal.valid?
|
||||
|
||||
@portal.save!
|
||||
process_attached_logo
|
||||
end
|
||||
@@ -66,4 +65,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_member_params
|
||||
params.require(:portal).permit(:account_id, member_ids: [])
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
user_full_name: account_params[:user_full_name],
|
||||
email: account_params[:email],
|
||||
user_password: account_params[:password],
|
||||
locale: account_params[:locale],
|
||||
user: current_user
|
||||
).perform
|
||||
if @user
|
||||
|
||||
@@ -36,9 +36,10 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: {
|
||||
browser_language: browser.accept_language&.first&.code,
|
||||
browser: browser_params,
|
||||
referer: permitted_params[:message][:referer_url],
|
||||
initiated_at: timestamp_params
|
||||
initiated_at: timestamp_params,
|
||||
referer: permitted_params[:message][:referer_url]
|
||||
},
|
||||
custom_attributes: permitted_params[:custom_attributes].presence || {}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
|
||||
Current.user = @user
|
||||
end
|
||||
|
||||
def current_subscription
|
||||
@subscription ||= Current.account.subscription
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
{
|
||||
user: Current.user,
|
||||
|
||||
@@ -8,6 +8,8 @@ module EnsureCurrentAccountHelper
|
||||
|
||||
def ensure_current_account
|
||||
account = Account.find(params[:account_id])
|
||||
ensure_account_is_active?(account)
|
||||
|
||||
if current_user
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
@@ -25,4 +27,8 @@ module EnsureCurrentAccountHelper
|
||||
def account_accessible_for_bot?(account)
|
||||
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||
end
|
||||
|
||||
def ensure_account_is_active?(account)
|
||||
render_unauthorized('Account is suspended') unless account.active?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,9 @@ module WebsiteTokenHelper
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
@current_account = @web_widget.account
|
||||
@current_account = @web_widget.inbox.account
|
||||
|
||||
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
|
||||
end
|
||||
|
||||
def set_contact
|
||||
|
||||
@@ -13,8 +13,7 @@ class DashboardController < ActionController::Base
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get(
|
||||
'LOGO',
|
||||
'LOGO_THUMBNAIL',
|
||||
'LOGO', 'LOGO_THUMBNAIL',
|
||||
'INSTALLATION_NAME',
|
||||
'WIDGET_BRAND_URL',
|
||||
'TERMS_URL',
|
||||
@@ -29,7 +28,8 @@ class DashboardController < ActionController::Base
|
||||
'DIRECT_UPLOADS_ENABLED',
|
||||
'HCAPTCHA_SITE_KEY',
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE'
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
|
||||
@@ -27,6 +27,6 @@ class Platform::Api::V1::AccountsController < PlatformController
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name)
|
||||
params.permit(:name, :locale)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ class Public::Api::V1::Portals::ArticlesController < ApplicationController
|
||||
|
||||
def index
|
||||
@articles = @portal.articles
|
||||
@articles = @articles.search(list_params) if params[:payload].present?
|
||||
@articles = @articles.search(list_params) if list_params.present?
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -20,6 +20,6 @@ class Public::Api::V1::Portals::ArticlesController < ApplicationController
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.require(:payload).permit(:query)
|
||||
params.permit(:query)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class WidgetsController < ActionController::Base
|
||||
|
||||
before_action :set_global_config
|
||||
before_action :set_web_widget
|
||||
before_action :ensure_account_is_active
|
||||
before_action :set_token
|
||||
before_action :set_contact
|
||||
before_action :build_contact
|
||||
@@ -46,6 +47,10 @@ class WidgetsController < ActionController::Base
|
||||
@contact = @contact_inbox.contact
|
||||
end
|
||||
|
||||
def ensure_account_is_active
|
||||
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
|
||||
{ created_at_ip: request.remote_ip }
|
||||
|
||||
@@ -17,6 +17,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
users: CountField,
|
||||
conversations: CountField,
|
||||
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
|
||||
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
|
||||
account_users: Field::HasMany
|
||||
}.merge(enterprise_attribute_types).freeze
|
||||
|
||||
@@ -31,6 +32,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
locale
|
||||
users
|
||||
conversations
|
||||
status
|
||||
].freeze
|
||||
|
||||
# SHOW_PAGE_ATTRIBUTES
|
||||
@@ -42,6 +44,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
created_at
|
||||
updated_at
|
||||
locale
|
||||
status
|
||||
conversations
|
||||
account_users
|
||||
] + enterprise_show_page_attributes).freeze
|
||||
@@ -53,6 +56,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
FORM_ATTRIBUTES = (%i[
|
||||
name
|
||||
locale
|
||||
status
|
||||
] + enterprise_form_attributes).freeze
|
||||
|
||||
# COLLECTION_FILTERS
|
||||
|
||||
@@ -6,7 +6,7 @@ class ConversationDrop < BaseDrop
|
||||
end
|
||||
|
||||
def contact_name
|
||||
@obj.try(:contact).name.capitalize || 'Customer'
|
||||
@obj.try(:contact).name.try(:capitalize) || 'Customer'
|
||||
end
|
||||
|
||||
def recent_messages
|
||||
|
||||
@@ -15,6 +15,11 @@ class ApiClient {
|
||||
|
||||
baseUrl() {
|
||||
let url = this.apiVersion;
|
||||
|
||||
if (this.options.enterprise) {
|
||||
url = `/enterprise${url}`;
|
||||
}
|
||||
|
||||
if (this.options.accountScoped) {
|
||||
const isInsideAccountScopedURLs = window.location.pathname.includes(
|
||||
'/app/accounts'
|
||||
|
||||
18
app/javascript/dashboard/api/enterprise/account.js
Normal file
18
app/javascript/dashboard/api/enterprise/account.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class EnterpriseAccountAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('', { accountScoped: true, enterprise: true });
|
||||
}
|
||||
|
||||
checkout() {
|
||||
return axios.post(`${this.url}checkout`);
|
||||
}
|
||||
|
||||
subscription() {
|
||||
return axios.post(`${this.url}subscription`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
@@ -0,0 +1,31 @@
|
||||
import accountAPI from '../account';
|
||||
import ApiClient from '../../ApiClient';
|
||||
import describeWithAPIMock from '../../specs/apiSpecHelper';
|
||||
|
||||
describe('#enterpriseAccountAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(accountAPI).toBeInstanceOf(ApiClient);
|
||||
expect(accountAPI).toHaveProperty('get');
|
||||
expect(accountAPI).toHaveProperty('show');
|
||||
expect(accountAPI).toHaveProperty('create');
|
||||
expect(accountAPI).toHaveProperty('update');
|
||||
expect(accountAPI).toHaveProperty('delete');
|
||||
expect(accountAPI).toHaveProperty('checkout');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#checkout', () => {
|
||||
accountAPI.checkout();
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/checkout'
|
||||
);
|
||||
});
|
||||
|
||||
it('#subscription', () => {
|
||||
accountAPI.subscription();
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/subscription'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
app/javascript/dashboard/api/helpCenter/articles.js
Normal file
26
app/javascript/dashboard/api/helpCenter/articles.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/* global axios */
|
||||
|
||||
import PortalsAPI from './portals';
|
||||
|
||||
class ArticlesAPI extends PortalsAPI {
|
||||
constructor() {
|
||||
super('articles', { accountScoped: true });
|
||||
}
|
||||
|
||||
getArticles({
|
||||
pageNumber,
|
||||
portalSlug,
|
||||
locale,
|
||||
status,
|
||||
author_id,
|
||||
category_slug,
|
||||
}) {
|
||||
let baseUrl = `${this.url}/${portalSlug}/articles?page=${pageNumber}&locale=${locale}`;
|
||||
if (status !== undefined) baseUrl += `&status=${status}`;
|
||||
if (author_id) baseUrl += `&author_id=${author_id}`;
|
||||
if (category_slug) baseUrl += `&category_slug=${category_slug}`;
|
||||
return axios.get(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
27
app/javascript/dashboard/api/helpCenter/categories.js
Normal file
27
app/javascript/dashboard/api/helpCenter/categories.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/* global axios */
|
||||
|
||||
import PortalsAPI from './portals';
|
||||
|
||||
class CategoriesAPI extends PortalsAPI {
|
||||
constructor() {
|
||||
super('categories', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ portalSlug }) {
|
||||
return axios.get(`${this.url}/${portalSlug}/categories`);
|
||||
}
|
||||
|
||||
create({ portalSlug, categoryObj }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/categories`, categoryObj);
|
||||
}
|
||||
|
||||
update({ portalSlug, categoryObj }) {
|
||||
return axios.patch(`${this.url}/${portalSlug}/categories`, categoryObj);
|
||||
}
|
||||
|
||||
delete({ portalSlug, categoryId }) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoriesAPI();
|
||||
9
app/javascript/dashboard/api/helpCenter/portals.js
Normal file
9
app/javascript/dashboard/api/helpCenter/portals.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class PortalsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('portals', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default PortalsAPI;
|
||||
29
app/javascript/dashboard/api/specs/article.spec.js
Normal file
29
app/javascript/dashboard/api/specs/article.spec.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import articlesAPI from '../helpCenter/articles';
|
||||
import ApiClient from 'dashboard/api/helpCenter/portals';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#PortalAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(articlesAPI).toBeInstanceOf(ApiClient);
|
||||
expect(articlesAPI).toHaveProperty('get');
|
||||
expect(articlesAPI).toHaveProperty('show');
|
||||
expect(articlesAPI).toHaveProperty('create');
|
||||
expect(articlesAPI).toHaveProperty('update');
|
||||
expect(articlesAPI).toHaveProperty('delete');
|
||||
expect(articlesAPI).toHaveProperty('getArticles');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getArticles', () => {
|
||||
articlesAPI.getArticles({
|
||||
pageNumber: 1,
|
||||
portalSlug: 'room-rental',
|
||||
locale: 'en-US',
|
||||
status: 'published',
|
||||
author_id: '1',
|
||||
});
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import categoriesAPI from '../../helpCenter/categories';
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
describe('#BulkActionsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(categoriesAPI).toBeInstanceOf(ApiClient);
|
||||
expect(categoriesAPI).toHaveProperty('get');
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
});
|
||||
});
|
||||
13
app/javascript/dashboard/api/specs/portals.spec.js
Normal file
13
app/javascript/dashboard/api/specs/portals.spec.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import PortalsAPI from '../helpCenter/portals';
|
||||
import ApiClient from '../ApiClient';
|
||||
const portalAPI = new PortalsAPI();
|
||||
describe('#PortalAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(portalAPI).toBeInstanceOf(ApiClient);
|
||||
expect(portalAPI).toHaveProperty('get');
|
||||
expect(portalAPI).toHaveProperty('show');
|
||||
expect(portalAPI).toHaveProperty('create');
|
||||
expect(portalAPI).toHaveProperty('update');
|
||||
expect(portalAPI).toHaveProperty('delete');
|
||||
});
|
||||
});
|
||||
3
app/javascript/dashboard/assets/images/bubble-logo.svg
Normal file
3
app/javascript/dashboard/assets/images/bubble-logo.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="66" height="66" viewBox="0 0 66 66" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 63H32.9976C16.4591 63 2.99996 49.5399 2.99996 32.9973C2.99996 16.4601 16.4591 3 32.9979 3C49.5408 3 63 16.4601 63 32.9973V63Z" fill="white" stroke="white" stroke-width="6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -1,11 +1,9 @@
|
||||
/* Enter and leave animations can use different */
|
||||
/* durations and timing functions. */
|
||||
.slide-fade-enter-active {
|
||||
transition: all .3s $ease-in-cubic;
|
||||
transition: all 0.3s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all .3s $ease-out-cubic;
|
||||
transition: all 0.3s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.slide-fade-enter,
|
||||
@@ -24,7 +22,7 @@
|
||||
|
||||
.conversations-list-enter-active,
|
||||
.conversations-list-leave-active {
|
||||
transition: all .25s $ease-out-cubic;
|
||||
transition: all 0.25s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.conversations-list-enter,
|
||||
@@ -35,11 +33,10 @@
|
||||
|
||||
.menu-list-enter-active,
|
||||
.menu-list-leave-active {
|
||||
transition: opacity .3s $ease-out-cubic,
|
||||
transform .2s $ease-out-cubic;
|
||||
transition: opacity 0.3s var(--ease-out-cubic),
|
||||
transform 0.2s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
|
||||
.menu-list-leave-to {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
@@ -52,23 +49,24 @@
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
transition: all .3s $ease-in-cubic;
|
||||
transition: all 0.3s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
transition: all .3s $ease-out-cubic;
|
||||
transition: all 0.3s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.slide-up-enter,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-$space-medium);
|
||||
opacity: 0;
|
||||
transform: translateY(-$space-medium);
|
||||
}
|
||||
|
||||
.menu-slide-enter-active,
|
||||
.menu-slide-leave-active {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
|
||||
transition: transform 0.25s var(--ease-in-cubic),
|
||||
opacity 0.15s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
.menu-slide-enter,
|
||||
@@ -77,13 +75,12 @@
|
||||
transform: translateY($space-small);
|
||||
}
|
||||
|
||||
|
||||
.toast-fade-enter-active {
|
||||
transition: all .3s $ease-in-sine;
|
||||
transition: all 0.3s var(--ease-in-sine);
|
||||
}
|
||||
|
||||
.toast-fade-leave-active {
|
||||
transition: all .1s $ease-out-sine;
|
||||
transition: all 0.1s var(--ease-out-sine);
|
||||
}
|
||||
|
||||
.toast-fade-enter,
|
||||
@@ -93,11 +90,11 @@
|
||||
}
|
||||
|
||||
.modal-fade-enter-active {
|
||||
transition: all .3s $ease-in-sine;
|
||||
transition: all 0.3s var(--ease-in-sine);
|
||||
}
|
||||
|
||||
.modal-fade-leave-active {
|
||||
transition: all .1s $ease-out-sine;
|
||||
transition: all 0.1s var(--ease-out-sine);
|
||||
}
|
||||
|
||||
.modal-fade-enter,
|
||||
@@ -106,15 +103,15 @@
|
||||
}
|
||||
|
||||
.network-notification-fade-enter-active {
|
||||
transition: all .1s $ease-in-sine;
|
||||
transition: all 0.1s var(--ease-in-sine);
|
||||
}
|
||||
|
||||
.network-notification-fade-leave-active {
|
||||
transition: all .1s $ease-out-sine;
|
||||
transition: all 0.1s var(--ease-out-sine);
|
||||
}
|
||||
|
||||
.network-notification-fade-enter,
|
||||
.network-notification-fade-leave-to {
|
||||
transform: translateY(-$space-small);
|
||||
opacity: 0;
|
||||
transform: translateY(-$space-small);
|
||||
}
|
||||
|
||||
@@ -41,24 +41,22 @@ is-closed .app-root {
|
||||
|
||||
.view-box {
|
||||
@include full-height;
|
||||
@include margin(0);
|
||||
@include space-between-column;
|
||||
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
@include margin($zero);
|
||||
@include padding($space-normal);
|
||||
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
padding: $space-normal;
|
||||
}
|
||||
|
||||
.content-box {
|
||||
@include padding($space-normal);
|
||||
|
||||
overflow: auto;
|
||||
padding: $space-normal;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@@ -91,8 +89,7 @@ is-closed .app-root {
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
@include padding($space-one);
|
||||
|
||||
max-width: $space-mega;
|
||||
padding: $space-one;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@@ -16,7 +17,6 @@
|
||||
@import 'date-picker';
|
||||
|
||||
@import 'foundation-sites/scss/foundation';
|
||||
@import '~bourbon/core/bourbon';
|
||||
|
||||
@include foundation-everything($flex: true);
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
&.multiselect--disabled {
|
||||
opacity: .8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
>.multiselect__tags {
|
||||
> .multiselect__tags {
|
||||
border-color: $color-woot;
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,9 @@
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@include margin(0);
|
||||
border: 1px solid $color-border;
|
||||
border-color: $color-border;
|
||||
margin: 0;
|
||||
min-height: 4.4rem;
|
||||
padding-top: $zero;
|
||||
}
|
||||
@@ -130,10 +130,10 @@
|
||||
|
||||
.multiselect__input {
|
||||
@include ghost-input;
|
||||
@include padding($zero);
|
||||
font-size: $font-size-small;
|
||||
height: 4.4rem;
|
||||
margin-bottom: $zero;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@@ -145,7 +145,6 @@
|
||||
}
|
||||
|
||||
.sidebar-labels-wrap {
|
||||
|
||||
&.has-edited,
|
||||
&:hover {
|
||||
.multiselect {
|
||||
@@ -154,15 +153,15 @@
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
>.multiselect__select {
|
||||
> .multiselect__select {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
>.multiselect__tags {
|
||||
> .multiselect__tags {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.multiselect--active>.multiselect__tags {
|
||||
&.multiselect--active > .multiselect__tags {
|
||||
border-color: $color-woot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@@ -17,8 +18,6 @@
|
||||
@import 'foundation-sites/scss/foundation';
|
||||
|
||||
@include foundation-prototype-spacing;
|
||||
@import '~bourbon/core/bourbon';
|
||||
|
||||
@include foundation-everything($flex: true);
|
||||
|
||||
@import 'typography';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
$channel-hover-color: rgba(0, 0, 0, 0.1);
|
||||
|
||||
.channels {
|
||||
margin-top: $space-medium;
|
||||
|
||||
@@ -7,14 +9,14 @@
|
||||
|
||||
.channel {
|
||||
@include flex;
|
||||
@include padding($space-normal $zero);
|
||||
@include background-white;
|
||||
@include border-light;
|
||||
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
margin: -1px;
|
||||
transition: all 0.200s ease-in;
|
||||
padding: $space-normal $zero;
|
||||
transition: all 0.2s ease-in;
|
||||
|
||||
&:last-child {
|
||||
@include border-light;
|
||||
@@ -22,16 +24,16 @@
|
||||
|
||||
&:hover {
|
||||
border: 1px solid $primary-color;
|
||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 8px $channel-hover-color;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: .6;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
img {
|
||||
@include margin($space-normal auto);
|
||||
margin: $space-normal auto;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@@ -43,8 +45,8 @@
|
||||
}
|
||||
|
||||
p {
|
||||
width: 100%;
|
||||
color: $medium-gray;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,33 +4,33 @@
|
||||
|
||||
// Conversation header - Light BG
|
||||
.settings-header {
|
||||
@include padding($space-small $space-normal);
|
||||
@include background-white;
|
||||
@include flex;
|
||||
@include flex-align($x: justify, $y: middle);
|
||||
border-bottom: 1px solid var(--s-50);
|
||||
height: $header-height;
|
||||
min-height: $header-height;
|
||||
padding: $space-small $space-normal;
|
||||
|
||||
// Resolve Button
|
||||
.button {
|
||||
@include margin(0);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// User thumbnail and text
|
||||
.page-title {
|
||||
@include flex;
|
||||
@include flex-align($x: center, $y: middle);
|
||||
@include margin($zero);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-box {
|
||||
.item {
|
||||
@include padding($space-normal $space-normal $space-normal $space-medium);
|
||||
@include background-light;
|
||||
|
||||
cursor: pointer;
|
||||
padding: $space-normal $space-normal $space-normal $space-medium;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
@@ -128,89 +128,27 @@
|
||||
|
||||
.wizard-body {
|
||||
@include background-white;
|
||||
@include padding($space-medium);
|
||||
@include border-light;
|
||||
@include full-height();
|
||||
padding: $space-medium;
|
||||
|
||||
&.height-auto {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inoboxes-list {
|
||||
.inbox-item {
|
||||
@include margin($space-normal);
|
||||
@include flex;
|
||||
@include flex-shrink;
|
||||
@include padding($space-normal $space-normal);
|
||||
@include border-light-bottom();
|
||||
|
||||
background: $color-white;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
float: left;
|
||||
min-height: 10rem;
|
||||
width: 20%;
|
||||
|
||||
&:last-child {
|
||||
@include border-nil;
|
||||
|
||||
margin-bottom: $zero;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include background-gray;
|
||||
|
||||
.arrow {
|
||||
opacity: 1;
|
||||
transform: translateX($space-small);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
align-self: center;
|
||||
margin-bottom: $zero;
|
||||
margin-right: $space-normal;
|
||||
}
|
||||
|
||||
.item--details {
|
||||
@include padding($space-normal $zero);
|
||||
|
||||
.item--name {
|
||||
font-size: $font-size-large;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.item--sub {
|
||||
font-size: $font-size-small;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
align-self: center;
|
||||
color: $medium-gray;
|
||||
font-size: $font-size-small;
|
||||
opacity: 0.7;
|
||||
transform: translateX(0);
|
||||
transition: opacity 0.1s ease-in 0s, transform 0.2s ease-in 0.03s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings--content {
|
||||
@include margin($space-small $space-large);
|
||||
margin: $space-small $space-large;
|
||||
|
||||
.title {
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.code {
|
||||
@include padding($space-one);
|
||||
|
||||
background: $color-background;
|
||||
max-height: $space-mega;
|
||||
overflow: auto;
|
||||
padding: $space-one;
|
||||
white-space: nowrap;
|
||||
|
||||
code {
|
||||
@@ -225,7 +163,7 @@
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
@include padding($space-medium);
|
||||
padding: $space-medium;
|
||||
}
|
||||
|
||||
> a > img {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@keyframes left-shift-animation {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -13,15 +12,15 @@
|
||||
.conversation {
|
||||
@include flex;
|
||||
@include flex-shrink;
|
||||
@include padding(0 0 0 $space-normal);
|
||||
border-bottom: 1px solid transparent;
|
||||
border-left: $space-micro solid transparent;
|
||||
border-top: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 $space-normal;
|
||||
position: relative;
|
||||
|
||||
&.active {
|
||||
animation: left-shift-animation .25s $swift-ease-out-function;
|
||||
animation: left-shift-animation 0.25s $swift-ease-out-function;
|
||||
background: $color-background;
|
||||
border-bottom-color: $color-border-light;
|
||||
border-left-color: $color-woot;
|
||||
@@ -31,7 +30,7 @@
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
+.conversation .conversation--details {
|
||||
+ .conversation .conversation--details {
|
||||
border-top-color: transparent;
|
||||
}
|
||||
}
|
||||
@@ -48,13 +47,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.conversation--details {
|
||||
@include margin(0 0 0 $space-one);
|
||||
@include border-light-bottom;
|
||||
@include border-light-top;
|
||||
@include padding($space-slab 0);
|
||||
border-bottom-color: transparent;
|
||||
margin: 0 0 0 $space-one;
|
||||
padding: $space-slab 0;
|
||||
}
|
||||
|
||||
.conversation--user {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// scss-lint:disable MergeableSelector
|
||||
|
||||
@mixin bubble-with-types {
|
||||
@include padding($space-small $space-normal);
|
||||
@include margin($zero);
|
||||
padding: $space-small $space-normal;
|
||||
margin: 0;
|
||||
background: $color-woot;
|
||||
border-radius: $space-one;
|
||||
color: var(--white);
|
||||
@@ -37,7 +39,11 @@
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-image: linear-gradient(-180deg, transparent 3%, $color-heading 130%);
|
||||
background-image: linear-gradient(
|
||||
-180deg,
|
||||
transparent 3%,
|
||||
$color-heading 130%
|
||||
);
|
||||
bottom: 0;
|
||||
content: '';
|
||||
height: 20%;
|
||||
@@ -75,16 +81,15 @@
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
@include flex-weight(1);
|
||||
@include scroll-on-hover;
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
.chat-list__top {
|
||||
@include flex;
|
||||
@include padding($zero $zero $space-micro $zero);
|
||||
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $zero $zero $space-micro;
|
||||
|
||||
.page-title {
|
||||
margin-bottom: $zero;
|
||||
@@ -92,13 +97,13 @@
|
||||
}
|
||||
|
||||
.status--filter {
|
||||
@include padding($zero null $zero $space-normal);
|
||||
@include margin($zero);
|
||||
background-color: $color-background-light;
|
||||
border: 1px solid $color-border;
|
||||
float: right;
|
||||
font-size: $font-size-mini;
|
||||
height: $space-medium;
|
||||
margin: 0;
|
||||
padding: $zero $space-medium $zero $space-normal;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -110,19 +115,19 @@
|
||||
|
||||
.conversation-panel {
|
||||
@include flex;
|
||||
@include flex-weight(1 1 1px);
|
||||
@include margin($zero);
|
||||
flex: 1 1 1px;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--space-normal);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-panel>li {
|
||||
.conversation-panel > li {
|
||||
@include flex;
|
||||
@include flex-shrink;
|
||||
@include margin($zero $zero $space-micro);
|
||||
margin: $zero $zero $space-micro;
|
||||
position: relative;
|
||||
|
||||
&:first-child {
|
||||
@@ -134,11 +139,11 @@
|
||||
}
|
||||
|
||||
&.unread--toast {
|
||||
+.right {
|
||||
+ .right {
|
||||
margin-bottom: var(--space-micro);
|
||||
}
|
||||
|
||||
+.left {
|
||||
+ .left {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -165,9 +170,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&.left {
|
||||
|
||||
.bubble {
|
||||
@include border-normal;
|
||||
background: $white;
|
||||
@@ -198,10 +201,9 @@
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+.right {
|
||||
+ .right {
|
||||
margin-top: $space-one;
|
||||
|
||||
.bubble {
|
||||
@@ -209,8 +211,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
+.unread--toast {
|
||||
+.right {
|
||||
+ .unread--toast {
|
||||
+ .right {
|
||||
margin-top: $space-one;
|
||||
|
||||
.bubble {
|
||||
@@ -218,7 +220,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
+.left {
|
||||
+ .left {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -264,7 +266,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
+.left {
|
||||
+ .left {
|
||||
margin-top: $space-one;
|
||||
|
||||
.bubble {
|
||||
@@ -272,8 +274,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
+.unread--toast {
|
||||
+.left {
|
||||
+ .unread--toast {
|
||||
+ .left {
|
||||
margin-top: $space-one;
|
||||
|
||||
.bubble {
|
||||
@@ -281,11 +283,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
+.right {
|
||||
+ .right {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&.center {
|
||||
@@ -293,10 +294,9 @@
|
||||
}
|
||||
|
||||
.wrap {
|
||||
@include margin($zero $space-normal);
|
||||
|
||||
--bubble-max-width: 49.6rem;
|
||||
max-width: Min(var(--bubble-max-width), 85%);
|
||||
margin: $zero $space-normal;
|
||||
max-width: Min(var(--bubble-max-width), 84%);
|
||||
|
||||
.sender--name {
|
||||
font-size: $font-size-mini;
|
||||
@@ -320,7 +320,8 @@
|
||||
font-size: var(--font-size-small);
|
||||
justify-content: center;
|
||||
margin: var(--space-smaller) 0;
|
||||
padding: var(--space-smaller) var(--space-micro) var(--space-smaller) var(--space-one);
|
||||
padding: var(--space-smaller) var(--space-micro) var(--space-smaller)
|
||||
var(--space-one);
|
||||
|
||||
.is-text {
|
||||
display: inline-flex;
|
||||
@@ -371,7 +372,6 @@
|
||||
}
|
||||
|
||||
.left .bubble .text-content {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -400,7 +400,6 @@
|
||||
}
|
||||
|
||||
.right .bubble .text-content {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
// scss-lint:disable QualifyingElement
|
||||
|
||||
.error {
|
||||
#{$all-text-inputs},
|
||||
input[type='color'],
|
||||
input[type='date'],
|
||||
input[type='datetime'],
|
||||
input[type='datetime-local'],
|
||||
input[type='email'],
|
||||
input[type='month'],
|
||||
input[type='number'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='tel'],
|
||||
input[type='text'],
|
||||
input[type='time'],
|
||||
input[type='url'],
|
||||
input[type='week'],
|
||||
input:not([type]),
|
||||
textarea,
|
||||
select,
|
||||
.multiselect > .multiselect__tags {
|
||||
@include thin-border(var(--r-400));
|
||||
|
||||
@@ -30,11 +30,9 @@
|
||||
.login-box {
|
||||
@include background-white;
|
||||
@include border-normal;
|
||||
@include border-top-radius($space-smaller);
|
||||
@include border-right-radius($space-smaller);
|
||||
@include border-bottom-radius($space-smaller);
|
||||
@include border-left-radius($space-smaller);
|
||||
@include elegant-card;
|
||||
|
||||
border-radius: $space-smaller;
|
||||
padding: $space-large;
|
||||
|
||||
label {
|
||||
@@ -61,7 +59,7 @@
|
||||
font-size: $font-size-default;
|
||||
padding: $space-medium;
|
||||
|
||||
>a {
|
||||
> a {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
.page-top-bar {
|
||||
@include padding($space-large $space-large $zero);
|
||||
padding: $space-large $space-large $zero;
|
||||
|
||||
img {
|
||||
max-height: 6rem;
|
||||
@@ -53,8 +53,8 @@
|
||||
}
|
||||
|
||||
.content-box {
|
||||
@include padding($zero);
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -64,29 +64,29 @@
|
||||
}
|
||||
|
||||
p {
|
||||
@include margin($zero);
|
||||
@include padding($zero);
|
||||
font-size: $font-size-small;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include padding($space-large);
|
||||
padding: $space-large;
|
||||
}
|
||||
|
||||
form,
|
||||
.modal-content {
|
||||
@include padding($space-large);
|
||||
align-self: center;
|
||||
padding: $space-large;
|
||||
|
||||
a {
|
||||
@include padding($space-normal);
|
||||
padding: $space-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@include flex;
|
||||
@include flex-align($x: flex-start, $y: middle);
|
||||
@include padding($space-small $zero);
|
||||
padding: $space-small $zero;
|
||||
|
||||
button {
|
||||
font-size: $font-size-small;
|
||||
@@ -98,10 +98,10 @@
|
||||
}
|
||||
|
||||
.delete-item {
|
||||
@include padding($space-large);
|
||||
padding: $space-large;
|
||||
|
||||
button {
|
||||
@include margin($zero);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
.reply-box {
|
||||
transition: box-shadow .35s $swift-ease-out-function,
|
||||
transition: box-shadow 0.35s $swift-ease-out-function,
|
||||
height 2s $swift-ease-out-function;
|
||||
|
||||
|
||||
&.is-focused {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
|
||||
.icon {
|
||||
color: $medium-gray;
|
||||
cursor: pointer;
|
||||
@@ -20,7 +18,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.attachment {
|
||||
cursor: pointer;
|
||||
margin-right: $space-one;
|
||||
@@ -37,13 +34,12 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
>textarea {
|
||||
> textarea {
|
||||
@include ghost-input();
|
||||
@include margin(0);
|
||||
background: transparent;
|
||||
// Override min-height : 50px in foundation
|
||||
//
|
||||
margin: 0;
|
||||
max-height: $space-mega * 2.4;
|
||||
// Override min-height : 50px in foundation
|
||||
min-height: 4.8rem;
|
||||
padding: var(--space-normal) 0 0;
|
||||
resize: none;
|
||||
@@ -56,10 +52,9 @@
|
||||
.reply-box__top {
|
||||
background: var(--y-50);
|
||||
|
||||
>input {
|
||||
> input {
|
||||
background: var(--y-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
.report-card {
|
||||
@include padding($space-normal $space-small $space-normal $space-two);
|
||||
@include margin($zero);
|
||||
cursor: pointer;
|
||||
@include custom-border-top(3px, transparent);
|
||||
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: $space-normal $space-small $space-normal $space-two;
|
||||
|
||||
&.active {
|
||||
@include custom-border-top(3px, $color-woot);
|
||||
@include background-white;
|
||||
@@ -14,12 +15,12 @@
|
||||
}
|
||||
|
||||
.heading {
|
||||
@include margin($zero);
|
||||
font-size: $font-size-small;
|
||||
font-weight: $font-weight-bold;
|
||||
align-items: center;
|
||||
color: $color-heading;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: $font-size-small;
|
||||
font-weight: $font-weight-bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
@@ -52,31 +53,31 @@
|
||||
}
|
||||
|
||||
.desc {
|
||||
@include margin($zero);
|
||||
font-size: $font-size-small;
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.report-bar {
|
||||
@include margin(-1px $zero);
|
||||
@include background-white;
|
||||
@include border-light;
|
||||
@include padding($space-small $space-medium);
|
||||
margin: -1px $zero;
|
||||
padding: $space-small $space-medium;
|
||||
|
||||
.chart-container {
|
||||
@include flex;
|
||||
flex-direction: column;
|
||||
@include flex-align(center, middle);
|
||||
flex-direction: column;
|
||||
|
||||
div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@include margin($space-jumbo);
|
||||
font-size: $font-size-default;
|
||||
color: $color-gray;
|
||||
font-size: $font-size-default;
|
||||
margin: $space-jumbo;
|
||||
}
|
||||
|
||||
.business-hours {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
.search {
|
||||
@include flex;
|
||||
@include flex-align($x: left, $y: middle);
|
||||
@include padding($space-one $space-normal);
|
||||
@include flex-shrink;
|
||||
transition: all .3s $ease-in-out-quad;
|
||||
|
||||
padding: $space-one $space-normal;
|
||||
transition: all 0.3s var(--ease-in-out-quad);
|
||||
|
||||
> .icon {
|
||||
font-size: $font-size-medium;
|
||||
color: $medium-gray;
|
||||
font-size: $font-size-medium;
|
||||
}
|
||||
|
||||
> input {
|
||||
@include ghost-input();
|
||||
@include margin(0);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
//logo
|
||||
.logo {
|
||||
img {
|
||||
@include padding($woot-logo-padding);
|
||||
max-height: 108px;
|
||||
padding: $woot-logo-padding;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
.bottom-nav {
|
||||
@include flex;
|
||||
@include space-between-column;
|
||||
@include padding($space-one $space-normal $space-one $space-one);
|
||||
@include border-normal-top;
|
||||
flex-direction: column;
|
||||
padding: $space-one $space-normal $space-one $space-one;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
}
|
||||
|
||||
.ui-snackbar {
|
||||
@include padding($space-slab $space-medium);
|
||||
@include shadow;
|
||||
background-color: $woot-snackbar-bg;
|
||||
border-radius: $space-smaller;
|
||||
@@ -20,6 +19,7 @@
|
||||
max-width: 40rem;
|
||||
min-height: 3rem;
|
||||
min-width: 24rem;
|
||||
padding: $space-slab $space-medium;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
padding-left: 3rem;
|
||||
|
||||
button {
|
||||
@include margin(0);
|
||||
@include padding(0);
|
||||
background: none;
|
||||
border: 0;
|
||||
color: $woot-snackbar-button;
|
||||
font-size: $font-size-small;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
.status-bar {
|
||||
@include flex;
|
||||
flex-direction: column;
|
||||
@include flex-align($x: center, $y: middle);
|
||||
background: lighten($warning-color, 36%);
|
||||
// @include elegant-card();
|
||||
@include margin($zero);
|
||||
@include padding($space-normal $space-smaller);
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: $space-normal $space-smaller;
|
||||
|
||||
.message {
|
||||
font-weight: $font-weight-medium;
|
||||
@@ -13,7 +12,7 @@
|
||||
}
|
||||
|
||||
.button {
|
||||
@include margin($space-smaller $zero $zero);
|
||||
margin: $space-smaller $zero $zero;
|
||||
padding: $space-small $space-normal;
|
||||
}
|
||||
|
||||
@@ -23,14 +22,18 @@
|
||||
.button {
|
||||
// Default and disabled states
|
||||
&,
|
||||
&.disabled, &[disabled],
|
||||
&.disabled:hover, &[disabled]:hover,
|
||||
&.disabled:focus, &[disabled]:focus {
|
||||
&.disabled,
|
||||
&[disabled],
|
||||
&.disabled:hover,
|
||||
&[disabled]:hover,
|
||||
&.disabled:focus,
|
||||
&[disabled]:focus {
|
||||
background-color: $alert-color;
|
||||
color: $color-white;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: darken($alert-color, 7%);
|
||||
color: $color-white;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@include padding($zero $space-normal);
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
border-top-width: 0;
|
||||
display: flex;
|
||||
min-width: var(--space-mega);
|
||||
padding: $zero $space-normal;
|
||||
}
|
||||
|
||||
.tabs--with-scroll {
|
||||
@@ -46,8 +46,8 @@
|
||||
}
|
||||
|
||||
.tabs-title {
|
||||
@include margin($zero $space-slab);
|
||||
flex-shrink: 0;
|
||||
margin: $zero $space-slab;
|
||||
|
||||
.badge {
|
||||
background: $color-background;
|
||||
@@ -75,14 +75,15 @@
|
||||
}
|
||||
|
||||
a {
|
||||
@include position(relative, 1px null null null);
|
||||
align-items: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: $medium-gray;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: $font-size-small;
|
||||
transition: border-color .15s $swift-ease-out-function;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
transition: border-color 0.15s $swift-ease-out-function;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
|
||||
@@ -15,7 +15,7 @@ table {
|
||||
}
|
||||
|
||||
td {
|
||||
@include padding($space-one $space-small);
|
||||
padding: $space-one $space-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,6 @@ table {
|
||||
}
|
||||
|
||||
.button {
|
||||
@include margin($zero);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="conversations-list-wrap">
|
||||
<div
|
||||
class="conversations-list-wrap"
|
||||
:class="{
|
||||
hide: !showConversationList,
|
||||
'list--full-width': isOnExpandedLayout,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
class="chat-list__top"
|
||||
@@ -46,7 +52,7 @@
|
||||
|
||||
<woot-button
|
||||
v-else
|
||||
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
|
||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="filter"
|
||||
@@ -97,7 +103,11 @@
|
||||
@update-conversations="onUpdateConversations"
|
||||
@assign-labels="onAssignLabels"
|
||||
/>
|
||||
<div ref="activeConversation" class="conversations-list">
|
||||
<div
|
||||
ref="activeConversation"
|
||||
class="conversations-list"
|
||||
:class="{ 'is-context-menu-open': isContextMenuOpen }"
|
||||
>
|
||||
<conversation-card
|
||||
v-for="chat in conversationList"
|
||||
:key="chat.id"
|
||||
@@ -110,6 +120,11 @@
|
||||
:selected="isConversationSelected(chat.id)"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-team="onAssignTeam"
|
||||
@assign-label="onAssignLabels"
|
||||
@update-conversation-status="toggleConversationStatus"
|
||||
@context-menu-toggle="onContextMenuToggle"
|
||||
/>
|
||||
|
||||
<div v-if="chatListLoading" class="text-center">
|
||||
@@ -202,6 +217,14 @@ export default {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showConversationList: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -217,6 +240,7 @@ export default {
|
||||
showDeleteFoldersModal: false,
|
||||
selectedConversations: [],
|
||||
selectedInboxes: [],
|
||||
isContextMenuOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -584,32 +608,79 @@ export default {
|
||||
this.resetBulkActions();
|
||||
}
|
||||
},
|
||||
async onAssignAgent(agent) {
|
||||
// Same method used in context menu, conversationId being passed from there.
|
||||
async onAssignAgent(agent, conversationId = null) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
ids: conversationId || this.selectedConversations,
|
||||
fields: {
|
||||
assignee_id: agent.id,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
|
||||
if (conversationId) {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'CONVERSATION.CARD_CONTEXT_MENU.API.AGENT_ASSIGNMENT.SUCCESFUL',
|
||||
{
|
||||
agentName: agent.name,
|
||||
conversationId,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
|
||||
}
|
||||
},
|
||||
async onAssignLabels(labels) {
|
||||
async onAssignTeam(team, conversationId = null) {
|
||||
try {
|
||||
await this.$store.dispatch('assignTeam', {
|
||||
conversationId,
|
||||
teamId: team.id,
|
||||
});
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL',
|
||||
{
|
||||
team: team.name,
|
||||
conversationId,
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED')
|
||||
);
|
||||
}
|
||||
},
|
||||
// Same method used in context menu, conversationId being passed from there.
|
||||
async onAssignLabels(labels, conversationId = null) {
|
||||
try {
|
||||
await this.$store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: this.selectedConversations,
|
||||
ids: conversationId || this.selectedConversations,
|
||||
labels: {
|
||||
add: labels,
|
||||
},
|
||||
});
|
||||
this.selectedConversations = [];
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
|
||||
if (conversationId) {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_ASSIGNMENT.SUCCESFUL',
|
||||
{
|
||||
labelName: labels[0],
|
||||
conversationId,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
|
||||
}
|
||||
@@ -629,12 +700,27 @@ export default {
|
||||
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
},
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil) {
|
||||
this.$store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
allSelectedConversationsStatus(status) {
|
||||
if (!this.selectedConversations.length) return false;
|
||||
return this.selectedConversations.every(item => {
|
||||
return this.$store.getters.getConversationById(item).status === status;
|
||||
});
|
||||
},
|
||||
onContextMenuToggle(state) {
|
||||
this.isContextMenuOpen = state;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -647,6 +733,13 @@ export default {
|
||||
margin-bottom: var(--space-normal);
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
// Prevent the list from scrolling if the submenu is opened
|
||||
&.is-context-menu-open {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 34rem;
|
||||
@@ -663,6 +756,17 @@ export default {
|
||||
@include breakpoint(xxxlarge up) {
|
||||
flex-basis: 46rem;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.list--full-width {
|
||||
width: 100%;
|
||||
@include breakpoint(xxxlarge up) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter--actions {
|
||||
display: flex;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<script>
|
||||
import 'highlight.js/styles/default.css';
|
||||
import copy from 'copy-text-to-clipboard';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -23,9 +23,9 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCopy(e) {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
copy(this.script);
|
||||
await copyTextToClipboard(this.script);
|
||||
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,33 +12,26 @@
|
||||
</template>
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import LocalStorage from '../../helper/localStorage';
|
||||
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage';
|
||||
import { mapGetters } from 'vuex';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
|
||||
const semver = require('semver');
|
||||
const dismissedUpdates = new LocalStorage('dismissedUpdates');
|
||||
import { hasAnUpdateAvailable } from './versionCheckHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
},
|
||||
components: { Banner },
|
||||
mixins: [adminMixin],
|
||||
props: {
|
||||
latestChatwootVersion: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
latestChatwootVersion: { type: String, default: '' },
|
||||
},
|
||||
data() {
|
||||
return { userDismissedBanner: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
hasAnUpdateAvailable() {
|
||||
if (!semver.valid(this.latestChatwootVersion)) {
|
||||
return false;
|
||||
}
|
||||
return semver.lt(
|
||||
this.globalConfig.appVersion,
|
||||
this.latestChatwootVersion
|
||||
updateAvailable() {
|
||||
return hasAnUpdateAvailable(
|
||||
this.latestChatwootVersion,
|
||||
this.globalConfig.appVersion
|
||||
);
|
||||
},
|
||||
bannerMessage() {
|
||||
@@ -48,8 +41,9 @@ export default {
|
||||
},
|
||||
shouldShowBanner() {
|
||||
return (
|
||||
!this.userDismissedBanner &&
|
||||
this.globalConfig.displayManifest &&
|
||||
this.hasAnUpdateAvailable &&
|
||||
this.updateAvailable &&
|
||||
!this.isVersionNotificationDismissed(this.latestChatwootVersion) &&
|
||||
this.isAdmin
|
||||
);
|
||||
@@ -57,17 +51,23 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
isVersionNotificationDismissed(version) {
|
||||
return dismissedUpdates.get().includes(version);
|
||||
const dismissedVersions =
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_UPDATES) || [];
|
||||
return dismissedVersions.includes(version);
|
||||
},
|
||||
dismissUpdateBanner() {
|
||||
let updatedDismissedItems = dismissedUpdates.get();
|
||||
let updatedDismissedItems =
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_UPDATES) || [];
|
||||
if (updatedDismissedItems instanceof Array) {
|
||||
updatedDismissedItems.push(this.latestChatwootVersion);
|
||||
} else {
|
||||
updatedDismissedItems = [this.latestChatwootVersion];
|
||||
}
|
||||
dismissedUpdates.store(updatedDismissedItems);
|
||||
this.latestChatwootVersion = this.globalConfig.appVersion;
|
||||
LocalStorage.set(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_UPDATES,
|
||||
updatedDismissedItems
|
||||
);
|
||||
this.userDismissedBanner = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { hasAnUpdateAvailable } from '../versionCheckHelper';
|
||||
|
||||
describe('#hasAnUpdateAvailable', () => {
|
||||
it('return false if latest version is invalid', () => {
|
||||
expect(hasAnUpdateAvailable('invalid', '1.0.0')).toBe(false);
|
||||
expect(hasAnUpdateAvailable(null, '1.0.0')).toBe(false);
|
||||
expect(hasAnUpdateAvailable(undefined, '1.0.0')).toBe(false);
|
||||
expect(hasAnUpdateAvailable('', '1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('return correct value if latest version is valid', () => {
|
||||
expect(hasAnUpdateAvailable('1.1.0', '1.0.0')).toBe(true);
|
||||
expect(hasAnUpdateAvailable('0.1.0', '1.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const semver = require('semver');
|
||||
|
||||
export const hasAnUpdateAvailable = (latestVersion, currentVersion) => {
|
||||
if (!semver.valid(latestVersion)) {
|
||||
return false;
|
||||
}
|
||||
return semver.lt(currentVersion, latestVersion);
|
||||
};
|
||||
@@ -113,6 +113,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin.js';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
hasPressedAltAndEKey,
|
||||
@@ -126,13 +127,6 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
|
||||
import wootConstants from '../../constants';
|
||||
import {
|
||||
getUnixTime,
|
||||
addHours,
|
||||
addWeeks,
|
||||
startOfTomorrow,
|
||||
startOfWeek,
|
||||
} from 'date-fns';
|
||||
import {
|
||||
CMD_REOPEN_CONVERSATION,
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
@@ -146,7 +140,7 @@ export default {
|
||||
WootDropdownSubMenu,
|
||||
WootDropdownDivider,
|
||||
},
|
||||
mixins: [clickaway, alertMixin, eventListenerMixins],
|
||||
mixins: [clickaway, alertMixin, eventListenerMixins, snoozeTimesMixin],
|
||||
props: { conversationId: { type: [String, Number], required: true } },
|
||||
data() {
|
||||
return {
|
||||
@@ -178,16 +172,6 @@ export default {
|
||||
showAdditionalActions() {
|
||||
return !this.isPending && !this.isSnoozed;
|
||||
},
|
||||
snoozeTimes() {
|
||||
return {
|
||||
// tomorrow = 9AM next day
|
||||
tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)),
|
||||
// next week = 9AM Monday, next week
|
||||
nextWeek: getUnixTime(
|
||||
addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9)
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import EditArticle from './EditArticle.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Help Center',
|
||||
component: EditArticle,
|
||||
argTypes: {
|
||||
article: {
|
||||
defaultValue: {},
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { EditArticle },
|
||||
template:
|
||||
'<edit-article v-bind="$props" @focus="onFocus" @blur="onBlur"></edit-article>',
|
||||
});
|
||||
|
||||
export const EditArticleView = Template.bind({});
|
||||
EditArticleView.args = {
|
||||
article: {
|
||||
id: '1',
|
||||
title: 'Lorem ipsum',
|
||||
content:
|
||||
'L**orem ipsum** dolor sit amet, consectetur adipiscing elit. Congue diam orci tellus *varius per cras turpis aliquet commodo dolor justo* rutrum lorem venenatis aliquet orci curae hac. Sagittis ultrices felis **`ante placerat condimentum parturient erat consequat`** sollicitudin *sagittis potenti sollicitudin* quis velit at placerat mi torquent. Dignissim luctus nulla suspendisse purus cras commodo ipsum orci tempus morbi metus conubia et hac potenti quam suspendisse feugiat. Turpis eros dictum tellus natoque laoreet lacus dolor cras interdum **vitae gravida tincidunt ultricies tempor convallis tortor rhoncus suspendisse.** Nisi lacinia etiam vivamus tellus sed taciti potenti quam praesent congue euismod mauris est eu risus convallis taciti etiam. Inceptos iaculis turpis leo porta pellentesque dictum `bibendum blandit parturient nulla leo pretium` rhoncus litora dapibus fringilla hac litora.',
|
||||
},
|
||||
onFocus: action('focus'),
|
||||
onBlur: action('blur'),
|
||||
};
|
||||
113
app/javascript/dashboard/components/helpCenter/EditArticle.vue
Normal file
113
app/javascript/dashboard/components/helpCenter/EditArticle.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div
|
||||
class="edit-article--container"
|
||||
:class="{ 'is-settings-sidebar-open': isSettingsSidebarOpen }"
|
||||
>
|
||||
<input
|
||||
v-model="articleTitle"
|
||||
type="text"
|
||||
class="article-heading"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onTitleInput"
|
||||
/>
|
||||
<woot-message-editor
|
||||
v-model="articleContent"
|
||||
class="article-content"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.CONTENT_PLACEHOLDER')"
|
||||
:is-format-mode="true"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onContentInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
export default {
|
||||
components: {
|
||||
WootMessageEditor,
|
||||
},
|
||||
props: {
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isSettingsSidebarOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
articleTitle: '',
|
||||
articleContent: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.articleTitle = this.article.title;
|
||||
this.articleContent = this.article.content;
|
||||
},
|
||||
methods: {
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
onBlur() {
|
||||
this.$emit('blur');
|
||||
},
|
||||
onTitleInput() {
|
||||
this.$emit('titleInput', this.articleTitle);
|
||||
},
|
||||
onContentInput() {
|
||||
this.$emit('contentInput', this.articleContent);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-article--container {
|
||||
margin: var(--space-large) auto;
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
.is-settings-sidebar-open {
|
||||
margin: var(--space-large) var(--space-small);
|
||||
}
|
||||
|
||||
.article-heading {
|
||||
font-size: var(--font-size-giga);
|
||||
font-weight: var(--font-weight-bold);
|
||||
min-height: var(--space-jumbo);
|
||||
max-height: var(--space-jumbo);
|
||||
border: 0px solid transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-menubar .ProseMirror-menuitem {
|
||||
.ProseMirror-icon {
|
||||
margin-right: var(--space-normal);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: var(--space-giga);
|
||||
max-height: 100%;
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-default);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
font-size: var(--font-size-default);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import Tabs from './ui/Tabs/Tabs';
|
||||
import TabsItem from './ui/Tabs/TabsItem';
|
||||
import Thumbnail from './widgets/Thumbnail.vue';
|
||||
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
|
||||
import ContextMenu from './ui/ContextMenu.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
AvatarUploader,
|
||||
@@ -47,6 +48,7 @@ const WootUIKit = {
|
||||
TabsItem,
|
||||
Thumbnail,
|
||||
ConfirmModal,
|
||||
ContextMenu,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<primary-sidebar
|
||||
:logo-source="globalConfig.logoThumbnail"
|
||||
:installation-name="globalConfig.installationName"
|
||||
:is-a-custom-branded-instance="isACustomBrandedInstance"
|
||||
:account-id="accountId"
|
||||
:menu-items="primaryMenuItems"
|
||||
:active-menu-item="activePrimaryMenu.key"
|
||||
@@ -19,6 +20,7 @@
|
||||
:custom-views="customViews"
|
||||
:menu-config="activeSecondaryMenu"
|
||||
:current-role="currentRole"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@add-label="showAddLabelPopup"
|
||||
@toggle-accounts="toggleAccountModal"
|
||||
/>
|
||||
@@ -67,6 +69,8 @@ export default {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
accountId: 'getCurrentAccountId',
|
||||
currentRole: 'getCurrentRole',
|
||||
|
||||
@@ -30,6 +30,7 @@ const settings = accountId => ({
|
||||
'settings_teams_edit',
|
||||
'settings_teams_edit_members',
|
||||
'settings_teams_edit_finish',
|
||||
'billing_settings_index',
|
||||
'automation_list',
|
||||
],
|
||||
menuItems: [
|
||||
@@ -100,6 +101,14 @@ const settings = accountId => ({
|
||||
toState: frontendURL(`accounts/${accountId}/settings/applications`),
|
||||
toStateName: 'settings_applications',
|
||||
},
|
||||
{
|
||||
icon: 'credit-card-person',
|
||||
label: 'BILLING',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/billing`),
|
||||
toStateName: 'billing_settings_index',
|
||||
showOnlyOnCloud: true,
|
||||
},
|
||||
{
|
||||
icon: 'settings',
|
||||
label: 'ACCOUNT_SETTINGS',
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
/>
|
||||
</nav>
|
||||
<div class="menu vertical user-menu">
|
||||
<primary-nav-item
|
||||
v-if="!isACustomBrandedInstance"
|
||||
icon="book-open-globe"
|
||||
name="DOCS"
|
||||
:open-in-new-page="true"
|
||||
:to="helpDocsURL"
|
||||
/>
|
||||
<notification-bell @open-notification-panel="openNotificationPanel" />
|
||||
<agent-details @toggle-menu="toggleOptions" />
|
||||
<options-menu
|
||||
@@ -34,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
|
||||
import OptionsMenu from './OptionsMenu';
|
||||
import AgentDetails from './AgentDetails';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
import wootConstants from 'dashboard/constants';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
@@ -46,6 +53,10 @@ export default {
|
||||
NotificationBell,
|
||||
},
|
||||
props: {
|
||||
isACustomBrandedInstance: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
logoSource: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -69,6 +80,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
helpDocsURL: wootConstants.DOCS_URL,
|
||||
showOptionsMenu: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
:href="href"
|
||||
class="button clear button--only-icon menu-item"
|
||||
:class="{ 'is-active': isActive || isChildMenuActive }"
|
||||
:rel="openInNewPage ? 'noopener noreferrer nofollow' : undefined"
|
||||
:target="openInNewPage ? '_blank' : undefined"
|
||||
@click="navigate"
|
||||
>
|
||||
<fluent-icon :icon="icon" />
|
||||
@@ -36,6 +38,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openInNewPage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -55,6 +55,10 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnChatwootCloud: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasSecondaryMenu() {
|
||||
@@ -67,12 +71,18 @@ export default {
|
||||
if (!this.currentRole) {
|
||||
return [];
|
||||
}
|
||||
return this.menuConfig.menuItems.filter(
|
||||
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
|
||||
menuItem =>
|
||||
window.roleWiseRoutes[this.currentRole].indexOf(
|
||||
menuItem.toStateName
|
||||
) > -1
|
||||
);
|
||||
return menuItemsFilteredByRole.filter(item => {
|
||||
if (item.showOnlyOnCloud) {
|
||||
return this.isOnChatwootCloud;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
inboxSection() {
|
||||
return {
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
</span>
|
||||
<div v-if="isHelpCenterSidebar" class="submenu-icons">
|
||||
<fluent-icon icon="search" class="submenu-icon" size="16" />
|
||||
<fluent-icon icon="add" class="submenu-icon" size="16" />
|
||||
<div class="submenu-icon">
|
||||
<fluent-icon icon="search" size="16" />
|
||||
</div>
|
||||
<div class="submenu-icon" @click="onClickOpen">
|
||||
<fluent-icon icon="add" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
@@ -71,6 +75,9 @@
|
||||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
|
||||
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
|
||||
</p>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -98,6 +105,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isCategoryEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ activeInbox: 'getSelectedInbox' }),
|
||||
@@ -134,6 +145,9 @@ export default {
|
||||
this.menuItem.toStateName === 'settings_applications'
|
||||
);
|
||||
},
|
||||
isArticlesView() {
|
||||
return this.$store.state.route.name === this.menuItem.toStateName;
|
||||
},
|
||||
|
||||
computedClass() {
|
||||
// If active Inbox is present
|
||||
@@ -151,6 +165,12 @@ export default {
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
if (this.isHelpCenterSidebar) {
|
||||
if (this.isArticlesView) {
|
||||
return 'is-active';
|
||||
}
|
||||
return ' ';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
@@ -183,6 +203,9 @@ export default {
|
||||
showItem(item) {
|
||||
return this.isAdmin && item.newLink !== undefined;
|
||||
},
|
||||
onClickOpen() {
|
||||
this.$emit('open');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -312,4 +335,10 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-small);
|
||||
margin: var(--space-smaller) 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
88
app/javascript/dashboard/components/ui/AnnouncementPopup.vue
Normal file
88
app/javascript/dashboard/components/ui/AnnouncementPopup.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="announcement-popup">
|
||||
<span v-if="popupMessage" class="popup-content">
|
||||
{{ popupMessage }}
|
||||
<span v-if="routeText" class="route-url" @click="onClickOpenPath">
|
||||
{{ routeText }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="hasCloseButton" class="popup-close">
|
||||
<woot-button
|
||||
v-if="hasCloseButton"
|
||||
color-scheme="primary"
|
||||
variant="link"
|
||||
size="small"
|
||||
@click="onClickClose"
|
||||
>
|
||||
{{ closeButtonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
popupMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
routeText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasCloseButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
closeButtonText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClickOpenPath() {
|
||||
this.$emit('open');
|
||||
},
|
||||
onClickClose(e) {
|
||||
this.$emit('close', e);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.announcement-popup {
|
||||
max-width: 24rem;
|
||||
min-width: 16rem;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: fit-content;
|
||||
background: var(--white);
|
||||
padding: 0 var(--space-normal);
|
||||
z-index: var(--z-index-much-higher);
|
||||
box-shadow: var(--b-200) 4px 4px 16px 4px;
|
||||
border-radius: var(--border-radius-normal);
|
||||
|
||||
.popup-content {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--s-700);
|
||||
padding: var(--space-one) 0;
|
||||
.route-url {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--s-600);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-one) 0;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
app/javascript/dashboard/components/ui/ContextMenu.vue
Normal file
53
app/javascript/dashboard/components/ui/ContextMenu.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="show"
|
||||
ref="context"
|
||||
class="context-menu-container"
|
||||
:style="style"
|
||||
tabindex="0"
|
||||
@blur="$emit('close')"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
left: this.x,
|
||||
top: this.y,
|
||||
show: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
style() {
|
||||
return {
|
||||
top: this.top + 'px',
|
||||
left: this.left + 'px',
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => this.$el.focus());
|
||||
this.show = true;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.context-menu-container {
|
||||
position: fixed;
|
||||
z-index: var(--z-index-very-high);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import WootAnnouncementPopup from '../AnnouncementPopup.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components/Popup/Announcement Popup',
|
||||
argTypes: {
|
||||
popupMessage: {
|
||||
defaultValue:
|
||||
'Now a new key shortcut (⌘ + ↵) is available to send messages. You can enable it in the',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
routeText: {
|
||||
defaultValue: 'profile settings',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
hasCloseButton: {
|
||||
defaultValue: true,
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
closeButtonText: {
|
||||
defaultValue: 'Got it',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { WootAnnouncementPopup },
|
||||
template:
|
||||
'<woot-announcement-popup v-bind="$props" @open="onClickOpenPath" @close="onClickClose"></woot-announcement-popup>',
|
||||
});
|
||||
|
||||
export const AnnouncementPopup = Template.bind({});
|
||||
AnnouncementPopup.args = {
|
||||
onClickOpenPath: action('opened path'),
|
||||
onClickClose: action('closed the popup'),
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:username="username"
|
||||
:username="userNameWithoutEmoji"
|
||||
:class="thumbnailClass"
|
||||
:size="avatarSize"
|
||||
:variant="variant"
|
||||
@@ -86,6 +86,7 @@
|
||||
* Username - User name for avatar
|
||||
*/
|
||||
import Avatar from './Avatar';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -131,6 +132,9 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userNameWithoutEmoji() {
|
||||
return removeEmoji(this.username);
|
||||
},
|
||||
showStatusIndicator() {
|
||||
if (this.shouldShowStatusAlways) return true;
|
||||
return this.status === 'online' || this.status === 'busy';
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<template>
|
||||
<div class="conversation-details-wrap">
|
||||
<div
|
||||
class="conversation-details-wrap"
|
||||
:class="{ 'with-border-left': !isOnExpandedLayout }"
|
||||
>
|
||||
<conversation-header
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:show-back-button="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<woot-tabs
|
||||
@@ -26,7 +30,7 @@
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<empty-state v-else />
|
||||
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" />
|
||||
<div v-show="showContactPanel" class="conversation-sidebar-wrap">
|
||||
<contact-panel
|
||||
v-if="showContactPanel"
|
||||
@@ -71,6 +75,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { activeIndex: 0 };
|
||||
@@ -134,8 +142,11 @@ export default {
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-background-light);
|
||||
|
||||
&.with-border-left {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-app--tabs {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@mouseenter="onCardHover"
|
||||
@mouseleave="onCardLeave"
|
||||
@click="cardClick(chat)"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
|
||||
<input
|
||||
@@ -91,6 +92,22 @@
|
||||
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<woot-context-menu
|
||||
v-if="showContextMenu"
|
||||
ref="menu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<conversation-context-menu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
/>
|
||||
</woot-context-menu>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
@@ -104,6 +121,8 @@ import router from '../../../routes';
|
||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||
import InboxName from '../InboxName';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
const ATTACHMENT_ICONS = {
|
||||
image: 'image',
|
||||
@@ -118,9 +137,16 @@ export default {
|
||||
components: {
|
||||
InboxName,
|
||||
Thumbnail,
|
||||
ConversationContextMenu,
|
||||
},
|
||||
|
||||
mixins: [inboxMixin, timeMixin, conversationMixin, messageFormatterMixin],
|
||||
mixins: [
|
||||
inboxMixin,
|
||||
timeMixin,
|
||||
conversationMixin,
|
||||
messageFormatterMixin,
|
||||
alertMixin,
|
||||
],
|
||||
props: {
|
||||
activeLabel: {
|
||||
type: String,
|
||||
@@ -162,6 +188,11 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
hovered: false,
|
||||
showContextMenu: false,
|
||||
contextMenu: {
|
||||
x: null,
|
||||
y: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -292,6 +323,40 @@ export default {
|
||||
const action = checked ? 'select-conversation' : 'de-select-conversation';
|
||||
this.$emit(action, this.chat.id, this.inbox.id);
|
||||
},
|
||||
openContextMenu(e) {
|
||||
e.preventDefault();
|
||||
this.$emit('context-menu-toggle', true);
|
||||
this.contextMenu.x = e.pageX || e.clientX;
|
||||
this.contextMenu.y = e.pageY || e.clientY;
|
||||
this.showContextMenu = true;
|
||||
},
|
||||
closeContextMenu() {
|
||||
this.$emit('context-menu-toggle', false);
|
||||
this.showContextMenu = false;
|
||||
this.contextMenu.x = null;
|
||||
this.contextMenu.y = null;
|
||||
},
|
||||
onUpdateConversation(status, snoozedUntil) {
|
||||
this.closeContextMenu();
|
||||
this.$emit(
|
||||
'update-conversation-status',
|
||||
this.chat.id,
|
||||
status,
|
||||
snoozedUntil
|
||||
);
|
||||
},
|
||||
async onAssignAgent(agent) {
|
||||
this.$emit('assign-agent', agent, [this.chat.id]);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async onAssignLabel(label) {
|
||||
this.$emit('assign-label', [label.title], [this.chat.id]);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async onAssignTeam(team) {
|
||||
this.$emit('assign-team', team, this.chat.id);
|
||||
this.closeContextMenu();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="conv-header">
|
||||
<div class="user">
|
||||
<back-button v-if="showBackButton" :back-url="backButtonUrl" />
|
||||
<Thumbnail
|
||||
:src="currentContact.thumbnail"
|
||||
size="40px"
|
||||
@@ -47,19 +48,21 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import { mapGetters } from 'vuex';
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton';
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import wootConstants from '../../../constants';
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import InboxName from '../InboxName';
|
||||
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import wootConstants from '../../../constants';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
export default {
|
||||
components: {
|
||||
BackButton,
|
||||
InboxName,
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
@@ -74,6 +77,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -83,6 +90,19 @@ export default {
|
||||
chatMetadata() {
|
||||
return this.chat.meta;
|
||||
},
|
||||
backButtonUrl() {
|
||||
const {
|
||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
||||
name,
|
||||
} = this.$route;
|
||||
return conversationListPageURL({
|
||||
accountId,
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
||||
});
|
||||
},
|
||||
isHMACVerified() {
|
||||
if (!this.isAWebWidgetInbox) {
|
||||
return true;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<!-- No conversation selected -->
|
||||
<div v-else-if="allConversations.length && !currentChat.id">
|
||||
<img src="~dashboard/assets/images/chat.svg" alt="No Chat" />
|
||||
<span>{{ $t('CONVERSATION.404') }}</span>
|
||||
<span>{{ conversationMissingMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +51,12 @@ export default {
|
||||
OnboardingView,
|
||||
},
|
||||
mixins: [accountMixin, adminMixin],
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
@@ -65,6 +71,12 @@ export default {
|
||||
}
|
||||
return this.$t('CONVERSATION.LOADING_CONVERSATIONS');
|
||||
},
|
||||
conversationMissingMessage() {
|
||||
if (!this.isOnExpandedLayout) {
|
||||
return this.$t('CONVERSATION.SELECT_A_CONVERSATION');
|
||||
}
|
||||
return this.$t('CONVERSATION.404');
|
||||
},
|
||||
newInboxURL() {
|
||||
return this.addAccountScoping('settings/inboxes/new');
|
||||
},
|
||||
|
||||
@@ -109,8 +109,6 @@
|
||||
</li>
|
||||
</template>
|
||||
<script>
|
||||
import copy from 'copy-text-to-clipboard';
|
||||
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import timeMixin from '../../../mixins/time';
|
||||
|
||||
@@ -128,6 +126,7 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
|
||||
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -170,7 +169,7 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
contentToBeParsed() {
|
||||
emailMessageContent() {
|
||||
const {
|
||||
html_content: { full: fullHTMLContent } = {},
|
||||
text_content: { full: fullTextContent } = {},
|
||||
@@ -182,13 +181,19 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.contentToBeParsed.includes('<blockquote')) {
|
||||
if (this.emailMessageContent.includes('<blockquote')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
message() {
|
||||
// If the message is an email, emailMessageContent would be present
|
||||
// In that case, we would use letter package to render the email
|
||||
if (this.emailMessageContent && this.isIncoming) {
|
||||
return this.emailMessageContent;
|
||||
}
|
||||
|
||||
const botMessageContent = generateBotMessageContent(
|
||||
this.contentType,
|
||||
this.contentAttributes,
|
||||
@@ -200,21 +205,6 @@ export default {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
email: { content_type: contentType = '' } = {},
|
||||
} = this.contentAttributes;
|
||||
if (this.contentToBeParsed && this.isIncoming) {
|
||||
const parsedContent = this.stripStyleCharacters(this.contentToBeParsed);
|
||||
if (parsedContent) {
|
||||
// This is a temporary fix for line-breaks in text/plain emails
|
||||
// Now, It is not rendered properly in the email preview.
|
||||
// FIXME: Remove this once we have a better solution for rendering text/plain emails
|
||||
return contentType.includes('text/plain')
|
||||
? parsedContent.replace(/\n/g, '<br />')
|
||||
: parsedContent;
|
||||
}
|
||||
}
|
||||
return (
|
||||
this.formatMessage(
|
||||
this.data.content,
|
||||
@@ -331,6 +321,7 @@ export default {
|
||||
'activity-wrap': !this.isBubble,
|
||||
'is-pending': this.isPending,
|
||||
'is-failed': this.isFailed,
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
bubbleClass() {
|
||||
@@ -342,6 +333,7 @@ export default {
|
||||
'is-text': this.hasText,
|
||||
'is-from-bot': this.isSentByBot,
|
||||
'is-failed': this.isFailed,
|
||||
'is-email': this.isEmailContentType,
|
||||
};
|
||||
},
|
||||
isPending() {
|
||||
@@ -412,8 +404,8 @@ export default {
|
||||
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
|
||||
}
|
||||
},
|
||||
handleCopy() {
|
||||
copy(this.data.content);
|
||||
async handleCopy() {
|
||||
await copyTextToClipboard(this.data.content);
|
||||
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
this.showContextMenu = false;
|
||||
},
|
||||
@@ -518,6 +510,10 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.wrap.is-email {
|
||||
--bubble-max-width: 84% !important;
|
||||
}
|
||||
|
||||
.sender--info {
|
||||
align-items: center;
|
||||
color: var(--b-700);
|
||||
|
||||
@@ -87,7 +87,6 @@
|
||||
:selected-tweet="selectedTweet"
|
||||
:popout-reply-box.sync="isPopoutReplyBox"
|
||||
@click="showPopoutReplyBox"
|
||||
@scrollToMessage="scrollToBottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +126,7 @@ export default {
|
||||
background: transparent;
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-bold);
|
||||
padding-left: 0;
|
||||
}
|
||||
.input-group-field::v-deep input {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -1,132 +1,742 @@
|
||||
const languages = [
|
||||
{
|
||||
id: 'eng',
|
||||
name: 'English (en)',
|
||||
name: 'Abkhazian',
|
||||
id: 'ab',
|
||||
},
|
||||
{
|
||||
name: 'Afar',
|
||||
id: 'aa',
|
||||
},
|
||||
{
|
||||
name: 'Afrikaans',
|
||||
id: 'af',
|
||||
},
|
||||
{
|
||||
name: 'Akan',
|
||||
id: 'ak',
|
||||
},
|
||||
{
|
||||
name: 'Albanian',
|
||||
id: 'sq',
|
||||
},
|
||||
{
|
||||
name: 'Amharic',
|
||||
id: 'am',
|
||||
},
|
||||
{
|
||||
name: 'Arabic',
|
||||
id: 'ar',
|
||||
name: 'العربية (ar)',
|
||||
},
|
||||
{
|
||||
id: 'nl',
|
||||
name: 'Nederlands (nl)',
|
||||
name: 'Aragonese',
|
||||
id: 'an',
|
||||
},
|
||||
{
|
||||
id: 'fr',
|
||||
name: 'Français (fr)',
|
||||
name: 'Armenian',
|
||||
id: 'hy',
|
||||
},
|
||||
{
|
||||
id: 'de',
|
||||
name: 'Deutsch (de)',
|
||||
name: 'Assamese',
|
||||
id: 'as',
|
||||
},
|
||||
{
|
||||
id: 'हिन्दी (hi)',
|
||||
name: 'hi',
|
||||
name: 'Avaric',
|
||||
id: 'av',
|
||||
},
|
||||
{
|
||||
id: 'it',
|
||||
name: 'Italiano (it)',
|
||||
name: 'Avestan',
|
||||
id: 'ae',
|
||||
},
|
||||
{
|
||||
id: 'ja',
|
||||
name: '日本語 (ja)',
|
||||
name: 'Aymara',
|
||||
id: 'ay',
|
||||
},
|
||||
{
|
||||
id: 'ko',
|
||||
name: '한국어 (ko)',
|
||||
name: 'Azerbaijani',
|
||||
id: 'az',
|
||||
},
|
||||
{
|
||||
id: 'pt',
|
||||
name: 'Português (pt)',
|
||||
name: 'Bambara',
|
||||
id: 'bm',
|
||||
},
|
||||
{
|
||||
id: 'ru',
|
||||
name: 'русский (ru)',
|
||||
name: 'Bashkir',
|
||||
id: 'ba',
|
||||
},
|
||||
{
|
||||
id: 'zh',
|
||||
name: '中文 (zh)',
|
||||
name: 'Basque',
|
||||
id: 'eu',
|
||||
},
|
||||
{
|
||||
id: 'es',
|
||||
name: 'Español (es)',
|
||||
name: 'Belarusian',
|
||||
id: 'be',
|
||||
},
|
||||
{
|
||||
id: 'ml',
|
||||
name: 'മലയാളം (ml)',
|
||||
name: 'Bengali',
|
||||
id: 'bn',
|
||||
},
|
||||
{
|
||||
name: 'Bislama',
|
||||
id: 'bi',
|
||||
},
|
||||
{
|
||||
name: 'Bosnian',
|
||||
id: 'bs',
|
||||
},
|
||||
{
|
||||
name: 'Breton',
|
||||
id: 'br',
|
||||
},
|
||||
{
|
||||
name: 'Bulgarian',
|
||||
id: 'bg',
|
||||
},
|
||||
{
|
||||
name: 'Burmese',
|
||||
id: 'my',
|
||||
},
|
||||
{
|
||||
name: 'Catalan',
|
||||
id: 'ca',
|
||||
name: 'Català (ca)',
|
||||
},
|
||||
{
|
||||
id: 'el',
|
||||
name: 'ελληνικά (el)',
|
||||
name: 'Chamorro',
|
||||
id: 'ch',
|
||||
},
|
||||
{
|
||||
id: 'pt-BR',
|
||||
name: 'Português Brasileiro (pt-BR)',
|
||||
name: 'Chechen',
|
||||
id: 'ce',
|
||||
},
|
||||
{
|
||||
id: 'ro',
|
||||
name: 'Română (ro)',
|
||||
name: 'Chichewa',
|
||||
id: 'ny',
|
||||
},
|
||||
{
|
||||
id: 'ta',
|
||||
name: 'தமிழ் (ta)',
|
||||
name: 'Chinese',
|
||||
id: 'zh',
|
||||
},
|
||||
{
|
||||
id: 'fa',
|
||||
name: 'فارسی (fa)',
|
||||
name: 'Church Slavonic',
|
||||
id: 'cu',
|
||||
},
|
||||
{
|
||||
id: 'zh-TW',
|
||||
name: '中文 (台湾) (zh-TW)',
|
||||
name: 'Chuvash',
|
||||
id: 'cv',
|
||||
},
|
||||
{
|
||||
id: 'vi',
|
||||
name: 'Tiếng Việt (vi)',
|
||||
name: 'Cornish',
|
||||
id: 'kw',
|
||||
},
|
||||
{
|
||||
id: 'da',
|
||||
name: 'dansk (da)',
|
||||
name: 'Corsican',
|
||||
id: 'co',
|
||||
},
|
||||
{
|
||||
id: 'tr',
|
||||
name: 'Türkçe (tr)',
|
||||
name: 'Cree',
|
||||
id: 'cr',
|
||||
},
|
||||
{
|
||||
name: 'Croatian',
|
||||
id: 'hr',
|
||||
},
|
||||
{
|
||||
name: 'Czech',
|
||||
id: 'cs',
|
||||
name: 'čeština (cs)',
|
||||
},
|
||||
{
|
||||
name: 'Danish',
|
||||
id: 'da',
|
||||
},
|
||||
{
|
||||
name: 'Divehi',
|
||||
id: 'dv',
|
||||
},
|
||||
{
|
||||
name: 'Dutch',
|
||||
id: 'nl',
|
||||
},
|
||||
{
|
||||
name: 'Dzongkha',
|
||||
id: 'dz',
|
||||
},
|
||||
{
|
||||
name: 'English',
|
||||
id: 'en',
|
||||
},
|
||||
{
|
||||
name: 'Esperanto',
|
||||
id: 'eo',
|
||||
},
|
||||
{
|
||||
name: 'Estonian',
|
||||
id: 'et',
|
||||
},
|
||||
{
|
||||
name: 'Ewe',
|
||||
id: 'ee',
|
||||
},
|
||||
{
|
||||
name: 'Faroese',
|
||||
id: 'fo',
|
||||
},
|
||||
{
|
||||
name: 'Fijian',
|
||||
id: 'fj',
|
||||
},
|
||||
{
|
||||
name: 'Finnish',
|
||||
id: 'fi',
|
||||
name: 'suomi, suomen kieli (fi)',
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
name: 'Bahasa Indonesia (id)',
|
||||
name: 'French',
|
||||
id: 'fr',
|
||||
},
|
||||
{
|
||||
id: 'sv',
|
||||
name: 'Svenska (sv)',
|
||||
name: 'Western Frisian',
|
||||
id: 'fy',
|
||||
},
|
||||
{
|
||||
name: 'Fulah',
|
||||
id: 'ff',
|
||||
},
|
||||
{
|
||||
name: 'Gaelic',
|
||||
id: 'gd',
|
||||
},
|
||||
{
|
||||
name: 'Galician',
|
||||
id: 'gl',
|
||||
},
|
||||
{
|
||||
name: 'Ganda',
|
||||
id: 'lg',
|
||||
},
|
||||
{
|
||||
name: 'Georgian',
|
||||
id: 'ka',
|
||||
},
|
||||
{
|
||||
name: 'German',
|
||||
id: 'de',
|
||||
},
|
||||
{
|
||||
name: 'Greek',
|
||||
id: 'el',
|
||||
},
|
||||
{
|
||||
name: 'Kalaallisut',
|
||||
id: 'kl',
|
||||
},
|
||||
{
|
||||
name: 'Guarani',
|
||||
id: 'gn',
|
||||
},
|
||||
{
|
||||
name: 'Gujarati',
|
||||
id: 'gu',
|
||||
},
|
||||
{
|
||||
name: 'Haitian',
|
||||
id: 'ht',
|
||||
},
|
||||
{
|
||||
name: 'Hausa',
|
||||
id: 'ha',
|
||||
},
|
||||
{
|
||||
name: 'Hebrew',
|
||||
id: 'he',
|
||||
},
|
||||
{
|
||||
name: 'Herero',
|
||||
id: 'hz',
|
||||
},
|
||||
{
|
||||
name: 'Hindi',
|
||||
id: 'hi',
|
||||
},
|
||||
{
|
||||
name: 'Hiri Motu',
|
||||
id: 'ho',
|
||||
},
|
||||
{
|
||||
name: 'Hungarian',
|
||||
id: 'hu',
|
||||
name: 'magyar nyelv (hu)',
|
||||
},
|
||||
{
|
||||
name: 'Icelandic',
|
||||
id: 'is',
|
||||
},
|
||||
{
|
||||
name: 'Ido',
|
||||
id: 'io',
|
||||
},
|
||||
{
|
||||
name: 'Igbo',
|
||||
id: 'ig',
|
||||
},
|
||||
{
|
||||
name: 'Indonesian',
|
||||
id: 'id',
|
||||
},
|
||||
{
|
||||
name: 'Interlingua',
|
||||
id: 'ia',
|
||||
},
|
||||
{
|
||||
name: 'Interlingue',
|
||||
id: 'ie',
|
||||
},
|
||||
{
|
||||
name: 'Inuktitut',
|
||||
id: 'iu',
|
||||
},
|
||||
{
|
||||
name: 'Inupiaq',
|
||||
id: 'ik',
|
||||
},
|
||||
{
|
||||
name: 'Irish',
|
||||
id: 'ga',
|
||||
},
|
||||
{
|
||||
name: 'Italian',
|
||||
id: 'it',
|
||||
},
|
||||
{
|
||||
name: 'Japanese',
|
||||
id: 'ja',
|
||||
},
|
||||
{
|
||||
name: 'Javanese',
|
||||
id: 'jv',
|
||||
},
|
||||
{
|
||||
name: 'Kannada',
|
||||
id: 'kn',
|
||||
},
|
||||
{
|
||||
name: 'Kanuri',
|
||||
id: 'kr',
|
||||
},
|
||||
{
|
||||
name: 'Kashmiri',
|
||||
id: 'ks',
|
||||
},
|
||||
{
|
||||
name: 'Kazakh',
|
||||
id: 'kk',
|
||||
},
|
||||
{
|
||||
name: 'Central Khmer',
|
||||
id: 'km',
|
||||
},
|
||||
{
|
||||
name: 'Kikuyu',
|
||||
id: 'ki',
|
||||
},
|
||||
{
|
||||
name: 'Kinyarwanda',
|
||||
id: 'rw',
|
||||
},
|
||||
{
|
||||
name: 'Kirghiz',
|
||||
id: 'ky',
|
||||
},
|
||||
{
|
||||
name: 'Komi',
|
||||
id: 'kv',
|
||||
},
|
||||
{
|
||||
name: 'Kongo',
|
||||
id: 'kg',
|
||||
},
|
||||
{
|
||||
name: 'Korean',
|
||||
id: 'ko',
|
||||
},
|
||||
{
|
||||
name: 'Kuanyama',
|
||||
id: 'kj',
|
||||
},
|
||||
{
|
||||
name: 'Kurdish',
|
||||
id: 'ku',
|
||||
},
|
||||
{
|
||||
name: 'Lao',
|
||||
id: 'lo',
|
||||
},
|
||||
{
|
||||
name: 'Latin',
|
||||
id: 'la',
|
||||
},
|
||||
{
|
||||
name: 'Latvian',
|
||||
id: 'lv',
|
||||
},
|
||||
{
|
||||
name: 'Limburgan',
|
||||
id: 'li',
|
||||
},
|
||||
{
|
||||
name: 'Lingala',
|
||||
id: 'ln',
|
||||
},
|
||||
{
|
||||
name: 'Lithuanian',
|
||||
id: 'lt',
|
||||
},
|
||||
{
|
||||
name: 'Luba-Katanga',
|
||||
id: 'lu',
|
||||
},
|
||||
{
|
||||
name: 'Luxembourgish',
|
||||
id: 'lb',
|
||||
},
|
||||
{
|
||||
name: 'Macedonian',
|
||||
id: 'mk',
|
||||
},
|
||||
{
|
||||
name: 'Malagasy',
|
||||
id: 'mg',
|
||||
},
|
||||
{
|
||||
name: 'Malay',
|
||||
id: 'ms',
|
||||
},
|
||||
{
|
||||
name: 'Malayalam',
|
||||
id: 'ml',
|
||||
},
|
||||
{
|
||||
name: 'Maltese',
|
||||
id: 'mt',
|
||||
},
|
||||
{
|
||||
name: 'Manx',
|
||||
id: 'gv',
|
||||
},
|
||||
{
|
||||
name: 'Maori',
|
||||
id: 'mi',
|
||||
},
|
||||
{
|
||||
name: 'Marathi',
|
||||
id: 'mr',
|
||||
},
|
||||
{
|
||||
name: 'Marshallese',
|
||||
id: 'mh',
|
||||
},
|
||||
{
|
||||
name: 'Mongolian',
|
||||
id: 'mn',
|
||||
},
|
||||
{
|
||||
name: 'Nauru',
|
||||
id: 'na',
|
||||
},
|
||||
{
|
||||
name: 'Navajo',
|
||||
id: 'nv',
|
||||
},
|
||||
{
|
||||
name: 'North Ndebele',
|
||||
id: 'nd',
|
||||
},
|
||||
{
|
||||
name: 'South Ndebele',
|
||||
id: 'nr',
|
||||
},
|
||||
{
|
||||
name: 'Ndonga',
|
||||
id: 'ng',
|
||||
},
|
||||
{
|
||||
name: 'Nepali',
|
||||
id: 'ne',
|
||||
},
|
||||
{
|
||||
name: 'Norwegian',
|
||||
id: 'no',
|
||||
name: 'norsk (no)',
|
||||
},
|
||||
{
|
||||
id: 'zh-CN',
|
||||
name: '中文 (zh-CN)',
|
||||
name: 'Norwegian Bokmål',
|
||||
id: 'nb',
|
||||
},
|
||||
{
|
||||
name: 'Norwegian Nynorsk',
|
||||
id: 'nn',
|
||||
},
|
||||
{
|
||||
name: 'Sichuan Yi',
|
||||
id: 'ii',
|
||||
},
|
||||
{
|
||||
name: 'Occitan',
|
||||
id: 'oc',
|
||||
},
|
||||
{
|
||||
name: 'Ojibwa',
|
||||
id: 'oj',
|
||||
},
|
||||
{
|
||||
name: 'Oriya',
|
||||
id: 'or',
|
||||
},
|
||||
{
|
||||
name: 'Oromo',
|
||||
id: 'om',
|
||||
},
|
||||
{
|
||||
name: 'Ossetian',
|
||||
id: 'os',
|
||||
},
|
||||
{
|
||||
name: 'Pali',
|
||||
id: 'pi',
|
||||
},
|
||||
{
|
||||
name: 'Pashto, Pushto',
|
||||
id: 'ps',
|
||||
},
|
||||
{
|
||||
name: 'Persian',
|
||||
id: 'fa',
|
||||
},
|
||||
{
|
||||
name: 'Polish',
|
||||
id: 'pl',
|
||||
name: 'język polski (pl)',
|
||||
},
|
||||
{
|
||||
name: 'Portuguese',
|
||||
id: 'pt',
|
||||
},
|
||||
{
|
||||
name: 'Punjabi',
|
||||
id: 'pa',
|
||||
},
|
||||
{
|
||||
name: 'Quechua',
|
||||
id: 'qu',
|
||||
},
|
||||
{
|
||||
name: 'Romanian',
|
||||
id: 'ro',
|
||||
},
|
||||
{
|
||||
name: 'Romansh',
|
||||
id: 'rm',
|
||||
},
|
||||
{
|
||||
name: 'Rundi',
|
||||
id: 'rn',
|
||||
},
|
||||
{
|
||||
name: 'Russian',
|
||||
id: 'ru',
|
||||
},
|
||||
{
|
||||
name: 'Northern Sami',
|
||||
id: 'se',
|
||||
},
|
||||
{
|
||||
name: 'Samoan',
|
||||
id: 'sm',
|
||||
},
|
||||
{
|
||||
name: 'Sango',
|
||||
id: 'sg',
|
||||
},
|
||||
{
|
||||
name: 'Sanskrit',
|
||||
id: 'sa',
|
||||
},
|
||||
{
|
||||
name: 'Sardinian',
|
||||
id: 'sc',
|
||||
},
|
||||
{
|
||||
name: 'Serbian',
|
||||
id: 'sr',
|
||||
},
|
||||
{
|
||||
name: 'Shona',
|
||||
id: 'sn',
|
||||
},
|
||||
{
|
||||
name: 'Sindhi',
|
||||
id: 'sd',
|
||||
},
|
||||
{
|
||||
name: 'Sinhala',
|
||||
id: 'si',
|
||||
},
|
||||
{
|
||||
name: 'Slovak',
|
||||
id: 'sk',
|
||||
},
|
||||
{
|
||||
name: 'Slovenian',
|
||||
id: 'sl',
|
||||
},
|
||||
{
|
||||
name: 'Somali',
|
||||
id: 'so',
|
||||
},
|
||||
{
|
||||
name: 'Southern Sotho',
|
||||
id: 'st',
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
id: 'es',
|
||||
},
|
||||
{
|
||||
name: 'Sundanese',
|
||||
id: 'su',
|
||||
},
|
||||
{
|
||||
name: 'Swahili',
|
||||
id: 'sw',
|
||||
},
|
||||
{
|
||||
name: 'Swati',
|
||||
id: 'ss',
|
||||
},
|
||||
{
|
||||
name: 'Swedish',
|
||||
id: 'sv',
|
||||
},
|
||||
{
|
||||
name: 'Tagalog',
|
||||
id: 'tl',
|
||||
},
|
||||
{
|
||||
name: 'Tahitian',
|
||||
id: 'ty',
|
||||
},
|
||||
{
|
||||
name: 'Tajik',
|
||||
id: 'tg',
|
||||
},
|
||||
{
|
||||
name: 'Tamil',
|
||||
id: 'ta',
|
||||
},
|
||||
{
|
||||
name: 'Tatar',
|
||||
id: 'tt',
|
||||
},
|
||||
{
|
||||
name: 'Telugu',
|
||||
id: 'te',
|
||||
},
|
||||
{
|
||||
name: 'Thai',
|
||||
id: 'th',
|
||||
},
|
||||
{
|
||||
name: 'Tibetan',
|
||||
id: 'bo',
|
||||
},
|
||||
{
|
||||
name: 'Tigrinya',
|
||||
id: 'ti',
|
||||
},
|
||||
{
|
||||
name: 'Tonga',
|
||||
id: 'to',
|
||||
},
|
||||
{
|
||||
name: 'Tsonga',
|
||||
id: 'ts',
|
||||
},
|
||||
{
|
||||
name: 'Tswana',
|
||||
id: 'tn',
|
||||
},
|
||||
{
|
||||
name: 'Turkish',
|
||||
id: 'tr',
|
||||
},
|
||||
{
|
||||
name: 'Turkmen',
|
||||
id: 'tk',
|
||||
},
|
||||
{
|
||||
name: 'Twi',
|
||||
id: 'tw',
|
||||
},
|
||||
{
|
||||
name: 'Uighur',
|
||||
id: 'ug',
|
||||
},
|
||||
{
|
||||
name: 'Ukrainian',
|
||||
id: 'uk',
|
||||
},
|
||||
{
|
||||
name: 'Urdu',
|
||||
id: 'ur',
|
||||
},
|
||||
{
|
||||
name: 'Uzbek',
|
||||
id: 'uz',
|
||||
},
|
||||
{
|
||||
name: 'Venda',
|
||||
id: 've',
|
||||
},
|
||||
{
|
||||
name: 'Vietnamese',
|
||||
id: 'vi',
|
||||
},
|
||||
{
|
||||
name: 'Volapük',
|
||||
id: 'vo',
|
||||
},
|
||||
{
|
||||
name: 'Walloon',
|
||||
id: 'wa',
|
||||
},
|
||||
{
|
||||
name: 'Welsh',
|
||||
id: 'cy',
|
||||
},
|
||||
{
|
||||
name: 'Wolof',
|
||||
id: 'wo',
|
||||
},
|
||||
{
|
||||
name: 'Xhosa',
|
||||
id: 'xh',
|
||||
},
|
||||
{
|
||||
name: 'Yiddish',
|
||||
id: 'yi',
|
||||
},
|
||||
{
|
||||
name: 'Yoruba',
|
||||
id: 'yo',
|
||||
},
|
||||
{
|
||||
name: 'Zhuang, Chuang',
|
||||
id: 'za',
|
||||
},
|
||||
{
|
||||
name: 'Zulu',
|
||||
id: 'zu',
|
||||
},
|
||||
];
|
||||
|
||||
export const getLanguageName = (languageCode = '') => {
|
||||
const languageObj =
|
||||
languages.find(language => language.id === languageCode) || {};
|
||||
return languageObj.name || '';
|
||||
};
|
||||
|
||||
export default languages;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import defaultFilters from './index';
|
||||
import { filterAttributeGroups } from './index';
|
||||
import defaultFilters from '../index';
|
||||
import { filterAttributeGroups } from '../index';
|
||||
|
||||
describe('#filterItems', () => {
|
||||
it('Matches the correct filterItems', () => {
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getLanguageName } from '../languages';
|
||||
|
||||
describe('#getLanguageName', () => {
|
||||
it('Returns correct language name', () => {
|
||||
expect(getLanguageName('es')).toEqual('Spanish');
|
||||
expect(getLanguageName()).toEqual('');
|
||||
expect(getLanguageName('rrr')).toEqual('');
|
||||
expect(getLanguageName('')).toEqual('');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
'hide--quoted': !showQuotedContent,
|
||||
}"
|
||||
>
|
||||
<div v-dompurify-html="message" class="text-content" />
|
||||
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
|
||||
<letter v-else class="text-content" :html="message" />
|
||||
<button
|
||||
v-if="displayQuotedButton"
|
||||
class="quoted-text--button"
|
||||
@@ -25,7 +26,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Letter from 'vue-letter';
|
||||
|
||||
export default {
|
||||
components: { Letter },
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
@@ -65,14 +69,16 @@ export default {
|
||||
padding-left: var(--space-two);
|
||||
}
|
||||
table {
|
||||
all: revert;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
|
||||
td {
|
||||
all: revert;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
all: revert;
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="menu-container">
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<menu-item
|
||||
v-if="show(option.key)"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
variant="icon"
|
||||
@click="toggleStatus(option.key, null)"
|
||||
/>
|
||||
</template>
|
||||
<menu-item-with-submenu :option="snoozeMenuConfig">
|
||||
<menu-item
|
||||
v-for="(option, i) in snoozeMenuConfig.options"
|
||||
:key="i"
|
||||
:option="option"
|
||||
@click="snoozeConversation(option.snoozedUntil)"
|
||||
/>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item-with-submenu :option="labelMenuConfig">
|
||||
<template>
|
||||
<menu-item
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
variant="label"
|
||||
@click="$emit('assign-label', label)"
|
||||
/>
|
||||
</template>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item-with-submenu :option="agentMenuConfig">
|
||||
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
|
||||
<template v-else>
|
||||
<menu-item
|
||||
v-for="agent in assignableAgents"
|
||||
:key="agent.id"
|
||||
:option="generateMenuLabelConfig(agent, 'agent')"
|
||||
variant="agent"
|
||||
@click="$emit('assign-agent', agent)"
|
||||
/>
|
||||
</template>
|
||||
</menu-item-with-submenu>
|
||||
<menu-item-with-submenu :option="teamMenuConfig">
|
||||
<menu-item
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
:option="generateMenuLabelConfig(team, 'team')"
|
||||
@click="$emit('assign-team', team)"
|
||||
/>
|
||||
</menu-item-with-submenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants.js';
|
||||
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
export default {
|
||||
components: {
|
||||
MenuItem,
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
},
|
||||
mixins: [snoozeTimesMixin],
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
statusMenuConfig: [
|
||||
{
|
||||
key: wootConstants.STATUS_TYPE.RESOLVED,
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
|
||||
icon: 'checkmark',
|
||||
},
|
||||
{
|
||||
key: wootConstants.STATUS_TYPE.PENDING,
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
|
||||
icon: 'book-clock',
|
||||
},
|
||||
{
|
||||
key: wootConstants.STATUS_TYPE.OPEN,
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
|
||||
icon: 'arrow-redo',
|
||||
},
|
||||
],
|
||||
snoozeMenuConfig: {
|
||||
key: 'snooze',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
|
||||
icon: 'snooze',
|
||||
options: [
|
||||
{
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_REPLY'),
|
||||
key: 'next-reply',
|
||||
snoozedUntil: null,
|
||||
},
|
||||
{
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TOMORROW'),
|
||||
key: 'tomorrow',
|
||||
snoozedUntil: 'tomorrow',
|
||||
},
|
||||
{
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_WEEK'),
|
||||
key: 'next-week',
|
||||
snoozedUntil: 'nextWeek',
|
||||
},
|
||||
],
|
||||
},
|
||||
labelMenuConfig: {
|
||||
key: 'label',
|
||||
icon: 'tag',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
|
||||
},
|
||||
agentMenuConfig: {
|
||||
key: 'agent',
|
||||
icon: 'person-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
|
||||
},
|
||||
teamMenuConfig: {
|
||||
key: 'team',
|
||||
icon: 'people-team-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
labels: 'labels/getLabels',
|
||||
teams: 'teams/getTeams',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
}),
|
||||
assignableAgents() {
|
||||
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
|
||||
this.inboxId
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
},
|
||||
methods: {
|
||||
toggleStatus(status, snoozedUntil) {
|
||||
this.$emit('update-conversation', status, snoozedUntil);
|
||||
},
|
||||
snoozeConversation(snoozedUntil) {
|
||||
this.$emit(
|
||||
'update-conversation',
|
||||
this.STATUS_TYPE.SNOOZED,
|
||||
this.snoozeTimes[snoozedUntil] || null
|
||||
);
|
||||
},
|
||||
show(key) {
|
||||
// If the conversation status is same as the action, then don't display the option
|
||||
// i.e.: Don't show an option to resolve if the conversation is already resolved.
|
||||
return this.status !== key;
|
||||
},
|
||||
generateMenuLabelConfig(option, type = 'text') {
|
||||
return {
|
||||
key: option.id,
|
||||
...(type === 'icon' && { icon: option.icon }),
|
||||
...(type === 'label' && { color: option.color }),
|
||||
...(type === 'agent' && { thumbnail: option.thumbnail }),
|
||||
...(type === 'text' && { label: option.label }),
|
||||
...(type === 'label' && { label: option.title }),
|
||||
...(type === 'agent' && { label: option.name }),
|
||||
...(type === 'team' && { label: option.name }),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-container {
|
||||
padding: var(--space-smaller);
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-context-menu);
|
||||
border-radius: var(--border-radius-normal);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="agent-placeholder">
|
||||
<spinner />
|
||||
<p>{{ $t('CONVERSATION.CARD_CONTEXT_MENU.AGENTS_LOADING') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.agent-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: var(--space-normal) 0;
|
||||
min-width: calc(var(--space-mega) * 2);
|
||||
|
||||
p {
|
||||
margin: var(--space-small) 0 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="menu" @click.stop="$emit('click')">
|
||||
<fluent-icon
|
||||
v-if="variant === 'icon' && option.icon"
|
||||
:icon="option.icon"
|
||||
size="14"
|
||||
class="menu-icon"
|
||||
/>
|
||||
<span
|
||||
v-if="variant === 'label' && option.color"
|
||||
class="label-pill"
|
||||
:style="{ backgroundColor: option.color }"
|
||||
/>
|
||||
<thumbnail
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:src="option.thumbnail"
|
||||
size="20px"
|
||||
class="agent-thumbnail"
|
||||
/>
|
||||
<p class="menu-label truncate-text">{{ option.label }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
props: {
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
width: calc(var(--space-mega) * 2);
|
||||
padding: var(--space-smaller);
|
||||
border-radius: var(--border-radius-small);
|
||||
overflow: hidden;
|
||||
|
||||
.menu-label {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-500);
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-thumbnail {
|
||||
margin-top: 0 !important;
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.label-pill {
|
||||
width: var(--space-normal);
|
||||
height: var(--space-normal);
|
||||
border-radius: var(--border-radius-rounded);
|
||||
border: 1px solid var(--s-50);
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="menu-with-submenu flex-between">
|
||||
<div class="menu-left">
|
||||
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
|
||||
<p class="menu-label">{{ option.label }}</p>
|
||||
</div>
|
||||
<fluent-icon icon="chevron-right" size="12" />
|
||||
<div class="submenu">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu-with-submenu {
|
||||
width: 100%;
|
||||
padding: var(--space-smaller);
|
||||
border-radius: var(--border-radius-small);
|
||||
position: relative;
|
||||
min-width: calc(var(--space-mega) * 2);
|
||||
background-color: var(--white);
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.menu-label {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
}
|
||||
|
||||
.submenu {
|
||||
padding: var(--space-smaller);
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-context-menu);
|
||||
border-radius: var(--border-radius-normal);
|
||||
position: absolute;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-75);
|
||||
.submenu {
|
||||
display: block;
|
||||
}
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: var(--z-index-highest);
|
||||
bottom: -65%;
|
||||
height: 75%;
|
||||
right: 0%;
|
||||
width: 50%;
|
||||
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<label>
|
||||
<label class="input-container">
|
||||
<span v-if="label">{{ label }}</span>
|
||||
<input
|
||||
:value="value"
|
||||
@@ -10,7 +10,7 @@
|
||||
@input="onChange"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<p v-if="helpText" class="help-text" />
|
||||
<p v-if="helpText" class="help-text">{{ helpText }}</p>
|
||||
<span v-if="error" class="message">
|
||||
{{ error }}
|
||||
</span>
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
deafaut: false,
|
||||
default: false,
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
@@ -63,3 +63,14 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.help-text {
|
||||
margin-top: var(--space-micro);
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--s-600);
|
||||
font-style: normal;
|
||||
}
|
||||
.message {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,5 +12,10 @@ export default {
|
||||
SNOOZED: 'snoozed',
|
||||
ALL: 'all',
|
||||
},
|
||||
LAYOUT_TYPES: {
|
||||
CONDENSED: 'condensed',
|
||||
EXPANDED: 'expanded',
|
||||
},
|
||||
DOCS_URL: '//www.chatwoot.com/docs/product/',
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import queryString from 'query-string';
|
||||
import { DEFAULT_REDIRECT_URL } from '../constants';
|
||||
|
||||
export const frontendURL = (path, params) => {
|
||||
const stringifiedParams = params ? `?${queryString.stringify(params)}` : '';
|
||||
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';
|
||||
return `/app/${path}${stringifiedParams}`;
|
||||
};
|
||||
|
||||
export const getLoginRedirectURL = (ssoAccountId, user) => {
|
||||
const getSSOAccountPath = ({ ssoAccountId, user }) => {
|
||||
const { accounts = [] } = user || {};
|
||||
const ssoAccount = accounts.find(
|
||||
account => account.id === Number(ssoAccountId)
|
||||
);
|
||||
let accountPath = '';
|
||||
if (ssoAccount) {
|
||||
return frontendURL(`accounts/${ssoAccountId}/dashboard`);
|
||||
accountPath = `accounts/${ssoAccountId}`;
|
||||
} else if (accounts.length) {
|
||||
accountPath = `accounts/${accounts[0].id}`;
|
||||
}
|
||||
if (accounts.length) {
|
||||
return frontendURL(`accounts/${accounts[0].id}/dashboard`);
|
||||
return accountPath;
|
||||
};
|
||||
|
||||
export const getLoginRedirectURL = ({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
user,
|
||||
}) => {
|
||||
const accountPath = getSSOAccountPath({ ssoAccountId, user });
|
||||
if (accountPath) {
|
||||
if (ssoConversationId) {
|
||||
return frontendURL(`${accountPath}/conversations/${ssoConversationId}`);
|
||||
}
|
||||
return frontendURL(`${accountPath}/dashboard`);
|
||||
}
|
||||
return DEFAULT_REDIRECT_URL;
|
||||
};
|
||||
@@ -44,6 +58,26 @@ export const conversationUrl = ({
|
||||
return url;
|
||||
};
|
||||
|
||||
export const conversationListPageURL = ({
|
||||
accountId,
|
||||
conversationType = '',
|
||||
inboxId,
|
||||
label,
|
||||
teamId,
|
||||
}) => {
|
||||
let url = `accounts/${accountId}/dashboard`;
|
||||
if (label) {
|
||||
url = `accounts/${accountId}/label/${label}`;
|
||||
} else if (teamId) {
|
||||
url = `accounts/${accountId}/team/${teamId}`;
|
||||
} else if (conversationType === 'mention') {
|
||||
url = `accounts/${accountId}/mentions/conversations`;
|
||||
} else if (inboxId) {
|
||||
url = `accounts/${accountId}/inbox/${inboxId}`;
|
||||
}
|
||||
return frontendURL(url);
|
||||
};
|
||||
|
||||
export const isValidURL = value => {
|
||||
/* eslint-disable no-useless-escape */
|
||||
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;
|
||||
|
||||
@@ -62,9 +62,23 @@ export const createPendingMessage = data => {
|
||||
return pendingMessage;
|
||||
};
|
||||
|
||||
export const convertToSlug = text => {
|
||||
export const convertToAttributeSlug = text => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '_');
|
||||
};
|
||||
|
||||
export const convertToCategorySlug = text => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '-');
|
||||
};
|
||||
|
||||
export const convertToPortalSlug = text => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '-');
|
||||
};
|
||||
|
||||
19
app/javascript/dashboard/helper/flag.js
Normal file
19
app/javascript/dashboard/helper/flag.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const FLAG_OFFSET = 127397;
|
||||
|
||||
/**
|
||||
* Gets emoji flag for given locale.
|
||||
*
|
||||
* @param {string} countryCode locale code
|
||||
* @return {string} emoji flag
|
||||
*
|
||||
* @example
|
||||
* getCountryFlag('cz') // '🇨🇿'
|
||||
*/
|
||||
export const getCountryFlag = countryCode => {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => FLAG_OFFSET + char.charCodeAt());
|
||||
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
@@ -1,17 +1,32 @@
|
||||
class LocalStorage {
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
}
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
};
|
||||
|
||||
store(allItems) {
|
||||
localStorage.setItem(this.key, JSON.stringify(allItems));
|
||||
localStorage.setItem(this.key + ':ts', Date.now());
|
||||
}
|
||||
export const LocalStorage = {
|
||||
clearAll() {
|
||||
window.localStorage.clear();
|
||||
},
|
||||
|
||||
get() {
|
||||
let stored = localStorage.getItem(this.key);
|
||||
return JSON.parse(stored) || [];
|
||||
}
|
||||
}
|
||||
get(key) {
|
||||
const value = window.localStorage.getItem(key);
|
||||
try {
|
||||
return typeof value === 'string' ? JSON.parse(value) : value;
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
window.localStorage.setItem(key + ':ts', Date.now());
|
||||
},
|
||||
|
||||
export default LocalStorage;
|
||||
remove(key) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.localStorage.removeItem(key + ':ts');
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user