Compare commits

..
3514 changed files with 81160 additions and 152798 deletions
+128 -116
View File
@@ -1,95 +1,84 @@
version: 2.1 # Ruby CircleCI 2.0 configuration file
orbs: #
node: circleci/node@6.1.0 # Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
defaults: &defaults defaults: &defaults
working_directory: ~/build working_directory: ~/build
machine: docker:
image: ubuntu-2204:2024.05.1 # specify the version you desire here
resource_class: large - image: cimg/ruby:3.3.3-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment: environment:
RAILS_LOG_TO_STDOUT: false - RAILS_LOG_TO_STDOUT: false
COVERAGE: true - COVERAGE: true
LOG_LEVEL: warn - LOG_LEVEL: warn
parallelism: 4 parallelism: 4
resource_class: large
jobs: jobs:
build: build:
<<: *defaults <<: *defaults
steps: steps:
- checkout - checkout
- node/install:
node-version: '20.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update -y
- run: - run:
name: Install System Dependencies name: Configure Bundler
command: | command: |
sudo apt-get update echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ source $BASH_ENV
libpq-dev \ gem install bundler
redis-server \
postgresql-common \
postgresql-16 \
postgresql-16-pgvector \
build-essential \
git \
curl \
libssl-dev \
zlib1g-dev \
libreadline-dev \
libyaml-dev \
openjdk-11-jdk \
jq \
software-properties-common \
ca-certificates \
imagemagick \
libxml2-dev \
libxslt1-dev \
file \
g++ \
gcc \
autoconf \
gnupg2 \
patch \
ruby-dev \
liblzma-dev \
libgmp-dev \
libncurses5-dev \
libffi-dev \
libgdbm6 \
libgdbm-dev \
libvips
- run: - run:
name: Install RVM and Ruby 3.3.3 name: Which bundler?
command: | command: bundle -v
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler -v 2.5.16
- run: - run:
name: Install Application Dependencies name: Swap node versions
command: | command: |
source ~/.rvm/scripts/rvm set +e
bundle install wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
# pnpm install export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler
# Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit
- restore_cache:
keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- run: bundle install --frozen --path ~/.bundle
- save_cache:
paths:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- chatwoot-yarn-
- run:
name: yarn
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
# Store yarn / webpacker cache
- save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
- run: - run:
name: Download cc-test-reporter name: Download cc-test-reporter
@@ -97,8 +86,12 @@ jobs:
mkdir -p ~/tmp mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
# Swagger verification # verify swagger specification
- run: - run:
name: Verify swagger API specification name: Verify swagger API specification
command: | command: |
@@ -111,62 +104,45 @@ jobs:
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
- run:
name: Database Setup and Configure Environment Variables
command: |
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql
chmod 644 ${PWD}/.circleci/setup_chatwoot.sql
mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/
sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql
cp .env.example .env
sed -i '/^FRONTEND_URL/d' .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
# Database setup # Database setup
- run: - run: bundle exec rake db:create
name: Run DB migrations - run: bundle exec rake db:schema:load
command: bundle exec rails db:chatwoot_prepare
# Bundle audit
- run: - run:
name: Bundle audit name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run: - run:
name: Rubocop name: Rubocop
command: bundle exec rubocop command: bundle exec rubocop
# ESLint linting # - run:
# name: Brakeman
# command: bundle exec brakeman
- run: - run:
name: eslint name: eslint
command: pnpm run eslint command: yarn run eslint
# Run frontend tests
- run: - run:
name: Run frontend tests name: Run frontend tests
command: | command: |
mkdir -p ~/build/coverage/frontend mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter before-build
pnpm run test:coverage yarn test:coverage
- run: - run:
name: Code Climate Test Coverage (Frontend) name: Code Climate Test Coverage
command: | command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests # Run rails tests
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend mkdir -p coverage
~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \ bundle exec rspec --format progress \
@@ -174,18 +150,54 @@ jobs:
--out ~/tmp/test-results/rspec.xml \ --out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES} -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
- run: - run:
name: Code Climate Test Coverage (Backend) name: Code Climate Test Coverage
command: | command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
- persist_to_workspace: - persist_to_workspace:
root: ~/build root: coverage
paths: paths:
- coverage - codeclimate.*.json
# collect reports
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/tmp/test-artifacts
- store_artifacts:
path: log
upload-coverage:
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:3.0.2-node-browsers
environment:
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
steps:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
- run:
name: Upload coverage results to Code Climate
command: |
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build
-11
View File
@@ -1,11 +0,0 @@
CREATE USER chatwoot CREATEDB;
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
ALTER ROLE chatwoot SUPERUSER;
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
DROP DATABASE template1;
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
\c template1;
VACUUM FREEZE;
+1 -5
View File
@@ -27,8 +27,7 @@ checks:
threshold: 50 threshold: 50
exclude_patterns: exclude_patterns:
- 'spec/' - 'spec/'
- '**/specs/**/**' - '**/specs/'
- '**/spec/**/**'
- 'db/*' - 'db/*'
- 'bin/**/*' - 'bin/**/*'
- 'db/**/*' - 'db/**/*'
@@ -51,12 +50,9 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js' - 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js' - 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js' - 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js' - 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js' - 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+1 -1
View File
@@ -40,7 +40,7 @@ services:
network_mode: service:db network_mode: service:db
db: db:
image: pgvector/pgvector:pg16 image: postgres:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
+13 -26
View File
@@ -2,36 +2,16 @@ module.exports = {
extends: [ extends: [
'airbnb-base/legacy', 'airbnb-base/legacy',
'prettier', 'prettier',
'plugin:vue/vue3-recommended', 'plugin:vue/recommended',
'plugin:vitest-globals/recommended', 'plugin:storybook/recommended',
'plugin:cypress/recommended',
], ],
overrides: [
{
files: ['**/*.spec.{j,t}s?(x)'],
env: {
'vitest-globals/env': true,
},
},
{
files: ['**/*.story.vue'],
rules: {
'vue/no-undef-components': [
'error',
{
ignorePatterns: ['Variant', 'Story'],
},
],
// Story files can have static strings, it doesn't need to handle i18n always.
'vue/no-bare-strings-in-template': 'off',
'no-console': 'off',
},
},
],
plugins: ['html', 'prettier'],
parserOptions: { parserOptions: {
ecmaVersion: 'latest', parser: '@babel/eslint-parser',
ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['html', 'prettier', 'babel'],
rules: { rules: {
'prettier/prettier': ['error'], 'prettier/prettier': ['error'],
camelcase: 'off', camelcase: 'off',
@@ -230,6 +210,13 @@ module.exports = {
'import/extensions': ['off'], 'import/extensions': ['off'],
'no-console': 'error', 'no-console': 'error',
}, },
settings: {
'import/resolver': {
webpack: {
config: 'config/webpack/resolve.js',
},
},
},
env: { env: {
browser: true, browser: true,
node: true, node: true,
-43
View File
@@ -1,43 +0,0 @@
name: Frontend Lint & Test
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile
- name: Run eslint
run: pnpm run eslint
- name: Run frontend tests with coverage
run: |
mkdir -p coverage
pnpm run test:coverage
+19 -101
View File
@@ -5,7 +5,6 @@
# # # #
name: Publish Chatwoot CE docker images name: Publish Chatwoot CE docker images
on: on:
push: push:
branches: branches:
@@ -15,30 +14,20 @@ on:
- v* - v*
workflow_dispatch: workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs: jobs:
build: build:
strategy: runs-on: ubuntu-latest
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env: env:
GIT_REF: ${{ github.head_ref || github.ref_name }} GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Prepare - name: Set up QEMU
run: | uses: docker/setup-qemu-action@v1
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
@@ -49,97 +38,26 @@ jobs:
run: | run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: Set Docker Tags - name: set docker tag
run: | run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
fi
- name: Set up QEMU - name: replace docker tag if master
uses: docker/setup-qemu-action@v3 if: github.ref_name == 'master'
run: |
- name: Set up Docker Buildx echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/login-action@v1
uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest - name: Build and push
id: build uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
platforms: ${{ matrix.platform }} platforms: linux/amd64
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} push: true
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true tags: ${{ env.DOCKER_TAG }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
+43 -39
View File
@@ -1,3 +1,9 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
on: on:
push: push:
@@ -12,10 +18,10 @@ jobs:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
services: services:
postgres: postgres:
image: pgvector/pgvector:pg15 image: postgres:15.3
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: '' POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ports: ports:
@@ -35,49 +41,47 @@ jobs:
options: --entrypoint redis-server options: --entrypoint redis-server
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 with:
with: ref: ${{ github.event.pull_request.head.ref }}
version: 9 repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm' cache: yarn
- name: Install pnpm dependencies - name: yarn
run: pnpm i run: yarn install
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
rm -rf enterprise rm -rf enterprise
rm -rf spec/enterprise rm -rf spec/enterprise
- name: Create database - name: Create database
run: bundle exec rake db:create run: bundle exec rake db:create
- name: Seed database - name: Seed database
run: bundle exec rake db:schema:load run: bundle exec rake db:schema:load
- name: Run frontend tests - name: yarn check-files
run: pnpm run test:coverage run: yarn install --check-files
# Run rails tests # Run rails tests
- name: Run backend tests - name: Run backend tests
run: | run: |
bundle exec rspec --profile=10 --format documentation bundle exec rspec --profile=10 --format documentation
env: env:
NODE_OPTIONS: --openssl-legacy-provider NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: rails-log-folder name: rails-log-folder
path: log path: log
@@ -0,0 +1,85 @@
# #
# # This workflow will run specs related to response bot
# # This can only be activated in installations Where vector extension is available.
# #
name: Run Response Bot spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-20.04
services:
postgres:
image: ankane/pgvector
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- name: yarn
run: yarn install
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Enable ResponseBotService in installation
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+5 -15
View File
@@ -19,33 +19,23 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm' cache: 'yarn'
- name: pnpm - name: yarn
run: pnpm install run: yarn install
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
rm -rf enterprise rm -rf enterprise
rm -rf spec/enterprise rm -rf spec/enterprise
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile - name: Run asset compile
run: bundle exec rake assets:precompile run: bundle exec rake assets:precompile
env: env:
RAILS_ENV: production NODE_OPTIONS: --openssl-legacy-provider
- name: Size Check - name: Size Check
run: pnpm run size run: yarn run size
+1 -15
View File
@@ -32,16 +32,6 @@ master.key
public/uploads public/uploads
public/packs* public/packs*
public/assets/administrate*
public/assets/action*.js
public/assets/activestorage*.js
public/assets/trix*
public/assets/belongs_to*.js
public/assets/manifest*.js
public/assets/manifest*.js
public/assets/*.js.gz
public/assets/secretField*
public/assets/.sprockets-manifest-*.json
# VIM files # VIM files
*.swp *.swp
@@ -85,8 +75,4 @@ yalc.lock
yarn-debug.log* yarn-debug.log*
.yarn-integrity .yarn-integrity
# Vite Ruby /storybook-static
/public/vite*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
+8 -8
View File
@@ -1,11 +1,11 @@
# #!/bin/sh #!/bin/sh
# . "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
# # lint js and vue files # lint js and vue files
# npx --no-install lint-staged npx --no-install lint-staged
# # lint only staged ruby files # lint only staged ruby files
# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# # stage rubocop changes to files # stage rubocop changes to files
# git diff --name-only --cached | xargs git add git diff --name-only --cached | xargs git add
+56
View File
@@ -0,0 +1,56 @@
const path = require('path');
const resolve = require('../config/webpack/resolve');
// Chatwoot's webpack.config.js
process.env.NODE_ENV = 'development';
const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{
name: '@storybook/addon-docs',
options: {
vueDocgenOptions: {
alias: {
'@': path.resolve(__dirname, '../'),
},
},
},
},
'@storybook/addon-links',
'@storybook/addon-essentials',
{
/**
* Fix Storybook issue with PostCSS@8
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
*/
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
webpackFinal: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
modules: custom.resolvedModules.map(i => i.value),
},
};
newConfig.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};
+46
View File
@@ -0,0 +1,46 @@
import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Multiselect from 'vue-multiselect';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function () {
this.$root._i18n = this.$i18n;
},
}));
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
+9 -12
View File
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively # original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client # we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'google-cloud-storage', require: false
gem 'image_processing' gem 'image_processing'
##-- gems for database --# ##-- gems for database --#
@@ -64,7 +64,7 @@ gem 'activerecord-import'
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma' gem 'puma'
gem 'vite_rails' gem 'webpacker'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
@@ -96,12 +96,12 @@ gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.2.0' gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'google-cloud-dialogflow-v2'
gem 'grpc' gem 'grpc'
# Translate integrations # Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version # 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem # this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3', '>= 0.7.0' gem 'google-cloud-translate-v3'
##-- apm and error monitoring ---# ##-- apm and error monitoring ---#
# loaded only when environment variables are set. # loaded only when environment variables are set.
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth # need for google auth
gem 'omniauth', '>= 2.1.2' gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot ## Gems for reponse bot
@@ -175,17 +175,12 @@ gem 'pgvector'
# Convert Website HTML to Markdown # Convert Website HTML to Markdown
gem 'reverse_markdown' gem 'reverse_markdown'
gem 'ruby-openai'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
group :production do group :production do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end end
group :development do group :development do
@@ -205,10 +200,12 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof' gem 'stackprof'
# Should install the associated chrome extension to view query logs # Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3' gem 'meta_request', '>= 0.8.0'
end end
group :test do group :test do
# Cypress in rails.
gem 'cypress-on-rails'
# fast cleaning of database # fast cleaning of database
gem 'database_cleaner' gem 'database_cleaner'
# mock http calls # mock http calls
@@ -231,7 +228,7 @@ group :development, :test do
gem 'mock_redis' gem 'mock_redis'
gem 'pry-rails' gem 'pry-rails'
gem 'rspec_junit_formatter' gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.5' gem 'rspec-rails', '>= 6.1.4'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
+139 -150
View File
@@ -33,70 +33,70 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.8.7) actioncable (7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.7) actionmailbox (7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
activejob (= 7.0.8.7) activejob (= 7.0.8.4)
activerecord (= 7.0.8.7) activerecord (= 7.0.8.4)
activestorage (= 7.0.8.7) activestorage (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.8.7) actionmailer (7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
actionview (= 7.0.8.7) actionview (= 7.0.8.4)
activejob (= 7.0.8.7) activejob (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.8.7) actionpack (7.0.8.4)
actionview (= 7.0.8.7) actionview (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.7) actiontext (7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
activerecord (= 7.0.8.7) activerecord (= 7.0.8.4)
activestorage (= 7.0.8.7) activestorage (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.8.7) actionview (7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.0.8.7) activejob (7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.8.7) activemodel (7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
activerecord (7.0.8.7) activerecord (7.0.8.4)
activemodel (= 7.0.8.7) activemodel (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
activerecord-import (1.4.1) activerecord-import (1.4.1)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.0.8.7) activestorage (7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
activejob (= 7.0.8.7) activejob (= 7.0.8.4)
activerecord (= 7.0.8.7) activerecord (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.8.7) activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
@@ -178,6 +178,8 @@ GEM
csv (3.3.0) csv (3.3.0)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
cypress-on-rails (1.16.0)
rack
database_cleaner (2.0.2) database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3) database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0) database_cleaner-active_record (2.1.0)
@@ -209,7 +211,7 @@ GEM
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2) rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1) diff-lcs (1.5.1)
digest-crc (0.6.5) digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.0) docile (1.4.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
@@ -220,7 +222,6 @@ GEM
railties (>= 6.1) railties (>= 6.1)
down (5.4.0) down (5.4.0)
addressable (~> 2.8) addressable (~> 2.8)
dry-cli (1.1.0)
ecma-re-validator (0.4.0) ecma-re-validator (0.4.0)
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
elastic-apm (4.6.2) elastic-apm (4.6.2)
@@ -229,9 +230,8 @@ GEM
ruby2_keywords ruby2_keywords
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.7)
tzinfo tzinfo
event_stream_parser (1.0.0)
execjs (2.8.1) execjs (2.8.1)
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
@@ -257,7 +257,7 @@ GEM
faraday-net_http_persistent (2.1.0) faraday-net_http_persistent (2.1.0)
faraday (~> 2.5) faraday (~> 2.5)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
faraday-retry (2.2.1) faraday-retry (2.1.0)
faraday (~> 2.0) faraday (~> 2.0)
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
@@ -268,10 +268,10 @@ GEM
rake rake
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.2) foreman (0.87.2)
fugit (1.11.1) fugit (1.9.0)
et-orbi (~> 1, >= 1.2.11) et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4) raabro (~> 1.4)
gapic-common (0.20.0) gapic-common (0.18.0)
faraday (>= 1.9, < 3.a) faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a) faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
@@ -285,57 +285,57 @@ GEM
activesupport (>= 6.1) activesupport (>= 6.1)
gmail_xoauth (0.4.3) gmail_xoauth (0.4.3)
oauth (>= 0.3.6) oauth (>= 0.3.6)
google-apis-core (0.15.1) google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.3, < 3.a) httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
mutex_m
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0) rexml
google-apis-core (>= 0.15.0, < 2.a) webrick
google-apis-storage_v1 (0.47.0) google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1) google-apis-storage_v1 (0.19.0)
google-cloud-env (>= 1.0, < 3.a) google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0) google-cloud-dialogflow-v2 (0.23.0)
gapic-common (>= 0.20.0, < 2.a) gapic-common (>= 0.18.0, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a) google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (2.2.1) google-cloud-env (1.6.0)
faraday (>= 1.0, < 3.a) faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1) google-cloud-errors (1.3.1)
google-cloud-location (0.6.0) google-cloud-location (0.4.0)
gapic-common (>= 0.20.0, < 2.a) gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-storage (1.52.0) google-cloud-storage (1.44.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (~> 0.13) google-apis-iamcredentials_v1 (~> 0.1)
google-apis-iamcredentials_v1 (~> 0.18) google-apis-storage_v1 (~> 0.19.0)
google-apis-storage_v1 (~> 0.38)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (~> 1.9) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0) google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.20.0, < 2.a) gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.5) google-protobuf (3.25.3)
google-protobuf (3.25.5-arm64-darwin) google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin) google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux) google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.6.0) googleapis-common-protos (1.4.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.7) googleapis-common-protos-types (~> 1.2)
grpc (~> 1.41) grpc (~> 1.27)
googleapis-common-protos-types (1.14.0) googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18) google-protobuf (~> 3.18)
googleauth (1.11.2) googleauth (1.5.2)
faraday (>= 1.0, < 3.a) faraday (>= 0.17.3, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
@@ -374,7 +374,7 @@ GEM
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.6) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@@ -398,13 +398,6 @@ GEM
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
judoscale-ruby (1.8.2)
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1) jwt (2.8.1)
base64 base64
kaminari (1.2.2) kaminari (1.2.2)
@@ -453,7 +446,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.23.1) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -463,7 +456,8 @@ GEM
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
maxminddb (0.1.22) maxminddb (0.1.22)
meta_request (0.8.3) memoist (0.16.2)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3) rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8) railties (>= 3.0.0, < 8)
method_source (1.1.0) method_source (1.1.0)
@@ -472,22 +466,21 @@ GEM
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.7)
minitest (5.25.4) minitest (5.24.1)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.7.0) msgpack (1.7.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)
activerecord (>= 5.2) activerecord (>= 5.2)
net-http (0.4.1) net-http (0.4.1)
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.17) net-imap (0.4.12)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -503,14 +496,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.17.1) nokogiri (1.16.6)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.1-arm64-darwin) nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.1-x86_64-darwin) nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.1-x86_64-linux) nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -529,7 +522,7 @@ GEM
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-google-oauth2 (1.1.3) omniauth-google-oauth2 (1.1.2)
jwt (>= 2.0) jwt (>= 2.0)
oauth2 (~> 2.0) oauth2 (~> 2.0)
omniauth (~> 2.0) omniauth (~> 2.0)
@@ -559,13 +552,13 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (6.0.0) public_suffix (6.0.0)
puma (6.4.3) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.0)
rack (2.2.10) rack (2.2.9)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -577,35 +570,35 @@ GEM
rack-protection (3.2.0) rack-protection (3.2.0)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4) rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7) rack-proxy (0.7.6)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rails (7.0.8.7) rails (7.0.8.4)
actioncable (= 7.0.8.7) actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.7) actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.7) actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
actiontext (= 7.0.8.7) actiontext (= 7.0.8.4)
actionview (= 7.0.8.7) actionview (= 7.0.8.4)
activejob (= 7.0.8.7) activejob (= 7.0.8.4)
activemodel (= 7.0.8.7) activemodel (= 7.0.8.4)
activerecord (= 7.0.8.7) activerecord (= 7.0.8.4)
activestorage (= 7.0.8.7) activestorage (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.8.7) railties (= 7.0.8.4)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (~> 1.14)
railties (7.0.8.7) railties (7.0.8.4)
actionpack (= 7.0.8.7) actionpack (= 7.0.8.4)
activesupport (= 7.0.8.7) activesupport (= 7.0.8.4)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@@ -641,19 +634,20 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.3.9) rexml (3.3.4)
strscan
rspec-core (3.13.0) rspec-core (3.13.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.2) rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.1) rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.0.1) rspec-rails (6.1.4)
actionpack (>= 7.0) actionpack (>= 6.1)
activesupport (>= 7.0) activesupport (>= 6.1)
railties (>= 7.0) railties (>= 6.1)
rspec-core (~> 3.13) rspec-core (~> 3.13)
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
@@ -685,10 +679,6 @@ GEM
rubocop-rspec (2.21.0) rubocop-rspec (2.21.0)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-capybara (~> 2.17) rubocop-capybara (~> 2.17)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
@@ -719,6 +709,7 @@ GEM
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.19.0) sentry-rails (5.19.0)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.19.0) sentry-ruby (~> 5.19.0)
@@ -775,6 +766,7 @@ GEM
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stripe (8.5.0) stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.1) thor (1.3.1)
@@ -804,17 +796,10 @@ GEM
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
uri (0.13.0) uri (0.13.0)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (4.0.6)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.4) version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2)
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.2.1)
@@ -829,6 +814,12 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.4)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
@@ -836,7 +827,7 @@ GEM
working_hours (1.4.1) working_hours (1.4.1)
activesupport (>= 3.2) activesupport (>= 3.2)
tzinfo tzinfo
zeitwerk (2.6.17) zeitwerk (2.6.16)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@@ -871,6 +862,7 @@ DEPENDENCIES
climate_control climate_control
commonmarker commonmarker
csv-safe csv-safe
cypress-on-rails
database_cleaner database_cleaner
ddtrace ddtrace
debug (~> 1.8) debug (~> 1.8)
@@ -889,9 +881,9 @@ DEPENDENCIES
foreman foreman
geocoder geocoder
gmail_xoauth gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0) google-cloud-dialogflow-v2
google-cloud-storage (>= 1.48.0) google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0) google-cloud-translate-v3
groupdate groupdate
grpc grpc
haikunator haikunator
@@ -902,8 +894,6 @@ DEPENDENCIES
jbuilder jbuilder
json_refs json_refs
json_schemer json_schemer
judoscale-rails
judoscale-sidekiq
jwt jwt
kaminari kaminari
koala koala
@@ -913,14 +903,14 @@ DEPENDENCIES
listen listen
lograge (~> 0.14.0) lograge (~> 0.14.0)
maxminddb maxminddb
meta_request (>= 0.8.3) meta_request (>= 0.8.0)
mock_redis mock_redis
neighbor neighbor
net-smtp (~> 0.3.4) net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2) newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm newrelic_rpm
omniauth (>= 2.1.2) omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3) omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2 omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg pg
@@ -940,13 +930,12 @@ DEPENDENCIES
responders (>= 3.1.1) responders (>= 3.1.1)
rest-client rest-client
reverse_markdown reverse_markdown
rspec-rails (>= 6.1.5) rspec-rails (>= 6.1.4)
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
@@ -971,10 +960,10 @@ DEPENDENCIES
tzinfo-data tzinfo-data
uglifier uglifier
valid_email2 valid_email2
vite_rails
web-console (>= 4.2.1) web-console (>= 4.2.1)
web-push (>= 3.0.1) web-push (>= 3.0.1)
webmock webmock
webpacker
wisper (= 2.0.0) wisper (= 2.0.0)
working_hours working_hours
+2 -2
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup: setup:
gem install bundler gem install bundler
bundle install bundle install
pnpm install yarn install
db_create: db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
@@ -30,7 +30,7 @@ server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000 RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn: burn:
bundle && pnpm install bundle && yarn
run: run:
@if [ -f ./.overmind.sock ]; then \ @if [ -f ./.overmind.sock ]; then \
+1 -1
View File
@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000 backend: bin/rails s -p 3000
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695 # https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev
+1 -1
View File
@@ -1,3 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050 backend: RAILS_ENV=test bin/rails s -p 5050
vite: bin/vite dev frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
+3 -6
View File
@@ -1,12 +1,9 @@
## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> <img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/> <img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
___ ___
# Chatwoot # Chatwoot
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p> <p>
@@ -101,7 +98,7 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
### Other deployment options ### Other deployment options
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy). For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security ## Security
@@ -120,4 +117,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2024, Chatwoot Inc - Released under the MIT License.
+4 -4
View File
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
## Reporting a Vulnerability ## Reporting a Vulnerability
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, contact security@chatwoot.com. If you have any questions about the process, contact security@chatwoot.com.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible. Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new) > Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
## Supported versions ## Supported versions
| Version | Supported | | Version | Supported |
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
- Brute force attacks - Brute force attacks
- DNSSEC - DNSSEC
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new). If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
## Thanks ## Thanks
+1 -1
View File
@@ -1 +1 @@
3.13.0 3.9.0
+1 -1
View File
@@ -1 +1 @@
3.1.0 2.8.0
+2 -2
View File
@@ -48,7 +48,7 @@
"size": "basic" "size": "basic"
} }
}, },
"stack": "heroku-24", "stack": "heroku-20",
"image": "heroku/ruby", "image": "heroku/ruby",
"addons": [ "addons": [
{ {
@@ -58,7 +58,7 @@
"plan": "heroku-postgresql:essential-0" "plan": "heroku-postgresql:essential-0"
} }
], ],
"stack": "heroku-24", "stack": "heroku-20",
"buildpacks": [ "buildpacks": [
{ {
"url": "heroku/nodejs" "url": "heroku/nodejs"
+1 -1
View File
@@ -54,7 +54,7 @@ class ContactMergeAction
# attributes in base contact are given preference # attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes) merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.reload.destroy! @mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)]) tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes) @base_contact.update!(merged_attributes)
+1
View File
@@ -2,4 +2,5 @@
//= link administrate/application.css //= link administrate/application.css
//= link administrate/application.js //= link administrate/application.js
//= link administrate-field-active_storage/application.css //= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js //= link secretField.js
+55
View File
@@ -0,0 +1,55 @@
// eslint-disable-next-line
function prepareData(data) {
var labels = [];
var dataSet = [];
data.forEach(item => {
labels.push(item[0]);
dataSet.push(item[1]);
});
return { labels, dataSet };
}
function getChartOptions() {
var fontFamily =
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
return {
responsive: true,
legend: { labels: { fontFamily } },
scales: {
xAxes: [
{
barPercentage: 1.26,
ticks: { fontFamily },
gridLines: { display: false },
},
],
yAxes: [
{
ticks: { fontFamily },
gridLines: { display: false },
},
],
},
};
}
// eslint-disable-next-line
function drawSuperAdminDashboard(data) {
var ctx = document.getElementById('dashboard-chart').getContext('2d');
var chartData = prepareData(data);
// eslint-disable-next-line
new Chart(ctx, {
type: 'bar',
data: {
labels: chartData.labels,
datasets: [
{
label: 'Conversations',
data: chartData.dataSet,
backgroundColor: '#1f93ff',
},
],
},
options: getChartOptions(),
});
}
@@ -86,5 +86,8 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default; $swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default; $swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions // Transitions
$transition-ease-in: all 0.250s ease-in; $transition-ease-in: all 0.250s ease-in;
+2 -21
View File
@@ -32,13 +32,11 @@ class AccountBuilder
end end
def validate_email def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email) address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable? if address.valid? # && !address.disposable?
true true
else else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? }) raise InvalidEmail.new(valid: address.valid?)
end end
end end
@@ -81,21 +79,4 @@ class AccountBuilder
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
end end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end end
-2
View File
@@ -25,8 +25,6 @@ class NotificationBuilder
def build_notification def build_notification
# Create conversation_creation notification only if user is subscribed to it # Create conversation_creation notification only if user is subscribed to it
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
user.notifications.create!( user.notifications.create!(
notification_type: notification_type, notification_type: notification_type,
@@ -2,38 +2,52 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!] pattr_initialize [:account!, :params!]
def build def build
load_data set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report prepare_report
end end
private private
attr_reader :conversations_count, :resolved_count, def set_grouped_conversations_count
:avg_resolution_time, :avg_first_response_time, :avg_reply_time @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end end
def prepare_report def set_grouped_avg_resolution_time
account.account_users.map do |account_user| @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
build_agent_stats(account_user)
end
end end
def build_agent_stats(account_user) def set_grouped_avg_first_response_time
user_id = account_user.user_id @grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
{ end
id: user_id,
conversations_count: conversations_count[user_id] || 0, def set_grouped_avg_reply_time
resolved_conversations_count: resolved_count[user_id] || 0, @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
avg_resolution_time: avg_resolution_time[user_id],
avg_first_response_time: avg_first_response_time[user_id],
avg_reply_time: avg_reply_time[user_id]
}
end end
def group_by_key def group_by_key
:user_id :user_id
end end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end end
@@ -1,50 +1,17 @@
class V2::Reports::BaseSummaryBuilder class V2::Reports::BaseSummaryBuilder
include DateRangeHelper include DateRangeHelper
def build
load_data
prepare_report
end
private private
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key def group_by_key
# Override this method # Override this method
end end
def prepare_report
# Override this method
end
def get_grouped_average(events) def get_grouped_average(events)
events.group(group_by_key).average(average_value_key) events.group(group_by_key).average(average_value_key)
end end
def average_value_key def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value params[:business_hours].present? ? :value_in_business_hours : :value
end end
end end
@@ -1,50 +0,0 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
end
end
def build_inbox_stats(inbox)
{
id: inbox.id,
conversations_count: conversations_count[inbox.id] || 0,
resolved_conversations_count: resolved_count[inbox.id] || 0,
avg_resolution_time: avg_resolution_time[inbox.id],
avg_first_response_time: avg_first_response_time[inbox.id],
avg_reply_time: avg_reply_time[inbox.id]
}
end
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
+34 -22
View File
@@ -1,37 +1,49 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!] pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private private
attr_reader :conversations_count, :resolved_count, def set_grouped_conversations_count
:avg_resolution_time, :avg_first_response_time, :avg_reply_time @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
def fetch_conversations_count def set_grouped_avg_resolution_time
account.conversations.where(created_at: range).group(:team_id).count @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end end
def reporting_events def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) @reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
end
end
def build_team_stats(team)
{
id: team.id,
conversations_count: conversations_count[team.id] || 0,
resolved_conversations_count: resolved_count[team.id] || 0,
avg_resolution_time: avg_resolution_time[team.id],
avg_first_response_time: avg_first_response_time[team.id],
avg_reply_time: avg_reply_time[team.id]
}
end end
def group_by_key def group_by_key
'conversations.team_id' 'conversations.team_id'
end end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end end
@@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end end
def object_scope def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) scope.reporting_events.where(name: event_name, created_at: range)
end end
def reporting_events def reporting_events
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def update def update
@agent.update!(agent_params.slice(:name).compact) @agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact) @agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end end
def destroy def destroy
@@ -67,16 +67,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id]) @agent = agents.find(params[:id])
end end
def account_user_attributes
[:role, :availability, :auto_offline]
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params def agent_params
params.require(:agent).permit(allowed_agent_params) params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end end
def new_agent_params def new_agent_params
@@ -109,5 +101,3 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank? DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end end
end end
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
@@ -6,15 +6,13 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def index def index
@portal_articles = @portal.articles @portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
set_article_count @articles_count = @all_articles.count
@articles = @articles.search(list_params)
@articles = if list_params[:category_slug].present? @articles = if list_params[:category_slug].present?
@articles.order_by_position.page(@current_page) @all_articles.order_by_position.page(@current_page)
else else
@articles.order_by_updated_at.page(@current_page) @all_articles.order_by_updated_at.page(@current_page)
end end
end end
@@ -45,19 +43,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
private private
def set_article_count
# Search the params without status and author_id, use this to
# compute mine count published draft etc
base_search_params = list_params.except(:status, :author_id)
@articles = @portal_articles.search(base_search_params)
@articles_count = @articles.count
@mine_articles_count = @articles.search_by_author(Current.user.id).count
@published_articles_count = @articles.published.count
@draft_articles_count = @articles.draft.count
@archived_articles_count = @articles.archived.count
end
def fetch_article def fetch_article
@article = @portal.articles.find(params[:id]) @article = @portal.articles.find(params[:id])
end end
@@ -68,10 +53,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
:locale, meta: [:title, :description,
:description, { tags: [] }]
{ tags: [] }]
) )
end end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -68,7 +68,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute, rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator, CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
message.attachments.destroy_all message.attachments.destroy_all
end end
end end
@@ -46,7 +46,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count] @conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute, rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator, CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -0,0 +1,22 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
end
private
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
end
end
@@ -11,17 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end end
def process_event def process_event
response = @hook.process_event(params[:event]) render json: { message: @hook.process_event(params[:event]) }
# for cases like an invalid event, or when conversation does not have enough messages
# for a label suggestion, the response is nil
if response.nil?
render json: { message: nil }
elsif response[:error]
render json: { error: response[:error] }, status: :unprocessable_entity
else
render json: { message: response[:message] }
end
end end
def destroy def destroy
@@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end end
def create def create
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params)) @portal = Current.account.portals.build(portal_params)
@portal.custom_domain = parsed_custom_domain @portal.custom_domain = parsed_custom_domain
@portal.save! @portal.save!
process_attached_logo process_attached_logo
@@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update def update
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present? @portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain # @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present? process_attached_logo if params[:blob_id].present?
rescue StandardError => e rescue StandardError => e
@@ -70,21 +70,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params def portal_params
params.require(:portal).permit( params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] } { allowed_locales: [] }] }
) )
end end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
end
def portal_member_params def portal_member_params
params.require(:portal).permit(:account_id, member_ids: []) params.require(:portal).permit(:account_id, member_ids: [])
end end
@@ -1,68 +1,13 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create def create
result = if params[:attachment].present? file_blob = ActiveStorage::Blob.create_and_upload!(
create_from_file key: nil,
elsif params[:external_url].present? io: params[:attachment].tempfile,
create_from_url filename: params[:attachment].original_filename,
else content_type: params[:attachment].content_type
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
end
private
def create_from_file
attachment = params[:attachment]
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
ActiveStorage::Blob.create_and_upload!(
io: io,
filename: filename,
content_type: content_type
) )
end file_blob.save!
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end end
def render_error(message, status)
render json: { error: message }, status: status
end
end end
@@ -17,7 +17,7 @@ class Api::V1::ProfilesController < Api::BaseController
def avatar def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached? @user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload head :ok
end end
def auto_offline def auto_offline
@@ -11,8 +11,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
process_update_contact process_update_contact
@conversation = create_conversation @conversation = create_conversation
conversation.messages.create!(message_params) conversation.messages.create!(message_params)
# TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer
conversation.reload
end end
end end
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end end
def check_authorization def check_authorization
return if Current.account_user.administrator? raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
raise Pundit::NotAuthorizedError
end end
def common_params def common_params
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end end
end end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox] before_action :prepare_builder_params, only: [:agent, :team]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,10 +10,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder) render_report_with(V2::Reports::TeamSummaryBuilder)
end end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private private
def check_authorization def check_authorization
@@ -30,7 +26,8 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class) def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params) builder = builder_class.new(account: Current.account, params: @builder_params)
render json: builder.build data = builder.build
render json: data
end end
def permitted_params def permitted_params
+1 -1
View File
@@ -72,7 +72,7 @@ class DashboardController < ActionController::Base
@application_pack = if request.path.include?('/auth') || request.path.include?('/login') @application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app' 'v3app'
else else
'dashboard' 'application'
end end
end end
@@ -1,15 +1,6 @@
class Google::CallbacksController < OauthCallbackController class Google::CallbacksController < OauthCallbackController
include GoogleConcern include GoogleConcern
def find_channel_by_email
# find by imap_login first, and then by email
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
return imap_channel if imap_channel
Channel::Email.find_by(email: users_data['email'], account: account)
end
private private
def provider_name def provider_name
+1 -5
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end end
def find_or_create_inbox def find_or_create_inbox
channel_email = find_channel_by_email channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback # we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present? channel_exists = channel_email.present?
@@ -39,10 +39,6 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists] [channel_email.inbox, channel_exists]
end end
def find_channel_by_email
Channel::Email.find_by(email: users_data['email'], account: account)
end
def update_channel(channel_email) def update_channel(channel_email)
channel_email.update!({ channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address, imap_login: users_data['email'], imap_address: imap_address,
@@ -4,7 +4,6 @@ class Platform::Api::V1::AccountsController < PlatformController
def create def create
@resource = Account.create!(account_params) @resource = Account.create!(account_params)
update_resource_features update_resource_features
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end end
@@ -6,11 +6,10 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal' layout 'portal'
def index def index
@articles = @portal.articles.published @articles = @portal.articles
@articles_count = @articles.count
search_articles search_articles
order_by_sort_param order_by_sort_param
@articles = @articles.page(list_params[:page]) if list_params[:page].present? @articles.page(list_params[:page]) if list_params[:page].present?
end end
def show; end def show; end
@@ -31,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published? @article.increment_view_count
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end
@@ -45,7 +44,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end end
def list_params def list_params
params.permit(:query, :locale, :sort, :status, :page) params.permit(:query, :locale, :sort, :status)
end end
def permitted_params def permitted_params
+2 -3
View File
@@ -26,8 +26,7 @@ class AccountDashboard < Administrate::BaseDashboard
conversations: CountField, conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), 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]]), status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany, account_users: Field::HasMany
custom_attributes: Field::String
}.merge(enterprise_attribute_types).freeze }.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES # COLLECTION_ATTRIBUTES
@@ -46,7 +45,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES # SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page. # an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : [] enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
SHOW_PAGE_ATTRIBUTES = (%i[ SHOW_PAGE_ATTRIBUTES = (%i[
id id
name name
-2
View File
@@ -22,5 +22,3 @@ class AsyncDispatcher < BaseDispatcher
] ]
end end
end end
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')
@@ -2,6 +2,6 @@ require 'administrate/field/base'
class Enterprise::AccountLimitsField < Administrate::Field::Base class Enterprise::AccountLimitsField < Administrate::Field::Base
def to_s def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json
end end
end end
+27 -39
View File
@@ -1,70 +1,58 @@
module Api::V2::Accounts::ReportsHelper module Api::V2::Accounts::ReportsHelper
def generate_agents_report def generate_agents_report
reports = V2::Reports::AgentSummaryBuilder.new(
account: Current.account,
params: build_params(type: :agent)
).build
Current.account.users.map do |agent| Current.account.users.map do |agent|
report = reports.find { |r| r[:id] == agent.id } agent_report = report_builder({ type: :agent, id: agent.id }).summary
[agent.name] + generate_readable_report_metrics(report) [agent.name] + generate_readable_report_metrics(agent_report)
end end
end end
def generate_inboxes_report def generate_inboxes_report
reports = V2::Reports::InboxSummaryBuilder.new(
account: Current.account,
params: build_params(type: :inbox)
).build
Current.account.inboxes.map do |inbox| Current.account.inboxes.map do |inbox|
report = reports.find { |r| r[:id] == inbox.id } inbox_report = generate_report({ type: :inbox, id: inbox.id })
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report) [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
end end
end end
def generate_teams_report def generate_teams_report
reports = V2::Reports::TeamSummaryBuilder.new(
account: Current.account,
params: build_params(type: :team)
).build
Current.account.teams.map do |team| Current.account.teams.map do |team|
report = reports.find { |r| r[:id] == team.id } team_report = report_builder({ type: :team, id: team.id }).summary
[team.name] + generate_readable_report_metrics(report) [team.name] + generate_readable_report_metrics(team_report)
end end
end end
def generate_labels_report def generate_labels_report
Current.account.labels.map do |label| Current.account.labels.map do |label|
label_report = report_builder({ type: :label, id: label.id }).short_summary label_report = generate_report({ type: :label, id: label.id })
[label.title] + generate_readable_report_metrics(label_report) [label.title] + generate_readable_report_metrics(label_report)
end end
end end
private def report_builder(report_params)
V2::ReportBuilder.new(
def build_params(base_params) Current.account,
base_params.merge( report_params.merge(
{ {
since: params[:since], since: params[:since],
until: params[:until], until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
} }
)
) )
end end
def report_builder(report_params) def generate_report(report_params)
V2::ReportBuilder.new(Current.account, build_params(report_params)) report_builder(report_params).short_summary
end end
def generate_readable_report_metrics(report) private
def generate_readable_report_metrics(report_metric)
[ [
report[:conversations_count], report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format, Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format, Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format, Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
report[:resolved_conversations_count] report_metric[:resolutions_count]
] ]
end end
end end
-8
View File
@@ -81,12 +81,4 @@ module FilterHelper
def default_filter(query_hash, filter_operator_value) def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}" "#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
end end
+14 -43
View File
@@ -1,5 +1,6 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
@@ -8,12 +9,9 @@ import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import WootSnackbarBox from './components/SnackbarContainer.vue'; import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper'; import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import { import {
registerSubscription, registerSubscription,
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
@@ -33,13 +31,6 @@ export default {
UpgradeBanner, UpgradeBanner,
PendingEmailVerificationBanner, PendingEmailVerificationBanner,
}, },
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store, currentAccountId: accountId };
},
data() { data() {
return { return {
showAddAccountModal: false, showAddAccountModal: false,
@@ -47,6 +38,7 @@ export default {
reconnectService: null, reconnectService: null,
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
getAccount: 'accounts/getAccount', getAccount: 'accounts/getAccount',
@@ -54,6 +46,7 @@ export default {
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags', authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags', accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}), }),
hasAccounts() { hasAccounts() {
const { accounts = [] } = this.currentUser || {}; const { accounts = [] } = this.currentUser || {};
@@ -70,13 +63,10 @@ export default {
this.showAddAccountModal = true; this.showAddAccountModal = true;
} }
}, },
currentAccountId: { currentAccountId() {
immediate: true, if (this.currentAccountId) {
handler() { this.initializeAccount();
if (this.currentAccountId) { }
this.initializeAccount();
}
},
}, },
}, },
mounted() { mounted() {
@@ -84,7 +74,7 @@ export default {
this.listenToThemeChanges(); this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale); this.setLocale(window.chatwootConfig.selectedLocale);
}, },
unmounted() { beforeDestroy() {
if (this.reconnectService) { if (this.reconnectService) {
this.reconnectService.disconnect(); this.reconnectService.disconnect();
} }
@@ -110,9 +100,8 @@ export default {
const { pubsub_token: pubsubToken } = this.currentUser || {}; const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale); this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion; this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken); vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router); this.reconnectService = new ReconnectService(this.$store, router);
window.reconnectService = this.reconnectService;
verifyServiceWorkerExistence(registration => verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => { registration.pushManager.getSubscription().then(subscription => {
@@ -140,11 +129,9 @@ export default {
<PaymentPendingBanner v-if="hideOnOnboardingView" /> <PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner /> <UpgradeBanner />
</template> </template>
<router-view v-slot="{ Component }"> <transition name="fade" mode="out-in">
<transition name="fade" mode="out-in"> <router-view />
<component :is="Component" /> </transition>
</transition>
</router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" /> <AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox /> <WootSnackbarBox />
<NetworkNotification /> <NetworkNotification />
@@ -154,22 +141,6 @@ export default {
<style lang="scss"> <style lang="scss">
@import './assets/scss/app'; @import './assets/scss/app';
.v-popper--theme-tooltip .v-popper__inner {
background: black !important;
font-size: 0.75rem;
padding: 4px 8px !important;
border-radius: 6px;
font-weight: 400;
}
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style> </style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style> <style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
@@ -1,19 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
},
});
}
}
export default new CaptainAssistant();
@@ -1,20 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainDocument extends ApiClient {
constructor() {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
},
});
}
}
export default new CaptainDocument();
@@ -1,26 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainInboxes extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId } = {}) {
return axios.get(`${this.url}/${assistantId}/inboxes`);
}
create(params = {}) {
const { assistantId, inboxId } = params;
return axios.post(`${this.url}/${assistantId}/inboxes`, {
inbox: { inbox_id: inboxId },
});
}
delete(params = {}) {
const { assistantId, inboxId } = params;
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
}
}
export default new CaptainInboxes();
@@ -1,22 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainResponses extends ApiClient {
constructor() {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
document_id: documentId,
status,
},
});
}
}
export default new CaptainResponses();
-8
View File
@@ -27,14 +27,6 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
show(id) {
return axios.get(`${this.url}/${id}?include_contact_inboxes=false`);
}
update(id, data) {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId) { getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`); return axios.get(`${this.url}/${contactId}/conversations`);
} }
@@ -1,9 +0,0 @@
import ApiClient from './ApiClient';
class CustomRole extends ApiClient {
constructor() {
super('custom_roles', { accountScoped: true });
}
}
export default new CustomRole();
@@ -52,13 +52,12 @@ class ArticlesAPI extends PortalsAPI {
} }
createArticle({ portalSlug, articleObj }) { createArticle({ portalSlug, articleObj }) {
const { content, title, authorId, categoryId, locale } = articleObj; const { content, title, author_id, category_id } = articleObj;
return axios.post(`${this.url}/${portalSlug}/articles`, { return axios.post(`${this.url}/${portalSlug}/articles`, {
content, content,
title, title,
author_id: authorId, author_id,
category_id: categoryId, category_id,
locale,
}); });
} }
@@ -133,10 +133,6 @@ class ConversationApi extends ApiClient {
getAllAttachments(conversationId) { getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`); return axios.get(`${this.url}/${conversationId}/attachments`);
} }
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
} }
export default new ConversationApi(); export default new ConversationApi();
@@ -32,6 +32,10 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) { deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
} }
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
}
} }
export default new IntegrationsAPI(); export default new IntegrationsAPI();
@@ -1,40 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 76 KiB

@@ -1,3 +0,0 @@
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M800 0H600V200H400V400H200V600H0V800H200H400H600H800V600V400V200V0Z" fill="#2773E4" fill-opacity="0.42"/>
</svg>

Before

Width:  |  Height:  |  Size: 262 B

@@ -1,3 +0,0 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M200 0H0V200V400V600H200V400H400V200H600V0H400H200Z" fill="#2773E4" fill-opacity="0.42"/>
</svg>

Before

Width:  |  Height:  |  Size: 246 B

@@ -31,6 +31,23 @@
transform: translateX($space-medium); transform: translateX($space-medium);
} }
.menu-list-enter-active,
.menu-list-leave-active {
transition: opacity 0.3s var(--ease-out-cubic),
transform 0.2s var(--ease-out-cubic);
}
.menu-list-leave-to {
opacity: 0;
position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
}
.slide-up-enter-active { .slide-up-enter-active {
transition: all 0.3s var(--ease-in-cubic); transition: all 0.3s var(--ease-in-cubic);
} }
@@ -48,8 +65,7 @@
.menu-slide-enter-active, .menu-slide-enter-active,
.menu-slide-leave-active { .menu-slide-leave-active {
transform: translateY(0); transform: translateY(0);
transition: transition: transform 0.25s var(--ease-in-cubic),
transform 0.25s var(--ease-in-cubic),
opacity 0.15s var(--ease-in-cubic); opacity 0.15s var(--ease-in-cubic);
} }
@@ -1,4 +1,4 @@
@import 'vue-datepicker-next/scss/index'; @import '~vue2-datepicker/scss/index';
.date-picker { .date-picker {
// To be removed one SLA reports date picker is created // To be removed one SLA reports date picker is created
@@ -25,7 +25,7 @@
} }
.mx-input { .mx-input {
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none; @apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
} }
.mx-input:disabled, .mx-input:disabled,
@@ -39,7 +39,7 @@
} }
.mx-datepicker-main { .mx-datepicker-main {
@apply border-0 bg-n-solid-2 rounded-xl; @apply border-0 bg-white dark:bg-slate-800;
.cell { .cell {
&.disabled { &.disabled {
@@ -53,14 +53,6 @@
} }
} }
.mx-calendar+.mx-calendar {
@apply border-l border-n-weak;
}
.mx-datepicker-footer {
@apply border border-n-weak;
}
.mx-time { .mx-time {
@apply border-0 bg-white dark:bg-slate-800; @apply border-0 bg-white dark:bg-slate-800;
@@ -1,4 +1,4 @@
@import 'dashboard/assets/scss/variables'; @import '~dashboard/assets/scss/variables';
.formulate-input { .formulate-input {
.formulate-input-errors { .formulate-input-errors {
@@ -1,11 +1,10 @@
// scss-lint:disable SpaceAfterPropertyColon // scss-lint:disable SpaceAfterPropertyColon
@import 'shared/assets/fonts/inter'; @import 'shared/assets/fonts/inter';
// Inter, // Inter,
html, html,
body { body {
font-family: font-family:
'Inter', 'PlusJakarta',
-apple-system, -apple-system,
system-ui, system-ui,
BlinkMacSystemFont, BlinkMacSystemFont,
@@ -24,7 +23,7 @@ body {
} }
.app-wrapper { .app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full; @apply h-full flex-grow-0 min-h-0 w-full;
.button--fixed-top { .button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row; @apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
@@ -1,5 +1,5 @@
@import 'dashboard/assets/scss/variables'; @import '~dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins'; @import '~widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7); $spinner-before-border-color: rgba(255, 255, 255, 0.7);
@@ -89,6 +89,9 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default; $swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default; $swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions // Transitions
$transition-ease-in: all 0.250s ease-in; $transition-ease-in: all 0.250s ease-in;
+4 -231
View File
@@ -2,6 +2,7 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
@import 'tailwindcss/utilities'; @import 'tailwindcss/utilities';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/fonts/InterDisplay/inter-display'; @import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter'; @import 'shared/assets/fonts/inter';
@@ -33,235 +34,19 @@
@import 'plugins/multiselect'; @import 'plugins/multiselect';
@import 'plugins/dropdown'; @import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons';
.tooltip { .tooltip {
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96; @apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900;
}
#app {
@apply h-full w-full;
} }
.hide { .hide {
@apply hidden; @apply hidden;
} }
.n-blue-border {
@apply border-n-blue-border;
}
.n-blue-text {
@apply text-n-blue-text;
}
// scss-lint:disable PropertySortOrder
@layer base { @layer base {
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
// scss-lint:disable PropertySortOrder
:root { :root {
--color-amber-25: 254 253 251; --color-amber-25: 254 253 251;
--color-amber-50: 255 249 237; --color-amber-50: 255 249 237;
@@ -604,15 +389,3 @@
--color-orange-900: 255 224 194; --color-orange-900: 255 224 194;
} }
} }
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
@@ -1,7 +1,7 @@
.dropdown-pane { .dropdown-pane {
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999]; @apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999];
&.dropdown-pane--open { &.dropdown-pane--open {
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible; @apply bg-white absolute dark:bg-slate-800 block visible;
} }
} }
@@ -222,7 +222,6 @@
.multiselect__input { .multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem]; @apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0px !important;
} }
.multiselect__single { .multiselect__single {
@@ -0,0 +1,39 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import 'vue-multiselect/dist/vue-multiselect.min.css';
@import '~shared/assets/stylesheets/ionicons';
@import 'mixins';
@import 'helper-classes';
@import 'typography';
@import 'layout';
@import 'animations';
@import 'widgets/buttons';
@import 'widgets/base';
@import 'plugins/multiselect';
@import 'widget/assets/scss/reset';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'widget/assets/scss/utilities';
html,
body {
font-family: 'PlusJakarta', sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
}
@@ -31,13 +31,7 @@ hr {
ul, ul,
ol, ol,
dl { dl {
@apply list-disc list-outside leading-[1.65]; @apply mb-2 list-disc list-outside leading-[1.65];
}
ul:not(.reset-base),
ol:not(.reset-base),
dl:not(.reset-base) {
@apply mb-0;
} }
// Form elements // Form elements
@@ -31,7 +31,7 @@ button {
} }
.button { .button {
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium; @apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content { .button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis; @apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
@@ -42,10 +42,8 @@ button {
} }
} }
&:hover:not(.secondary):not(.success):not(.alert):not(.warning):not( &:hover {
.clear @apply bg-woot-600 dark:bg-woot-600;
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
} }
&:disabled, &:disabled,
@@ -54,23 +52,23 @@ button {
} }
&.success { &.success {
@apply bg-n-teal-9 text-white dark:text-white; @apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white;
} }
&.secondary { &.secondary {
@apply bg-n-solid-3 text-white dark:text-white; @apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white;
} }
&.primary { &.primary {
@apply bg-n-brand text-white dark:text-white; @apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white;
} }
&.clear { &.clear {
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent; @apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent;
} }
&.alert { &.alert {
@apply bg-n-ruby-9 text-white dark:text-white; @apply bg-red-500 dark:bg-red-500 text-white dark:text-white;
&.clear { &.clear {
@apply bg-transparent dark:bg-transparent; @apply bg-transparent dark:bg-transparent;
@@ -78,7 +76,7 @@ button {
} }
&.warning { &.warning {
@apply bg-n-amber-9 text-white dark:text-white; @apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white;
&.clear { &.clear {
@apply bg-transparent dark:bg-transparent; @apply bg-transparent dark:bg-transparent;
@@ -98,7 +96,7 @@ button {
} }
// @TODDO - Remove after moving all buttons to woot-button // @TODDO - Remove after moving all buttons to woot-button
.icon + .button__content { .icon+.button__content {
@apply w-auto; @apply w-auto;
} }
@@ -117,74 +115,114 @@ button {
} }
&.hollow { &.hollow {
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20; @apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
&.secondary { &.secondary {
@apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5; @apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
} }
&.success { &.success {
@apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5; @apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
} }
&.alert { &.alert {
@apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5; @apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
} }
&.warning { &.warning {
@apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5; @apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
}
&:hover {
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
&.secondary {
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
}
&.success {
@apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100;
}
&.alert {
@apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100;
}
&.warning {
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
}
} }
} }
// Smooth style // Smooth style
&.smooth { &.smooth {
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40; @apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900;
&.secondary { &.secondary {
@apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5; @apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800;
} }
&.success { &.success {
@apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5; @apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
} }
&.alert { &.alert {
@apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5; @apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30;
} }
&.warning { &.warning {
@apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5; @apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200;
} }
} }
&.clear { &.clear {
@apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30; @apply text-woot-500 dark:text-woot-500;
&.secondary { &.secondary {
@apply text-n-slate-12 hover:bg-n-slate-4; @apply text-slate-700 dark:text-slate-100;
} }
&.success { &.success {
@apply text-n-teal-10 hover:bg-n-teal-4; @apply text-green-700 dark:text-green-100;
} }
&.alert { &.alert {
@apply text-n-ruby-11 hover:bg-n-ruby-4; @apply text-red-700 dark:text-red-100;
} }
&.warning { &.warning {
@apply text-n-amber-11 hover:bg-n-amber-4; @apply text-yellow-700 dark:text-yellow-600;
}
&:hover {
@apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100;
&.secondary {
@apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100;
}
&.success {
@apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
}
&.alert {
@apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100;
}
&.warning {
@apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600;
}
} }
&:active { &:active {
&.secondary { &.secondary {
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7; @apply active:bg-slate-100 dark:active:bg-slate-900;
} }
} }
&:focus { &:focus {
&.secondary { &.secondary {
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6; @apply focus:bg-slate-50 dark:focus:bg-slate-700;
} }
} }
} }
@@ -3,15 +3,7 @@
} }
.tabs--container--with-border { .tabs--container--with-border {
@apply border-b border-n-weak; @apply border-b border-slate-50 dark:border-slate-800/50;
}
.tabs--container--compact.tab--chat-type {
.tabs-title {
a {
@apply py-2 text-sm;
}
}
} }
.tabs { .tabs {
@@ -42,7 +34,7 @@
@apply flex-shrink-0 my-0 mx-2; @apply flex-shrink-0 my-0 mx-2;
.badge { .badge {
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0; @apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
} }
&:first-child { &:first-child {
@@ -56,22 +48,22 @@
&:hover, &:hover,
&:focus { &:focus {
a { a {
@apply text-n-slate-12; @apply text-slate-800 dark:text-slate-100;
} }
} }
a { a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative; @apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function; transition: border-color 0.15s $swift-ease-out-function;
} }
&.is-active { &.is-active {
a { a {
@apply border-b border-n-brand text-n-blue-text; @apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
} }
.badge { .badge {
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text; @apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
} }
} }
} }
@@ -1,137 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
import SMSCampaignDetails from './SMSCampaignDetails.vue';
const props = defineProps({
title: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
isLiveChatType: {
type: Boolean,
default: false,
},
isEnabled: {
type: Boolean,
default: false,
},
status: {
type: String,
default: '',
},
sender: {
type: Object,
default: null,
},
inbox: {
type: Object,
default: null,
},
scheduledAt: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const { formatMessage } = useMessageFormatter();
const isActive = computed(() =>
props.isLiveChatType ? props.isEnabled : props.status !== STATUS_COMPLETED
);
const statusTextColor = computed(() => ({
'text-n-teal-11': isActive.value,
'text-n-slate-12': !isActive.value,
}));
const campaignStatus = computed(() => {
if (props.isLiveChatType) {
return props.isEnabled
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
});
</script>
<template>
<CardLayout layout="row">
<div class="flex flex-col items-start justify-between flex-1 min-w-0 gap-2">
<div class="flex justify-between gap-3 w-fit">
<span
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
>
{{ title }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
<div
v-dompurify-html="formatMessage(message, false, false, false)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
<LiveChatCampaignDetails
v-if="isLiveChatType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
/>
</div>
</div>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
/>
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
/>
</div>
</CardLayout>
</template>
@@ -1,56 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
sender: {
type: Object,
default: null,
},
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
});
const { t } = useI18n();
const senderName = computed(
() => props.sender?.name || t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.BOT')
);
const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Avatar
:name="senderName"
:src="senderThumbnailSrc"
:size="16"
rounded-full
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
</template>
@@ -1,41 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
defineProps({
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
scheduledAt: {
type: Number,
default: 0,
},
});
const { t } = useI18n();
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.SENT_FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.ON') }}
</span>
<span class="flex-1 text-sm font-medium truncate text-n-slate-12">
{{ messageStamp(new Date(scheduledAt), 'LLL d, h:mm a') }}
</span>
</template>
@@ -1,52 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = () => {
emit('click');
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
<slot name="action" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
</div>
</main>
</section>
</template>
@@ -1,212 +0,0 @@
export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Chatbot Assistance',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Alexa Rivera',
},
message: 'Hello! 👋 Need help with our chatbot features? Feel free to ask!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://www.chatwoot.com/features/chatbot/',
time_on_page: 10,
},
trigger_only_during_business_hours: true,
created_at: '2024-10-24T13:10:26.636Z',
updated_at: '2024-10-24T13:10:26.636Z',
},
{
id: 2,
title: 'Pricing Information Support',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Jamie Lee',
},
message: 'Hello! 👋 Any questions on pricing? I’m here to help!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://www.chatwoot.com/pricings',
time_on_page: 10,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:11:08.763Z',
updated_at: '2024-10-24T13:11:08.763Z',
},
{
id: 3,
title: 'Product Setup Assistance',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Chatwoot',
},
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://{*.}?chatwoot.com/apps/account/*/settings/inboxes/new/',
time_on_page: 10,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:11:44.285Z',
updated_at: '2024-10-24T13:11:44.285Z',
},
{
id: 4,
title: 'General Assistance Campaign',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Chris Barlow',
},
message:
'Hi there! 👋 I’m here for any questions you may have. Let’s chat!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://siv.com',
time_on_page: 200,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-29T19:54:33.741Z',
updated_at: '2024-10-29T19:56:26.296Z',
},
];
export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Customer Feedback Request',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message:
'Hello! Enjoying our product? Share your feedback on G2 and earn a $25 Amazon coupon: https://chwt.app/g2-review',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1729775588,
audience: [
{ id: 4, type: 'Label' },
{ id: 5, type: 'Label' },
{ id: 6, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:13:08.496Z',
updated_at: '2024-10-24T13:15:38.698Z',
},
{
id: 2,
title: 'Welcome New Customer',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Welcome aboard! 🎉 Let us know if you have any questions.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1729732500,
audience: [
{ id: 1, type: 'Label' },
{ id: 6, type: 'Label' },
{ id: 5, type: 'Label' },
{ id: 2, type: 'Label' },
{ id: 4, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:14:00.168Z',
updated_at: '2024-10-24T13:15:38.707Z',
},
{
id: 3,
title: 'New Business Welcome',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Hello! We’re excited to have your business with us!',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1730368440,
audience: [
{ id: 1, type: 'Label' },
{ id: 3, type: 'Label' },
{ id: 6, type: 'Label' },
{ id: 4, type: 'Label' },
{ id: 2, type: 'Label' },
{ id: 5, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-30T07:54:49.915Z',
updated_at: '2024-10-30T07:54:49.915Z',
},
{
id: 4,
title: 'New Member Onboarding',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Welcome to the team! Reach out if you have questions.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1730304840,
audience: [
{ id: 1, type: 'Label' },
{ id: 3, type: 'Label' },
{ id: 6, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-29T16:14:10.374Z',
updated_at: '2024-10-30T16:15:03.157Z',
},
];
@@ -1,38 +0,0 @@
<script setup>
import { ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
is-live-chat-type
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,37 +0,0 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,38 +0,0 @@
<script setup>
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
campaigns: {
type: Array,
required: true,
},
isLiveChatType: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete']);
const handleEdit = campaign => emit('edit', campaign);
const handleDelete = campaign => emit('delete', campaign);
</script>
<template>
<div class="flex flex-col gap-4">
<CampaignCard
v-for="campaign in campaigns"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
/>
</div>
</template>
@@ -1,49 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedCampaign: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const deleteCampaign = async id => {
if (!id) return;
try {
await store.dispatch('campaigns/delete', id);
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
await deleteCampaign(props.selectedCampaign.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CAMPAIGN.CONFIRM_DELETE.TITLE')"
:description="t('CAMPAIGN.CONFIRM_DELETE.DESCRIPTION')"
:confirm-button-label="t('CAMPAIGN.CONFIRM_DELETE.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -1,74 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
const props = defineProps({
selectedCampaign: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const liveChatCampaignFormRef = ref(null);
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isUpdatingCampaign = computed(() => uiFlags.value.isUpdating);
const isInvalidForm = computed(
() => liveChatCampaignFormRef.value?.isSubmitDisabled
);
const selectedCampaignId = computed(() => props.selectedCampaign.id);
const updateCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/update', {
id: selectedCampaignId.value,
...campaignDetails,
});
useAlert(t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.SUCCESS_MESSAGE'));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = () => {
updateCampaign(liveChatCampaignFormRef.value.prepareCampaignDetails());
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAMPAIGN.LIVE_CHAT.EDIT.TITLE')"
:is-loading="isUpdatingCampaign"
:disable-confirm-button="isUpdatingCampaign || isInvalidForm"
overflow-y-auto
@confirm="handleSubmit"
>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</Dialog>
</template>
@@ -1,54 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleClose = () => emit('close');
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
handleClose();
};
</script>
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
</h3>
<LiveChatCampaignForm
mode="create"
@submit="handleSubmit"
@cancel="handleClose"
/>
</div>
</template>
@@ -1,323 +0,0 @@
<script setup>
import { reactive, computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { URLPattern } from 'urlpattern-polyfill';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
selectedCampaign: {
type: Object,
default: () => ({}),
},
showActionButtons: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const store = useStore();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
inboxes: useMapGetter('inboxes/getWebsiteInboxes'),
};
const senderList = ref([]);
const initialState = {
title: '',
message: '',
inboxId: null,
senderId: 0,
enabled: true,
triggerOnlyDuringBusinessHours: false,
endPoint: '',
timeOnPage: 10,
};
const state = reactive({ ...initialState });
const urlValidators = {
shouldBeAValidURLPattern: value => {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch {
return false;
}
},
shouldStartWithHTTP: value =>
value ? value.startsWith('https://') || value.startsWith('http://') : false,
};
const validationRules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
senderId: { required },
endPoint: { required, ...urlValidators },
timeOnPage: { required },
};
const v$ = useVuelidate(validationRules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const sendersAndBotList = computed(() => [
{ value: 0, label: 'Bot' },
...mapToOptions(senderList.value, 'id', 'name'),
]);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.LIVE_CHAT.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
endPoint: getErrorMessage('endPoint', 'END_POINT'),
timeOnPage: getErrorMessage('timeOnPage', 'TIME_ON_PAGE'),
sender: getErrorMessage('senderId', 'SENT_BY'),
}));
const resetState = () => Object.assign(state, initialState);
const handleCancel = () => emit('cancel');
const handleInboxChange = async inboxId => {
if (!inboxId) {
senderList.value = [];
return;
}
try {
const response = await store.dispatch('inboxMembers/get', { inboxId });
senderList.value = response?.data?.payload ?? [];
} catch (error) {
senderList.value = [];
useAlert(
error?.response?.message ??
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE')
);
}
};
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
sender_id: state.senderId || null,
enabled: state.enabled,
trigger_only_during_business_hours: state.triggerOnlyDuringBusinessHours,
trigger_rules: {
url: state.endPoint,
time_on_page: state.timeOnPage,
},
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
if (props.mode === 'create') {
resetState();
handleCancel();
}
};
const updateStateFromCampaign = campaign => {
if (!campaign) return;
const {
title,
message,
inbox: { id: inboxId },
sender,
enabled,
trigger_only_during_business_hours: triggerOnlyDuringBusinessHours,
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
} = campaign;
Object.assign(state, {
title,
message,
inboxId,
senderId: sender?.id ?? 0,
enabled,
triggerOnlyDuringBusinessHours,
endPoint,
timeOnPage,
});
};
watch(
() => state.inboxId,
newInboxId => {
if (newInboxId) {
handleInboxChange(newInboxId);
}
},
{ immediate: true }
);
watch(
() => props.selectedCampaign,
newCampaign => {
if (props.mode === 'edit' && newCampaign) {
updateStateFromCampaign(newCampaign);
}
},
{ immediate: true }
);
defineExpose({ prepareCampaignDetails, isSubmitDisabled });
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<Editor
v-model="state.message"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.PLACEHOLDER')"
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="sentBy" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.LABEL') }}
</label>
<ComboBox
id="sentBy"
v-model="state.senderId"
:options="sendersAndBotList"
:has-error="!!formErrors.sender"
:disabled="!state.inboxId"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.sender"
/>
</div>
<Input
v-model="state.endPoint"
type="url"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.PLACEHOLDER')"
:message="formErrors.endPoint"
:message-type="formErrors.endPoint ? 'error' : 'info'"
/>
<Input
v-model="state.timeOnPage"
type="number"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.LABEL')"
:placeholder="
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.PLACEHOLDER')
"
:message="formErrors.timeOnPage"
:message-type="formErrors.timeOnPage ? 'error' : 'info'"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-2.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TITLE') }}
</legend>
<label class="flex items-center gap-2">
<input v-model="state.enabled" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.ENABLED') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.triggerOnlyDuringBusinessHours" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{
t(
'CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TRIGGER_ONLY_BUSINESS_HOURS'
)
}}
</span>
</label>
</fieldset>
<div
v-if="showActionButtons"
class="flex items-center justify-between w-full gap-3"
>
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="
t(`CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.${mode.toUpperCase()}`)
"
class="w-full"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -1,49 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import SMSCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
</h3>
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -1,189 +0,0 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getSMSInboxes'),
};
const initialState = {
title: '',
message: '',
inboxId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const rules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.SMS.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.message"
:label="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.PLACEHOLDER')"
show-character-count
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.SMS.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -1,32 +0,0 @@
<script setup>
defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
@click="handleClick"
>
<slot />
</div>
<slot name="after" />
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More