diff --git a/.circleci/config.yml b/.circleci/config.yml
index 15c40dc77..459faf1c8 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,84 +1,87 @@
-# Ruby CircleCI 2.0 configuration file
-#
-# Check https://circleci.com/docs/2.0/language-ruby/ for more details
-#
-version: 2
+version: 2.1
+orbs:
+ node: circleci/node@6.1.0
+
defaults: &defaults
working_directory: ~/build
- docker:
- # specify the version you desire here
- - 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:
- - RAILS_LOG_TO_STDOUT: false
- - COVERAGE: true
- - LOG_LEVEL: warn
- parallelism: 4
+ machine:
+ image: ubuntu-2204:2024.05.1
resource_class: large
+ environment:
+ RAILS_LOG_TO_STDOUT: false
+ COVERAGE: true
+ LOG_LEVEL: warn
+ parallelism: 4
jobs:
build:
<<: *defaults
steps:
- 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: Configure Bundler
+ name: Install System Dependencies
command: |
- echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
- source $BASH_ENV
+ sudo apt-get update
+ DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
+ libpq-dev \
+ redis-server \
+ postgresql \
+ 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:
+ name: Install RVM and Ruby 3.3.3
+ command: |
+ 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
- run:
- name: Which bundler?
- command: bundle -v
-
- - run:
- name: Swap node versions
+ name: Install Application Dependencies
command: |
- set +e
- wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
- 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
+ source ~/.rvm/scripts/rvm
+ bundle install
+ # pnpm install
- run:
name: Download cc-test-reporter
@@ -86,12 +89,8 @@ jobs:
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
- # verify swagger specification
+ # Swagger verification
- run:
name: Verify swagger API specification
command: |
@@ -104,45 +103,62 @@ 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
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
- # Database setup
- - run: bundle exec rake db:create
- - run: bundle exec rake db:schema:load
+ # 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
+ - run:
+ name: Run DB migrations
+ command: bundle exec rails db:chatwoot_prepare
+
+ # Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
+ # Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
- # - run:
- # name: Brakeman
- # command: bundle exec brakeman
-
+ # ESLint linting
- run:
name: eslint
- command: yarn run eslint
+ command: pnpm run eslint
- # Run frontend tests
- run:
name: Run frontend tests
command: |
- mkdir -p ~/tmp/test-results/frontend_specs
+ mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
- yarn test:coverage
- - run:
- name: Code Climate Test Coverage
- command: |
- ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
+ pnpm run test:coverage
- # Run rails tests
+ - run:
+ name: Code Climate Test Coverage (Frontend)
+ command: |
+ ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
+
+ # Run backend tests
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
- mkdir -p coverage
+ mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
@@ -150,54 +166,18 @@ jobs:
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
+
- run:
- name: Code Climate Test Coverage
+ name: Code Climate Test Coverage (Backend)
command: |
- ~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
+ ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
+
+ - run:
+ name: List coverage directory contents
+ command: |
+ ls -R ~/build/coverage
- persist_to_workspace:
- root: coverage
+ root: ~/build
paths:
- - 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
+ - coverage
diff --git a/.circleci/setup_chatwoot.sql b/.circleci/setup_chatwoot.sql
new file mode 100644
index 000000000..4e5430f1d
--- /dev/null
+++ b/.circleci/setup_chatwoot.sql
@@ -0,0 +1,11 @@
+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;
diff --git a/.codeclimate.yml b/.codeclimate.yml
index d8b8d985b..213f7d172 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -27,7 +27,8 @@ checks:
threshold: 50
exclude_patterns:
- 'spec/'
- - '**/specs/'
+ - '**/specs/**/**'
+ - '**/spec/**/**'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
@@ -56,3 +57,5 @@ exclude_patterns:
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
+ - '**/fixtures/**'
+ - '**/*/fixtures.js'
diff --git a/.eslintrc.js b/.eslintrc.js
index f3d050ab3..a2932b2a9 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -2,16 +2,36 @@ module.exports = {
extends: [
'airbnb-base/legacy',
'prettier',
- 'plugin:vue/recommended',
- 'plugin:storybook/recommended',
- 'plugin:cypress/recommended',
+ 'plugin:vue/vue3-recommended',
+ 'plugin:vitest-globals/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: {
- parser: '@babel/eslint-parser',
- ecmaVersion: 2020,
+ ecmaVersion: 'latest',
sourceType: 'module',
},
- plugins: ['html', 'prettier', 'babel'],
rules: {
'prettier/prettier': ['error'],
camelcase: 'off',
@@ -210,13 +230,6 @@ module.exports = {
'import/extensions': ['off'],
'no-console': 'error',
},
- settings: {
- 'import/resolver': {
- webpack: {
- config: 'config/webpack/resolve.js',
- },
- },
- },
env: {
browser: true,
node: true,
diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml
new file mode 100644
index 000000000..15bb6f5e9
--- /dev/null
+++ b/.github/workflows/frontend-fe.yml
@@ -0,0 +1,43 @@
+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
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
index c42909856..5420e829b 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -25,13 +25,5 @@ jobs:
with:
issue-inactive-days: '30'
issue-lock-reason: 'resolved'
- issue-comment: >
- This issue has been automatically locked since there
- has not been any recent activity after it was closed.
- Please open a new issue for related bugs.
pr-inactive-days: '30'
pr-lock-reason: 'resolved'
- pr-comment: >
- This pull request has been automatically locked since there
- has not been any recent activity after it was closed.
- Please open a new issue for related bugs.
diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml
index b0b2372ae..6a018b314 100644
--- a/.github/workflows/run_foss_spec.yml
+++ b/.github/workflows/run_foss_spec.yml
@@ -21,7 +21,7 @@ jobs:
image: postgres:15.3
env:
POSTGRES_USER: postgres
- POSTGRES_PASSWORD: ""
+ POSTGRES_PASSWORD: ''
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
@@ -41,47 +41,49 @@ jobs:
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: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 9
+ 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: 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
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
- - name: yarn
- run: yarn install
+ - name: Install pnpm dependencies
+ run: pnpm i
- - name: Strip enterprise code
- run: |
- rm -rf enterprise
- rm -rf spec/enterprise
+ - name: Strip enterprise code
+ run: |
+ rm -rf enterprise
+ rm -rf spec/enterprise
- - name: Create database
- run: bundle exec rake db:create
+ - name: Create database
+ run: bundle exec rake db:create
- - name: Seed database
- run: bundle exec rake db:schema:load
+ - name: Seed database
+ run: bundle exec rake db:schema:load
- - name: yarn check-files
- run: yarn install --check-files
+ - name: Run frontend tests
+ run: pnpm run test:coverage
- # Run rails tests
- - name: Run backend tests
- run: |
- bundle exec rspec --profile=10 --format documentation
- env:
- NODE_OPTIONS: --openssl-legacy-provider
+ # Run rails tests
+ - name: Run backend tests
+ run: |
+ bundle exec rspec --profile=10 --format documentation
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
- - name: Upload rails log folder
- uses: actions/upload-artifact@v4
- if: always()
- with:
- name: rails-log-folder
- path: log
+ - name: Upload rails log folder
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: rails-log-folder
+ path: log
diff --git a/.github/workflows/run_response_bot_spec.yml b/.github/workflows/run_response_bot_spec.yml
index c594ff404..c788a0400 100644
--- a/.github/workflows/run_response_bot_spec.yml
+++ b/.github/workflows/run_response_bot_spec.yml
@@ -49,13 +49,17 @@ jobs:
with:
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
with:
node-version: 20
- cache: yarn
+ cache: pnpm
- - name: yarn
- run: yarn install
+ - name: pnpm
+ run: pnpm install
- name: Create database
run: bundle exec rake db:create
diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml
index 0526aeedb..0758ca7d0 100644
--- a/.github/workflows/size-limit.yml
+++ b/.github/workflows/size-limit.yml
@@ -19,23 +19,33 @@ jobs:
with:
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
with:
node-version: 20
- cache: 'yarn'
+ cache: 'pnpm'
- - name: yarn
- run: yarn install
+ - name: pnpm
+ run: pnpm install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
+ - name: setup env
+ run: |
+ cp .env.example .env
+
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
- NODE_OPTIONS: --openssl-legacy-provider
+ RAILS_ENV: production
- name: Size Check
- run: yarn run size
+ run: pnpm run size
+
+
diff --git a/.gitignore b/.gitignore
index 028a97f09..5eb883db0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,16 @@ master.key
public/uploads
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
*.swp
@@ -75,4 +85,8 @@ yalc.lock
yarn-debug.log*
.yarn-integrity
-/storybook-static
\ No newline at end of file
+# Vite Ruby
+/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
diff --git a/.husky/pre-commit b/.husky/pre-commit
index adda426ad..2525dde4e 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,11 +1,11 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
+# #!/bin/sh
+# . "$(dirname "$0")/_/husky.sh"
-# lint js and vue files
-npx --no-install lint-staged
+# # lint js and vue files
+# npx --no-install lint-staged
-# 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
+# # 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
-# stage rubocop changes to files
-git diff --name-only --cached | xargs git add
+# # stage rubocop changes to files
+# git diff --name-only --cached | xargs git add
diff --git a/.storybook/main.js b/.storybook/main.js
deleted file mode 100644
index cb32634c2..000000000
--- a/.storybook/main.js
+++ /dev/null
@@ -1,56 +0,0 @@
-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;
- },
-};
diff --git a/.storybook/preview.js b/.storybook/preview.js
deleted file mode 100644
index 7c046d51a..000000000
--- a/.storybook/preview.js
+++ /dev/null
@@ -1,46 +0,0 @@
-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: '',
- 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$/,
- },
- },
-};
diff --git a/Gemfile b/Gemfile
index 7d34ffda0..439b0f63e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# 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 'google-cloud-storage', require: false
+gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing'
##-- gems for database --#
@@ -64,7 +64,7 @@ gem 'activerecord-import'
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma'
-gem 'webpacker'
+gem 'vite_rails'
# metrics on heroku
gem 'barnes'
@@ -96,12 +96,12 @@ gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
-gem 'google-cloud-dialogflow-v2'
+gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
-gem 'google-cloud-translate-v3'
+gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -111,12 +111,12 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
-gem 'sentry-rails', '>= 5.18.2', require: false
+gem 'sentry-rails', '>= 5.19.0', require: false
gem 'sentry-ruby', require: false
-gem 'sentry-sidekiq', '>= 5.18.2', require: false
+gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
-gem 'sidekiq', '>= 7.3.0'
+gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
-gem 'omniauth-google-oauth2', '>= 1.1.2'
+gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
@@ -181,6 +181,9 @@ gem 'reverse_markdown'
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
+ # for heroku autoscaling
+ gem 'judoscale-rails', require: false
+ gem 'judoscale-sidekiq', require: false
end
group :development do
@@ -200,12 +203,10 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
- gem 'meta_request', '>= 0.8.0'
+ gem 'meta_request', '>= 0.8.3'
end
group :test do
- # Cypress in rails.
- gem 'cypress-on-rails'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
@@ -228,7 +229,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
- gem 'rspec-rails', '>= 6.1.3'
+ gem 'rspec-rails', '>= 6.1.5'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index cf522bf39..298d8e041 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- actioncable (7.0.8.4)
- actionpack (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ actioncable (7.0.8.5)
+ actionpack (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (7.0.8.4)
- actionpack (= 7.0.8.4)
- activejob (= 7.0.8.4)
- activerecord (= 7.0.8.4)
- activestorage (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ actionmailbox (7.0.8.5)
+ actionpack (= 7.0.8.5)
+ activejob (= 7.0.8.5)
+ activerecord (= 7.0.8.5)
+ activestorage (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
- actionmailer (7.0.8.4)
- actionpack (= 7.0.8.4)
- actionview (= 7.0.8.4)
- activejob (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ actionmailer (7.0.8.5)
+ actionpack (= 7.0.8.5)
+ actionview (= 7.0.8.5)
+ activejob (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
- actionpack (7.0.8.4)
- actionview (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ actionpack (7.0.8.5)
+ actionview (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (7.0.8.4)
- actionpack (= 7.0.8.4)
- activerecord (= 7.0.8.4)
- activestorage (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ actiontext (7.0.8.5)
+ actionpack (= 7.0.8.5)
+ activerecord (= 7.0.8.5)
+ activestorage (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (7.0.8.4)
- activesupport (= 7.0.8.4)
+ actionview (7.0.8.5)
+ activesupport (= 7.0.8.5)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
- activejob (7.0.8.4)
- activesupport (= 7.0.8.4)
+ activejob (7.0.8.5)
+ activesupport (= 7.0.8.5)
globalid (>= 0.3.6)
- activemodel (7.0.8.4)
- activesupport (= 7.0.8.4)
- activerecord (7.0.8.4)
- activemodel (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ activemodel (7.0.8.5)
+ activesupport (= 7.0.8.5)
+ activerecord (7.0.8.5)
+ activemodel (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
activerecord-import (1.4.1)
activerecord (>= 4.2)
- activestorage (7.0.8.4)
- actionpack (= 7.0.8.4)
- activejob (= 7.0.8.4)
- activerecord (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ activestorage (7.0.8.5)
+ actionpack (= 7.0.8.5)
+ activejob (= 7.0.8.5)
+ activerecord (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
- activesupport (7.0.8.4)
+ activesupport (7.0.8.5)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
- concurrent-ruby (1.3.3)
+ concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -178,8 +178,6 @@ GEM
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
- cypress-on-rails (1.16.0)
- rack
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0)
@@ -211,7 +209,7 @@ GEM
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1)
- digest-crc (0.6.4)
+ digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
@@ -222,6 +220,7 @@ GEM
railties (>= 6.1)
down (5.4.0)
addressable (~> 2.8)
+ dry-cli (1.1.0)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.6.2)
@@ -230,7 +229,7 @@ GEM
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.13.0)
- et-orbi (1.2.7)
+ et-orbi (1.2.11)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@@ -257,7 +256,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
- faraday-retry (2.1.0)
+ faraday-retry (2.2.1)
faraday (~> 2.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
@@ -268,10 +267,10 @@ GEM
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
- fugit (1.9.0)
- et-orbi (~> 1, >= 1.2.7)
+ fugit (1.11.1)
+ et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
- gapic-common (0.18.0)
+ gapic-common (0.20.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
@@ -285,57 +284,57 @@ GEM
activesupport (>= 6.1)
gmail_xoauth (0.4.3)
oauth (>= 0.3.6)
- google-apis-core (0.11.0)
+ google-apis-core (0.15.1)
addressable (~> 2.5, >= 2.5.1)
- googleauth (>= 0.16.2, < 2.a)
- httpclient (>= 2.8.1, < 3.a)
+ googleauth (~> 1.9)
+ httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
+ mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
- rexml
- webrick
- google-apis-iamcredentials_v1 (0.17.0)
- google-apis-core (>= 0.11.0, < 2.a)
- google-apis-storage_v1 (0.19.0)
- google-apis-core (>= 0.9.0, < 2.a)
- google-cloud-core (1.6.0)
- google-cloud-env (~> 1.0)
+ google-apis-iamcredentials_v1 (0.22.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-storage_v1 (0.47.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-cloud-core (1.7.1)
+ google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
- google-cloud-dialogflow-v2 (0.23.0)
- gapic-common (>= 0.18.0, < 2.a)
+ google-cloud-dialogflow-v2 (0.31.0)
+ gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
- google-cloud-env (1.6.0)
- faraday (>= 0.17.3, < 3.0)
+ google-cloud-env (2.2.1)
+ faraday (>= 1.0, < 3.a)
google-cloud-errors (1.3.1)
- google-cloud-location (0.4.0)
- gapic-common (>= 0.17.1, < 2.a)
+ google-cloud-location (0.6.0)
+ gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
- google-cloud-storage (1.44.0)
+ google-cloud-storage (1.52.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
- google-apis-iamcredentials_v1 (~> 0.1)
- google-apis-storage_v1 (~> 0.19.0)
+ google-apis-core (~> 0.13)
+ google-apis-iamcredentials_v1 (~> 0.18)
+ google-apis-storage_v1 (~> 0.38)
google-cloud-core (~> 1.6)
- googleauth (>= 0.16.2, < 2.a)
+ googleauth (~> 1.9)
mini_mime (~> 1.0)
- google-cloud-translate-v3 (0.6.0)
- gapic-common (>= 0.17.1, < 2.a)
+ google-cloud-translate-v3 (0.10.0)
+ gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
- google-protobuf (3.25.3)
- google-protobuf (3.25.3-arm64-darwin)
- google-protobuf (3.25.3-x86_64-darwin)
- google-protobuf (3.25.3-x86_64-linux)
- googleapis-common-protos (1.4.0)
- google-protobuf (~> 3.14)
- googleapis-common-protos-types (~> 1.2)
- grpc (~> 1.27)
+ google-protobuf (3.25.5)
+ google-protobuf (3.25.5-arm64-darwin)
+ google-protobuf (3.25.5-x86_64-darwin)
+ google-protobuf (3.25.5-x86_64-linux)
+ googleapis-common-protos (1.6.0)
+ google-protobuf (>= 3.18, < 5.a)
+ googleapis-common-protos-types (~> 1.7)
+ grpc (~> 1.41)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
- googleauth (1.5.2)
- faraday (>= 0.17.3, < 3.a)
+ googleauth (1.11.2)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
- memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
@@ -374,7 +373,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
- i18n (1.14.5)
+ i18n (1.14.6)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -398,6 +397,13 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
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)
base64
kaminari (1.2.2)
@@ -446,7 +452,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
- loofah (2.22.0)
+ loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -456,8 +462,7 @@ GEM
net-smtp
marcel (1.0.4)
maxminddb (0.1.22)
- memoist (0.16.2)
- meta_request (0.8.2)
+ meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
@@ -466,21 +471,22 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
- mini_portile2 (2.8.7)
- minitest (5.24.1)
+ mini_portile2 (2.8.8)
+ minitest (5.25.1)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
+ mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
- net-imap (0.4.12)
+ net-imap (0.4.17)
date
net-protocol
net-pop (0.1.2)
@@ -496,14 +502,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
- nokogiri (1.16.6)
+ nokogiri (1.16.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.16.6-arm64-darwin)
+ nokogiri (1.16.8-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.16.6-x86_64-darwin)
+ nokogiri (1.16.8-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.16.6-x86_64-linux)
+ nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -522,7 +528,7 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
- omniauth-google-oauth2 (1.1.2)
+ omniauth-google-oauth2 (1.1.3)
jwt (>= 2.0)
oauth2 (~> 2.0)
omniauth (~> 2.0)
@@ -552,13 +558,13 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.0)
- puma (6.4.2)
+ puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
- racc (1.8.0)
- rack (2.2.9)
+ racc (1.8.1)
+ rack (2.2.10)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -570,35 +576,35 @@ GEM
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
- rack-proxy (0.7.6)
+ rack-proxy (0.7.7)
rack
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
- rails (7.0.8.4)
- actioncable (= 7.0.8.4)
- actionmailbox (= 7.0.8.4)
- actionmailer (= 7.0.8.4)
- actionpack (= 7.0.8.4)
- actiontext (= 7.0.8.4)
- actionview (= 7.0.8.4)
- activejob (= 7.0.8.4)
- activemodel (= 7.0.8.4)
- activerecord (= 7.0.8.4)
- activestorage (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ rails (7.0.8.5)
+ actioncable (= 7.0.8.5)
+ actionmailbox (= 7.0.8.5)
+ actionmailer (= 7.0.8.5)
+ actionpack (= 7.0.8.5)
+ actiontext (= 7.0.8.5)
+ actionview (= 7.0.8.5)
+ activejob (= 7.0.8.5)
+ activemodel (= 7.0.8.5)
+ activerecord (= 7.0.8.5)
+ activestorage (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
bundler (>= 1.15.0)
- railties (= 7.0.8.4)
+ railties (= 7.0.8.5)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.6.0)
+ rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
- nokogiri (~> 1.14)
- railties (7.0.8.4)
- actionpack (= 7.0.8.4)
- activesupport (= 7.0.8.4)
+ 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)
+ railties (7.0.8.5)
+ actionpack (= 7.0.8.5)
+ activesupport (= 7.0.8.5)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -634,20 +640,19 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
- rexml (3.3.4)
- strscan
+ rexml (3.3.9)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
- rspec-expectations (3.13.1)
+ rspec-expectations (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
- rspec-rails (6.1.3)
- actionpack (>= 6.1)
- activesupport (>= 6.1)
- railties (>= 6.1)
+ rspec-rails (7.0.1)
+ actionpack (>= 7.0)
+ activesupport (>= 7.0)
+ railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
@@ -709,20 +714,19 @@ GEM
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
- semantic_range (3.0.0)
- sentry-rails (5.18.2)
+ sentry-rails (5.19.0)
railties (>= 5.0)
- sentry-ruby (~> 5.18.2)
- sentry-ruby (5.18.2)
+ sentry-ruby (~> 5.19.0)
+ sentry-ruby (5.19.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
- sentry-sidekiq (5.18.2)
- sentry-ruby (~> 5.18.2)
+ sentry-sidekiq (5.19.0)
+ sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
- sidekiq (7.3.0)
+ sidekiq (7.3.1)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
logger
@@ -766,7 +770,6 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
- strscan (3.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
@@ -796,10 +799,17 @@ GEM
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
- valid_email2 (4.0.6)
+ valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
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)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -814,12 +824,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
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-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -827,7 +831,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
- zeitwerk (2.6.16)
+ zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -862,7 +866,6 @@ DEPENDENCIES
climate_control
commonmarker
csv-safe
- cypress-on-rails
database_cleaner
ddtrace
debug (~> 1.8)
@@ -881,9 +884,9 @@ DEPENDENCIES
foreman
geocoder
gmail_xoauth
- google-cloud-dialogflow-v2
- google-cloud-storage
- google-cloud-translate-v3
+ google-cloud-dialogflow-v2 (>= 0.24.0)
+ google-cloud-storage (>= 1.48.0)
+ google-cloud-translate-v3 (>= 0.7.0)
groupdate
grpc
haikunator
@@ -894,6 +897,8 @@ DEPENDENCIES
jbuilder
json_refs
json_schemer
+ judoscale-rails
+ judoscale-sidekiq
jwt
kaminari
koala
@@ -903,14 +908,14 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
- meta_request (>= 0.8.0)
+ meta_request (>= 0.8.3)
mock_redis
neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
- omniauth-google-oauth2 (>= 1.1.2)
+ omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
@@ -930,7 +935,7 @@ DEPENDENCIES
responders (>= 3.1.1)
rest-client
reverse_markdown
- rspec-rails (>= 6.1.3)
+ rspec-rails (>= 6.1.5)
rspec_junit_formatter
rubocop
rubocop-performance
@@ -939,11 +944,11 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
- sentry-rails (>= 5.18.2)
+ sentry-rails (>= 5.19.0)
sentry-ruby
- sentry-sidekiq (>= 5.18.2)
+ sentry-sidekiq (>= 5.19.0)
shoulda-matchers
- sidekiq (>= 7.3.0)
+ sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
@@ -960,10 +965,10 @@ DEPENDENCIES
tzinfo-data
uglifier
valid_email2
+ vite_rails
web-console (>= 4.2.1)
web-push (>= 3.0.1)
webmock
- webpacker
wisper (= 2.0.0)
working_hours
diff --git a/Makefile b/Makefile
index 7f2680a2d..1c5ce297c 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup:
gem install bundler
bundle install
- yarn install
+ pnpm install
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
burn:
- bundle && yarn
+ bundle && pnpm install
run:
@if [ -f ./.overmind.sock ]; then \
diff --git a/Procfile.dev b/Procfile.dev
index 73c2af7e8..651b84edd 100644
--- a/Procfile.dev
+++ b/Procfile.dev
@@ -1,4 +1,4 @@
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
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
+vite: bin/vite dev
diff --git a/Procfile.test b/Procfile.test
index 1770cdbc6..f7ac060ba 100644
--- a/Procfile.test
+++ b/Procfile.test
@@ -1,3 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050
-frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
+vite: bin/vite dev
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
diff --git a/README.md b/README.md
index 152660fb7..e5f73eb97 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,12 @@
+## 🚨 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)
+
+
___
-# Chatwoot
+# Chatwoot
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
@@ -98,7 +101,7 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
### 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
diff --git a/VERSION_CW b/VERSION_CW
index a5c4c7633..4eba2a62e 100644
--- a/VERSION_CW
+++ b/VERSION_CW
@@ -1 +1 @@
-3.9.0
+3.13.0
diff --git a/VERSION_CWCTL b/VERSION_CWCTL
index 834f26295..4a36342fc 100644
--- a/VERSION_CWCTL
+++ b/VERSION_CWCTL
@@ -1 +1 @@
-2.8.0
+3.0.0
diff --git a/app.json b/app.json
index d50040814..08e725c8e 100644
--- a/app.json
+++ b/app.json
@@ -48,7 +48,7 @@
"size": "basic"
}
},
- "stack": "heroku-20",
+ "stack": "heroku-24",
"image": "heroku/ruby",
"addons": [
{
@@ -58,7 +58,7 @@
"plan": "heroku-postgresql:essential-0"
}
],
- "stack": "heroku-20",
+ "stack": "heroku-24",
"buildpacks": [
{
"url": "heroku/nodejs"
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index 2cc9549bc..00c9417ba 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -2,5 +2,4 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
-//= link dashboardChart.js
//= link secretField.js
diff --git a/app/assets/javascripts/dashboardChart.js b/app/assets/javascripts/dashboardChart.js
deleted file mode 100644
index 46f278b80..000000000
--- a/app/assets/javascripts/dashboardChart.js
+++ /dev/null
@@ -1,55 +0,0 @@
-// 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(),
- });
-}
diff --git a/app/assets/stylesheets/administrate/utilities/_variables.scss b/app/assets/stylesheets/administrate/utilities/_variables.scss
index 3a1129c41..9123667e2 100644
--- a/app/assets/stylesheets/administrate/utilities/_variables.scss
+++ b/app/assets/stylesheets/administrate/utilities/_variables.scss
@@ -86,8 +86,5 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
-// Ionicons
-$ionicons-font-path: '~ionicons/fonts';
-
// Transitions
$transition-ease-in: all 0.250s ease-in;
diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb
index f179c6405..6f7980586 100644
--- a/app/builders/account_builder.rb
+++ b/app/builders/account_builder.rb
@@ -32,11 +32,13 @@ class AccountBuilder
end
def validate_email
+ raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
+
address = ValidEmail2::Address.new(@email)
- if address.valid? # && !address.disposable?
+ if address.valid? && !address.disposable?
true
else
- raise InvalidEmail.new(valid: address.valid?)
+ raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
end
end
@@ -79,4 +81,21 @@ class AccountBuilder
@user.confirm if @confirmed
@user.save!
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
diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb
index 4eff10127..01aa42620 100644
--- a/app/controllers/api/v1/accounts/agents_controller.rb
+++ b/app/controllers/api/v1/accounts/agents_controller.rb
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def update
@agent.update!(agent_params.slice(:name).compact)
- @agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
+ @agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
end
def destroy
@@ -67,8 +67,16 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id])
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
- params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
+ params.require(:agent).permit(allowed_agent_params)
end
def new_agent_params
@@ -101,3 +109,5 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end
+
+Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb
index d164778ac..9148e4386 100644
--- a/app/controllers/api/v1/accounts/articles_controller.rb
+++ b/app/controllers/api/v1/accounts/articles_controller.rb
@@ -6,13 +6,15 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def index
@portal_articles = @portal.articles
- @all_articles = @portal_articles.search(list_params)
- @articles_count = @all_articles.count
+
+ set_article_count
+
+ @articles = @articles.search(list_params)
@articles = if list_params[:category_slug].present?
- @all_articles.order_by_position.page(@current_page)
+ @articles.order_by_position.page(@current_page)
else
- @all_articles.order_by_updated_at.page(@current_page)
+ @articles.order_by_updated_at.page(@current_page)
end
end
@@ -43,6 +45,19 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
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
@article = @portal.articles.find(params[:id])
end
@@ -53,9 +68,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
- :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
- :description,
- { tags: [] }]
+ :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
+ :locale, meta: [:title,
+ :description,
+ { tags: [] }]
)
end
diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb
index 250c64a86..0d8e0ed93 100644
--- a/app/controllers/api/v1/accounts/contacts_controller.rb
+++ b/app/controllers/api/v1/accounts/contacts_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
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 :set_include_contact_inboxes, only: [:index, :search, :filter]
+ before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb
index d34561e36..63226f342 100644
--- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb
+++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
- message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
+ message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.attachments.destroy_all
end
end
diff --git a/app/controllers/api/v1/accounts/integrations/captain_controller.rb b/app/controllers/api/v1/accounts/integrations/captain_controller.rb
index 7df0e61df..15e81b726 100644
--- a/app/controllers/api/v1/accounts/integrations/captain_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/captain_controller.rb
@@ -1,22 +1,53 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
- before_action :check_admin_authorization?
- before_action :fetch_hook
+ before_action :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
+ def proxy
+ response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
+ render plain: response.body, status: response.code
end
private
- def fetch_hook
- @hook = Current.account.hooks.find_by!(app_id: 'captain')
+ def headers
+ {
+ 'X-User-Email' => hook.settings['account_email'],
+ 'X-User-Token' => hook.settings['access_token'],
+ 'Content-Type' => 'application/json',
+ 'Accept' => '*/*'
+ }
+ end
+
+ def request_path
+ request_route = with_leading_hash_on_route(params[:route])
+
+ return 'api/sessions/profile' if request_route == '/sessions/profile'
+
+ "api/accounts/#{hook.settings['account_id']}#{request_route}"
+ end
+
+ def request_url
+ base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
+ URI.join(base_url, request_path).to_s
+ end
+
+ def hook
+ @hook ||= Current.account.hooks.find_by!(app_id: 'captain')
+ end
+
+ def request_method
+ method = permitted_params[:method].downcase
+ raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
+
+ method
+ end
+
+ def with_leading_hash_on_route(request_route)
+ return '' if request_route.blank?
+
+ request_route.start_with?('/') ? request_route : "/#{request_route}"
+ end
+
+ def permitted_params
+ params.permit(:method, :route, body: {})
end
end
diff --git a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb
index d09ea2f00..087a9b78d 100644
--- a/app/controllers/api/v1/accounts/integrations/hooks_controller.rb
+++ b/app/controllers/api/v1/accounts/integrations/hooks_controller.rb
@@ -11,7 +11,17 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def process_event
- render json: { message: @hook.process_event(params[:event]) }
+ response = @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
def destroy
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index bd8da5549..fe9a03ef5 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def create
- @portal = Current.account.portals.build(portal_params)
+ @portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
@@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
- @portal.update!(portal_params) if params[:portal].present?
+ @portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
rescue StandardError => e
@@ -70,11 +70,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
- :account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
- { allowed_locales: [] }] }
+ :account_id, :color, :custom_domain, :header_text, :homepage_link,
+ :name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
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
params.require(:portal).permit(:account_id, member_ids: [])
end
diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb
index 4c54938b7..6530279da 100644
--- a/app/controllers/api/v1/accounts/upload_controller.rb
+++ b/app/controllers/api/v1/accounts/upload_controller.rb
@@ -1,13 +1,68 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
- file_blob = ActiveStorage::Blob.create_and_upload!(
- key: nil,
- io: params[:attachment].tempfile,
- filename: params[:attachment].original_filename,
- content_type: params[:attachment].content_type
- )
- file_blob.save!
+ result = if params[:attachment].present?
+ create_from_file
+ elsif params[:external_url].present?
+ create_from_url
+ else
+ 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
+
+ def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
+
+ def render_error(message, status)
+ render json: { error: message }, status: status
+ end
end
diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb
index bd3c2673c..ae1a1fe30 100644
--- a/app/controllers/api/v1/profiles_controller.rb
+++ b/app/controllers/api/v1/profiles_controller.rb
@@ -17,7 +17,7 @@ class Api::V1::ProfilesController < Api::BaseController
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
- head :ok
+ @user.reload
end
def auto_offline
diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb
index 7e6d84bd2..fe5facc1a 100644
--- a/app/controllers/api/v1/widget/conversations_controller.rb
+++ b/app/controllers/api/v1/widget/conversations_controller.rb
@@ -11,6 +11,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
process_update_contact
@conversation = create_conversation
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
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index ed5be5518..af3655e59 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
- raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
+ return if Current.account_user.administrator?
+
+ raise Pundit::NotAuthorizedError
end
def common_params
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
+
+Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 332f1528f..6ddaab73b 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -72,7 +72,7 @@ class DashboardController < ActionController::Base
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app'
else
- 'application'
+ 'dashboard'
end
end
diff --git a/app/controllers/google/callbacks_controller.rb b/app/controllers/google/callbacks_controller.rb
index 391e1de0f..766d984df 100644
--- a/app/controllers/google/callbacks_controller.rb
+++ b/app/controllers/google/callbacks_controller.rb
@@ -1,6 +1,15 @@
class Google::CallbacksController < OauthCallbackController
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
def provider_name
diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb
index 309160f1f..4cb02d266 100644
--- a/app/controllers/oauth_callback_controller.rb
+++ b/app/controllers/oauth_callback_controller.rb
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
def find_or_create_inbox
- channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
+ channel_email = find_channel_by_email
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
@@ -39,6 +39,10 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists]
end
+ def find_channel_by_email
+ Channel::Email.find_by(email: users_data['email'], account: account)
+ end
+
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
diff --git a/app/controllers/platform/api/v1/accounts_controller.rb b/app/controllers/platform/api/v1/accounts_controller.rb
index 9e6d53fef..e11cf9d4a 100644
--- a/app/controllers/platform/api/v1/accounts_controller.rb
+++ b/app/controllers/platform/api/v1/accounts_controller.rb
@@ -4,6 +4,7 @@ class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.create!(account_params)
update_resource_features
+ @resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index 46ecf19a7..08c62bada 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -6,7 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
- @articles = @portal.articles
+ @articles = @portal.articles.published
search_articles
order_by_sort_param
@articles.page(list_params[:page]) if list_params[:page].present?
@@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
- @article.increment_view_count
+ @article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content)
end
diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb
index de6ab42bb..b566551f4 100644
--- a/app/dashboards/account_dashboard.rb
+++ b/app/dashboards/account_dashboard.rb
@@ -26,7 +26,8 @@ class AccountDashboard < Administrate::BaseDashboard
conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
- account_users: Field::HasMany
+ account_users: Field::HasMany,
+ custom_attributes: Field::String
}.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES
@@ -45,7 +46,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
- enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
+ enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
SHOW_PAGE_ATTRIBUTES = (%i[
id
name
diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue
index da169d61f..c32cb98d2 100644
--- a/app/javascript/dashboard/App.vue
+++ b/app/javascript/dashboard/App.vue
@@ -1,6 +1,5 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ campaignStatus }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/LiveChatCampaignDetails.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/LiveChatCampaignDetails.vue
new file mode 100644
index 000000000..6a883ce6b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/LiveChatCampaignDetails.vue
@@ -0,0 +1,56 @@
+
+
+
+
+ {{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
+
+
+
+
+ {{ senderName }}
+
+
+
+ {{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
+
+
+
+
+ {{ inboxName }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignCard/SMSCampaignDetails.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/SMSCampaignDetails.vue
new file mode 100644
index 000000000..0c54c43eb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignCard/SMSCampaignDetails.vue
@@ -0,0 +1,41 @@
+
+
+
+
+ {{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.SENT_FROM') }}
+
+
+
+
+ {{ inboxName }}
+
+
+
+
+ {{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.ON') }}
+
+
+ {{ messageStamp(new Date(scheduledAt), 'LLL d, h:mm a') }}
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
new file mode 100644
index 000000000..3169dbc3e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/CampaignLayout.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+ {{ headerTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/CampaignEmptyStateContent.js b/app/javascript/dashboard/components-next/Campaigns/EmptyState/CampaignEmptyStateContent.js
new file mode 100644
index 000000000..4f409196f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/CampaignEmptyStateContent.js
@@ -0,0 +1,212 @@
+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',
+ },
+];
diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/LiveChatCampaignEmptyState.vue b/app/javascript/dashboard/components-next/Campaigns/EmptyState/LiveChatCampaignEmptyState.vue
new file mode 100644
index 000000000..d2086b1d4
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/LiveChatCampaignEmptyState.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/SMSCampaignEmptyState.vue b/app/javascript/dashboard/components-next/Campaigns/EmptyState/SMSCampaignEmptyState.vue
new file mode 100644
index 000000000..ab01acb4a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/SMSCampaignEmptyState.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue
new file mode 100644
index 000000000..183573606
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue
new file mode 100644
index 000000000..f55c88e46
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/EditLiveChatCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/EditLiveChatCampaignDialog.vue
new file mode 100644
index 000000000..9f46a1bde
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/EditLiveChatCampaignDialog.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue
new file mode 100644
index 000000000..2c4b9848c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+ {{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
new file mode 100644
index 000000000..1a9bc8cdf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue
@@ -0,0 +1,323 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignDialog.vue
new file mode 100644
index 000000000..b190b031b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignDialog.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+ {{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue
new file mode 100644
index 000000000..653e58a9a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue
@@ -0,0 +1,189 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CardLayout.vue b/app/javascript/dashboard/components-next/CardLayout.vue
new file mode 100644
index 000000000..0fd3f5986
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CardLayout.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue b/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue
new file mode 100644
index 000000000..255c1b113
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
new file mode 100644
index 000000000..9875ae2ac
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+
+ {{ additionalAttributes.companyName }}
+
+
+
+
+
+
+ {{ email }}
+
+
+
+
+ {{ phoneNumber }}
+
+
+
+ {{ countryDetails }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/story/ContactsCard.story.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/story/ContactsCard.story.vue
new file mode 100644
index 000000000..3f8d5a411
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/story/ContactsCard.story.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ {}"
+ @show-contact="() => {}"
+ />
+
+
+
+
+
+ {}"
+ @update-contact="() => {}"
+ @show-contact="() => {}"
+ />
+
+
+
+
+
+ {}"
+ @update-contact="() => {}"
+ @show-contact="() => {}"
+ />
+
+
+
+
+
+ {}"
+ @update-contact="() => {}"
+ @show-contact="() => {}"
+ />
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/story/fixtures.js b/app/javascript/dashboard/components-next/Contacts/ContactsCard/story/fixtures.js
new file mode 100644
index 000000000..67bdba9b4
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/story/fixtures.js
@@ -0,0 +1,149 @@
+export default [
+ {
+ additionalAttributes: {
+ socialProfiles: {},
+ },
+ availabilityStatus: null,
+ email: 'johndoe@chatwoot.com',
+ id: 370,
+ name: 'John Doe',
+ phoneNumber: '+918634322418',
+ identifier: null,
+ thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Felix',
+ customAttributes: {},
+ lastActivityAt: 1731608270,
+ createdAt: 1731586271,
+ },
+ {
+ additionalAttributes: {
+ city: 'kerala',
+ country: 'India',
+ description: 'Curious about the web. ',
+ companyName: 'Chatwoot',
+ countryCode: '',
+ socialProfiles: {
+ github: 'abozler',
+ twitter: 'ozler',
+ facebook: 'abozler',
+ linkedin: 'abozler',
+ instagram: 'ozler',
+ },
+ },
+ availabilityStatus: null,
+ email: 'ozler@chatwoot.com',
+ id: 29,
+ name: 'Abraham Ozlers',
+ phoneNumber: '+246232222222',
+ identifier: null,
+ thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Upload',
+ customAttributes: {
+ dateContact: '2024-02-01T00:00:00.000Z',
+ linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
+ listContact: 'Not spam',
+ numberContact: '12',
+ },
+ lastActivityAt: 1712127410,
+ createdAt: 1712127389,
+ },
+ {
+ additionalAttributes: {
+ city: 'Kerala',
+ country: 'India',
+ description:
+ "I'm Candice developer focusing on building things for the web 🌍. Currently, I’m working as a Product Developer here at @chatwootapp ⚡️🔥",
+ companyName: 'Chatwoot',
+ countryCode: 'IN',
+ socialProfiles: {
+ github: 'cmathersonj',
+ twitter: 'cmather',
+ facebook: 'cmathersonj',
+ linkedin: 'cmathersonj',
+ instagram: 'cmathersonjs',
+ },
+ },
+ availabilityStatus: null,
+ email: 'cmathersonj@va.test',
+ id: 22,
+ name: 'Candice Matherson',
+ phoneNumber: '+917474774742',
+ identifier: null,
+ thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Emery',
+ customAttributes: {
+ dateContact: '2024-11-12T03:23:06.963Z',
+ linkContact: 'https://sd.sd',
+ textContact: 'hey',
+ numberContact: '12',
+ checkboxContact: true,
+ },
+ lastActivityAt: 1712123233,
+ createdAt: 1712123233,
+ },
+ {
+ additionalAttributes: {
+ city: '',
+ country: '',
+ description: '',
+ companyName: '',
+ countryCode: '',
+ socialProfiles: {
+ github: '',
+ twitter: '',
+ facebook: '',
+ linkedin: '',
+ instagram: '',
+ },
+ },
+ availabilityStatus: null,
+ email: 'ofolkardi@taobao.test',
+ id: 21,
+ name: 'Ophelia Folkard',
+ phoneNumber: '',
+ identifier: null,
+ thumbnail:
+ 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBPZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--08dcac8eb72ef12b2cad92d58dddd04cd8a5f513/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/32.jpg',
+ customAttributes: {},
+ lastActivityAt: 1712123233,
+ createdAt: 1712123233,
+ },
+ {
+ additionalAttributes: {
+ socialProfiles: {},
+ },
+ availabilityStatus: null,
+ email: 'wcasteloth@exblog.jp',
+ id: 20,
+ name: 'Willy Castelot',
+ phoneNumber: '+919384',
+ identifier: null,
+ thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Jade',
+ customAttributes: {},
+ lastActivityAt: 1712123233,
+ createdAt: 1712123233,
+ },
+ {
+ additionalAttributes: {
+ city: '',
+ country: '',
+ description: '',
+ companyName: '',
+ countryCode: '',
+ socialProfiles: {
+ github: '',
+ twitter: '',
+ facebook: '',
+ linkedin: '',
+ instagram: '',
+ },
+ },
+ availabilityStatus: null,
+ email: 'ederingtong@printfriendly.test',
+ id: 19,
+ name: 'Elisabeth Derington',
+ phoneNumber: '',
+ identifier: null,
+ thumbnail: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Jade',
+ customAttributes: {},
+ lastActivityAt: 1712123232,
+ createdAt: 1712123232,
+ },
+];
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
new file mode 100644
index 000000000..ad007af1c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue
new file mode 100644
index 000000000..d9c0deb1b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue
new file mode 100644
index 000000000..d2b57cbb8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue
new file mode 100644
index 000000000..acb8f0f1c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue
@@ -0,0 +1,134 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue
new file mode 100644
index 000000000..8adcce6b2
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
+
+
+
emit('update:primaryContactId', value)"
+ @search="query => emit('search', query)"
+ />
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
+
+
+
+
+
+
+ {{ selectedContact.name }}
+
+
+ {{ selectedContact.email }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
new file mode 100644
index 000000000..a49236cb1
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
new file mode 100644
index 000000000..806fcf4ac
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue
new file mode 100644
index 000000000..a246c742c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue
new file mode 100644
index 000000000..60aa0e55e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/ContactMergeForm.story.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/ContactMergeForm.story.vue
new file mode 100644
index 000000000..dbf22dadd
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/ContactMergeForm.story.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/ContactsForm.story.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/ContactsForm.story.vue
new file mode 100644
index 000000000..306df1df0
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/ContactsForm.story.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/fixtures.js b/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/fixtures.js
new file mode 100644
index 000000000..9d9590d63
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/story/fixtures.js
@@ -0,0 +1,47 @@
+export const contactData = {
+ id: 370,
+ name: 'John Doe',
+ email: 'johndoe@chatwoot.com',
+ phoneNumber: '+918634322418',
+ additionalAttributes: {
+ city: 'Kerala',
+ country: 'India',
+ description: 'Curious about the web.',
+ companyName: 'Chatwoot',
+ countryCode: 'IN',
+ socialProfiles: {
+ github: 'johndoe',
+ twitter: 'johndoe',
+ facebook: 'johndoe',
+ linkedin: 'johndoe',
+ instagram: 'johndoe',
+ },
+ },
+};
+
+export const primaryContactList = [
+ {
+ id: 1,
+ name: 'Jane Smith',
+ email: 'jane@chatwoot.com',
+ thumbnail: '',
+ label: '(ID: 1) Jane Smith',
+ value: 1,
+ },
+ {
+ id: 2,
+ name: 'Mike Johnson',
+ email: 'mike@chatwoot.com',
+ thumbnail: '',
+ label: '(ID: 2) Mike Johnson',
+ value: 2,
+ },
+ {
+ id: 3,
+ name: 'Sarah Wilson',
+ email: 'sarah@chatwoot.com',
+ thumbnail: '',
+ label: '(ID: 3) Sarah Wilson',
+ value: 3,
+ },
+];
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue
new file mode 100644
index 000000000..44350bba1
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+ {{ headerTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue
new file mode 100644
index 000000000..8b2a53b27
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue
@@ -0,0 +1,306 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue
new file mode 100644
index 000000000..d5932535c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue
new file mode 100644
index 000000000..b263b882d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactSortMenu.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue
new file mode 100644
index 000000000..36162520d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/story/ContactHeader.story.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/story/ContactHeader.story.vue
new file mode 100644
index 000000000..a89aa724c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/story/ContactHeader.story.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue
new file mode 100644
index 000000000..22b32d7b8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue
new file mode 100644
index 000000000..dc52d8912
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+ {{ attribute.attributeDisplayName }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
new file mode 100644
index 000000000..f2a5c1ee8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
{{
+ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
+ count: unusedAttributesCount,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue
new file mode 100644
index 000000000..3763d198a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue
new file mode 100644
index 000000000..2a0e79931
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue
new file mode 100644
index 000000000..dfe3f437c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue
new file mode 100644
index 000000000..504be1547
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+ {{ writtenBy }}
+ {{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
+ {{ dynamicTime(note.createdAt) }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/story/ContactNoteItem.story.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/story/ContactNoteItem.story.vue
new file mode 100644
index 000000000..0dbf8bf01
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/story/ContactNoteItem.story.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/story/fixtures.js b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/story/fixtures.js
new file mode 100644
index 000000000..440ad95df
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/story/fixtures.js
@@ -0,0 +1,69 @@
+export default [
+ {
+ id: 12,
+ content:
+ 'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
+ accountId: null,
+ contactId: null,
+ user: {
+ id: 30,
+ account_id: 2,
+ availability_status: 'offline',
+ auto_offline: true,
+ confirmed: true,
+ email: 'bruce@paperlayer.test',
+ available_name: 'Bruce',
+ name: 'Bruce',
+ role: 'administrator',
+ thumbnail:
+ 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
+ custom_role_id: null,
+ },
+ createdAt: 1730786556,
+ updatedAt: 1730786556,
+ },
+ {
+ id: 10,
+ content:
+ 'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
+ accountId: null,
+ contactId: null,
+ user: {
+ id: 1,
+ account_id: 2,
+ availability_status: 'online',
+ auto_offline: false,
+ confirmed: true,
+ email: 'hillary@chatwoot.com',
+ available_name: 'Hillary',
+ name: 'Hillary',
+ role: 'administrator',
+ thumbnail: '',
+ custom_role_id: null,
+ },
+ createdAt: 1730782566,
+ updatedAt: 1730782566,
+ },
+ {
+ id: 9,
+ content:
+ 'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
+ accountId: null,
+ contactId: null,
+ user: {
+ id: 1,
+ account_id: 2,
+ availability_status: 'online',
+ auto_offline: false,
+ confirmed: true,
+ email: 'john@chatwoot.com',
+ available_name: 'John',
+ name: 'John',
+ role: 'administrator',
+ thumbnail: '',
+ custom_role_id: null,
+ },
+ createdAt: 1730782564,
+ updatedAt: 1730782564,
+ },
+];
diff --git a/app/javascript/dashboard/components-next/Contacts/EmptyState/ContactEmptyState.story.vue b/app/javascript/dashboard/components-next/Contacts/EmptyState/ContactEmptyState.story.vue
new file mode 100644
index 000000000..0c1998a84
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/EmptyState/ContactEmptyState.story.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue b/app/javascript/dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue
new file mode 100644
index 000000000..f6f5ddaf6
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent.js b/app/javascript/dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent.js
new file mode 100644
index 000000000..6d80c9151
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent.js
@@ -0,0 +1,228 @@
+export default [
+ {
+ additionalAttributes: {
+ city: 'Los Angeles',
+ country: 'United States',
+ description:
+ "I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
+ companyName: 'Chatwoot',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'candice-dev',
+ twitter: 'candice_w_dev',
+ facebook: 'candice.dev',
+ linkedin: 'candice-matherson',
+ instagram: 'candice.codes',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'candice.matherson@chatwoot.com',
+ id: 22,
+ name: 'Candice Matherson',
+ phoneNumber: '+14155552671',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {
+ dateContact: '2024-11-11T11:53:09.299Z',
+ linkContact: 'https://example.com',
+ listContact: 'Follow-Up',
+ textContact: 'Hi there!',
+ numberContact: '42',
+ checkboxContact: false,
+ },
+ lastActivityAt: 1712123233,
+ createdAt: 1712123233,
+ },
+ {
+ additionalAttributes: {
+ city: 'San Francisco',
+ country: 'United States',
+ description: 'Passionate about design and user experience.',
+ companyName: 'Designify',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'ophelia-folkard',
+ twitter: 'oph_designs',
+ facebook: 'ophelia.folkard',
+ linkedin: 'ophelia-folkard',
+ instagram: 'ophelia.design',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'ophelia.folkard@designify.com',
+ id: 21,
+ name: 'Ophelia Folkard',
+ phoneNumber: '+14155552672',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {
+ dateContact: '2024-10-05T10:12:34.567Z',
+ linkContact: 'https://designify.com',
+ listContact: 'Prospects',
+ textContact: 'Looking forward to connecting!',
+ },
+ lastActivityAt: 1712123233,
+ createdAt: 1712123233,
+ },
+ {
+ additionalAttributes: {
+ city: 'Austin',
+ country: 'United States',
+ description: 'Avid coder and tech enthusiast.',
+ companyName: 'CodeHub',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'willy_castelot',
+ twitter: 'willy_code',
+ facebook: 'willy.castelot',
+ linkedin: 'willy-castelot',
+ instagram: 'willy.coder',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'willy.castelot@codehub.io',
+ id: 20,
+ name: 'Willy Castelot',
+ phoneNumber: '+14155552673',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {
+ textContact: 'Let’s collaborate!',
+ checkboxContact: true,
+ },
+ lastActivityAt: 1712123233,
+ createdAt: 1712123233,
+ },
+ {
+ additionalAttributes: {
+ city: 'Seattle',
+ country: 'United States',
+ description: 'Product manager with a love for innovation.',
+ companyName: 'InnovaTech',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'elisabeth-d',
+ twitter: 'elisabeth_innova',
+ facebook: 'elisabeth.derington',
+ linkedin: 'elisabeth-derington',
+ instagram: 'elisabeth.innovates',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'elisabeth.derington@innova.com',
+ id: 19,
+ name: 'Elisabeth Derington',
+ phoneNumber: '+14155552674',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {
+ textContact: 'Let’s schedule a call.',
+ },
+ lastActivityAt: 1712123232,
+ createdAt: 1712123232,
+ },
+ {
+ additionalAttributes: {
+ city: 'Chicago',
+ country: 'United States',
+ description: 'Marketing specialist and content creator.',
+ companyName: 'Contently',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'olia-olenchenko',
+ twitter: 'olia_content',
+ facebook: 'olia.olenchenko',
+ linkedin: 'olia-olenchenko',
+ instagram: 'olia.creates',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'olia.olenchenko@contently.com',
+ id: 18,
+ name: 'Olia Olenchenko',
+ phoneNumber: '+14155552675',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {},
+ lastActivityAt: 1712123232,
+ createdAt: 1712123232,
+ },
+ {
+ additionalAttributes: {
+ city: 'Boston',
+ country: 'United States',
+ description: 'SEO expert and analytics enthusiast.',
+ companyName: 'OptiSearch',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'nate-vannuchi',
+ twitter: 'nate_seo',
+ facebook: 'nathaniel.vannuchi',
+ linkedin: 'nathaniel-vannuchi',
+ instagram: 'nate.optimizes',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'nathaniel.vannuchi@optisearch.com',
+ id: 17,
+ name: 'Nathaniel Vannuchi',
+ phoneNumber: '+14155552676',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {},
+ lastActivityAt: 1712123232,
+ createdAt: 1712123232,
+ },
+ {
+ additionalAttributes: {
+ city: 'Denver',
+ country: 'United States',
+ description: 'UI/UX designer with a flair for minimalist designs.',
+ companyName: 'Minimal Designs',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'merrile-petruk',
+ twitter: 'merrile_ux',
+ facebook: 'merrile.petruk',
+ linkedin: 'merrile-petruk',
+ instagram: 'merrile.designs',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'merrile.petruk@minimal.com',
+ id: 16,
+ name: 'Merrile Petruk',
+ phoneNumber: '+14155552677',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {},
+ lastActivityAt: 1712123232,
+ createdAt: 1712123232,
+ },
+ {
+ additionalAttributes: {
+ city: 'Miami',
+ country: 'United States',
+ description: 'Entrepreneur with a background in e-commerce.',
+ companyName: 'Ecom Solutions',
+ countryCode: 'US',
+ socialProfiles: {
+ github: 'cordell-d',
+ twitter: 'cordell_entrepreneur',
+ facebook: 'cordell.dalinder',
+ linkedin: 'cordell-dalinder',
+ instagram: 'cordell.ecom',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'cordell.dalinder@ecomsolutions.com',
+ id: 15,
+ name: 'Cordell Dalinder',
+ phoneNumber: '+14155552678',
+ identifier: null,
+ thumbnail: '',
+ customAttributes: {},
+ lastActivityAt: 1712123232,
+ createdAt: 1712123232,
+ },
+];
diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue
new file mode 100644
index 000000000..aec21a976
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue
@@ -0,0 +1,200 @@
+
+
+
+
+
+
+
+
+ {{ selectedContact?.name }}
+
+
+
+
+ {{ selectedContact?.identifier }}
+
+
+
+ {{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
+ •
+ {{
+ $t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
+ date: lastActivityAt,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue
new file mode 100644
index 000000000..33532edcf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardLabels.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardLabels.vue
new file mode 100644
index 000000000..dfd4115e8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardLabels.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+ {{ label.title }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue
new file mode 100644
index 000000000..a81c80b87
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreview.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+ {{ lastNonActivityMessageContent }}
+
+
+
+
+
+ {{ unreadMessagesCount }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue
new file mode 100644
index 000000000..19dc7008a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardMessagePreviewWithMeta.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+ {{ lastNonActivityMessageContent }}
+
+
+
+
+ {{ unreadMessagesCount }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue
new file mode 100644
index 000000000..d51764ef5
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.story.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.story.vue
new file mode 100644
index 000000000..a71ab24d7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.story.vue
@@ -0,0 +1,477 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue
new file mode 100644
index 000000000..bb643ee22
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+ {{ currentContactName }}
+
+
+
+
+
+
+
+ {{ lastActivityAt }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue
new file mode 100644
index 000000000..a9db5d99b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+ {{ `${slaStatusText}: ${slaStatus.threshold}` }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/CheckboxAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/CheckboxAttribute.vue
new file mode 100644
index 000000000..d5ecc3aab
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CustomAttributes/CheckboxAttribute.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
new file mode 100644
index 000000000..ba8090e02
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CustomAttributes/DateAttribute.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+ {{ formattedDate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/ListAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/ListAttribute.vue
new file mode 100644
index 000000000..a9831762f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CustomAttributes/ListAttribute.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+ {{
+ attribute.value ||
+ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
+ }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue
new file mode 100644
index 000000000..e76a744be
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CustomAttributes/OtherAttribute.vue
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+ {{ attribute.value }}
+
+
+ {{
+ attribute.value ||
+ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/story/CustomAttributes.story.vue b/app/javascript/dashboard/components-next/CustomAttributes/story/CustomAttributes.story.vue
new file mode 100644
index 000000000..ea87a3f0c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CustomAttributes/story/CustomAttributes.story.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+ {{ attribute.attributeDisplayName }}
+
+
+
handleUpdate(attribute.attributeDisplayType, value)
+ "
+ @delete="() => handleDelete(attribute.attributeDisplayType)"
+ />
+
+
+
+
+
+
+
+
+
+ {{ attribute.attributeDisplayName }}
+
+
+
handleUpdate(attribute.attributeDisplayType, value)
+ "
+ @delete="() => handleDelete(attribute.attributeDisplayType)"
+ />
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/CustomAttributes/story/fixtures.js b/app/javascript/dashboard/components-next/CustomAttributes/story/fixtures.js
new file mode 100644
index 000000000..7cd600dcf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/CustomAttributes/story/fixtures.js
@@ -0,0 +1,39 @@
+export default [
+ {
+ attributeKey: 'textContact',
+ attributeDisplayName: 'Text Input',
+ attributeDisplayType: 'text',
+ value: 'Sample text value',
+ },
+ {
+ attributeKey: 'linkContact',
+ attributeDisplayName: 'URL Input',
+ attributeDisplayType: 'link',
+ value: 'https://www.chatwoot.com',
+ },
+ {
+ attributeKey: 'numberContact',
+ attributeDisplayName: 'Number Input',
+ attributeDisplayType: 'number',
+ value: '42',
+ },
+ {
+ attributeKey: 'listContact',
+ attributeDisplayName: 'List Input',
+ attributeDisplayType: 'list',
+ value: 'Option 2',
+ attributeValues: ['Option 1', 'Option 2', 'Option 3'],
+ },
+ {
+ attributeKey: 'dateContact',
+ attributeDisplayName: 'Date Input',
+ attributeDisplayType: 'date',
+ value: '2024-03-25T00:00:00.000Z',
+ },
+ {
+ attributeKey: 'checkboxContact',
+ attributeDisplayName: 'Checkbox Input',
+ attributeDisplayType: 'checkbox',
+ value: true,
+ },
+];
diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue
new file mode 100644
index 000000000..671a628dc
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Editor/Editor.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+ {{ characterCount }} / {{ maxLength }}
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/EmptyStateLayout.vue b/app/javascript/dashboard/components-next/EmptyStateLayout.vue
new file mode 100644
index 000000000..04c478ba6
--- /dev/null
+++ b/app/javascript/dashboard/components-next/EmptyStateLayout.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ subtitle }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.story.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.story.vue
new file mode 100644
index 000000000..be0c41163
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.story.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
new file mode 100644
index 000000000..9b705d441
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+
+ {{ statusText }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ authorName || '-' }}
+
+
+
+ {{ categoryName }}
+
+
+
+
+ {{
+ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
+ count: views,
+ })
+ }}
+
+
+
+
+ {{ lastUpdatedAt }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.story.vue b/app/javascript/dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.story.vue
new file mode 100644
index 000000000..e279c8dfc
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.story.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue b/app/javascript/dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue
new file mode 100644
index 000000000..d9a82fde5
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+ {{ categoryTitleWithIcon }}
+
+
+ {{
+ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
+ count: articlesCount,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.story.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.story.vue
new file mode 100644
index 000000000..4a3cb8135
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.story.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue
new file mode 100644
index 000000000..7bdf424b2
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue
new file mode 100644
index 000000000..3c6b2bc64
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js
new file mode 100644
index 000000000..fd772f340
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js
@@ -0,0 +1,142 @@
+export default [
+ {
+ id: 1,
+ name: 'Getting Started',
+ icon: '🚀',
+ description: 'Quick guides to help new users onboard.',
+ slug: 'getting-started',
+ meta: {
+ articles_count: 5,
+ },
+ },
+ {
+ id: 2,
+ name: 'Advanced Features',
+ icon: '💡',
+ description: 'Explore advanced features for power users.',
+ slug: 'advanced-features',
+ meta: {
+ articles_count: 8,
+ },
+ },
+ {
+ id: 3,
+ name: 'FAQs',
+ icon: '❓',
+ description: 'Commonly asked questions and helpful answers.',
+ slug: 'faqs',
+ meta: {
+ articles_count: 3,
+ },
+ },
+ {
+ id: 4,
+ name: 'Troubleshooting',
+ icon: '🛠️',
+ description: 'Resolve common issues with step-by-step guidance.',
+ slug: 'troubleshooting',
+ meta: {
+ articles_count: 6,
+ },
+ },
+ {
+ id: 5,
+ name: 'Community Guidelines',
+ icon: '👥',
+ description: 'Rules and practices for community engagement.',
+ slug: 'community-guidelines',
+ meta: {
+ articles_count: 2,
+ },
+ },
+ {
+ id: 6,
+ name: 'Account Management',
+ icon: '🔑',
+ description: 'Manage your account and settings efficiently.',
+ slug: 'account-management',
+ meta: {
+ articles_count: 7,
+ },
+ },
+ {
+ id: 7,
+ name: 'Security Tips',
+ icon: '🔒',
+ description: 'Best practices for securing your account.',
+ slug: 'security-tips',
+ meta: {
+ articles_count: 4,
+ },
+ },
+ {
+ id: 8,
+ name: 'Integrations',
+ icon: '🔗',
+ description: 'Connect to third-party services and tools easily.',
+ slug: 'integrations',
+ meta: {
+ articles_count: 9,
+ },
+ },
+ {
+ id: 9,
+ name: 'Billing & Payments',
+ icon: '💳',
+ description: 'Manage your billing and payment details seamlessly.',
+ slug: 'billing-payments',
+ meta: {
+ articles_count: 5,
+ },
+ },
+ {
+ id: 10,
+ name: 'Customization',
+ icon: '🎨',
+ description: 'Personalize and customize your user experience.',
+ slug: 'customization',
+ meta: {
+ articles_count: 7,
+ },
+ },
+ {
+ id: 11,
+ name: 'Notifications',
+ icon: '🔔',
+ description: 'Adjust your notification settings and preferences.',
+ slug: 'notifications',
+ meta: {
+ articles_count: 3,
+ },
+ },
+ {
+ id: 12,
+ name: 'Privacy',
+ icon: '🛡️',
+ description: 'Understand how your data is collected and used.',
+ slug: 'privacy',
+ meta: {
+ articles_count: 2,
+ },
+ },
+ {
+ id: 13,
+ name: 'Mobile App',
+ icon: '📱',
+ description: 'Guides for using the mobile app effectively.',
+ slug: 'mobile-app',
+ meta: {
+ articles_count: 6,
+ },
+ },
+ {
+ id: 14,
+ name: 'Beta Features',
+ icon: '🧪',
+ description: 'Learn about new experimental features in beta.',
+ slug: 'beta-features',
+ meta: {
+ articles_count: 4,
+ },
+ },
+];
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.story.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.story.vue
new file mode 100644
index 000000000..56ab5edc3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.story.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue
new file mode 100644
index 000000000..b7a986254
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js
new file mode 100644
index 000000000..bc8a49e12
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js
@@ -0,0 +1,172 @@
+export default [
+ {
+ id: 1,
+ title: "How to get an SSL certificate for your Help Center's custom domain",
+ status: 'draft',
+ updatedAt: 1729205669,
+ author: { availableName: 'Michael' },
+ category: {
+ slug: 'configuration',
+ icon: '📦',
+ name: 'Setup & Configuration',
+ },
+ views: 3400,
+ },
+ {
+ id: 2,
+ title: 'Setting up your first Help Center portal',
+ status: 'published',
+ updatedAt: 1729205669,
+ author: { availableName: 'John' },
+ category: { slug: 'onboarding', icon: '🧑🍳', name: 'Onboarding' },
+ views: 400,
+ },
+ {
+ id: 3,
+ title: 'Best practices for organizing your Help Center content',
+ status: 'archived',
+ updatedAt: 1729205669,
+ author: { availableName: 'Fernando' },
+ category: { slug: 'best-practices', icon: '⛺️', name: 'Best Practices' },
+ views: 400,
+ },
+ {
+ id: 4,
+ title: 'Customizing the appearance of your Help Center',
+ status: 'draft',
+ updatedAt: 1729205669,
+ author: { availableName: 'Jane' },
+ category: { slug: 'design', icon: '🎨', name: 'Design' },
+ views: 400,
+ },
+ {
+ id: 5,
+ title: 'Integrating your Help Center with third-party tools',
+ status: 'published',
+ updatedAt: 1729205669,
+ author: { availableName: 'Sarah' },
+ category: {
+ slug: 'integrations',
+ icon: '🔗',
+ name: 'Integrations',
+ },
+ views: 2800,
+ },
+ {
+ id: 6,
+ title: 'Managing user permissions in your Help Center',
+ status: 'draft',
+ updatedAt: 1729205669,
+ author: { availableName: 'Alex' },
+ category: {
+ slug: 'administration',
+ icon: '🔐',
+ name: 'Administration',
+ },
+ views: 1200,
+ },
+ {
+ id: 7,
+ title: 'Creating and managing FAQ sections',
+ status: 'published',
+ updatedAt: 1729205669,
+ author: { availableName: 'Emily' },
+ category: {
+ slug: 'content-management',
+ icon: '📝',
+ name: 'Content Management',
+ },
+ views: 5600,
+ },
+ {
+ id: 8,
+ title: 'Implementing search functionality in your Help Center',
+ status: 'archived',
+ updatedAt: 1729205669,
+ author: { availableName: 'David' },
+ category: {
+ slug: 'features',
+ icon: '🔍',
+ name: 'Features',
+ },
+ views: 1800,
+ },
+ {
+ id: 9,
+ title: 'Analyzing Help Center usage metrics',
+ status: 'published',
+ updatedAt: 1729205669,
+ author: { availableName: 'Rachel' },
+ category: {
+ slug: 'analytics',
+ icon: '📊',
+ name: 'Analytics',
+ },
+ views: 3200,
+ },
+ {
+ id: 10,
+ title: 'Setting up multilingual support in your Help Center',
+ status: 'draft',
+ updatedAt: 1729205669,
+ author: { availableName: 'Carlos' },
+ category: {
+ slug: 'localization',
+ icon: '🌍',
+ name: 'Localization',
+ },
+ views: 900,
+ },
+ {
+ id: 11,
+ title: 'Creating interactive tutorials for your products',
+ status: 'published',
+ updatedAt: 1729205669,
+ author: { availableName: 'Olivia' },
+ category: {
+ slug: 'education',
+ icon: '🎓',
+ name: 'Education',
+ },
+ views: 4100,
+ },
+ {
+ id: 12,
+ title: 'Implementing a feedback system in your Help Center',
+ status: 'draft',
+ updatedAt: 1729205669,
+ author: { availableName: 'Nathan' },
+ category: {
+ slug: 'user-engagement',
+ icon: '💬',
+ name: 'User Engagement',
+ },
+ views: 750,
+ },
+ {
+ id: 13,
+ title: 'Optimizing Help Center content for SEO',
+ status: 'published',
+ updatedAt: 1729205669,
+ author: { availableName: 'Sophia' },
+ category: {
+ slug: 'seo',
+ icon: '🚀',
+ name: 'SEO',
+ },
+ views: 2900,
+ },
+ {
+ id: 14,
+ title: 'Creating a knowledge base for internal teams',
+ status: 'archived',
+ updatedAt: 1729205669,
+ author: { availableName: 'Daniel' },
+ category: {
+ slug: 'internal-resources',
+ icon: '🏢',
+ name: 'Internal Resources',
+ },
+ views: 1500,
+ },
+];
diff --git a/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue b/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue
new file mode 100644
index 000000000..9e3c9ee1e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/HelpCenterLayout.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+ {{ activePortalName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.story.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.story.vue
new file mode 100644
index 000000000..2bc5facbb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.story.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
new file mode 100644
index 000000000..0f148fd7b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+ {{ locale }} ({{ localeCode }})
+
+
+ {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
+
+
+
+
+
+ {{
+ $t(
+ 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
+ articleCount
+ )
+ }}
+
+
+
+ {{
+ $t(
+ 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
+ categoryCount
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
new file mode 100644
index 000000000..f5b925fbe
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue
new file mode 100644
index 000000000..d46fb5264
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue
@@ -0,0 +1,264 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue
new file mode 100644
index 000000000..cfaa4ce11
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+ {{ statusText }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue
new file mode 100644
index 000000000..4de31a6ba
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.ARTICLE_PROPERTIES'
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue
new file mode 100644
index 000000000..5e7feb9fa
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
new file mode 100644
index 000000000..ca302c3a3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.story.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.story.vue
new file mode 100644
index 000000000..cfb353200
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.story.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
new file mode 100644
index 000000000..9f76cf41f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.story.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.story.vue
new file mode 100644
index 000000000..8fdafd86a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.story.vue
@@ -0,0 +1,209 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue
new file mode 100644
index 000000000..12acd1bd8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue
new file mode 100644
index 000000000..78e0594e7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+ {{
+ t(
+ `HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}`
+ )
+ }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue
new file mode 100644
index 000000000..b0adfa6e9
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue
@@ -0,0 +1,271 @@
+
+
+
+
+
+
+
+ {{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
+
+
+ {{ portalName }}
+
+
+
+
+
+ {{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
+
+
+ {{ `${activeLocaleName} (${activeLocaleCode})` }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue
new file mode 100644
index 000000000..a13e807b8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
+ n: categoriesCount,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue
new file mode 100644
index 000000000..62cfd5c6c
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue
new file mode 100644
index 000000000..5b339e568
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue
new file mode 100644
index 000000000..6e5547ee7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
new file mode 100644
index 000000000..11e5ba401
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.story.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.story.vue
new file mode 100644
index 000000000..630c50ca4
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.story.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue
new file mode 100644
index 000000000..5b25779c7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+ {{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue
new file mode 100644
index 000000000..b43e2f2fd
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue
new file mode 100644
index 000000000..6f9337c0f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue
new file mode 100644
index 000000000..2c283b34a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue
new file mode 100644
index 000000000..7a066c265
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue
@@ -0,0 +1,329 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalConfigurationSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalConfigurationSettings.vue
new file mode 100644
index 000000000..8d18f58a0
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalConfigurationSettings.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER'
+ )
+ }}
+
+
+ {{
+ t(
+ 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION'
+ )
+ }}
+
+
+
+
+
+
+
+ {{ customDomainAddress }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.story.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.story.vue
new file mode 100644
index 000000000..14d460a04
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.story.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
new file mode 100644
index 000000000..ccf1ad997
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t(
+ 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
+ )
+ }}
+
+
+ {{
+ t(
+ 'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
+ )
+ }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue
new file mode 100644
index 000000000..d245656d0
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.story.vue b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.story.vue
new file mode 100644
index 000000000..e1a9dfa17
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.story.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue
new file mode 100644
index 000000000..1b4668b91
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+ {{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
+
+
+
+
+ {{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Label/AddLabel.vue b/app/javascript/dashboard/components-next/Label/AddLabel.vue
new file mode 100644
index 000000000..c9571e771
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Label/AddLabel.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Label/LabelItem.vue b/app/javascript/dashboard/components-next/Label/LabelItem.vue
new file mode 100644
index 000000000..b96b9b368
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Label/LabelItem.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ {{ label.title }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/Label/story/AddLabel.story.vue b/app/javascript/dashboard/components-next/Label/story/AddLabel.story.vue
new file mode 100644
index 000000000..ea6466feb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Label/story/AddLabel.story.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Label/story/Label.story.vue b/app/javascript/dashboard/components-next/Label/story/Label.story.vue
new file mode 100644
index 000000000..5319d56ae
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Label/story/Label.story.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/Label/story/fixtures.js b/app/javascript/dashboard/components-next/Label/story/fixtures.js
new file mode 100644
index 000000000..980ba8c0d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/Label/story/fixtures.js
@@ -0,0 +1,62 @@
+export const label = {
+ id: 1,
+ title: 'delivery',
+ color: '#A2FDD5',
+};
+
+export const labelMenuItems = [
+ {
+ label: 'delivery',
+ value: 3,
+ thumbnail: {
+ color: '#A2FDD5',
+ },
+ isSelected: true,
+ action: 'addLabel',
+ },
+ {
+ label: 'lead',
+ value: 6,
+ thumbnail: {
+ color: '#F161C8',
+ },
+ isSelected: false,
+ action: 'addLabel',
+ },
+ {
+ label: 'ops-handover',
+ value: 4,
+ thumbnail: {
+ color: '#A53326',
+ },
+ isSelected: false,
+ action: 'addLabel',
+ },
+ {
+ label: 'billing',
+ value: 1,
+ thumbnail: {
+ color: '#28AD21',
+ },
+ isSelected: false,
+ action: 'addLabel',
+ },
+ {
+ label: 'premium-customer',
+ value: 5,
+ thumbnail: {
+ color: '#6FD4EF',
+ },
+ isSelected: false,
+ action: 'addLabel',
+ },
+ {
+ label: 'software',
+ value: 2,
+ thumbnail: {
+ color: '#8F6EF2',
+ },
+ isSelected: false,
+ action: 'addLabel',
+ },
+];
diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
new file mode 100644
index 000000000..b5d57f202
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue
new file mode 100644
index 000000000..89784037d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue
@@ -0,0 +1,251 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/AttachmentPreviews.vue b/app/javascript/dashboard/components-next/NewConversation/components/AttachmentPreviews.vue
new file mode 100644
index 000000000..2e03f64c0
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/AttachmentPreviews.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+ {{ fileNameWithEllipsis(attachment.resource) }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
new file mode 100644
index 000000000..b1596ac04
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue
@@ -0,0 +1,336 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ContactSelector.vue b/app/javascript/dashboard/components-next/NewConversation/components/ContactSelector.vue
new file mode 100644
index 000000000..33a2f8f20
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/ContactSelector.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+ {{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
+
+
+
+
+ {{
+ isCreatingContact
+ ? t(`${i18nPrefix}.CONTACT_CREATING`)
+ : selectedContactLabel
+ }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue b/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue
new file mode 100644
index 000000000..ac7e8d659
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/InboxEmptyState.vue b/app/javascript/dashboard/components-next/NewConversation/components/InboxEmptyState.vue
new file mode 100644
index 000000000..7fed98806
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/InboxEmptyState.vue
@@ -0,0 +1,9 @@
+
+
+
+ {{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/InboxSelector.vue b/app/javascript/dashboard/components-next/NewConversation/components/InboxSelector.vue
new file mode 100644
index 000000000..4e1b0f510
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/InboxSelector.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+ {{ targetInboxLabel }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue b/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue
new file mode 100644
index 000000000..ca54f7eca
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue
new file mode 100644
index 000000000..8d32509b8
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsAppOptions.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ template.name }}
+
+ {{ getTemplateBody(template) }}
+
+
+
+
+ {{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue
new file mode 100644
index 000000000..85f69a665
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
+ {{
+ t(
+ 'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
+ { templateName: templateName }
+ )
+ }}
+
+
+
+
+ {{
+ t(
+ 'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
+ )
+ }}
+
+
+
+
+ {{ key }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/story/ComposeNewConversationForm.story.vue b/app/javascript/dashboard/components-next/NewConversation/components/story/ComposeNewConversationForm.story.vue
new file mode 100644
index 000000000..192c1b8ae
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/story/ComposeNewConversationForm.story.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/story/fixtures.js b/app/javascript/dashboard/components-next/NewConversation/components/story/fixtures.js
new file mode 100644
index 000000000..5c80466fa
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/components/story/fixtures.js
@@ -0,0 +1,168 @@
+export const contacts = [
+ {
+ additionalAttributes: {
+ city: 'kerala',
+ country: 'India',
+ description: 'Curious about the web. ',
+ companyName: 'Chatwoot',
+ countryCode: '',
+ socialProfiles: {
+ github: 'abozler',
+ twitter: 'ozler',
+ facebook: 'abozler',
+ linkedin: 'abozler',
+ instagram: 'ozler',
+ },
+ },
+ availabilityStatus: 'offline',
+ email: 'ozler@chatwoot.com',
+ id: 29,
+ name: 'Abraham Ozlers',
+ phoneNumber: '+246232222222',
+ identifier: null,
+ thumbnail:
+ 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
+ customAttributes: {
+ dateContact: '2024-02-01T00:00:00.000Z',
+ linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
+ listContact: 'Not spam',
+ numberContact: '12',
+ },
+ lastActivityAt: 1712127410,
+ createdAt: 1712127389,
+ },
+];
+
+export const activeContact = {
+ email: 'ozler@chatwoot.com',
+ id: 29,
+ label: 'Abraham Ozlers (ozler@chatwoot.com)',
+ name: 'Abraham Ozlers',
+ thumbnail: {
+ name: 'Abraham Ozlers',
+ src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
+ },
+ contactInboxes: [
+ {
+ id: 7,
+ label: 'PaperLayer Email (testba@paperlayer.test)',
+ name: 'PaperLayer Email',
+ email: 'testba@paperlayer.test',
+ channelType: 'Channel::Email',
+ },
+ {
+ id: 8,
+ label: 'PaperLayer WhatsApp',
+ name: 'PaperLayer WhatsApp',
+ sourceId: '123456',
+ phoneNumber: '+1223233434',
+ channelType: 'Channel::Whatsapp',
+ messageTemplates: [
+ {
+ id: '1',
+ name: 'shipment_confirmation',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ text: '{{1}}, great news! Your order {{2}} has shipped.\n\nTracking #: {{3}}\nEstimated delivery: {{4}}\n\nWe will provide updates until delivery.',
+ type: 'BODY',
+ example: {
+ bodyText: [['John', '#12345', 'ZK4539O2311J', 'Jan 1, 2024']],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://www.example.com/',
+ text: 'Track shipment',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ parameterFormat: 'POSITIONAL',
+ libraryTemplateName: 'shipment_confirmation_2',
+ },
+ {
+ id: '2',
+ name: 'otp_test',
+ status: 'APPROVED',
+ category: 'AUTHENTICATION',
+ language: 'en_US',
+ components: [
+ {
+ text: 'Use code *{{1}}* to authorize your transaction.',
+ type: 'BODY',
+ example: {
+ bodyText: [['123456']],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp{{1}}',
+ text: 'Copy code',
+ type: 'URL',
+ example: [
+ 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp123456',
+ ],
+ },
+ ],
+ },
+ ],
+ parameterFormat: 'POSITIONAL',
+ libraryTemplateName: 'verify_transaction_1',
+ messageSendTtlSeconds: 900,
+ },
+ {
+ id: '3',
+ name: 'hello_world',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ text: 'Hello World',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
+ type: 'BODY',
+ },
+ {
+ text: 'WhatsApp Business Platform sample message',
+ type: 'FOOTER',
+ },
+ ],
+ parameterFormat: 'POSITIONAL',
+ },
+ ],
+ },
+ {
+ id: 9,
+ label: 'PaperLayer API',
+ name: 'PaperLayer API',
+ email: '',
+ channelType: 'Channel::Api',
+ },
+ ],
+};
+
+export const emailInbox = {
+ id: 7,
+ label: 'PaperLayer Email (testba@paperlayer.test)',
+ name: 'PaperLayer Email',
+ email: 'testba@paperlayer.test',
+ channelType: 'Channel::Email',
+};
+
+export const currentUser = {
+ id: 1,
+ name: 'John Doe',
+ email: 'john@example.com',
+};
diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
new file mode 100644
index 000000000..12a5e76b3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js
@@ -0,0 +1,219 @@
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
+import { getInboxIconByType } from 'dashboard/helper/inbox';
+import camelcaseKeys from 'camelcase-keys';
+import ContactAPI from 'dashboard/api/contacts';
+
+const CHANNEL_PRIORITY = {
+ 'Channel::Email': 1,
+ 'Channel::Whatsapp': 2,
+ 'Channel::Sms': 3,
+ 'Channel::TwilioSms': 4,
+ 'Channel::WebWidget': 5,
+ 'Channel::Api': 6,
+};
+
+export const generateLabelForContactableInboxesList = ({
+ name,
+ email,
+ channelType,
+ phoneNumber,
+}) => {
+ if (channelType === INBOX_TYPES.EMAIL) {
+ return `${name} (${email})`;
+ }
+ if (
+ channelType === INBOX_TYPES.TWILIO ||
+ channelType === INBOX_TYPES.WHATSAPP
+ ) {
+ return `${name} (${phoneNumber})`;
+ }
+ return name;
+};
+
+const transformInbox = ({
+ name,
+ id,
+ email,
+ channelType,
+ phoneNumber,
+ ...rest
+}) => ({
+ id,
+ icon: getInboxIconByType(channelType, phoneNumber, 'line'),
+ label: generateLabelForContactableInboxesList({
+ name,
+ email,
+ channelType,
+ phoneNumber,
+ }),
+ action: 'inbox',
+ value: id,
+ name,
+ email,
+ phoneNumber,
+ channelType,
+ ...rest,
+});
+
+export const compareInboxes = (a, b) => {
+ // Channels that have no priority defined should come at the end.
+ const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
+ const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
+
+ if (priorityA !== priorityB) {
+ return priorityA - priorityB;
+ }
+
+ const nameA = a.name || '';
+ const nameB = b.name || '';
+ return nameA.localeCompare(nameB);
+};
+
+export const buildContactableInboxesList = contactInboxes => {
+ if (!contactInboxes) return [];
+
+ return contactInboxes.map(transformInbox).sort(compareInboxes);
+};
+
+export const getCapitalizedNameFromEmail = email => {
+ const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
+ return name.charAt(0).toUpperCase() + name.slice(1);
+};
+
+export const processContactableInboxes = inboxes => {
+ return inboxes.map(inbox => ({
+ ...inbox.inbox,
+ sourceId: inbox.sourceId,
+ }));
+};
+
+export const prepareAttachmentPayload = (
+ attachedFiles,
+ directUploadsEnabled
+) => {
+ const files = [];
+ attachedFiles.forEach(attachment => {
+ if (directUploadsEnabled) {
+ files.push(attachment.blobSignedId);
+ } else {
+ files.push(attachment.resource.file);
+ }
+ });
+ return files;
+};
+
+export const prepareNewMessagePayload = ({
+ targetInbox,
+ selectedContact,
+ message,
+ subject,
+ ccEmails,
+ bccEmails,
+ currentUser,
+ attachedFiles = [],
+ directUploadsEnabled = false,
+}) => {
+ const payload = {
+ inboxId: targetInbox.id,
+ sourceId: targetInbox.sourceId,
+ contactId: Number(selectedContact.id),
+ message: { content: message },
+ assigneeId: currentUser.id,
+ };
+
+ if (attachedFiles?.length) {
+ payload.files = prepareAttachmentPayload(
+ attachedFiles,
+ directUploadsEnabled
+ );
+ }
+
+ if (subject) {
+ payload.mailSubject = subject;
+ }
+
+ if (ccEmails) {
+ payload.message.cc_emails = ccEmails;
+ }
+
+ if (bccEmails) {
+ payload.message.bcc_emails = bccEmails;
+ }
+
+ return payload;
+};
+
+export const prepareWhatsAppMessagePayload = ({
+ targetInbox,
+ selectedContact,
+ message,
+ templateParams,
+ currentUser,
+}) => {
+ return {
+ inboxId: targetInbox.id,
+ sourceId: targetInbox.sourceId,
+ contactId: selectedContact.id,
+ message: { content: message, template_params: templateParams },
+ assigneeId: currentUser.id,
+ };
+};
+
+export const generateContactQuery = ({ keys = ['email'], query }) => {
+ return {
+ payload: keys.map(key => {
+ const filterPayload = {
+ attribute_key: key,
+ filter_operator: 'contains',
+ values: [query],
+ attribute_model: 'standard',
+ };
+ if (keys.findIndex(k => k === key) !== keys.length - 1) {
+ filterPayload.query_operator = 'or';
+ }
+ return filterPayload;
+ }),
+ };
+};
+
+// API Calls
+export const searchContacts = async ({ keys, query }) => {
+ const {
+ data: { payload },
+ } = await ContactAPI.filter(
+ undefined,
+ 'name',
+ generateContactQuery({ keys, query })
+ );
+ const camelCasedPayload = camelcaseKeys(payload, { deep: true });
+ // Filter contacts that have either phone_number or email
+ const filteredPayload = camelCasedPayload?.filter(
+ contact => contact.phoneNumber || contact.email
+ );
+ return filteredPayload || [];
+};
+
+export const createNewContact = async email => {
+ const payload = {
+ name: getCapitalizedNameFromEmail(email),
+ email,
+ };
+
+ const {
+ data: {
+ payload: { contact: newContact },
+ },
+ } = await ContactAPI.create(payload);
+
+ return camelcaseKeys(newContact, { deep: true });
+};
+
+export const fetchContactableInboxes = async contactId => {
+ const {
+ data: { payload: inboxes = [] },
+ } = await ContactAPI.getContactableInboxes(contactId);
+
+ const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
+
+ return processContactableInboxes(convertInboxesToCamelKeys);
+};
diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
new file mode 100644
index 000000000..5a2d0092d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js
@@ -0,0 +1,536 @@
+import { describe, it, expect, vi } from 'vitest';
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
+import ContactAPI from 'dashboard/api/contacts';
+import * as helpers from '../composeConversationHelper';
+
+vi.mock('dashboard/api/contacts');
+
+describe('composeConversationHelper', () => {
+ describe('generateLabelForContactableInboxesList', () => {
+ const contact = {
+ name: 'John Doe',
+ email: 'john@example.com',
+ phoneNumber: '+1234567890',
+ };
+
+ it('generates label for email inbox', () => {
+ expect(
+ helpers.generateLabelForContactableInboxesList({
+ ...contact,
+ channelType: INBOX_TYPES.EMAIL,
+ })
+ ).toBe('John Doe (john@example.com)');
+ });
+
+ it('generates label for twilio inbox', () => {
+ expect(
+ helpers.generateLabelForContactableInboxesList({
+ ...contact,
+ channelType: INBOX_TYPES.TWILIO,
+ })
+ ).toBe('John Doe (+1234567890)');
+ });
+
+ it('generates label for whatsapp inbox', () => {
+ expect(
+ helpers.generateLabelForContactableInboxesList({
+ ...contact,
+ channelType: INBOX_TYPES.WHATSAPP,
+ })
+ ).toBe('John Doe (+1234567890)');
+ });
+
+ it('generates label for other inbox types', () => {
+ expect(
+ helpers.generateLabelForContactableInboxesList({
+ ...contact,
+ channelType: 'Channel::Api',
+ })
+ ).toBe('John Doe');
+ });
+ });
+
+ describe('buildContactableInboxesList', () => {
+ it('returns empty array if no contact inboxes', () => {
+ expect(helpers.buildContactableInboxesList(null)).toEqual([]);
+ expect(helpers.buildContactableInboxesList(undefined)).toEqual([]);
+ });
+
+ it('builds list of contactable inboxes with correct format', () => {
+ const inboxes = [
+ {
+ id: 1,
+ name: 'Email Inbox',
+ email: 'support@example.com',
+ channelType: INBOX_TYPES.EMAIL,
+ phoneNumber: null,
+ },
+ ];
+
+ const result = helpers.buildContactableInboxesList(inboxes);
+ expect(result[0]).toMatchObject({
+ id: 1,
+ icon: 'i-ri-mail-line',
+ label: 'Email Inbox (support@example.com)',
+ action: 'inbox',
+ value: 1,
+ name: 'Email Inbox',
+ email: 'support@example.com',
+ channelType: INBOX_TYPES.EMAIL,
+ });
+ });
+ });
+
+ describe('getCapitalizedNameFromEmail', () => {
+ it('extracts and capitalizes name from email', () => {
+ expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
+ 'John.doe'
+ );
+ expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
+ 'Jane'
+ );
+ });
+ });
+
+ describe('processContactableInboxes', () => {
+ it('processes inboxes with correct structure', () => {
+ const inboxes = [
+ {
+ inbox: { id: 1, name: 'Inbox 1' },
+ sourceId: 'source1',
+ },
+ ];
+
+ const result = helpers.processContactableInboxes(inboxes);
+ expect(result[0]).toEqual({
+ id: 1,
+ name: 'Inbox 1',
+ sourceId: 'source1',
+ });
+ });
+ });
+
+ describe('prepareAttachmentPayload', () => {
+ it('prepares direct upload files', () => {
+ const files = [{ blobSignedId: 'signed1' }];
+ expect(helpers.prepareAttachmentPayload(files, true)).toEqual([
+ 'signed1',
+ ]);
+ });
+
+ it('prepares regular files', () => {
+ const files = [{ resource: { file: 'file1' } }];
+ expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']);
+ });
+ });
+
+ describe('prepareNewMessagePayload', () => {
+ const baseParams = {
+ targetInbox: { id: 1, sourceId: 'source1' },
+ selectedContact: { id: '2' },
+ message: 'Hello',
+ currentUser: { id: 3 },
+ };
+
+ it('prepares basic message payload', () => {
+ const result = helpers.prepareNewMessagePayload(baseParams);
+ expect(result).toEqual({
+ inboxId: 1,
+ sourceId: 'source1',
+ contactId: 2,
+ message: { content: 'Hello' },
+ assigneeId: 3,
+ });
+ });
+
+ it('includes optional fields when provided', () => {
+ const result = helpers.prepareNewMessagePayload({
+ ...baseParams,
+ subject: 'Test',
+ ccEmails: 'cc@test.com',
+ bccEmails: 'bcc@test.com',
+ attachedFiles: [{ blobSignedId: 'file1' }],
+ directUploadsEnabled: true,
+ });
+
+ expect(result).toMatchObject({
+ mailSubject: 'Test',
+ message: {
+ content: 'Hello',
+ cc_emails: 'cc@test.com',
+ bcc_emails: 'bcc@test.com',
+ },
+ files: ['file1'],
+ });
+ });
+ });
+
+ describe('prepareWhatsAppMessagePayload', () => {
+ it('prepares whatsapp message payload', () => {
+ const params = {
+ targetInbox: { id: 1, sourceId: 'source1' },
+ selectedContact: { id: 2 },
+ message: 'Hello',
+ templateParams: { param1: 'value1' },
+ currentUser: { id: 3 },
+ };
+
+ const result = helpers.prepareWhatsAppMessagePayload(params);
+ expect(result).toEqual({
+ inboxId: 1,
+ sourceId: 'source1',
+ contactId: 2,
+ message: {
+ content: 'Hello',
+ template_params: { param1: 'value1' },
+ },
+ assigneeId: 3,
+ });
+ });
+ });
+
+ describe('generateContactQuery', () => {
+ it('generates correct query structure for contact search', () => {
+ const query = 'test@example.com';
+ const expected = {
+ payload: [
+ {
+ attribute_key: 'email',
+ filter_operator: 'contains',
+ values: [query],
+ attribute_model: 'standard',
+ },
+ ],
+ };
+
+ expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
+ expected
+ );
+ });
+
+ it('handles empty query', () => {
+ const expected = {
+ payload: [
+ {
+ attribute_key: 'email',
+ filter_operator: 'contains',
+ values: [''],
+ attribute_model: 'standard',
+ },
+ ],
+ };
+
+ expect(
+ helpers.generateContactQuery({ keys: ['email'], query: '' })
+ ).toEqual(expected);
+ });
+
+ it('handles mutliple keys', () => {
+ const expected = {
+ payload: [
+ {
+ attribute_key: 'email',
+ filter_operator: 'contains',
+ values: ['john'],
+ attribute_model: 'standard',
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'phone_number',
+ filter_operator: 'contains',
+ values: ['john'],
+ attribute_model: 'standard',
+ },
+ ],
+ };
+
+ expect(
+ helpers.generateContactQuery({
+ keys: ['email', 'phone_number'],
+ query: 'john',
+ })
+ ).toEqual(expected);
+ });
+ });
+
+ describe('API calls', () => {
+ describe('searchContacts', () => {
+ it('searches contacts and returns camelCase results', async () => {
+ const mockPayload = [
+ {
+ id: 1,
+ name: 'John Doe',
+ email: 'john@example.com',
+ phone_number: '+1234567890',
+ created_at: '2023-01-01',
+ },
+ ];
+
+ ContactAPI.filter.mockResolvedValue({
+ data: { payload: mockPayload },
+ });
+
+ const result = await helpers.searchContacts({
+ keys: ['email'],
+ query: 'john',
+ });
+
+ expect(result).toEqual([
+ {
+ id: 1,
+ name: 'John Doe',
+ email: 'john@example.com',
+ phoneNumber: '+1234567890',
+ createdAt: '2023-01-01',
+ },
+ ]);
+
+ expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
+ payload: [
+ {
+ attribute_key: 'email',
+ filter_operator: 'contains',
+ values: ['john'],
+ attribute_model: 'standard',
+ },
+ ],
+ });
+ });
+
+ it('searches contacts and returns only contacts with email or phone number', async () => {
+ const mockPayload = [
+ {
+ id: 1,
+ name: 'John Doe',
+ email: 'john@example.com',
+ phone_number: '+1234567890',
+ created_at: '2023-01-01',
+ },
+ {
+ id: 2,
+ name: 'Jane Doe',
+ email: null,
+ phone_number: null,
+ created_at: '2023-01-01',
+ },
+ {
+ id: 3,
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ phone_number: null,
+ created_at: '2023-01-01',
+ },
+ ];
+
+ ContactAPI.filter.mockResolvedValue({
+ data: { payload: mockPayload },
+ });
+
+ const result = await helpers.searchContacts({
+ keys: ['email'],
+ query: 'john',
+ });
+
+ // Should only return contacts with either email or phone number
+ expect(result).toEqual([
+ {
+ id: 1,
+ name: 'John Doe',
+ email: 'john@example.com',
+ phoneNumber: '+1234567890',
+ createdAt: '2023-01-01',
+ },
+ {
+ id: 3,
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ phoneNumber: null,
+ createdAt: '2023-01-01',
+ },
+ ]);
+
+ expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
+ payload: [
+ {
+ attribute_key: 'email',
+ filter_operator: 'contains',
+ values: ['john'],
+ attribute_model: 'standard',
+ },
+ ],
+ });
+ });
+
+ it('handles empty search results', async () => {
+ ContactAPI.filter.mockResolvedValue({
+ data: { payload: [] },
+ });
+
+ const result = await helpers.searchContacts('nonexistent');
+ expect(result).toEqual([]);
+ });
+
+ it('transforms nested objects to camelCase', async () => {
+ const mockPayload = [
+ {
+ id: 1,
+ name: 'John Doe',
+ phone_number: '+1234567890',
+ contact_inboxes: [
+ {
+ inbox_id: 1,
+ source_id: 'source1',
+ created_at: '2023-01-01',
+ },
+ ],
+ custom_attributes: {
+ custom_field_name: 'value',
+ },
+ },
+ ];
+
+ ContactAPI.filter.mockResolvedValue({
+ data: { payload: mockPayload },
+ });
+
+ const result = await helpers.searchContacts('test');
+
+ expect(result).toEqual([
+ {
+ id: 1,
+ name: 'John Doe',
+ phoneNumber: '+1234567890',
+ contactInboxes: [
+ {
+ inboxId: 1,
+ sourceId: 'source1',
+ createdAt: '2023-01-01',
+ },
+ ],
+ customAttributes: {
+ customFieldName: 'value',
+ },
+ },
+ ]);
+ });
+ });
+
+ describe('createNewContact', () => {
+ it('creates new contact with capitalized name', async () => {
+ const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
+ ContactAPI.create.mockResolvedValue({
+ data: { payload: { contact: mockContact } },
+ });
+
+ const result = await helpers.createNewContact('john@example.com');
+ expect(result).toEqual(mockContact);
+ expect(ContactAPI.create).toHaveBeenCalledWith({
+ name: 'John',
+ email: 'john@example.com',
+ });
+ });
+ });
+
+ describe('fetchContactableInboxes', () => {
+ it('fetches and processes contactable inboxes', async () => {
+ const mockInboxes = [
+ {
+ inbox: { id: 1, name: 'Inbox 1' },
+ sourceId: 'source1',
+ },
+ ];
+ ContactAPI.getContactableInboxes.mockResolvedValue({
+ data: { payload: mockInboxes },
+ });
+
+ const result = await helpers.fetchContactableInboxes(1);
+ expect(result[0]).toEqual({
+ id: 1,
+ name: 'Inbox 1',
+ sourceId: 'source1',
+ });
+ expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
+ });
+
+ it('returns empty array when no inboxes found', async () => {
+ ContactAPI.getContactableInboxes.mockResolvedValue({
+ data: { payload: [] },
+ });
+
+ const result = await helpers.fetchContactableInboxes(1);
+ expect(result).toEqual([]);
+ });
+ });
+ });
+});
+
+describe('compareInboxes', () => {
+ it('should sort inboxes by channel priority', () => {
+ const inboxes = [
+ { channelType: 'Channel::Api', name: 'API Inbox' },
+ { channelType: 'Channel::Email', name: 'Email Inbox' },
+ { channelType: 'Channel::WebWidget', name: 'Widget' },
+ { channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
+ ];
+
+ const sorted = [...inboxes].sort(helpers.compareInboxes);
+
+ expect(sorted[0].channelType).toBe('Channel::Email');
+ expect(sorted[1].channelType).toBe('Channel::Whatsapp');
+ expect(sorted[2].channelType).toBe('Channel::WebWidget');
+ expect(sorted[3].channelType).toBe('Channel::Api');
+ });
+
+ it('should sort SMS channels correctly', () => {
+ const inboxes = [
+ { channelType: 'Channel::TwilioSms', name: 'Twilio' },
+ { channelType: 'Channel::Sms', name: 'Regular SMS' },
+ ];
+
+ const sorted = [...inboxes].sort(helpers.compareInboxes);
+
+ expect(sorted[0].channelType).toBe('Channel::Sms');
+ expect(sorted[1].channelType).toBe('Channel::TwilioSms');
+ });
+
+ it('should sort by name when channel types are same', () => {
+ const inboxes = [
+ { channelType: 'Channel::Email', name: 'Support' },
+ { channelType: 'Channel::Email', name: 'Marketing' },
+ { channelType: 'Channel::Email', name: 'Billing' },
+ ];
+
+ const sorted = [...inboxes].sort(helpers.compareInboxes);
+
+ expect(sorted.map(inbox => inbox.name)).toEqual([
+ 'Billing',
+ 'Marketing',
+ 'Support',
+ ]);
+ });
+
+ it('should put channels without priority at the end', () => {
+ const inboxes = [
+ { channelType: 'Channel::Unknown', name: 'Unknown' },
+ { channelType: 'Channel::Email', name: 'Email' },
+ { channelType: 'Channel::LineChannel', name: 'Line' },
+ { channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
+ ];
+
+ const sorted = [...inboxes].sort(helpers.compareInboxes);
+
+ expect(sorted.map(i => i.channelType)).toEqual([
+ 'Channel::Email',
+ 'Channel::Whatsapp',
+
+ 'Channel::LineChannel',
+ 'Channel::Unknown',
+ ]);
+ });
+
+ it('should handle empty array', () => {
+ const inboxes = [];
+ const sorted = [...inboxes].sort(helpers.compareInboxes);
+ expect(sorted).toEqual([]);
+ });
+});
diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.story.vue b/app/javascript/dashboard/components-next/avatar/Avatar.story.vue
new file mode 100644
index 000000000..12b804e72
--- /dev/null
+++ b/app/javascript/dashboard/components-next/avatar/Avatar.story.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/avatar/Avatar.vue b/app/javascript/dashboard/components-next/avatar/Avatar.vue
new file mode 100644
index 000000000..fb23b3cff
--- /dev/null
+++ b/app/javascript/dashboard/components-next/avatar/Avatar.vue
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ initials }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/breadcrumb/Breadcrumb.story.vue b/app/javascript/dashboard/components-next/breadcrumb/Breadcrumb.story.vue
new file mode 100644
index 000000000..0c2bdf115
--- /dev/null
+++ b/app/javascript/dashboard/components-next/breadcrumb/Breadcrumb.story.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/breadcrumb/Breadcrumb.vue b/app/javascript/dashboard/components-next/breadcrumb/Breadcrumb.vue
new file mode 100644
index 000000000..bb848c62b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/breadcrumb/Breadcrumb.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/button/Button.story.vue b/app/javascript/dashboard/components-next/button/Button.story.vue
new file mode 100644
index 000000000..404712b34
--- /dev/null
+++ b/app/javascript/dashboard/components-next/button/Button.story.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/button/Button.vue b/app/javascript/dashboard/components-next/button/Button.vue
new file mode 100644
index 000000000..fecdbaf9b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/button/Button.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/button/constants.js b/app/javascript/dashboard/components-next/button/constants.js
new file mode 100644
index 000000000..e65299420
--- /dev/null
+++ b/app/javascript/dashboard/components-next/button/constants.js
@@ -0,0 +1,15 @@
+export const VARIANT_OPTIONS = ['solid', 'outline', 'faded', 'link', 'ghost'];
+export const COLOR_OPTIONS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
+export const SIZE_OPTIONS = ['xs', 'sm', 'md', 'lg'];
+
+export const EXCLUDED_ATTRS = [
+ 'variant',
+ 'color',
+ 'size',
+ 'icon',
+ 'trailingIcon',
+ 'isLoading',
+ ...VARIANT_OPTIONS,
+ ...COLOR_OPTIONS,
+ ...SIZE_OPTIONS,
+];
diff --git a/app/javascript/dashboard/components-next/colorpicker/ColorPicker.vue b/app/javascript/dashboard/components-next/colorpicker/ColorPicker.vue
new file mode 100644
index 000000000..de04b115a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/colorpicker/ColorPicker.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/ComboBox.story.vue b/app/javascript/dashboard/components-next/combobox/ComboBox.story.vue
new file mode 100644
index 000000000..caf26ad43
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/ComboBox.story.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
Selected value: {{ selectedValue }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/ComboBox.vue b/app/javascript/dashboard/components-next/combobox/ComboBox.vue
new file mode 100644
index 000000000..de84974e6
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/ComboBox.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
new file mode 100644
index 000000000..e1cd8a086
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+ -
+
+ {{ option.label }}
+
+
+
+ -
+ {{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/TagMultiSelectComboBox.vue b/app/javascript/dashboard/components-next/combobox/TagMultiSelectComboBox.vue
new file mode 100644
index 000000000..4ce05bcbb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/TagMultiSelectComboBox.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+ {{ tag.label }}
+
+
+
+
+ {{ selectPlaceholder }}
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/TagMultiSelectComboxBox.story.vue b/app/javascript/dashboard/components-next/combobox/TagMultiSelectComboxBox.story.vue
new file mode 100644
index 000000000..dc4b5b0f3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/TagMultiSelectComboxBox.story.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
Selected values: {{ selectedValues }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dialog/Dialog.story.vue b/app/javascript/dashboard/components-next/dialog/Dialog.story.vue
new file mode 100644
index 000000000..60fb6000e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dialog/Dialog.story.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dialog/Dialog.vue b/app/javascript/dashboard/components-next/dialog/Dialog.vue
new file mode 100644
index 000000000..c485549ae
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dialog/Dialog.vue
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/DropdownMenu.story.vue b/app/javascript/dashboard/components-next/dropdown-menu/DropdownMenu.story.vue
new file mode 100644
index 000000000..f1fb01af3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/DropdownMenu.story.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/DropdownMenu.vue b/app/javascript/dashboard/components-next/dropdown-menu/DropdownMenu.vue
new file mode 100644
index 000000000..95b368dae
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/DropdownMenu.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+ {{
+ isSearching
+ ? t('DROPDOWN_MENU.SEARCHING')
+ : t('DROPDOWN_MENU.EMPTY_STATE')
+ }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/DropdownPrimitives.story.vue b/app/javascript/dashboard/components-next/dropdown-menu/DropdownPrimitives.story.vue
new file mode 100644
index 000000000..085aa0d28
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/DropdownPrimitives.story.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue
new file mode 100644
index 000000000..246eba5aa
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownBody.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownContainer.vue b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownContainer.vue
new file mode 100644
index 000000000..373bb526f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownContainer.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownItem.vue b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownItem.vue
new file mode 100644
index 000000000..821b4218b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownItem.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownSection.vue b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownSection.vue
new file mode 100644
index 000000000..3879af0a7
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownSection.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownSeparator.vue b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownSeparator.vue
new file mode 100644
index 000000000..373aa4479
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/DropdownSeparator.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/index.js b/app/javascript/dashboard/components-next/dropdown-menu/base/index.js
new file mode 100644
index 000000000..69a15a850
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/index.js
@@ -0,0 +1,13 @@
+import DropdownBody from './DropdownBody.vue';
+import DropdownContainer from './DropdownContainer.vue';
+import DropdownItem from './DropdownItem.vue';
+import DropdownSection from './DropdownSection.vue';
+import DropdownSeparator from './DropdownSeparator.vue';
+
+export {
+ DropdownBody,
+ DropdownContainer,
+ DropdownItem,
+ DropdownSection,
+ DropdownSeparator,
+};
diff --git a/app/javascript/dashboard/components-next/dropdown-menu/base/provider.js b/app/javascript/dashboard/components-next/dropdown-menu/base/provider.js
new file mode 100644
index 000000000..67f2cbd0a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/dropdown-menu/base/provider.js
@@ -0,0 +1,19 @@
+import { inject, provide } from 'vue';
+
+const DropdownControl = Symbol('DropdownControl');
+
+export function useDropdownContext() {
+ const context = inject(DropdownControl, null);
+
+ if (context === null) {
+ throw new Error(
+ `Component is missing a parent component.`
+ );
+ }
+
+ return context;
+}
+
+export function provideDropdownContext(context) {
+ provide(DropdownControl, context);
+}
diff --git a/app/javascript/dashboard/components-next/filter/ActiveFilterPreview.story.vue b/app/javascript/dashboard/components-next/filter/ActiveFilterPreview.story.vue
new file mode 100644
index 000000000..0c79b97da
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/ActiveFilterPreview.story.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+ {}"
+ />
+
+
+
+ {}"
+ />
+
+
+
+ {}"
+ />
+
+
+
+ {}"
+ />
+
+
+
+ {}"
+ />
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/ActiveFilterPreview.vue b/app/javascript/dashboard/components-next/filter/ActiveFilterPreview.vue
new file mode 100644
index 000000000..959d95ccc
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/ActiveFilterPreview.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+ {{ replaceUnderscoreWithSpace(filter.attributeKey) }}
+
+
+ {{ formatOperatorLabel(filter.filterOperator) }}
+
+
+ {{ formatFilterValue(filter.values) }}
+
+
+
+
+ {{ filter.queryOperator }}
+
+
+
+
+
+ {{ moreFiltersLabel }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.story.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.story.vue
new file mode 100644
index 000000000..0a59a902f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/ConditionRow.story.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue
new file mode 100644
index 000000000..e2a2bfb45
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(`FILTER.ERRORS.${validationError}`) }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/ContactsFilter.vue b/app/javascript/dashboard/components-next/filter/ContactsFilter.vue
new file mode 100644
index 000000000..cff2e71e3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/ContactsFilter.vue
@@ -0,0 +1,174 @@
+
+
+
+
+
+ {{ filterModalHeaderTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/ConversationFilter.vue b/app/javascript/dashboard/components-next/filter/ConversationFilter.vue
new file mode 100644
index 000000000..d9ccb2c88
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/ConversationFilter.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+ {{ filterModalHeaderTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/SaveCustomView.vue b/app/javascript/dashboard/components-next/filter/SaveCustomView.vue
new file mode 100644
index 000000000..95b2e0388
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/SaveCustomView.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+ {{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/contactProvider.js b/app/javascript/dashboard/components-next/filter/contactProvider.js
new file mode 100644
index 000000000..4aceac359
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/contactProvider.js
@@ -0,0 +1,184 @@
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useOperators } from './operators';
+import { useMapGetter } from 'dashboard/composables/store.js';
+import { buildAttributesFilterTypes } from './helper/filterHelper.js';
+import countries from 'shared/constants/countries.js';
+
+/**
+ * @typedef {Object} FilterOption
+ * @property {string|number} id
+ * @property {string} name
+ * @property {import('vue').VNode} [icon]
+ */
+
+/**
+ * @typedef {Object} FilterOperator
+ * @property {string} value
+ * @property {string} label
+ * @property {string} icon
+ * @property {boolean} hasInput
+ */
+
+/**
+ * @typedef {Object} FilterType
+ * @property {string} attributeKey - The attribute key
+ * @property {string} value - This is a proxy for the attribute key used in FilterSelect
+ * @property {string} attributeName - The attribute name used to display on the UI
+ * @property {string} label - This is a proxy for the attribute name used in FilterSelect
+ * @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
+ * @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
+ * @property {'text'|'number'} dataType
+ * @property {FilterOperator[]} filterOperators - The operators available for the attribute
+ * @property {'standard'|'additional'|'customAttributes'} attributeModel
+ */
+
+/**
+ * @typedef {Object} FilterGroup
+ * @property {string} name
+ * @property {FilterType[]} attributes
+ */
+
+/**
+ * Composable that provides conversation filtering context
+ * @returns {{ filterTypes: import('vue').ComputedRef, filterGroups: import('vue').ComputedRef }}
+ */
+export function useContactFilterContext() {
+ const { t } = useI18n();
+
+ const contactAttributes = useMapGetter('attributes/getContactAttributes');
+
+ const {
+ equalityOperators,
+ containmentOperators,
+ dateOperators,
+ getOperatorTypes,
+ } = useOperators();
+
+ /**
+ * @type {import('vue').ComputedRef}
+ */
+ const customFilterTypes = computed(() =>
+ buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
+ );
+
+ /**
+ * @type {import('vue').ComputedRef}
+ */
+ const filterTypes = computed(() => [
+ {
+ attributeKey: 'name',
+ value: 'name',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
+ label: t('CONTACTS_LAYOUT.FILTER.NAME'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'email',
+ value: 'email',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
+ label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'phone_number',
+ value: 'phone_number',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
+ label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'identifier',
+ value: 'identifier',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
+ label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
+ inputType: 'plainText',
+ dataType: 'number',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'country_code',
+ value: 'country_code',
+ attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
+ label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
+ inputType: 'searchSelect',
+ options: countries,
+ dataType: 'number',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'city',
+ value: 'city',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
+ label: t('CONTACTS_LAYOUT.FILTER.CITY'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'created_at',
+ value: 'created_at',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
+ label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: dateOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'last_activity_at',
+ value: 'last_activity_at',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
+ label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: dateOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'referer',
+ value: 'referer',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
+ label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'blocked',
+ value: 'blocked',
+ attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
+ label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
+ inputType: 'searchSelect',
+ options: [
+ {
+ id: 'true',
+ name: t('CONTACTS_LAYOUT.FILTER.BLOCKED_TRUE'),
+ },
+ {
+ id: 'false',
+ name: t('CONTACTS_LAYOUT.FILTER.BLOCKED_FALSE'),
+ },
+ ],
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'standard',
+ },
+ ...customFilterTypes.value,
+ ]);
+
+ return { filterTypes };
+}
diff --git a/app/javascript/dashboard/components-next/filter/fixtures/filterTypes.js b/app/javascript/dashboard/components-next/filter/fixtures/filterTypes.js
new file mode 100644
index 000000000..0eb618535
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/fixtures/filterTypes.js
@@ -0,0 +1,603 @@
+export const filterTypes = [
+ {
+ attributeKey: 'status',
+ value: 'status',
+ attributeName: 'Status',
+ label: 'Status',
+ inputType: 'multiSelect',
+ options: [
+ { id: 'open', name: 'Open' },
+ { id: 'resolved', name: 'Resolved' },
+ { id: 'pending', name: 'Pending' },
+ { id: 'snoozed', name: 'Snoozed' },
+ { id: 'all', name: 'All' },
+ ],
+ dataType: 'text',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'assignee_id',
+ value: 'assignee_id',
+ attributeName: 'Assignee name',
+ label: 'Assignee name',
+ inputType: 'searchSelect',
+ options: [
+ { id: 14, name: 'Ben Nugent' },
+ { id: 30, name: 'Bruce' },
+ { id: 16, name: 'Cathy Simms' },
+ { id: 7, name: 'Charles Miner' },
+ { id: 10, name: 'Craig D' },
+ { id: 9, name: 'Dan Gore' },
+ { id: 13, name: 'Danny Cordray' },
+ { id: 3, name: 'David Wallace' },
+ { id: 4, name: 'Deangelo Vickers' },
+ { id: 33, name: 'Devon White' },
+ { id: 8, name: 'Ed Truck' },
+ { id: 31, name: 'Frank' },
+ { id: 29, name: 'Gideon' },
+ { id: 24, name: 'Glenn Max' },
+ ],
+ dataType: 'text',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'is_present',
+ label: 'Is present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-member-of-bold',
+ },
+ {
+ value: 'is_not_present',
+ label: 'Is not present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-not-member-of',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'team_id',
+ value: 'team_id',
+ attributeName: 'Team name',
+ label: 'Team name',
+ inputType: 'searchSelect',
+ options: [
+ { id: 223, name: '💰 sales' },
+ { id: 224, name: '💼 management' },
+ { id: 225, name: '👩💼 administration' },
+ { id: 226, name: '🚛 warehouse' },
+ ],
+ dataType: 'number',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'is_present',
+ label: 'Is present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-member-of-bold',
+ },
+ {
+ value: 'is_not_present',
+ label: 'Is not present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-not-member-of',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'display_id',
+ value: 'display_id',
+ attributeName: 'Conversation identifier',
+ label: 'Conversation identifier',
+ inputType: 'plainText',
+ datatype: 'number',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'contains',
+ label: 'Contains',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-superset-of-bold',
+ },
+ {
+ value: 'does_not_contain',
+ label: 'Does not contain',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-superset-of',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'campaign_id',
+ value: 'campaign_id',
+ attributeName: 'Campaign name',
+ label: 'Campaign name',
+ inputType: 'searchSelect',
+ options: [],
+ datatype: 'number',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'is_present',
+ label: 'Is present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-member-of-bold',
+ },
+ {
+ value: 'is_not_present',
+ label: 'Is not present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-not-member-of',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'labels',
+ value: 'labels',
+ attributeName: 'Labels',
+ label: 'Labels',
+ inputType: 'multiSelect',
+ options: [
+ { id: 'billing', name: 'billing' },
+ { id: 'delivery', name: 'delivery' },
+ { id: 'lead', name: 'lead' },
+ { id: 'ops-handover', name: 'ops-handover' },
+ { id: 'premium-customer', name: 'premium-customer' },
+ { id: 'software', name: 'software' },
+ ],
+ dataType: 'text',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'is_present',
+ label: 'Is present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-member-of-bold',
+ },
+ {
+ value: 'is_not_present',
+ label: 'Is not present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-not-member-of',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'referer',
+ value: 'referer',
+ attributeName: 'Referer link',
+ label: 'Referer link',
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'contains',
+ label: 'Contains',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-superset-of-bold',
+ },
+ {
+ value: 'does_not_contain',
+ label: 'Does not contain',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-superset-of',
+ },
+ ],
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'created_at',
+ value: 'created_at',
+ attributeName: 'Created at',
+ label: 'Created at',
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: [
+ {
+ value: 'is_greater_than',
+ label: 'Is greater than',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-greater-than-bold',
+ },
+ {
+ value: 'is_less_than',
+ label: 'Is lesser than',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-less-than-bold',
+ },
+ {
+ value: 'days_before',
+ label: 'Is x days before',
+ hasInput: true,
+ inputOverride: 'plainText',
+ icon: 'i-ph-calendar-minus-bold',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'last_activity_at',
+ value: 'last_activity_at',
+ attributeName: 'Last activity',
+ label: 'Last activity',
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: [
+ {
+ value: 'is_greater_than',
+ label: 'Is greater than',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-greater-than-bold',
+ },
+ {
+ value: 'is_less_than',
+ label: 'Is lesser than',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-less-than-bold',
+ },
+ {
+ value: 'days_before',
+ label: 'Is x days before',
+ hasInput: true,
+ inputOverride: 'plainText',
+ icon: 'i-ph-calendar-minus-bold',
+ },
+ ],
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'are_you_a_paid_customer',
+ value: 'are_you_a_paid_customer',
+ attributeName: 'Are you a paid customer?',
+ label: 'Are you a paid customer?',
+ inputType: 'booleanSelect',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ ],
+ options: [],
+ attributeModel: 'customAttributes',
+ },
+ {
+ attributeKey: 'date_of_purchase',
+ value: 'date_of_purchase',
+ attributeName: 'Date of Purchase',
+ label: 'Date of Purchase',
+ inputType: 'date',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ {
+ value: 'is_present',
+ label: 'Is present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-member-of-bold',
+ },
+ {
+ value: 'is_not_present',
+ label: 'Is not present',
+ hasInput: false,
+ inputOverride: null,
+ icon: 'i-ph-not-member-of',
+ },
+ {
+ value: 'is_greater_than',
+ label: 'Is greater than',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-greater-than-bold',
+ },
+ {
+ value: 'is_less_than',
+ label: 'Is lesser than',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-less-than-bold',
+ },
+ ],
+ options: [],
+ attributeModel: 'customAttributes',
+ },
+ {
+ attributeKey: 'your_website',
+ value: 'your_website',
+ attributeName: 'Your website',
+ label: 'Your website',
+ inputType: 'plainText',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ ],
+ options: [],
+ attributeModel: 'customAttributes',
+ },
+ {
+ attributeKey: 'are_you_residing_in_india',
+ value: 'are_you_residing_in_india',
+ attributeName: 'Are you residing in India?',
+ label: 'Are you residing in India?',
+ inputType: 'booleanSelect',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ ],
+ options: [],
+ attributeModel: 'customAttributes',
+ },
+ {
+ attributeKey: 'cloud',
+ value: 'cloud',
+ attributeName: 'Cloud',
+ label: 'Cloud',
+ inputType: 'booleanSelect',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ ],
+ options: [],
+ attributeModel: 'customAttributes',
+ },
+ {
+ attributeKey: 'license_type',
+ value: 'license_type',
+ attributeName: 'License Type',
+ label: 'License Type',
+ inputType: 'searchSelect',
+ filterOperators: [
+ {
+ value: 'equal_to',
+ label: 'Equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-equals-bold',
+ },
+ {
+ value: 'not_equal_to',
+ label: 'Not equal to',
+ hasInput: true,
+ inputOverride: null,
+ icon: 'i-ph-not-equals-bold',
+ },
+ ],
+ options: [
+ {
+ id: 'Personal',
+ name: 'Personal',
+ },
+ {
+ id: 'Enterprise',
+ name: 'Enterprise',
+ },
+ {
+ id: 'Teams',
+ name: 'Teams',
+ },
+ {
+ id: 'Professional',
+ name: 'Professional',
+ },
+ ],
+ attributeModel: 'customAttributes',
+ },
+];
+
+export const sampleActiveFilters = [
+ {
+ attributeKey: 'name',
+ filterOperator: 'contains',
+ values: 'John',
+ queryOperator: 'and',
+ },
+ {
+ attributeKey: 'email',
+ filterOperator: 'does_not_contain',
+ values: 'test@chatwoot.com',
+ queryOperator: 'or',
+ },
+ {
+ attributeKey: 'phone_number',
+ filterOperator: 'is_present',
+ values: '+928383822',
+ queryOperator: 'and',
+ },
+ {
+ attributeKey: 'created_at',
+ filterOperator: 'is_greater_than',
+ values: '2024-01-01',
+ queryOperator: 'and',
+ },
+ {
+ attributeKey: 'last_activity',
+ filterOperator: 'days_before',
+ values: '30',
+ queryOperator: 'and',
+ },
+ {
+ attributeKey: 'date_of_birth',
+ filterOperator: 'is_not_present',
+ values: '',
+ queryOperator: 'and',
+ },
+ {
+ attributeKey: 'country',
+ filterOperator: 'not_equal_to',
+ values: { id: 1, name: 'India' },
+ queryOperator: 'and',
+ },
+];
diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
new file mode 100644
index 000000000..2160365f6
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
@@ -0,0 +1,49 @@
+/**
+ * Determines the input type for a custom attribute based on its key
+ * @param {string} key - The attribute display type key
+ * @returns {'date'|'plainText'|'searchSelect'|'booleanSelect'} The corresponding input type
+ */
+export const getCustomAttributeInputType = key => {
+ switch (key) {
+ case 'date':
+ return 'date';
+ case 'text':
+ return 'plainText';
+ case 'list':
+ return 'searchSelect';
+ case 'checkbox':
+ return 'booleanSelect';
+ default:
+ return 'plainText';
+ }
+};
+
+/**
+ * Builds filter types for custom attributes
+ * @param {Array} attributes - The attributes array
+ * @param {Function} getOperatorTypes - Function to get operator types
+ * @returns {Array} Array of filter types
+ */
+export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
+ return attributes.map(attr => ({
+ attributeKey: attr.attributeKey,
+ value: attr.attributeKey,
+ attributeName: attr.attributeDisplayName,
+ label: attr.attributeDisplayName,
+ inputType: getCustomAttributeInputType(attr.attributeDisplayType),
+ filterOperators: getOperatorTypes(attr.attributeDisplayType),
+ options:
+ attr.attributeDisplayType === 'list'
+ ? attr.attributeValues.map(item => ({ id: item, name: item }))
+ : [],
+ attributeModel: 'customAttributes',
+ }));
+};
+
+/**
+ * Replaces underscores with spaces in a string
+ * @param {string} text - The input string
+ * @returns {string} The string with underscores replaced by spaces
+ */
+export const replaceUnderscoreWithSpace = text =>
+ text?.replaceAll('_', ' ') ?? '';
diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
new file mode 100644
index 000000000..58cf92cf3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
@@ -0,0 +1,137 @@
+import {
+ getCustomAttributeInputType,
+ buildAttributesFilterTypes,
+ replaceUnderscoreWithSpace,
+} from './filterHelper';
+
+describe('filterHelper', () => {
+ describe('getCustomAttributeInputType', () => {
+ it('returns date for date type', () => {
+ expect(getCustomAttributeInputType('date')).toBe('date');
+ });
+
+ it('returns plainText for text type', () => {
+ expect(getCustomAttributeInputType('text')).toBe('plainText');
+ });
+
+ it('returns searchSelect for list type', () => {
+ expect(getCustomAttributeInputType('list')).toBe('searchSelect');
+ });
+
+ it('returns booleanSelect for checkbox type', () => {
+ expect(getCustomAttributeInputType('checkbox')).toBe('booleanSelect');
+ });
+
+ it('returns plainText for unknown type', () => {
+ expect(getCustomAttributeInputType('unknown')).toBe('plainText');
+ });
+ });
+
+ describe('buildAttributesFilterTypes', () => {
+ const mockGetOperatorTypes = type => {
+ return type === 'list' ? ['is', 'is_not'] : ['contains', 'not_contains'];
+ };
+
+ it('builds filter types for text attributes', () => {
+ const attributes = [
+ {
+ attributeKey: 'test_key',
+ attributeDisplayName: 'Test Name',
+ attributeDisplayType: 'text',
+ attributeValues: [],
+ },
+ ];
+
+ const result = buildAttributesFilterTypes(
+ attributes,
+ mockGetOperatorTypes
+ );
+
+ expect(result).toEqual([
+ {
+ attributeKey: 'test_key',
+ value: 'test_key',
+ attributeName: 'Test Name',
+ label: 'Test Name',
+ inputType: 'plainText',
+ filterOperators: ['contains', 'not_contains'],
+ options: [],
+ attributeModel: 'customAttributes',
+ },
+ ]);
+ });
+
+ it('builds filter types for list attributes with options', () => {
+ const attributes = [
+ {
+ attributeKey: 'list_key',
+ attributeDisplayName: 'List Name',
+ attributeDisplayType: 'list',
+ attributeValues: ['option1', 'option2'],
+ },
+ ];
+
+ const result = buildAttributesFilterTypes(
+ attributes,
+ mockGetOperatorTypes
+ );
+
+ expect(result).toEqual([
+ {
+ attributeKey: 'list_key',
+ value: 'list_key',
+ attributeName: 'List Name',
+ label: 'List Name',
+ inputType: 'searchSelect',
+ filterOperators: ['is', 'is_not'],
+ options: [
+ { id: 'option1', name: 'option1' },
+ { id: 'option2', name: 'option2' },
+ ],
+ attributeModel: 'customAttributes',
+ },
+ ]);
+ });
+
+ it('handles multiple attributes', () => {
+ const attributes = [
+ {
+ attributeKey: 'date_key',
+ attributeDisplayName: 'Date Name',
+ attributeDisplayType: 'date',
+ attributeValues: [],
+ },
+ {
+ attributeKey: 'checkbox_key',
+ attributeDisplayName: 'Checkbox Name',
+ attributeDisplayType: 'checkbox',
+ attributeValues: [],
+ },
+ ];
+
+ const result = buildAttributesFilterTypes(
+ attributes,
+ mockGetOperatorTypes
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].inputType).toBe('date');
+ expect(result[1].inputType).toBe('booleanSelect');
+ });
+
+ it('handles empty attributes array', () => {
+ const result = buildAttributesFilterTypes([], mockGetOperatorTypes);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('replaceUnderscoreWithSpace', () => {
+ it('replaces underscores with spaces', () => {
+ expect(replaceUnderscoreWithSpace('test_key')).toBe('test key');
+ });
+
+ it('returns empty string if input is null', () => {
+ expect(replaceUnderscoreWithSpace(null)).toBe('');
+ });
+ });
+});
diff --git a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.story.vue b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.story.vue
new file mode 100644
index 000000000..23b76081e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.story.vue
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
new file mode 100644
index 000000000..db420920b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/inputs/MultiSelect.story.vue b/app/javascript/dashboard/components-next/filter/inputs/MultiSelect.story.vue
new file mode 100644
index 000000000..6452945f3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/inputs/MultiSelect.story.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/inputs/MultiSelect.vue b/app/javascript/dashboard/components-next/filter/inputs/MultiSelect.vue
new file mode 100644
index 000000000..be11847fb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/inputs/MultiSelect.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.name }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/inputs/SingleSelect.story.vue b/app/javascript/dashboard/components-next/filter/inputs/SingleSelect.story.vue
new file mode 100644
index 000000000..233a958f2
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/inputs/SingleSelect.story.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/inputs/SingleSelect.vue b/app/javascript/dashboard/components-next/filter/inputs/SingleSelect.vue
new file mode 100644
index 000000000..7fb143883
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/inputs/SingleSelect.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.name }}
+
+
+
+
+
+
+ {{ t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm: searchTerm }) }}
+
+
+
+
+ {{ t('COMBOBOX.EMPTY_STATE') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/filter/operators.js b/app/javascript/dashboard/components-next/filter/operators.js
new file mode 100644
index 000000000..22af84a89
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/operators.js
@@ -0,0 +1,164 @@
+import { computed, h } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+/**
+ * @typedef {Object} FilterOperations
+ * @property {string} EQUAL_TO - Equals comparison
+ * @property {string} NOT_EQUAL_TO - Not equals comparison
+ * @property {string} IS_PRESENT - Present check
+ * @property {string} IS_NOT_PRESENT - Not present check
+ * @property {string} CONTAINS - Contains check
+ * @property {string} DOES_NOT_CONTAIN - Does not contain check
+ * @property {string} IS_GREATER_THAN - Greater than comparison
+ * @property {string} IS_LESS_THAN - Less than comparison
+ * @property {string} DAYS_BEFORE - Days before check
+ * @property {string} STARTS_WITH - Starts with check
+ */
+
+/**
+ * @typedef {Object} Operator
+ * @property {string} value - Operator value from FILTER_OPS
+ * @property {string} label - Translated display label
+ * @property {import('vue').VNode} icon - Vue icon component instance
+ * @property {string|null} inputOverride - Input field type override
+ * @property {boolean} hasInput - Whether operator requires an input value
+ */
+
+const FILTER_OPS = {
+ EQUAL_TO: 'equal_to',
+ NOT_EQUAL_TO: 'not_equal_to',
+ IS_PRESENT: 'is_present',
+ IS_NOT_PRESENT: 'is_not_present',
+ CONTAINS: 'contains',
+ DOES_NOT_CONTAIN: 'does_not_contain',
+ IS_GREATER_THAN: 'is_greater_than',
+ IS_LESS_THAN: 'is_less_than',
+ DAYS_BEFORE: 'days_before',
+ STARTS_WITH: 'starts_with',
+};
+
+const NO_INPUT_OPTS = [FILTER_OPS.IS_PRESENT, FILTER_OPS.IS_NOT_PRESENT];
+
+const OPS_INPUT_OVERRIDE = {
+ [FILTER_OPS.DAYS_BEFORE]: 'plainText',
+};
+
+/**
+ * @type {Record}
+ */
+const filterOperatorIcon = {
+ [FILTER_OPS.EQUAL_TO]: 'i-ph-equals-bold',
+ [FILTER_OPS.NOT_EQUAL_TO]: 'i-ph-not-equals-bold',
+ [FILTER_OPS.IS_PRESENT]: 'i-ph-member-of-bold',
+ [FILTER_OPS.IS_NOT_PRESENT]: 'i-ph-not-member-of-bold',
+ [FILTER_OPS.CONTAINS]: 'i-ph-superset-of-bold',
+ [FILTER_OPS.DOES_NOT_CONTAIN]: 'i-ph-not-superset-of-bold',
+ [FILTER_OPS.IS_GREATER_THAN]: 'i-ph-greater-than-bold',
+ [FILTER_OPS.IS_LESS_THAN]: 'i-ph-less-than-bold',
+ [FILTER_OPS.DAYS_BEFORE]: 'i-ph-calendar-minus-bold',
+ [FILTER_OPS.STARTS_WITH]: 'i-ph-caret-line-right-bold',
+};
+
+/**
+ * Vue composable providing access to filter operators and related functionality
+ * @returns {Object} Collection of operators and utility functions
+ * @property {import('vue').ComputedRef>} operators - All available operators
+ * @property {import('vue').ComputedRef} equalityOperators - Equality comparison operators
+ * @property {import('vue').ComputedRef} presenceOperators - Presence check operators
+ * @property {import('vue').ComputedRef} containmentOperators - Containment check operators
+ * @property {import('vue').ComputedRef} comparisonOperators - Numeric comparison operators
+ * @property {import('vue').ComputedRef} dateOperators - Date-specific operators
+ * @property {(key: 'list'|'text'|'number'|'link'|'date'|'checkbox') => Operator[]} getOperatorTypes - Get operators for a field type
+ */
+export function useOperators() {
+ const { t } = useI18n();
+
+ /** @type {import('vue').ComputedRef>} */
+ const operators = computed(() => {
+ return Object.values(FILTER_OPS).reduce((acc, value) => {
+ acc[value] = {
+ value,
+ label: t(`FILTER.OPERATOR_LABELS.${value}`),
+ hasInput: !NO_INPUT_OPTS.includes(value),
+ inputOverride: OPS_INPUT_OVERRIDE[value] || null,
+ icon: h('span', {
+ class: `${filterOperatorIcon[value]} !text-n-blue-text`,
+ }),
+ };
+ return acc;
+ }, {});
+ });
+
+ /** @type {import('vue').ComputedRef>} */
+ const equalityOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const presenceOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ operators.value[FILTER_OPS.IS_PRESENT],
+ operators.value[FILTER_OPS.IS_NOT_PRESENT],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const containmentOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ operators.value[FILTER_OPS.CONTAINS],
+ operators.value[FILTER_OPS.DOES_NOT_CONTAIN],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const comparisonOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ operators.value[FILTER_OPS.IS_PRESENT],
+ operators.value[FILTER_OPS.IS_NOT_PRESENT],
+ operators.value[FILTER_OPS.IS_GREATER_THAN],
+ operators.value[FILTER_OPS.IS_LESS_THAN],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const dateOperators = computed(() => [
+ operators.value[FILTER_OPS.IS_GREATER_THAN],
+ operators.value[FILTER_OPS.IS_LESS_THAN],
+ operators.value[FILTER_OPS.DAYS_BEFORE],
+ ]);
+
+ /**
+ * Get operator types based on key
+ * @param {string} key - Type of operator to get
+ * @returns {Array}
+ */
+ const getOperatorTypes = key => {
+ switch (key) {
+ case 'list':
+ return equalityOperators.value;
+ case 'text':
+ return containmentOperators.value;
+ case 'number':
+ return equalityOperators.value;
+ case 'link':
+ return equalityOperators.value;
+ case 'date':
+ return comparisonOperators.value;
+ case 'checkbox':
+ return equalityOperators.value;
+ default:
+ return equalityOperators.value;
+ }
+ };
+
+ return {
+ operators,
+ equalityOperators,
+ presenceOperators,
+ containmentOperators,
+ comparisonOperators,
+ dateOperators,
+ getOperatorTypes,
+ };
+}
diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js
new file mode 100644
index 000000000..bb775a663
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/provider.js
@@ -0,0 +1,243 @@
+import { computed, h } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useOperators } from './operators';
+import { useMapGetter } from 'dashboard/composables/store.js';
+import { useChannelIcon } from 'next/icon/provider';
+import { buildAttributesFilterTypes } from './helper/filterHelper';
+import countries from 'shared/constants/countries.js';
+import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
+
+/**
+ * @typedef {Object} FilterOption
+ * @property {string|number} id
+ * @property {string} name
+ * @property {import('vue').VNode} [icon]
+ */
+
+/**
+ * @typedef {Object} FilterOperator
+ * @property {string} value
+ * @property {string} label
+ * @property {string} icon
+ * @property {boolean} hasInput
+ */
+
+/**
+ * @typedef {Object} FilterType
+ * @property {string} attributeKey - The attribute key
+ * @property {string} value - This is a proxy for the attribute key used in FilterSelect
+ * @property {string} attributeName - The attribute name used to display on the UI
+ * @property {string} label - This is a proxy for the attribute name used in FilterSelect
+ * @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
+ * @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
+ * @property {'text'|'number'} dataType
+ * @property {FilterOperator[]} filterOperators - The operators available for the attribute
+ * @property {'standard'|'additional'|'customAttributes'} attributeModel
+ */
+
+/**
+ * @typedef {Object} FilterGroup
+ * @property {string} name
+ * @property {FilterType[]} attributes
+ */
+
+/**
+ * Composable that provides conversation filtering context
+ * @returns {{ filterTypes: import('vue').ComputedRef, filterGroups: import('vue').ComputedRef }}
+ */
+export function useConversationFilterContext() {
+ const { t } = useI18n();
+
+ const conversationAttributes = useMapGetter(
+ 'attributes/getConversationAttributes'
+ );
+
+ const labels = useMapGetter('labels/getLabels');
+ const agents = useMapGetter('agents/getAgents');
+ const inboxes = useMapGetter('inboxes/getInboxes');
+ const teams = useMapGetter('teams/getTeams');
+ const campaigns = useMapGetter('campaigns/getAllCampaigns');
+
+ const {
+ equalityOperators,
+ presenceOperators,
+ containmentOperators,
+ dateOperators,
+ getOperatorTypes,
+ } = useOperators();
+
+ /**
+ * @type {import('vue').ComputedRef}
+ */
+ const customFilterTypes = computed(() =>
+ buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
+ );
+
+ /**
+ * @type {import('vue').ComputedRef}
+ */
+ const filterTypes = computed(() => [
+ {
+ attributeKey: 'status',
+ value: 'status',
+ attributeName: t('FILTER.ATTRIBUTES.STATUS'),
+ label: t('FILTER.ATTRIBUTES.STATUS'),
+ inputType: 'multiSelect',
+ options: ['open', 'resolved', 'pending', 'snoozed', 'all'].map(id => {
+ return {
+ id,
+ name: t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${id}.TEXT`),
+ };
+ }),
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'assignee_id',
+ value: 'assignee_id',
+ attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
+ label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
+ inputType: 'searchSelect',
+ options: agents.value.map(agent => {
+ return {
+ id: agent.id,
+ name: agent.name,
+ };
+ }),
+ dataType: 'text',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'inbox_id',
+ value: 'inbox_id',
+ attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
+ label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
+ inputType: 'searchSelect',
+ options: inboxes.value.map(inbox => {
+ return {
+ ...inbox,
+ icon: useChannelIcon(inbox).value,
+ };
+ }),
+ dataType: 'text',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'team_id',
+ value: 'team_id',
+ attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
+ label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
+ inputType: 'searchSelect',
+ options: teams.value,
+ dataType: 'number',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'display_id',
+ value: 'display_id',
+ attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
+ label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
+ inputType: 'plainText',
+ datatype: 'number',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'campaign_id',
+ value: 'campaign_id',
+ attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
+ label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
+ inputType: 'searchSelect',
+ options: campaigns.value.map(campaign => ({
+ id: campaign.id,
+ name: campaign.title,
+ })),
+ datatype: 'number',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'labels',
+ value: 'labels',
+ attributeName: t('FILTER.ATTRIBUTES.LABELS'),
+ label: t('FILTER.ATTRIBUTES.LABELS'),
+ inputType: 'multiSelect',
+ options: labels.value.map(label => {
+ return {
+ id: label.title,
+ name: label.title,
+ icon: h('span', {
+ class: `rounded-full`,
+ style: {
+ backgroundColor: label.color,
+ height: '6px',
+ width: '6px',
+ },
+ }),
+ };
+ }),
+ dataType: 'text',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'browser_language',
+ value: 'browser_language',
+ attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
+ label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
+ inputType: 'searchSelect',
+ options: languages,
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'country_code',
+ value: 'country_code',
+ attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
+ label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
+ inputType: 'searchSelect',
+ options: countries,
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'referer',
+ value: 'referer',
+ attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
+ label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'created_at',
+ value: 'created_at',
+ attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
+ label: t('FILTER.ATTRIBUTES.CREATED_AT'),
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: dateOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'last_activity_at',
+ value: 'last_activity_at',
+ attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
+ label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: dateOperators.value,
+ attributeModel: 'standard',
+ },
+ ...customFilterTypes.value,
+ ]);
+
+ return { filterTypes };
+}
diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
new file mode 100644
index 000000000..11117cc18
--- /dev/null
+++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/icon/Icon.vue b/app/javascript/dashboard/components-next/icon/Icon.vue
new file mode 100644
index 000000000..2c0156b09
--- /dev/null
+++ b/app/javascript/dashboard/components-next/icon/Icon.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/icon/Logo.vue b/app/javascript/dashboard/components-next/icon/Logo.vue
new file mode 100644
index 000000000..2330021c3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/icon/Logo.vue
@@ -0,0 +1,28 @@
+
+
+
diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js
new file mode 100644
index 000000000..5b00376d1
--- /dev/null
+++ b/app/javascript/dashboard/components-next/icon/provider.js
@@ -0,0 +1,36 @@
+import { computed } from 'vue';
+
+export function useChannelIcon(inbox) {
+ const channelTypeIconMap = {
+ 'Channel::Api': 'i-ri-cloudy-fill',
+ 'Channel::Email': 'i-ri-mail-fill',
+ 'Channel::FacebookPage': 'i-ri-messenger-fill',
+ 'Channel::Line': 'i-ri-line-fill',
+ 'Channel::Sms': 'i-ri-chat-1-fill',
+ 'Channel::Telegram': 'i-ri-telegram-fill',
+ 'Channel::TwilioSms': 'i-ri-chat-1-fill',
+ 'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
+ 'Channel::WebWidget': 'i-ri-global-fill',
+ 'Channel::Whatsapp': 'i-ri-whatsapp-fill',
+ };
+
+ const providerIconMap = {
+ microsoft: 'i-ri-microsoft-fill',
+ google: 'i-ri-google-fill',
+ };
+
+ const channelIcon = computed(() => {
+ const type = inbox.channel_type;
+ let icon = channelTypeIconMap[type];
+
+ if (type === 'Channel::Email' && inbox.provider) {
+ if (Object.keys(providerIconMap).includes(inbox.provider)) {
+ icon = providerIconMap[inbox.provider];
+ }
+ }
+
+ return icon ?? 'i-ri-global-fill';
+ });
+
+ return channelIcon;
+}
diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
new file mode 100644
index 000000000..df30d7138
--- /dev/null
+++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
@@ -0,0 +1,59 @@
+import { useChannelIcon } from '../provider';
+
+describe('useChannelIcon', () => {
+ it('returns correct icon for API channel', () => {
+ const inbox = { channel_type: 'Channel::Api' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-cloudy-fill');
+ });
+
+ it('returns correct icon for Facebook channel', () => {
+ const inbox = { channel_type: 'Channel::FacebookPage' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-messenger-fill');
+ });
+
+ it('returns correct icon for WhatsApp channel', () => {
+ const inbox = { channel_type: 'Channel::Whatsapp' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-whatsapp-fill');
+ });
+
+ describe('Email channel', () => {
+ it('returns mail icon for generic email channel', () => {
+ const inbox = { channel_type: 'Channel::Email' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-mail-fill');
+ });
+
+ it('returns Microsoft icon for Microsoft email provider', () => {
+ const inbox = {
+ channel_type: 'Channel::Email',
+ provider: 'microsoft',
+ };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-microsoft-fill');
+ });
+
+ it('returns Google icon for Google email provider', () => {
+ const inbox = {
+ channel_type: 'Channel::Email',
+ provider: 'google',
+ };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-google-fill');
+ });
+ });
+
+ it('returns default icon for unknown channel type', () => {
+ const inbox = { channel_type: 'Channel::Unknown' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-global-fill');
+ });
+
+ it('returns default icon when channel type is undefined', () => {
+ const inbox = {};
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-global-fill');
+ });
+});
diff --git a/app/javascript/dashboard/components-next/inline-input/InlineInput.story.vue b/app/javascript/dashboard/components-next/inline-input/InlineInput.story.vue
new file mode 100644
index 000000000..7338d9cc9
--- /dev/null
+++ b/app/javascript/dashboard/components-next/inline-input/InlineInput.story.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/inline-input/InlineInput.vue b/app/javascript/dashboard/components-next/inline-input/InlineInput.vue
new file mode 100644
index 000000000..f63662745
--- /dev/null
+++ b/app/javascript/dashboard/components-next/inline-input/InlineInput.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/input/Input.story.vue b/app/javascript/dashboard/components-next/input/Input.story.vue
new file mode 100644
index 000000000..579c2437a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/input/Input.story.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue
new file mode 100644
index 000000000..5bfdd5c71
--- /dev/null
+++ b/app/javascript/dashboard/components-next/input/Input.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+ {{ message }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/pagination/PaginationFooter.story.vue b/app/javascript/dashboard/components-next/pagination/PaginationFooter.story.vue
new file mode 100644
index 000000000..255bbc8dc
--- /dev/null
+++ b/app/javascript/dashboard/components-next/pagination/PaginationFooter.story.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue b/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
new file mode 100644
index 000000000..4f6f47c85
--- /dev/null
+++ b/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+ {{ currentPageInformation }}
+
+
+
+
+
+
+
+ {{ currentPage }}
+
+ {{ pageInfo }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.story.vue b/app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.story.vue
new file mode 100644
index 000000000..b51b7f023
--- /dev/null
+++ b/app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.story.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
Phone number value: {{ phoneNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.vue b/app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.vue
new file mode 100644
index 000000000..e523c306b
--- /dev/null
+++ b/app/javascript/dashboard/components-next/phonenumberinput/PhoneNumberInput.vue
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+
+
+ {{ activeDialCode }}
+
+
+
+
+
+
+
+
+ {{ phoneNumberError }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/selectmenu/SelectMenu.story.vue b/app/javascript/dashboard/components-next/selectmenu/SelectMenu.story.vue
new file mode 100644
index 000000000..13d823889
--- /dev/null
+++ b/app/javascript/dashboard/components-next/selectmenu/SelectMenu.story.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
Selected value: {{ selectedValue }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/selectmenu/SelectMenu.vue b/app/javascript/dashboard/components-next/selectmenu/SelectMenu.vue
new file mode 100644
index 000000000..cc584dcbb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/selectmenu/SelectMenu.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
new file mode 100644
index 000000000..2a93089a9
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
new file mode 100644
index 000000000..d5e968f4a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -0,0 +1,525 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue
new file mode 100644
index 000000000..f232faeab
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarAccountSwitcher.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ account.name }}
+
+
+
+ {{
+ account.custom_role_id
+ ? account.custom_role.name
+ : account.role
+ }}
+
+
+
+
+
+
+
+
+ {{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue
new file mode 100644
index 000000000..f22e003f1
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupEmptyLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupEmptyLeaf.vue
new file mode 100644
index 000000000..dd4128dfe
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupEmptyLeaf.vue
@@ -0,0 +1,13 @@
+
+
+
+
+ {{ t('SIDEBAR.NO_ITEMS') }}
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupHeader.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupHeader.vue
new file mode 100644
index 000000000..160483a6a
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupHeader.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
new file mode 100644
index 000000000..13bdb040d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupSeparator.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupSeparator.vue
new file mode 100644
index 000000000..ddc92a2b3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupSeparator.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarNotificationBell.vue b/app/javascript/dashboard/components-next/sidebar/SidebarNotificationBell.vue
new file mode 100644
index 000000000..94f3dd6bd
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarNotificationBell.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue
new file mode 100644
index 000000000..b9bb9f421
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue
new file mode 100644
index 000000000..0fdf96162
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+ {{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarSubGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarSubGroup.vue
new file mode 100644
index 000000000..52886ebde
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/SidebarSubGroup.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/sidebar/provider.js b/app/javascript/dashboard/components-next/sidebar/provider.js
new file mode 100644
index 000000000..d6571dcd9
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/provider.js
@@ -0,0 +1,49 @@
+import { inject, provide } from 'vue';
+import { usePolicy } from 'dashboard/composables/usePolicy';
+import { useRouter } from 'vue-router';
+
+const SidebarControl = Symbol('SidebarControl');
+
+export function useSidebarContext() {
+ const context = inject(SidebarControl, null);
+ if (context === null) {
+ throw new Error(`Component is missing a parent component.`);
+ }
+
+ const router = useRouter();
+ const { checkFeatureAllowed, checkPermissions } = usePolicy();
+
+ const resolvePath = to => {
+ if (to) return router.resolve(to)?.path || '/';
+ return '/';
+ };
+
+ const resolvePermissions = to => {
+ if (to) return router.resolve(to)?.meta?.permissions ?? [];
+ return [];
+ };
+
+ const resolveFeatureFlag = to => {
+ if (to) return router.resolve(to)?.meta?.featureFlag || '';
+ return '';
+ };
+
+ const isAllowed = to => {
+ const permissions = resolvePermissions(to);
+ const featureFlag = resolveFeatureFlag(to);
+
+ return checkPermissions(permissions) && checkFeatureAllowed(featureFlag);
+ };
+
+ return {
+ ...context,
+ resolvePath,
+ resolvePermissions,
+ resolveFeatureFlag,
+ isAllowed,
+ };
+}
+
+export function provideSidebarContext(context) {
+ provide(SidebarControl, context);
+}
diff --git a/app/javascript/dashboard/components-next/sidebar/useSidebarKeyboardShortcuts.js b/app/javascript/dashboard/components-next/sidebar/useSidebarKeyboardShortcuts.js
new file mode 100644
index 000000000..0ef050369
--- /dev/null
+++ b/app/javascript/dashboard/components-next/sidebar/useSidebarKeyboardShortcuts.js
@@ -0,0 +1,39 @@
+import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
+import { useRoute, useRouter } from 'vue-router';
+
+export function useSidebarKeyboardShortcuts(toggleShortcutModalFn) {
+ const route = useRoute();
+ const router = useRouter();
+
+ const isCurrentRouteSameAsNavigation = routeName => {
+ return route.name === routeName;
+ };
+
+ const navigateToRoute = routeName => {
+ if (!isCurrentRouteSameAsNavigation(routeName)) {
+ router.push({ name: routeName });
+ }
+ };
+ const keyboardEvents = {
+ '$mod+Slash': {
+ action: () => toggleShortcutModalFn(true),
+ },
+ '$mod+Escape': {
+ action: () => toggleShortcutModalFn(false),
+ },
+ 'Alt+KeyC': {
+ action: () => navigateToRoute('home'),
+ },
+ 'Alt+KeyV': {
+ action: () => navigateToRoute('contacts_dashboard'),
+ },
+ 'Alt+KeyR': {
+ action: () => navigateToRoute('account_overview_reports'),
+ },
+ 'Alt+KeyS': {
+ action: () => navigateToRoute('agent_list'),
+ },
+ };
+
+ return useKeyboardEvents(keyboardEvents);
+}
diff --git a/app/javascript/dashboard/components-next/spinner/Spinner.story.vue b/app/javascript/dashboard/components-next/spinner/Spinner.story.vue
new file mode 100644
index 000000000..0031a5dfc
--- /dev/null
+++ b/app/javascript/dashboard/components-next/spinner/Spinner.story.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/spinner/Spinner.vue b/app/javascript/dashboard/components-next/spinner/Spinner.vue
new file mode 100644
index 000000000..d1cf8fc42
--- /dev/null
+++ b/app/javascript/dashboard/components-next/spinner/Spinner.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/switch/Switch.story.vue b/app/javascript/dashboard/components-next/switch/Switch.story.vue
new file mode 100644
index 000000000..9e4f7ae63
--- /dev/null
+++ b/app/javascript/dashboard/components-next/switch/Switch.story.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Default:
+
+
+
+
+ Checked:
+
+
+
+
+
+
+
+
+
Last change: {{ lastChange }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/switch/Switch.vue b/app/javascript/dashboard/components-next/switch/Switch.vue
new file mode 100644
index 000000000..e8f409c0e
--- /dev/null
+++ b/app/javascript/dashboard/components-next/switch/Switch.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/tabbar/TabBar.story.vue b/app/javascript/dashboard/components-next/tabbar/TabBar.story.vue
new file mode 100644
index 000000000..7b1946cf6
--- /dev/null
+++ b/app/javascript/dashboard/components-next/tabbar/TabBar.story.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/tabbar/TabBar.vue b/app/javascript/dashboard/components-next/tabbar/TabBar.vue
new file mode 100644
index 000000000..072b80907
--- /dev/null
+++ b/app/javascript/dashboard/components-next/tabbar/TabBar.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/taginput/TagInput.vue b/app/javascript/dashboard/components-next/taginput/TagInput.vue
new file mode 100644
index 000000000..63d757415
--- /dev/null
+++ b/app/javascript/dashboard/components-next/taginput/TagInput.vue
@@ -0,0 +1,249 @@
+
+
+
+
+
+ {{
+ tag
+ }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/textarea/TextArea.story.vue b/app/javascript/dashboard/components-next/textarea/TextArea.story.vue
new file mode 100644
index 000000000..0a31f76c0
--- /dev/null
+++ b/app/javascript/dashboard/components-next/textarea/TextArea.story.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/textarea/TextArea.vue b/app/javascript/dashboard/components-next/textarea/TextArea.vue
new file mode 100644
index 000000000..16e0fe052
--- /dev/null
+++ b/app/javascript/dashboard/components-next/textarea/TextArea.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+ {{ characterCount }} / {{ maxLength }}
+
+
+
+
+ {{ message }}
+
+
+
diff --git a/app/javascript/dashboard/components/Accordion/AccordionItem.stories.js b/app/javascript/dashboard/components/Accordion/AccordionItem.stories.js
deleted file mode 100644
index 5ff6af8cf..000000000
--- a/app/javascript/dashboard/components/Accordion/AccordionItem.stories.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { action } from '@storybook/addon-actions';
-import AccordionItemComponent from './AccordionItem';
-
-export default {
- title: 'Components/Generic/Accordion',
- component: AccordionItemComponent,
- argTypes: {
- title: {
- control: {
- type: 'string',
- },
- },
- },
-};
-
-const Template = (args, { argTypes }) => ({
- props: Object.keys(argTypes),
- components: { AccordionItem: AccordionItemComponent },
- template: `
-
- This is a sample content you can pass as a slot
-
- `,
-});
-
-export const AccordionItem = Template.bind({});
-AccordionItem.args = {
- onClick: action('Added'),
- title: 'Title of the accordion item',
-};
diff --git a/app/javascript/dashboard/components/Accordion/AccordionItem.vue b/app/javascript/dashboard/components/Accordion/AccordionItem.vue
index 6fc0ee0d9..e8a90cc83 100644
--- a/app/javascript/dashboard/components/Accordion/AccordionItem.vue
+++ b/app/javascript/dashboard/components/Accordion/AccordionItem.vue
@@ -1,32 +1,34 @@
-
@@ -34,7 +36,7 @@ export default {
-
-
+
+
+
{
icon="dismiss-circle"
@click="emit('resetFilters')"
/>
-
-
-
+
+
+
{
icon="delete"
@click="emit('deleteFolders')"
/>
+
+
-
diff --git a/app/javascript/dashboard/components/Code.vue b/app/javascript/dashboard/components/Code.vue
index d8656da75..e6061182a 100644
--- a/app/javascript/dashboard/components/Code.vue
+++ b/app/javascript/dashboard/components/Code.vue
@@ -1,5 +1,7 @@
-
-
+
+
-
+
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
-
+
-
-
diff --git a/app/javascript/dashboard/components/ConversationItem.vue b/app/javascript/dashboard/components/ConversationItem.vue
index 55721ac21..27ba4c6d6 100644
--- a/app/javascript/dashboard/components/ConversationItem.vue
+++ b/app/javascript/dashboard/components/ConversationItem.vue
@@ -14,6 +14,7 @@ export default {
'toggleContextMenu',
'markAsUnread',
'assignPriority',
+ 'isConversationSelected',
],
props: {
source: {
@@ -36,10 +37,6 @@ export default {
type: [String, Number],
default: 0,
},
- isConversationSelected: {
- type: Function,
- default: () => {},
- },
showAssignee: {
type: Boolean,
default: false,
@@ -59,14 +56,14 @@ export default {
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
enable-context-menu
- @selectConversation="selectConversation"
- @deSelectConversation="deSelectConversation"
- @assignAgent="assignAgent"
- @assignTeam="assignTeam"
- @assignLabel="assignLabels"
- @updateConversationStatus="updateConversationStatus"
- @contextMenuToggle="toggleContextMenu"
- @markAsUnread="markAsUnread"
- @assignPriority="assignPriority"
+ @select-conversation="selectConversation"
+ @de-select-conversation="deSelectConversation"
+ @assign-agent="assignAgent"
+ @assign-team="assignTeam"
+ @assign-label="assignLabels"
+ @update-conversation-status="updateConversationStatus"
+ @context-menu-toggle="toggleContextMenu"
+ @mark-as-unread="markAsUnread"
+ @assign-priority="assignPriority"
/>
diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue
index 4bf97320b..c58243ba9 100644
--- a/app/javascript/dashboard/components/CustomAttribute.vue
+++ b/app/javascript/dashboard/components/CustomAttribute.vue
@@ -7,6 +7,7 @@ import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
+import { emitter } from 'shared/helpers/mitt';
const DATE_FORMAT = 'yyyy-MM-dd';
@@ -30,6 +31,7 @@ export default {
attributeKey: { type: String, required: true },
contactId: { type: Number, default: null },
},
+ emits: ['update', 'delete', 'copy'],
setup() {
return { v$: useVuelidate() };
},
@@ -135,10 +137,10 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
- this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
+ emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
- destroyed() {
- this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
+ unmounted() {
+ emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
@@ -321,7 +323,7 @@ export default {
'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
)
"
- @click="onUpdateListValue"
+ @select="onUpdateListValue"
/>
@@ -331,10 +333,12 @@ export default {
::v-deep {
.selector-wrap {
@apply m-0 top-1;
+
.selector-name {
@apply ml-0;
}
}
+
.name {
@apply ml-0;
}
diff --git a/app/javascript/dashboard/components/CustomBrandPolicyWrapper.vue b/app/javascript/dashboard/components/CustomBrandPolicyWrapper.vue
index b07b24657..42a1c4401 100644
--- a/app/javascript/dashboard/components/CustomBrandPolicyWrapper.vue
+++ b/app/javascript/dashboard/components/CustomBrandPolicyWrapper.vue
@@ -18,6 +18,7 @@ const shouldShowContent = computed(
);
+
diff --git a/app/javascript/dashboard/components/CustomSnoozeModal.vue b/app/javascript/dashboard/components/CustomSnoozeModal.vue
index 4b5a41951..3d4790bf5 100644
--- a/app/javascript/dashboard/components/CustomSnoozeModal.vue
+++ b/app/javascript/dashboard/components/CustomSnoozeModal.vue
@@ -1,10 +1,11 @@
diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue
index ae126aa11..5f65c34dd 100644
--- a/app/javascript/dashboard/components/Modal.vue
+++ b/app/javascript/dashboard/components/Modal.vue
@@ -1,78 +1,69 @@
-
diff --git a/app/javascript/dashboard/components/ModalHeader.vue b/app/javascript/dashboard/components/ModalHeader.vue
index 63d361278..9e2330aa4 100644
--- a/app/javascript/dashboard/components/ModalHeader.vue
+++ b/app/javascript/dashboard/components/ModalHeader.vue
@@ -27,14 +27,14 @@ export default {
{{ headerTitle }}
{{ headerContent }}
diff --git a/app/javascript/dashboard/components/NetworkNotification.vue b/app/javascript/dashboard/components/NetworkNotification.vue
index 72e74e8a9..98149217e 100644
--- a/app/javascript/dashboard/components/NetworkNotification.vue
+++ b/app/javascript/dashboard/components/NetworkNotification.vue
@@ -1,7 +1,7 @@
+
import WootSnackbar from './Snackbar.vue';
+import { emitter } from 'shared/helpers/mitt';
export default {
components: {
@@ -19,13 +20,19 @@ export default {
},
mounted() {
- this.$emitter.on('newToastMessage', this.onNewToastMessage);
+ emitter.on('newToastMessage', this.onNewToastMessage);
},
- beforeDestroy() {
- this.$emitter.off('newToastMessage', this.onNewToastMessage);
+ unmounted() {
+ emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
- onNewToastMessage({ message, action }) {
+ onNewToastMessage({ message: originalMessage, action }) {
+ // FIX ME: This is a temporary workaround to pass string from functions
+ // that doesn't have the context of the VueApp.
+ const usei18n = action?.usei18n;
+ const duration = action?.duration || this.duration;
+ const message = usei18n ? this.$t(originalMessage) : originalMessage;
+
this.snackMessages.push({
key: new Date().getTime(),
message,
@@ -33,7 +40,7 @@ export default {
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
- }, this.duration);
+ }, duration);
},
},
};
diff --git a/app/javascript/dashboard/components/app/PaymentPendingBanner.vue b/app/javascript/dashboard/components/app/PaymentPendingBanner.vue
index 1d89ebfde..97f33a43a 100644
--- a/app/javascript/dashboard/components/app/PaymentPendingBanner.vue
+++ b/app/javascript/dashboard/components/app/PaymentPendingBanner.vue
@@ -79,6 +79,7 @@ export default {
};
+
diff --git a/app/javascript/dashboard/components/app/PendingEmailVerificationBanner.vue b/app/javascript/dashboard/components/app/PendingEmailVerificationBanner.vue
index 544e78709..a349786e6 100644
--- a/app/javascript/dashboard/components/app/PendingEmailVerificationBanner.vue
+++ b/app/javascript/dashboard/components/app/PendingEmailVerificationBanner.vue
@@ -28,6 +28,7 @@ export default {
};
+
diff --git a/app/javascript/dashboard/components/app/UpdateBanner.vue b/app/javascript/dashboard/components/app/UpdateBanner.vue
index 382b3c3f8..fcfcfe11f 100644
--- a/app/javascript/dashboard/components/app/UpdateBanner.vue
+++ b/app/javascript/dashboard/components/app/UpdateBanner.vue
@@ -67,6 +67,7 @@ export default {
};
+
+
diff --git a/app/javascript/dashboard/components/buttons/Button.vue b/app/javascript/dashboard/components/buttons/Button.vue
index 32c2e923a..1ec445890 100644
--- a/app/javascript/dashboard/components/buttons/Button.vue
+++ b/app/javascript/dashboard/components/buttons/Button.vue
@@ -27,16 +27,17 @@ export default {
default: 'primary',
},
},
- methods: {
- onClick(e) {
- this.$emit('click', e);
- },
+ created() {
+ // eslint-disable-next-line
+ console.warn(
+ '[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
+ );
},
};
-
+
@@ -54,7 +49,6 @@ export default {
data-testid="submit_button"
:disabled="disabled"
:class="computedClass"
- @click="onClick"
>
{{ buttonText }}
diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue
index 7c4c4daaa..4197440a7 100644
--- a/app/javascript/dashboard/components/buttons/ResolveAction.vue
+++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useToggle } from '@vueuse/core';
-import { useI18n } from 'dashboard/composables/useI18n';
+import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -13,13 +13,12 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
-} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
+} from 'dashboard/helper/commandbar/events';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
-const resolveActionsRef = ref(null);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
@@ -131,17 +130,14 @@ const keyboardEvents = {
},
};
-useKeyboardEvents(keyboardEvents, resolveActionsRef);
+useKeyboardEvents(keyboardEvents);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
-