diff --git a/.codeclimate.yml b/.codeclimate.yml index 916b98510..974681a09 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -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' diff --git a/.env.example b/.env.example index d46acac9f..81a65810c 100644 --- a/.env.example +++ b/.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 ` MAILER_SENDER_EMAIL="Chatwoot " - #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= diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 000000000..790a322fd --- /dev/null +++ b/.github/workflows/lock.yml @@ -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. diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml new file mode 100644 index 000000000..623f34592 --- /dev/null +++ b/.github/workflows/nightly_installer.yml @@ -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 + diff --git a/.rubocop.yml b/.rubocop.yml index 898f1ff24..d63f0418d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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 diff --git a/Gemfile b/Gemfile index 270e6527c..ba83ce964 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 7e0583757..8cf640103 100644 --- a/Gemfile.lock +++ b/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 diff --git a/app/actions/contact_identify_action.rb b/app/actions/contact_identify_action.rb index f19caac77..a1c39e2a0 100644 --- a/app/actions/contact_identify_action.rb +++ b/app/actions/contact_identify_action.rb @@ -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) diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 575f14aaa..3e2ac9d6e 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -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? diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index 10ce8ee26..938072643 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -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 diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index f19d3c8b7..9f670602a 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -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 diff --git a/app/builders/notification_builder.rb b/app/builders/notification_builder.rb index d41debe0c..6d2096233 100644 --- a/app/builders/notification_builder.rb +++ b/app/builders/notification_builder.rb @@ -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}?") diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 09b648a6f..6b2f9ea75 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index 77a1fdf65..ccaa33a42 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 8a163f011..3575e28e6 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/categories_controller.rb b/app/controllers/api/v1/accounts/categories_controller.rb index c4491b2e2..bc18b61b8 100644 --- a/app/controllers/api/v1/accounts/categories_controller.rb +++ b/app/controllers/api/v1/accounts/categories_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 44b1280ee..7ef3c0d87 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -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) diff --git a/app/controllers/api/v1/accounts/macros_controller.rb b/app/controllers/api/v1/accounts/macros_controller.rb new file mode 100644 index 000000000..60e1b62c2 --- /dev/null +++ b/app/controllers/api/v1/accounts/macros_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index b081978b9..c3d8ad024 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index e75d3f852..2f18fbac2 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb index d854114c4..9ec702e6c 100644 --- a/app/controllers/api/v1/widget/base_controller.rb +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -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 || {} } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0762c3d91..d2960a699 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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, diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index dccc64350..eb781dbfe 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -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 diff --git a/app/controllers/concerns/website_token_helper.rb b/app/controllers/concerns/website_token_helper.rb index 0158a4107..ea53a26ad 100644 --- a/app/controllers/concerns/website_token_helper.rb +++ b/app/controllers/concerns/website_token_helper.rb @@ -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 diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 36c789b52..bd144b0df 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb index a037f4e2f..9c8f60f38 100644 --- a/app/controllers/platform/api/v1/accounts_controller.rb +++ b/app/controllers/platform/api/v1/accounts_controller.rb @@ -27,6 +27,6 @@ class Platform::Api::V1::AccountsController < PlatformController end def account_params - params.permit(:name) + params.permit(:name, :locale) end end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 2a961b7a9..e861d30ed 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -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 diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 653d3360f..9639b28b2 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -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 } diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 408589597..0dfe7bbcd 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -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 diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index ffd664659..d77e150c2 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -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 diff --git a/app/javascript/dashboard/api/ApiClient.js b/app/javascript/dashboard/api/ApiClient.js index 097caa906..25083dc99 100644 --- a/app/javascript/dashboard/api/ApiClient.js +++ b/app/javascript/dashboard/api/ApiClient.js @@ -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' diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js new file mode 100644 index 000000000..bc0d51dfc --- /dev/null +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -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(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js new file mode 100644 index 000000000..28fd83cd1 --- /dev/null +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -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' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js new file mode 100644 index 000000000..7430d65a4 --- /dev/null +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -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(); diff --git a/app/javascript/dashboard/api/helpCenter/categories.js b/app/javascript/dashboard/api/helpCenter/categories.js new file mode 100644 index 000000000..c5cb40cd4 --- /dev/null +++ b/app/javascript/dashboard/api/helpCenter/categories.js @@ -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(); diff --git a/app/javascript/dashboard/api/helpCenter/portals.js b/app/javascript/dashboard/api/helpCenter/portals.js new file mode 100644 index 000000000..594050cdf --- /dev/null +++ b/app/javascript/dashboard/api/helpCenter/portals.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class PortalsAPI extends ApiClient { + constructor() { + super('portals', { accountScoped: true }); + } +} + +export default PortalsAPI; diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js new file mode 100644 index 000000000..a60a784c0 --- /dev/null +++ b/app/javascript/dashboard/api/specs/article.spec.js @@ -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' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js new file mode 100644 index 000000000..2c56f4e00 --- /dev/null +++ b/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js @@ -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'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/portals.spec.js b/app/javascript/dashboard/api/specs/portals.spec.js new file mode 100644 index 000000000..8d6968cb7 --- /dev/null +++ b/app/javascript/dashboard/api/specs/portals.spec.js @@ -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'); + }); +}); diff --git a/app/javascript/dashboard/assets/images/bubble-logo.svg b/app/javascript/dashboard/assets/images/bubble-logo.svg new file mode 100644 index 000000000..1eb8c620e --- /dev/null +++ b/app/javascript/dashboard/assets/images/bubble-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/javascript/dashboard/assets/scss/_animations.scss b/app/javascript/dashboard/assets/scss/_animations.scss index 4f5a2d43f..bb01c369f 100644 --- a/app/javascript/dashboard/assets/scss/_animations.scss +++ b/app/javascript/dashboard/assets/scss/_animations.scss @@ -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); } diff --git a/app/javascript/dashboard/assets/scss/_layout.scss b/app/javascript/dashboard/assets/scss/_layout.scss index 198bb75e5..682ca7670 100644 --- a/app/javascript/dashboard/assets/scss/_layout.scss +++ b/app/javascript/dashboard/assets/scss/_layout.scss @@ -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; } } diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index e709327dc..416aa808b 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -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); diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index deba11150..525697d91 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -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; } } diff --git a/app/javascript/dashboard/assets/scss/storybook.scss b/app/javascript/dashboard/assets/scss/storybook.scss index 2364c3d5b..e9973b150 100644 --- a/app/javascript/dashboard/assets/scss/storybook.scss +++ b/app/javascript/dashboard/assets/scss/storybook.scss @@ -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'; diff --git a/app/javascript/dashboard/assets/scss/views/settings/channel.scss b/app/javascript/dashboard/assets/scss/views/settings/channel.scss index c22888ba5..5782744d3 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/channel.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/channel.scss @@ -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%; } } } diff --git a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss index d2b145286..15edb4e76 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index 0ad6dff38..52a50afb6 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index 3066d0e43..b67932e6d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -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, diff --git a/app/javascript/dashboard/assets/scss/widgets/_forms.scss b/app/javascript/dashboard/assets/scss/widgets/_forms.scss index 13548db5f..17c0dc859 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_forms.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_forms.scss @@ -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)); diff --git a/app/javascript/dashboard/assets/scss/widgets/_login.scss b/app/javascript/dashboard/assets/scss/widgets/_login.scss index 8deaef1f8..a5b140ede 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_login.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_login.scss @@ -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; } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_modal.scss b/app/javascript/dashboard/assets/scss/widgets/_modal.scss index cf48bcd6e..68ea09589 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_modal.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_modal.scss @@ -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; } } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss index 1d2c3f63d..d33f558e3 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss @@ -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); } } } - } diff --git a/app/javascript/dashboard/assets/scss/widgets/_report.scss b/app/javascript/dashboard/assets/scss/widgets/_report.scss index 5299f1155..6caf2cac7 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_report.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_report.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/widgets/_search-box.scss b/app/javascript/dashboard/assets/scss/widgets/_search-box.scss index 6bd3ba2d6..643757e60 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_search-box.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_search-box.scss @@ -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; } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss b/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss index e5e9276fd..a10ef530f 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_sidemenu.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/widgets/_snackbar.scss b/app/javascript/dashboard/assets/scss/widgets/_snackbar.scss index 5f60d827c..85d8f747d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_snackbar.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_snackbar.scss @@ -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; } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_status-bar.scss b/app/javascript/dashboard/assets/scss/widgets/_status-bar.scss index df0eead9f..ad0f805e2 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_status-bar.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_status-bar.scss @@ -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; } diff --git a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss index 234d7e171..9a6f6951c 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_tabs.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_tabs.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss b/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss index 0a1a5c4b6..915840786 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss @@ -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; } } diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index d1ac4945f..16865d0dc 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -1,5 +1,11 @@ @@ -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'); + }, }, }; @@ -312,4 +335,10 @@ export default { } } } + +.empty-text { + color: var(--s-600); + font-size: var(--font-size-small); + margin: var(--space-smaller) 0; +} diff --git a/app/javascript/dashboard/components/ui/AnnouncementPopup.vue b/app/javascript/dashboard/components/ui/AnnouncementPopup.vue new file mode 100644 index 000000000..ad59bc8ac --- /dev/null +++ b/app/javascript/dashboard/components/ui/AnnouncementPopup.vue @@ -0,0 +1,88 @@ + + + + diff --git a/app/javascript/dashboard/components/ui/ContextMenu.vue b/app/javascript/dashboard/components/ui/ContextMenu.vue new file mode 100644 index 000000000..b968cda19 --- /dev/null +++ b/app/javascript/dashboard/components/ui/ContextMenu.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/javascript/dashboard/components/ui/stories/AnnouncementPopup.stories.js b/app/javascript/dashboard/components/ui/stories/AnnouncementPopup.stories.js new file mode 100644 index 000000000..3166eff05 --- /dev/null +++ b/app/javascript/dashboard/components/ui/stories/AnnouncementPopup.stories.js @@ -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: + '', +}); + +export const AnnouncementPopup = Template.bind({}); +AnnouncementPopup.args = { + onClickOpenPath: action('opened path'), + onClickClose: action('closed the popup'), +}; diff --git a/app/javascript/dashboard/components/widgets/Thumbnail.vue b/app/javascript/dashboard/components/widgets/Thumbnail.vue index ea0e0f99a..4bfc729de 100644 --- a/app/javascript/dashboard/components/widgets/Thumbnail.vue +++ b/app/javascript/dashboard/components/widgets/Thumbnail.vue @@ -9,7 +9,7 @@ /> -
+
- +
+ + +
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index c30f23c20..5abf0eb5c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -1,6 +1,7 @@ + + diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/agentLoadingPlaceholder.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/agentLoadingPlaceholder.vue new file mode 100644 index 000000000..975d57fdc --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/agentLoadingPlaceholder.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue new file mode 100644 index 000000000..7534430d5 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItem.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItemWithSubmenu.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItemWithSubmenu.vue new file mode 100644 index 000000000..08922bc37 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/menuItemWithSubmenu.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/forms/Input.vue b/app/javascript/dashboard/components/widgets/forms/Input.vue index 0161240b8..057438895 100644 --- a/app/javascript/dashboard/components/widgets/forms/Input.vue +++ b/app/javascript/dashboard/components/widgets/forms/Input.vue @@ -1,5 +1,5 @@