Compare commits
154
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cc046358 | ||
|
|
399df7304d | ||
|
|
f73798a1aa | ||
|
|
035a037313 | ||
|
|
c6c36b1b36 | ||
|
|
7ba6c1d87d | ||
|
|
6df2d76c1e | ||
|
|
80c9434069 | ||
|
|
73b6e2cf37 | ||
|
|
a3855a8d1d | ||
|
|
6d3ecfe3c1 | ||
|
|
601a0f8a76 | ||
|
|
c49f5ed800 | ||
|
|
5133f4e579 | ||
|
|
c8657c55a8 | ||
|
|
2a832f8ed5 | ||
|
|
35a1dcce43 | ||
|
|
3fe771df6f | ||
|
|
4e64097421 | ||
|
|
dff57013c3 | ||
|
|
a37d44758b | ||
|
|
c796832a58 | ||
|
|
604c592e89 | ||
|
|
fe2d91e0d3 | ||
|
|
6d40986a64 | ||
|
|
35b21c1cea | ||
|
|
306a6c6ce0 | ||
|
|
902a9aa7d7 | ||
|
|
6a383dc3ca | ||
|
|
e2db5b8cff | ||
|
|
544fe2b913 | ||
|
|
a04f8182a2 | ||
|
|
6d6dc0c153 | ||
|
|
32a9d5b0ce | ||
|
|
0082c6adb9 | ||
|
|
431d533635 | ||
|
|
5fd389e15d | ||
|
|
44be3c9eec | ||
|
|
7be1ecaf96 | ||
|
|
f13644245e | ||
|
|
62f4f127aa | ||
|
|
392e58b0be | ||
|
|
3a0fd9b777 | ||
|
|
dec637ab8a | ||
|
|
e0ef007047 | ||
|
|
593270d10b | ||
|
|
6986d34bd9 | ||
|
|
694302b930 | ||
|
|
1e9959bb65 | ||
|
|
ed9dc6d419 | ||
|
|
2d5afef7c2 | ||
|
|
1fc06f8959 | ||
|
|
16c6ef0e11 | ||
|
|
01cc46b318 | ||
|
|
4c7a539f9d | ||
|
|
a2f32f7232 | ||
|
|
220a947380 | ||
|
|
8505aa48c3 | ||
|
|
b49eaa5c45 | ||
|
|
f18ed01eb7 | ||
|
|
aa5fa0c758 | ||
|
|
42eca69763 | ||
|
|
3a78192e74 | ||
|
|
a8c12ffb25 | ||
|
|
97d4ce65c4 | ||
|
|
ea787fecd8 | ||
|
|
ee02923ace | ||
|
|
0677d8763d | ||
|
|
095aaf3de6 | ||
|
|
b7aea99f44 | ||
|
|
d0bf66fe7a | ||
|
|
490fc4eaba | ||
|
|
e6e0f7de5a | ||
|
|
d2eda8bd08 | ||
|
|
55bf05d2d4 | ||
|
|
5b22af6af8 | ||
|
|
9338bc1391 | ||
|
|
88a16b8e96 | ||
|
|
8c54d7f794 | ||
|
|
bd88bfb0fe | ||
|
|
83100b8f60 | ||
|
|
0f26351fd3 | ||
|
|
6d053e5816 | ||
|
|
bbb8e57fee | ||
|
|
5eac95732b | ||
|
|
b3262597c1 | ||
|
|
701135df92 | ||
|
|
fdc3e370ef | ||
|
|
f14edd5242 | ||
|
|
578dce81a1 | ||
|
|
c51a458c25 | ||
|
|
b8d0252511 | ||
|
|
edc1fe2363 | ||
|
|
0491ffc94e | ||
|
|
b52950ba5a | ||
|
|
a3b602290a | ||
|
|
fce4d5e26e | ||
|
|
42f6621afb | ||
|
|
e0bf2bd9d4 | ||
|
|
a0dddae289 | ||
|
|
f4f2d678cf | ||
|
|
4a7a0427e9 | ||
|
|
d107d0adec | ||
|
|
d79d9e8b46 | ||
|
|
b524ceeca1 | ||
|
|
e68dac8c45 | ||
|
|
f0d00b6965 | ||
|
|
f15a4f8aa4 | ||
|
|
c0d5b24881 | ||
|
|
fd01a5056a | ||
|
|
377fb5c3b9 | ||
|
|
58e78621ba | ||
|
|
fba73c7186 | ||
|
|
467700499b | ||
|
|
5d52e4e0a6 | ||
|
|
05b8486538 | ||
|
|
b9ff164041 | ||
|
|
73d448a203 | ||
|
|
8d8ec23aa1 | ||
|
|
add6403aa6 | ||
|
|
4b779dbe0f | ||
|
|
ab86f62fcc | ||
|
|
aaab2ac788 | ||
|
|
aa4edaa1e6 | ||
|
|
a76cd7684a | ||
|
|
2c17c95eab | ||
|
|
bb74c621b5 | ||
|
|
185a122ec7 | ||
|
|
05dece826d | ||
|
|
528b984a8d | ||
|
|
db0e654c03 | ||
|
|
e99e6a8443 | ||
|
|
d6fd3ce048 | ||
|
|
8a2f652b94 | ||
|
|
a3732c8f51 | ||
|
|
3a0e68030a | ||
|
|
f087461abc | ||
|
|
3a47b7e3d1 | ||
|
|
6dda1e8c8f | ||
|
|
098825c149 | ||
|
|
31e7663258 | ||
|
|
3b5f5b41ad | ||
|
|
fe5670832a | ||
|
|
bc6420019f | ||
|
|
f82ec3b885 | ||
|
|
32c25047c4 | ||
|
|
7f8d718da3 | ||
|
|
3489783cb8 | ||
|
|
53d68868c6 | ||
|
|
b61ad6e41a | ||
|
|
41c5e7d3f1 | ||
|
|
abc511d00f | ||
|
|
a48f98de9d | ||
|
|
d70ba8ff40 |
+113
-133
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+2
-1
@@ -27,7 +27,8 @@ checks:
|
||||
threshold: 50
|
||||
exclude_patterns:
|
||||
- 'spec/'
|
||||
- '**/specs/'
|
||||
- '**/specs/**/**'
|
||||
- '**/spec/**/**'
|
||||
- 'db/*'
|
||||
- 'bin/**/*'
|
||||
- 'db/**/*'
|
||||
|
||||
+26
-13
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+15
-1
@@ -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
|
||||
# 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
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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: '<story/>',
|
||||
i18n: i18nConfig,
|
||||
store,
|
||||
beforeCreate: function () {
|
||||
this.$root._i18n = this.$i18n;
|
||||
},
|
||||
}));
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -200,12 +200,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 +226,7 @@ group :development, :test do
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '>= 6.1.4'
|
||||
gem 'rspec-rails', '>= 6.1.5'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
|
||||
+103
-106
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -271,7 +270,7 @@ GEM
|
||||
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)
|
||||
@@ -301,15 +300,15 @@ GEM
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
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-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)
|
||||
addressable (~> 2.8)
|
||||
@@ -319,17 +318,17 @@ GEM
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -457,7 +456,7 @@ GEM
|
||||
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)
|
||||
@@ -480,7 +479,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.14)
|
||||
net-imap (0.4.17)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -496,14 +495,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6)
|
||||
nokogiri (1.16.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -522,7 +521,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 +551,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,25 +569,25 @@ 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
|
||||
@@ -596,9 +595,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
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 +633,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.4)
|
||||
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,7 +707,6 @@ GEM
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.19.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
@@ -766,7 +763,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)
|
||||
@@ -800,6 +796,13 @@ GEM
|
||||
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 +817,7 @@ 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)
|
||||
webrick (1.8.2)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -827,7 +825,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 +860,6 @@ DEPENDENCIES
|
||||
climate_control
|
||||
commonmarker
|
||||
csv-safe
|
||||
cypress-on-rails
|
||||
database_cleaner
|
||||
ddtrace
|
||||
debug (~> 1.8)
|
||||
@@ -881,9 +878,9 @@ DEPENDENCIES
|
||||
foreman
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
google-cloud-storage
|
||||
google-cloud-translate-v3
|
||||
google-cloud-translate-v3 (>= 0.7.0)
|
||||
groupdate
|
||||
grpc
|
||||
haikunator
|
||||
@@ -903,14 +900,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 +927,7 @@ DEPENDENCIES
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
rspec-rails (>= 6.1.4)
|
||||
rspec-rails (>= 6.1.5)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-performance
|
||||
@@ -960,10 +957,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
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
___
|
||||
|
||||
# Chatwoot
|
||||
# Chatwoot
|
||||
|
||||
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
<p>
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.9.0
|
||||
3.13.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.8.0
|
||||
3.0.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,8 @@ 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?
|
||||
true
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@contacts_count = Current.account.contactable_contacts_count
|
||||
@contacts = fetch_contacts(resolved_contacts)
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import router from '../dashboard/routes';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
@@ -9,9 +8,12 @@ import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -31,6 +33,13 @@ export default {
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
return { router, store, currentAccountId: accountId };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
@@ -38,7 +47,6 @@ export default {
|
||||
reconnectService: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
@@ -46,7 +54,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -63,10 +70,13 @@ export default {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -74,7 +84,7 @@ export default {
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
},
|
||||
beforeDestroy() {
|
||||
unmounted() {
|
||||
if (this.reconnectService) {
|
||||
this.reconnectService.disconnect();
|
||||
}
|
||||
@@ -100,8 +110,9 @@ export default {
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
this.setLocale(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.$store, router);
|
||||
vueActionCable.init(this.store, pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.store, this.router);
|
||||
window.reconnectService = this.reconnectService;
|
||||
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
@@ -129,9 +140,11 @@ export default {
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
<UpgradeBanner />
|
||||
</template>
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view />
|
||||
</transition>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
@@ -141,6 +154,22 @@ export default {
|
||||
|
||||
<style lang="scss">
|
||||
@import './assets/scss/app';
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__inner {
|
||||
background: black !important;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px !important;
|
||||
border-radius: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CustomRole extends ApiClient {
|
||||
constructor() {
|
||||
super('custom_roles', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CustomRole();
|
||||
@@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI {
|
||||
}
|
||||
|
||||
createArticle({ portalSlug, articleObj }) {
|
||||
const { content, title, author_id, category_id } = articleObj;
|
||||
const { content, title, authorId, categoryId, locale } = articleObj;
|
||||
return axios.post(`${this.url}/${portalSlug}/articles`, {
|
||||
content,
|
||||
title,
|
||||
author_id,
|
||||
category_id,
|
||||
author_id: authorId,
|
||||
category_id: categoryId,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M800 0H600V200H400V400H200V600H0V800H200H400H600H800V600V400V200V0Z" fill="#2773E4" fill-opacity="0.42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M200 0H0V200V400V600H200V400H400V200H600V0H400H200Z" fill="#2773E4" fill-opacity="0.42"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -31,23 +31,6 @@
|
||||
transform: translateX($space-medium);
|
||||
}
|
||||
|
||||
.menu-list-enter-active,
|
||||
.menu-list-leave-active {
|
||||
transition: opacity 0.3s var(--ease-out-cubic),
|
||||
transform 0.2s var(--ease-out-cubic);
|
||||
}
|
||||
|
||||
.menu-list-leave-to {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
transform: translateX($space-small);
|
||||
}
|
||||
|
||||
.menu-list-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(-$space-small);
|
||||
}
|
||||
|
||||
.slide-up-enter-active {
|
||||
transition: all 0.3s var(--ease-in-cubic);
|
||||
}
|
||||
@@ -65,7 +48,8 @@
|
||||
.menu-slide-enter-active,
|
||||
.menu-slide-leave-active {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.25s var(--ease-in-cubic),
|
||||
transition:
|
||||
transform 0.25s var(--ease-in-cubic),
|
||||
opacity 0.15s var(--ease-in-cubic);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~vue2-datepicker/scss/index';
|
||||
@import 'vue-datepicker-next/scss/index';
|
||||
|
||||
.date-picker {
|
||||
// To be removed one SLA reports date picker is created
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
|
||||
.formulate-input {
|
||||
.formulate-input-errors {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
// Inter,
|
||||
html,
|
||||
body {
|
||||
font-family:
|
||||
'PlusJakarta',
|
||||
'Inter',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
@@ -23,7 +24,7 @@ body {
|
||||
}
|
||||
|
||||
.app-wrapper {
|
||||
@apply h-full flex-grow-0 min-h-0 w-full;
|
||||
@apply h-screen flex-grow-0 min-h-0 w-full;
|
||||
|
||||
.button--fixed-top {
|
||||
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~widget/assets/scss/mixins';
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
@import 'widget/assets/scss/mixins';
|
||||
|
||||
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
|
||||
@@ -89,9 +89,6 @@ $swift-ease-out-duration: .4s !default;
|
||||
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
|
||||
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
|
||||
|
||||
// Ionicons
|
||||
$ionicons-font-path: '~ionicons/fonts';
|
||||
|
||||
// Transitions
|
||||
$transition-ease-in: all 0.250s ease-in;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
@@ -34,19 +33,183 @@
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
.tooltip {
|
||||
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900;
|
||||
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
|
||||
}
|
||||
|
||||
#app {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
.hide {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.n-blue-border {
|
||||
@apply border-n-blue-border;
|
||||
}
|
||||
|
||||
.n-blue-text {
|
||||
@apply text-n-blue-text;
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
/* NEXT COLORS START */
|
||||
:root {
|
||||
/* slate */
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
--ruby-4: 255 220 225;
|
||||
--ruby-5: 255 206 214;
|
||||
--ruby-6: 248 191 200;
|
||||
--ruby-7: 239 172 184;
|
||||
--ruby-8: 229 146 163;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
--amber-4: 255 238 156;
|
||||
--amber-5: 251 229 119;
|
||||
--amber-6: 243 214 115;
|
||||
--amber-7: 233 193 98;
|
||||
--amber-8: 226 163 54;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
--teal-4: 204 243 234;
|
||||
--teal-5: 184 234 224;
|
||||
--teal-6: 161 222 210;
|
||||
--teal-7: 131 205 193;
|
||||
--teal-8: 83 185 171;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 1;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--text-blue: 8, 109, 224, 1;
|
||||
|
||||
/* alpha is added by default */
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
/* slate */
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
--ruby-4: 78 19 37;
|
||||
--ruby-5: 94 26 46;
|
||||
--ruby-6: 111 37 57;
|
||||
--ruby-7: 136 52 71;
|
||||
--ruby-8: 179 68 90;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
--amber-4: 63 39 0;
|
||||
--amber-5: 77 48 0;
|
||||
--amber-6: 92 61 5;
|
||||
--amber-7: 113 79 25;
|
||||
--amber-8: 143 100 36;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
--teal-4: 2 59 55;
|
||||
--teal-5: 8 72 67;
|
||||
--teal-6: 20 87 80;
|
||||
--teal-7: 28 105 97;
|
||||
--teal-8: 32 126 115;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--text-blue: 126, 182, 255, 1;
|
||||
|
||||
/* alpha is added by default */
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
/* NEXT COLORS END */
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
:root {
|
||||
--color-amber-25: 254 253 251;
|
||||
--color-amber-50: 255 249 237;
|
||||
@@ -389,3 +552,15 @@
|
||||
--color-orange-900: 255 224 194;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.875rem] min-h-[2.875rem];
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/fonts/plus-jakarta';
|
||||
@import 'shared/assets/stylesheets/animations';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@import 'shared/assets/stylesheets/font-weights';
|
||||
@import 'shared/assets/stylesheets/shadows';
|
||||
@import 'shared/assets/stylesheets/border-radius';
|
||||
@import 'variables';
|
||||
|
||||
@import 'vue-multiselect/dist/vue-multiselect.min.css';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
@import 'mixins';
|
||||
@import 'helper-classes';
|
||||
|
||||
@import 'typography';
|
||||
@import 'layout';
|
||||
@import 'animations';
|
||||
|
||||
@import 'widgets/buttons';
|
||||
@import 'widgets/base';
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
|
||||
@import 'widget/assets/scss/reset';
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'widget/assets/scss/utilities';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'PlusJakarta', sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['click']);
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import ArticleCard from './ArticleCard.vue';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
id: 1,
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: 1729048936,
|
||||
author: {
|
||||
name: 'John',
|
||||
thumbnail: 'https://i.pravatar.cc/300',
|
||||
},
|
||||
category: {
|
||||
title: 'Marketing',
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
},
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: 1729048936,
|
||||
author: {
|
||||
name: 'John',
|
||||
thumbnail: 'https://i.pravatar.cc/300',
|
||||
},
|
||||
category: {
|
||||
title: 'Development',
|
||||
slug: 'development',
|
||||
icon: '🛠️',
|
||||
},
|
||||
views: 1400,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: 1729048936,
|
||||
author: {
|
||||
name: 'Fernando',
|
||||
thumbnail: 'https://i.pravatar.cc/300',
|
||||
},
|
||||
category: {
|
||||
title: 'Finance',
|
||||
slug: 'finance',
|
||||
icon: '💰',
|
||||
},
|
||||
views: 4300,
|
||||
},
|
||||
];
|
||||
|
||||
const category = {
|
||||
name: 'Marketing',
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/ArticleCard"
|
||||
:layout="{ type: 'grid', width: '700px' }"
|
||||
>
|
||||
<Variant title="Article Card">
|
||||
<div
|
||||
v-for="(article, index) in articles"
|
||||
:key="index"
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<ArticleCard
|
||||
:id="article.id"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:author="article.author"
|
||||
:category="category"
|
||||
:views="article.views"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import {
|
||||
ARTICLE_MENU_ITEMS,
|
||||
ARTICLE_MENU_OPTIONS,
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
author: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
category: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
views: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||
(acc, [key, item]) => {
|
||||
acc[key] = { ...item, label: t(item.label) };
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const statusItems = (
|
||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||
).map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
});
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return '!text-n-slate-12';
|
||||
case 'draft':
|
||||
return '!text-n-amber-11';
|
||||
default:
|
||||
return '!text-n-teal-11';
|
||||
}
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED');
|
||||
case 'draft':
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT');
|
||||
default:
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED');
|
||||
}
|
||||
});
|
||||
|
||||
const categoryName = computed(() => {
|
||||
if (props.category?.slug) {
|
||||
return `${props.category.icon} ${props.category.name}`;
|
||||
}
|
||||
return t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
|
||||
);
|
||||
});
|
||||
|
||||
const authorName = computed(() => {
|
||||
return props.author?.name || props.author?.availableName || '-';
|
||||
});
|
||||
|
||||
const authorThumbnailSrc = computed(() => {
|
||||
return props.author?.thumbnail;
|
||||
});
|
||||
|
||||
const lastUpdatedAt = computed(() => {
|
||||
return dynamicTime(props.updatedAt);
|
||||
});
|
||||
|
||||
const handleArticleAction = ({ action, value }) => {
|
||||
isOpen.value = false;
|
||||
emit('articleAction', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const handleClick = id => {
|
||||
emit('openArticle', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="relative group" @click.stop>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs font-medium bg-n-alpha-2 hover:bg-n-alpha-1 !h-6 rounded-md border-0 !px-2 !py-0.5"
|
||||
:label="statusText"
|
||||
:class="statusTextColor"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ authorName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<FluentIcon icon="eye-show" size="18" />
|
||||
<span class="text-sm">
|
||||
{{
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
|
||||
count: views,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import CategoryCard from './CategoryCard.vue';
|
||||
const categories = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: 5,
|
||||
slug: 'getting-started',
|
||||
icon: '🚀',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Marketing',
|
||||
description: '',
|
||||
articlesCount: 4,
|
||||
slug: 'marketing',
|
||||
icon: '📈',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/CategoryCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Category Card">
|
||||
<div
|
||||
v-for="(category, index) in categories"
|
||||
:key="index"
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<CategoryCard
|
||||
:id="category.id"
|
||||
:slug="category.slug"
|
||||
:title="category.title"
|
||||
:description="category.description"
|
||||
:articles-count="category.articlesCount"
|
||||
:icon="category.icon"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articlesCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const categoryMenuItems = [
|
||||
{
|
||||
label: 'Edit',
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
icon: 'edit',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'delete',
|
||||
},
|
||||
];
|
||||
|
||||
const categoryTitleWithIcon = computed(() => {
|
||||
return `${props.icon} ${props.title}`;
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
return props.description ? props.description : 'No description added';
|
||||
});
|
||||
|
||||
const hasDescription = computed(() => {
|
||||
return props.description.length > 0;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
};
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value, id: props.id });
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ categoryTitleWithIcon }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center truncate border rounded-lg min-w-fit text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
|
||||
>
|
||||
{{
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
|
||||
count: articlesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative group" @click.stop>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="more-vertical"
|
||||
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span
|
||||
class="text-sm line-clamp-3"
|
||||
:class="
|
||||
hasDescription
|
||||
? 'text-slate-500 dark:text-slate-400'
|
||||
: 'text-slate-400 dark:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import ArticleEmptyState from './ArticleEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/EmptyState/ArticleEmptyState"
|
||||
:layout="{ type: 'single', width: '1100px' }"
|
||||
>
|
||||
<Variant title="Article Empty State">
|
||||
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
|
||||
<ArticleEmptyState />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import articleContent from 'dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 overflow-hidden">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articleContent.slice(0, 5)"
|
||||
:id="article.id"
|
||||
:key="`article-${index}`"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:updated-at="article.updatedAt"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="showButton">
|
||||
<Button
|
||||
variant="default"
|
||||
:label="buttonLabel"
|
||||
icon="add"
|
||||
@click="onClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
import categoryContent from 'dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-4">
|
||||
<CategoryCard
|
||||
v-for="category in categoryContent"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<CategoryCard
|
||||
v-for="category in categoryContent.reverse()"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+142
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import PortalEmptyState from './PortalEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/EmptyState/PortalEmptyState"
|
||||
:layout="{ type: 'single', width: '1100px' }"
|
||||
>
|
||||
<Variant title="Portal Empty State">
|
||||
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
|
||||
<PortalEmptyState />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import articleContent from './portalEmptyStateContent';
|
||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||
|
||||
const createPortalDialogRef = ref(null);
|
||||
const openDialog = () => {
|
||||
createPortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
router.push({
|
||||
name: 'portals_articles_index',
|
||||
params: { portalSlug, locale },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articleContent"
|
||||
:id="article.id"
|
||||
:key="`article-${index}`"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:updated-at="article.updatedAt"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="(article, index) in articleContent.reverse()"
|
||||
:id="article.id"
|
||||
:key="`article-${index}`"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:updated-at="article.updatedAt"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="default"
|
||||
:label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
|
||||
icon="add"
|
||||
@click="openDialog"
|
||||
/>
|
||||
<CreatePortalDialog
|
||||
ref="createPortalDialogRef"
|
||||
@create="onPortalCreate"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+172
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
|
||||
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
|
||||
|
||||
defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
showHeaderTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showPaginationFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const createPortalDialogRef = ref(null);
|
||||
|
||||
const showPortalSwitcher = ref(false);
|
||||
|
||||
const portals = useMapGetter('portals/allPortals');
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const activePortalName = computed(() => {
|
||||
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
|
||||
?.name;
|
||||
});
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
};
|
||||
const togglePortalSwitcher = () => {
|
||||
showPortalSwitcher.value = !showPortalSwitcher.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
>
|
||||
<span
|
||||
v-if="activePortalName"
|
||||
class="text-xl font-medium text-slate-900 dark:text-white"
|
||||
>
|
||||
{{ activePortalName }}
|
||||
</span>
|
||||
<div v-if="activePortalName" class="relative group">
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<Button
|
||||
icon="chevron-lucide-down"
|
||||
variant="ghost"
|
||||
icon-lib="lucide"
|
||||
class="!w-6 !h-6 group-hover:bg-n-solid-2 !p-0.5 rounded-md"
|
||||
@click="togglePortalSwitcher"
|
||||
/>
|
||||
|
||||
<PortalSwitcher
|
||||
v-if="showPortalSwitcher"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showPortalSwitcher = false"
|
||||
@create-portal="createPortalDialogRef.dialogRef.open()"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<CreatePortalDialog ref="createPortalDialogRef" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto py-3">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
<!-- Do not remove this slot. It can be used to add dialogs. -->
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/LocaleCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Locale Card">
|
||||
<div class="px-10 py-4 bg-white dark:bg-slate-900">
|
||||
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
locale: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articleCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
categoryCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showDropdownMenu = ref(false);
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
...item,
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value });
|
||||
showDropdownMenu.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="ltr:pr-2 rtl:pl-2">
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
>
|
||||
{{ locale }} ({{ localeCode }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
|
||||
articleCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
|
||||
categoryCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<OnClickOutside @trigger="showDropdownMenu = false">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="more-vertical"
|
||||
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||
@click="showDropdownMenu = !showDropdownMenu"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
||||
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
|
||||
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
isUpdating: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaved: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'saveArticle',
|
||||
'goBack',
|
||||
'setAuthor',
|
||||
'setCategory',
|
||||
'previewArticle',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => props.article.title,
|
||||
set: value => {
|
||||
saveArticle({ title: value });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => props.article.content,
|
||||
set: content => {
|
||||
saveArticle({ content });
|
||||
},
|
||||
});
|
||||
|
||||
const onClickGoBack = () => {
|
||||
emit('goBack');
|
||||
};
|
||||
|
||||
const setAuthorId = authorId => {
|
||||
emit('setAuthor', authorId);
|
||||
};
|
||||
|
||||
const setCategoryId = categoryId => {
|
||||
emit('setCategory', categoryId);
|
||||
};
|
||||
|
||||
const previewArticle = () => {
|
||||
emit('previewArticle');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<ArticleEditorHeader
|
||||
:is-updating="isUpdating"
|
||||
:is-saved="isSaved"
|
||||
:status="article.status"
|
||||
:article-id="article.id"
|
||||
@go-back="onClickGoBack"
|
||||
@preview-article="previewArticle"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
||||
<TextArea
|
||||
v-model="articleTitle"
|
||||
auto-height
|
||||
min-height="4rem"
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
:article="article"
|
||||
@save-article="saveArticle"
|
||||
@set-author="setAuthorId"
|
||||
@set-category="setCategoryId"
|
||||
/>
|
||||
</div>
|
||||
<FullEditor
|
||||
v-model="articleContent"
|
||||
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep {
|
||||
.ProseMirror .empty-node::before {
|
||||
@apply text-slate-200 dark:text-slate-500 text-base;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar {
|
||||
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const openAgentsList = ref(false);
|
||||
const openCategoryList = ref(false);
|
||||
const openProperties = ref(false);
|
||||
const selectedAuthorId = ref(null);
|
||||
const selectedCategoryId = ref(null);
|
||||
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
|
||||
const isNewArticle = computed(() => !props.article?.id);
|
||||
|
||||
const currentUser = computed(() =>
|
||||
agents.value.find(agent => agent.id === currentUserId.value)
|
||||
);
|
||||
|
||||
const categorySlugFromRoute = computed(() => route.params.categorySlug);
|
||||
|
||||
const author = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
return selectedAuthorId.value
|
||||
? agents.value.find(agent => agent.id === selectedAuthorId.value)
|
||||
: currentUser.value;
|
||||
}
|
||||
return props.article?.author || null;
|
||||
});
|
||||
|
||||
const authorName = computed(
|
||||
() => author.value?.name || author.value?.available_name || '-'
|
||||
);
|
||||
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
|
||||
|
||||
const agentList = computed(() => {
|
||||
return (
|
||||
agents.value
|
||||
?.map(({ name, id, thumbnail }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
thumbnail: { name, src: thumbnail },
|
||||
isSelected:
|
||||
id === props.article?.author?.id ||
|
||||
id === (selectedAuthorId.value || currentUserId.value),
|
||||
action: 'assignAuthor',
|
||||
}))
|
||||
// Sort the list by isSelected first, then by name(label)
|
||||
.toSorted((a, b) => {
|
||||
if (a.isSelected !== b.isSelected) {
|
||||
return Number(b.isSelected) - Number(a.isSelected);
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
const hasAgentList = computed(() => {
|
||||
return agents.value?.length > 1;
|
||||
});
|
||||
|
||||
const findCategoryFromSlug = slug => {
|
||||
return categories.value?.find(category => category.slug === slug);
|
||||
};
|
||||
|
||||
const assignCategoryFromSlug = slug => {
|
||||
const categoryFromSlug = findCategoryFromSlug(slug);
|
||||
if (categoryFromSlug) {
|
||||
selectedCategoryId.value = categoryFromSlug.id;
|
||||
return categoryFromSlug;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedCategory = computed(() => {
|
||||
if (isNewArticle.value) {
|
||||
if (categorySlugFromRoute.value) {
|
||||
const categoryFromSlug = assignCategoryFromSlug(
|
||||
categorySlugFromRoute.value
|
||||
);
|
||||
if (categoryFromSlug) return categoryFromSlug;
|
||||
}
|
||||
return selectedCategoryId.value
|
||||
? categories.value.find(
|
||||
category => category.id === selectedCategoryId.value
|
||||
)
|
||||
: categories.value[0] || null;
|
||||
}
|
||||
return categories.value.find(
|
||||
category => category.id === props.article?.category?.id
|
||||
);
|
||||
});
|
||||
|
||||
const categoryList = computed(() => {
|
||||
return (
|
||||
categories.value
|
||||
.map(({ name, id, icon }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
emoji: icon,
|
||||
isSelected: isNewArticle.value
|
||||
? id === (selectedCategoryId.value || selectedCategory.value?.id)
|
||||
: id === props.article?.category?.id,
|
||||
action: 'assignCategory',
|
||||
}))
|
||||
// Sort categories by isSelected
|
||||
.toSorted((a, b) => Number(b.isSelected) - Number(a.isSelected))
|
||||
);
|
||||
});
|
||||
|
||||
const hasCategoryMenuItems = computed(() => {
|
||||
return categoryList.value?.length > 0;
|
||||
});
|
||||
|
||||
const handleArticleAction = ({ action, value }) => {
|
||||
const actions = {
|
||||
assignAuthor: () => {
|
||||
if (isNewArticle.value) {
|
||||
selectedAuthorId.value = value;
|
||||
emit('setAuthor', value);
|
||||
} else {
|
||||
emit('saveArticle', { author_id: value });
|
||||
}
|
||||
openAgentsList.value = false;
|
||||
},
|
||||
assignCategory: () => {
|
||||
if (isNewArticle.value) {
|
||||
selectedCategoryId.value = value;
|
||||
emit('setCategory', value);
|
||||
} else {
|
||||
emit('saveArticle', { category_id: value });
|
||||
}
|
||||
openCategoryList.value = false;
|
||||
},
|
||||
};
|
||||
|
||||
actions[action]?.();
|
||||
};
|
||||
|
||||
const updateMeta = meta => {
|
||||
emit('saveArticle', { meta });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (categorySlugFromRoute.value && isNewArticle.value) {
|
||||
// Assign category from slug if there is one
|
||||
const categoryFromSlug = findCategoryFromSlug(categorySlugFromRoute.value);
|
||||
if (categoryFromSlug) {
|
||||
handleArticleAction({
|
||||
action: 'assignCategory',
|
||||
value: categoryFromSlug?.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="openAgentsList = false">
|
||||
<Button
|
||||
:label="authorName"
|
||||
variant="ghost"
|
||||
class="!px-0 font-normal"
|
||||
text-variant="info"
|
||||
@click="openAgentsList = !openAgentsList"
|
||||
>
|
||||
<template #leftPrefix>
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:size="20"
|
||||
:src="authorThumbnailSrc"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openAgentsList && hasAgentList"
|
||||
:menu-items="agentList"
|
||||
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
|
||||
@action="handleArticleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="openCategoryList = false">
|
||||
<Button
|
||||
:label="
|
||||
selectedCategory?.name ||
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
|
||||
"
|
||||
:emoji="selectedCategory?.icon || ''"
|
||||
:icon="!selectedCategory?.icon ? 'play-shape' : ''"
|
||||
variant="ghost"
|
||||
class="!px-2 font-normal"
|
||||
text-variant="info"
|
||||
@click="openCategoryList = !openCategoryList"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="openCategoryList && hasCategoryMenuItems"
|
||||
:menu-items="categoryList"
|
||||
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
|
||||
@action="handleArticleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="openProperties = false">
|
||||
<Button
|
||||
:label="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES')
|
||||
"
|
||||
icon="add"
|
||||
variant="ghost"
|
||||
:disabled="isNewArticle"
|
||||
text-variant="info"
|
||||
class="!px-2 font-normal"
|
||||
@click="openProperties = !openProperties"
|
||||
/>
|
||||
<ArticleEditorProperties
|
||||
v-if="openProperties"
|
||||
:article="article"
|
||||
class="right-0 z-[100] mt-2 xl:left-0 top-full"
|
||||
@save-article="updateMeta"
|
||||
@close="openProperties = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import {
|
||||
ARTICLE_EDITOR_STATUS_OPTIONS,
|
||||
ARTICLE_STATUSES,
|
||||
ARTICLE_MENU_ITEMS,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isUpdating: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSaved: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
articleId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goBack', 'previewArticle']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const isArticlePublishing = ref(false);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const showArticleActionMenu = ref(false);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
|
||||
return statusOptions.map(option => {
|
||||
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
|
||||
return {
|
||||
label: t(label),
|
||||
value,
|
||||
action: 'update-status',
|
||||
icon,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const statusText = computed(() =>
|
||||
t(
|
||||
`HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.STATUS.${props.isUpdating ? 'SAVING' : 'SAVED'}`
|
||||
)
|
||||
);
|
||||
|
||||
const onClickGoBack = () => emit('goBack');
|
||||
|
||||
const previewArticle = () => emit('previewArticle');
|
||||
|
||||
const getStatusMessage = (status, isSuccess) => {
|
||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||
const statusMap = {
|
||||
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
|
||||
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
|
||||
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
|
||||
};
|
||||
|
||||
return statusMap[status]
|
||||
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const updateArticleStatus = async ({ value }) => {
|
||||
showArticleActionMenu.value = false;
|
||||
const status = getArticleStatus(value);
|
||||
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
isArticlePublishing.value = true;
|
||||
}
|
||||
const { portalSlug } = route.params;
|
||||
|
||||
try {
|
||||
await store.dispatch('articles/update', {
|
||||
portalSlug,
|
||||
articleId: props.articleId,
|
||||
status,
|
||||
});
|
||||
|
||||
useAlert(getStatusMessage(status, true));
|
||||
|
||||
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
|
||||
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
isArticlePublishing.value = false;
|
||||
} catch (error) {
|
||||
useAlert(error?.message ?? getStatusMessage(status, false));
|
||||
isArticlePublishing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between h-20">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.BACK_TO_ARTICLES')"
|
||||
icon="chevron-lucide-left"
|
||||
icon-lib="lucide"
|
||||
variant="link"
|
||||
text-variant="info"
|
||||
size="sm"
|
||||
@click="onClickGoBack"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
v-if="isUpdating || isSaved"
|
||||
class="text-xs font-medium transition-all duration-300 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="!articleId"
|
||||
@click="previewArticle"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||
size="sm"
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||
:is-loading="isArticlePublishing"
|
||||
:disabled="
|
||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||
!articleId ||
|
||||
isArticlePublishing
|
||||
"
|
||||
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
|
||||
/>
|
||||
<div class="relative">
|
||||
<OnClickOutside @trigger="showArticleActionMenu = false">
|
||||
<Button
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
size="sm"
|
||||
:disabled="!articleId"
|
||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showArticleActionMenu"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-2 ltr:right-0 rtl:left-0 top-full"
|
||||
@action="updateArticleStatus($event)"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<script setup>
|
||||
import { reactive, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['saveArticle', 'close']);
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const updateState = () => {
|
||||
state.title = props.article.meta?.title || '';
|
||||
state.description = props.article.meta?.description || '';
|
||||
state.tags = props.article.meta?.tags || [];
|
||||
};
|
||||
|
||||
watch(
|
||||
state,
|
||||
newState => {
|
||||
saveArticle({
|
||||
title: newState.title,
|
||||
description: newState.description,
|
||||
tags: newState.tags,
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
updateState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col absolute w-[400px] bg-n-alpha-3 backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.ARTICLE_PROPERTIES'
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<Button
|
||||
icon="dismiss"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="w-8 hover:text-n-slate-11"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<div class="flex justify-between w-full gap-4 py-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-[224px]"
|
||||
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
|
||||
custom-text-area-class="max-h-[150px]"
|
||||
auto-height
|
||||
min-height="3rem"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-2 py-2">
|
||||
<InlineInput
|
||||
v-model="state.title"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:label="
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
|
||||
"
|
||||
custom-label-class="min-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-2 py-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
|
||||
}}
|
||||
</label>
|
||||
<TagInput
|
||||
v-model="state.tags"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-[224px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import {
|
||||
ARTICLE_TABS,
|
||||
CATEGORY_ALL,
|
||||
ARTICLE_TABS_OPTIONS,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
meta: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'tabChange',
|
||||
'localeChange',
|
||||
'categoryChange',
|
||||
'newArticle',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const isLocaleMenuOpen = ref(false);
|
||||
|
||||
const countKey = tab => {
|
||||
if (tab.value === 'all') {
|
||||
return 'articlesCount';
|
||||
}
|
||||
return `${tab.value}ArticlesCount`;
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
return ARTICLE_TABS_OPTIONS.map(tab => ({
|
||||
label: t(`HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.TABS.${tab.key}`),
|
||||
value: tab.value,
|
||||
count: props.meta[countKey(tab)],
|
||||
}));
|
||||
});
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
const tabParam = route.params.tab || ARTICLE_TABS.ALL;
|
||||
return tabs.value.findIndex(tab => tab.value === tabParam);
|
||||
});
|
||||
|
||||
const activeCategoryName = computed(() => {
|
||||
const activeCategory = props.categories.find(
|
||||
category => category.slug === route.params.categorySlug
|
||||
);
|
||||
|
||||
if (activeCategory) {
|
||||
const { icon, name } = activeCategory;
|
||||
return `${icon} ${name}`;
|
||||
}
|
||||
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
|
||||
});
|
||||
|
||||
const activeLocaleName = computed(() => {
|
||||
return props.allowedLocales.find(
|
||||
locale => locale.code === route.params.locale
|
||||
)?.name;
|
||||
});
|
||||
|
||||
const categoryMenuItems = computed(() => {
|
||||
const defaultMenuItem = {
|
||||
label: t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL'),
|
||||
value: CATEGORY_ALL,
|
||||
action: 'filter',
|
||||
};
|
||||
|
||||
const categoryItems = props.categories.map(category => ({
|
||||
label: category.name,
|
||||
value: category.slug,
|
||||
action: 'filter',
|
||||
emoji: category.icon,
|
||||
}));
|
||||
|
||||
const hasCategorySlug = !!route.params.categorySlug;
|
||||
|
||||
return hasCategorySlug ? [defaultMenuItem, ...categoryItems] : categoryItems;
|
||||
});
|
||||
|
||||
const hasCategoryMenuItems = computed(() => {
|
||||
return categoryMenuItems.value?.length > 0;
|
||||
});
|
||||
|
||||
const localeMenuItems = computed(() => {
|
||||
return props.allowedLocales.map(locale => ({
|
||||
label: locale.name,
|
||||
value: locale.code,
|
||||
action: 'filter',
|
||||
}));
|
||||
});
|
||||
|
||||
const hasMoreThanOneLocaleMenuItems = computed(() => {
|
||||
return localeMenuItems.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleLocaleAction = ({ value }) => {
|
||||
emit('localeChange', value);
|
||||
isLocaleMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const handleCategoryAction = ({ value }) => {
|
||||
emit('categoryChange', value);
|
||||
isCategoryMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const handleNewArticle = () => {
|
||||
emit('newArticle');
|
||||
};
|
||||
|
||||
const handleTabChange = value => {
|
||||
emit('tabChange', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="hasMoreThanOneLocaleMenuItems" class="relative group">
|
||||
<OnClickOutside @trigger="isLocaleMenuOpen = false">
|
||||
<Button
|
||||
:label="activeLocaleName"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isLocaleMenuOpen"
|
||||
:menu-items="localeMenuItems"
|
||||
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
@action="handleLocaleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-if="hasCategoryMenuItems" class="relative group">
|
||||
<OnClickOutside @trigger="isCategoryMenuOpen = false">
|
||||
<Button
|
||||
:label="activeCategoryName"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
class="max-w-48"
|
||||
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isCategoryMenuOpen"
|
||||
:menu-items="categoryMenuItems"
|
||||
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
@action="handleCategoryAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="handleNewArticle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isCategoryArticles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
// Enable dragging only for category articles and when there's more than one article
|
||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
||||
});
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const openArticle = id => {
|
||||
const { tab, categorySlug, locale } = route.params;
|
||||
if (props.isCategoryArticles) {
|
||||
router.push({
|
||||
name: 'portals_categories_articles_edit',
|
||||
params: { articleSlug: id },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
name: 'portals_articles_edit',
|
||||
params: {
|
||||
articleSlug: id,
|
||||
tab,
|
||||
categorySlug,
|
||||
locale,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
// Create a map of article IDs to their new positions
|
||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
||||
obj[key] = sortedArticlePositions[index];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
onReorder(reorderedGroup);
|
||||
};
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const getStatusMessage = (status, isSuccess) => {
|
||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||
const statusMap = {
|
||||
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
|
||||
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
|
||||
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
|
||||
};
|
||||
|
||||
return statusMap[status]
|
||||
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const updateMeta = () => {
|
||||
const { portalSlug, locale } = route.params;
|
||||
return store.dispatch('portals/show', { portalSlug, locale });
|
||||
};
|
||||
|
||||
const handleArticleAction = async (action, { status, id }) => {
|
||||
const { portalSlug } = route.params;
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
await store.dispatch('articles/delete', {
|
||||
portalSlug,
|
||||
articleId: id,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
|
||||
} else {
|
||||
await store.dispatch('articles/update', {
|
||||
portalSlug,
|
||||
articleId: id,
|
||||
status,
|
||||
});
|
||||
useAlert(getStatusMessage(status, true));
|
||||
|
||||
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
|
||||
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
|
||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||
}
|
||||
}
|
||||
await updateMeta();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
(action === 'delete'
|
||||
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
|
||||
: getStatusMessage(status, false));
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const updateArticle = ({ action, value, id }) => {
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
|
||||
// Watch for changes in the articles prop and update the localArticles ref
|
||||
watch(
|
||||
() => props.articles,
|
||||
newArticles => {
|
||||
localArticles.value = newArticles;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Draggable
|
||||
v-model="localArticles"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
ghost-class="article-ghost-class"
|
||||
class="w-full h-full space-y-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none rounded-2xl">
|
||||
<ArticleCard
|
||||
:id="element.id"
|
||||
:key="element.id"
|
||||
:title="element.title"
|
||||
:status="element.status"
|
||||
:author="element.author"
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-ghost-class {
|
||||
@apply opacity-50 bg-n-solid-1;
|
||||
}
|
||||
</style>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import ArticlesPage from './ArticlesPage.vue';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Articles">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<ArticlesPage :articles="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue';
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
portalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
meta: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isCategoryArticles: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isFetching = useMapGetter('articles/isFetching');
|
||||
|
||||
const hasNoArticles = computed(
|
||||
() => !isFetching.value && !props.articles.length
|
||||
);
|
||||
|
||||
const isLoading = computed(() => isFetching.value || isSwitchingPortal.value);
|
||||
|
||||
const totalArticlesCount = computed(() => props.meta.allArticlesCount);
|
||||
|
||||
const hasNoArticlesInPortal = computed(
|
||||
() => totalArticlesCount.value === 0 && !props.isCategoryArticles
|
||||
);
|
||||
|
||||
const shouldShowPaginationFooter = computed(() => {
|
||||
return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value);
|
||||
});
|
||||
|
||||
const updateRoute = newParams => {
|
||||
const { portalSlug, locale, tab, categorySlug } = route.params;
|
||||
router.push({
|
||||
name: 'portals_articles_index',
|
||||
params: {
|
||||
portalSlug,
|
||||
locale: newParams.locale ?? locale,
|
||||
tab: newParams.tab ?? tab,
|
||||
categorySlug: newParams.categorySlug ?? categorySlug,
|
||||
...newParams,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const articlesCount = computed(() => {
|
||||
const { tab } = route.params;
|
||||
const { meta } = props;
|
||||
const countMap = {
|
||||
'': meta.articlesCount,
|
||||
mine: meta.mineArticlesCount,
|
||||
draft: meta.draftArticlesCount,
|
||||
archived: meta.archivedArticlesCount,
|
||||
};
|
||||
return Number(countMap[tab] || countMap['']);
|
||||
});
|
||||
|
||||
const showArticleHeaderControls = computed(
|
||||
() =>
|
||||
!hasNoArticlesInPortal.value &&
|
||||
!props.isCategoryArticles &&
|
||||
!isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const showCategoryHeaderControls = computed(
|
||||
() => props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
|
||||
const getEmptyStateText = type => {
|
||||
if (props.isCategoryArticles) {
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
|
||||
}
|
||||
const tabName = route.params.tab?.toUpperCase() || 'ALL';
|
||||
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.${tabName}.${type}`);
|
||||
};
|
||||
|
||||
const getEmptyStateTitle = computed(() => getEmptyStateText('TITLE'));
|
||||
const getEmptyStateSubtitle = computed(() => getEmptyStateText('SUBTITLE'));
|
||||
|
||||
const handleTabChange = tab =>
|
||||
updateRoute({ tab: tab.value === ARTICLE_TABS.ALL ? '' : tab.value });
|
||||
|
||||
const handleCategoryAction = value =>
|
||||
updateRoute({ categorySlug: value === CATEGORY_ALL ? '' : value });
|
||||
|
||||
const handleLocaleAction = value => {
|
||||
updateRoute({ locale: value, categorySlug: '' });
|
||||
emit('fetchPortal', value);
|
||||
};
|
||||
const handlePageChange = page => emit('pageChange', page);
|
||||
|
||||
const navigateToNewArticlePage = () => {
|
||||
const { categorySlug, locale } = route.params;
|
||||
router.push({
|
||||
name: 'portals_articles_new',
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-items="articlesCount"
|
||||
:items-per-page="25"
|
||||
:header="portalName"
|
||||
:show-pagination-footer="shouldShowPaginationFooter"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<ArticleHeaderControls
|
||||
v-if="showArticleHeaderControls"
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:meta="meta"
|
||||
@tab-change="handleTabChange"
|
||||
@locale-change="handleLocaleAction"
|
||||
@category-change="handleCategoryAction"
|
||||
@new-article="navigateToNewArticlePage"
|
||||
/>
|
||||
<CategoryHeaderControls
|
||||
v-else-if="showCategoryHeaderControls"
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:has-selected-category="isCategoryArticles"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<ArticleList
|
||||
v-else-if="!hasNoArticles"
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
/>
|
||||
<ArticleEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
:title="getEmptyStateTitle"
|
||||
:subtitle="getEmptyStateSubtitle"
|
||||
:show-button="hasNoArticlesInPortal"
|
||||
:button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
|
||||
"
|
||||
@click="navigateToNewArticlePage"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import CategoriesPage from './CategoriesPage.vue';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: 'Marketing',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
|
||||
articlesCount: '4',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
title: 'Development',
|
||||
description: '',
|
||||
articlesCount: '5',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'roadmap',
|
||||
title: '🛣️ Roadmap',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '3',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: '💰 Finance',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Categories">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<CategoriesPage :categories="categories" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import CategoryEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue';
|
||||
import EditCategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['fetchCategories']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const editCategoryDialog = ref(null);
|
||||
const selectedCategory = ref(null);
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
const hasCategories = computed(() => props.categories?.length > 0);
|
||||
|
||||
const updateRoute = (newParams, routeName) => {
|
||||
const { accountId, portalSlug, locale } = route.params;
|
||||
const baseParams = { accountId, portalSlug, locale };
|
||||
|
||||
router.push({
|
||||
name: routeName,
|
||||
params: {
|
||||
...baseParams,
|
||||
...newParams,
|
||||
categorySlug: newParams.categorySlug,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openCategoryArticles = slug => {
|
||||
updateRoute({ categorySlug: slug }, 'portals_categories_articles_index');
|
||||
};
|
||||
|
||||
const handleLocaleChange = value => {
|
||||
updateRoute({ locale: value }, 'portals_categories_index');
|
||||
emit('fetchCategories', value);
|
||||
};
|
||||
|
||||
async function deleteCategory(category) {
|
||||
try {
|
||||
await store.dispatch('categories/delete', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
categoryId: category.id,
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.DELETE_CATEGORY, {
|
||||
hasArticles: category?.meta?.articles_count > 0,
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = ({ action, id, category: categoryData }) => {
|
||||
if (action === 'edit') {
|
||||
selectedCategory.value = props.categories.find(
|
||||
category => category.id === id
|
||||
);
|
||||
editCategoryDialog.value.dialogRef.open();
|
||||
}
|
||||
if (action === 'delete') {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<CategoryHeaderControls
|
||||
:categories="categories"
|
||||
:is-category-articles="false"
|
||||
:allowed-locales="allowedLocales"
|
||||
@locale-change="handleLocaleChange"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<CategoryList
|
||||
v-else-if="hasCategories"
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.SUBTITLE')"
|
||||
/>
|
||||
</template>
|
||||
<EditCategoryDialog
|
||||
ref="editCategoryDialog"
|
||||
:allowed-locales="allowedLocales"
|
||||
:selected-category="selectedCategory"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'edit',
|
||||
validator: value => ['edit', 'create'].includes(value),
|
||||
},
|
||||
selectedCategory: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
portalName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeLocaleName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeLocaleCode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const handleCategory = async formData => {
|
||||
const { id, name, slug, icon, description, locale } = formData;
|
||||
const categoryData = { name, icon, slug, description };
|
||||
|
||||
if (props.mode === 'create') {
|
||||
categoryData.locale = locale;
|
||||
} else {
|
||||
categoryData.id = id;
|
||||
}
|
||||
|
||||
try {
|
||||
const action = props.mode === 'edit' ? 'update' : 'create';
|
||||
const payload = {
|
||||
portalSlug: route.params.portalSlug,
|
||||
categoryObj: categoryData,
|
||||
};
|
||||
|
||||
if (action === 'update') {
|
||||
payload.categoryId = id;
|
||||
}
|
||||
|
||||
await store.dispatch(`categories/${action}`, payload);
|
||||
|
||||
const successMessage = t(
|
||||
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.SUCCESS_MESSAGE`
|
||||
);
|
||||
useAlert(successMessage);
|
||||
|
||||
const trackEvent =
|
||||
props.mode === 'edit'
|
||||
? PORTALS_EVENTS.EDIT_CATEGORY
|
||||
: PORTALS_EVENTS.CREATE_CATEGORY;
|
||||
useTrack(
|
||||
trackEvent,
|
||||
props.mode === 'create'
|
||||
? { hasDescription: Boolean(description) }
|
||||
: undefined
|
||||
);
|
||||
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
t(
|
||||
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.ERROR_MESSAGE`
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
{{
|
||||
t(
|
||||
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<CategoryForm
|
||||
:mode="mode"
|
||||
:selected-category="selectedCategory"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
:portal-name="portalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
@submit="handleCategory"
|
||||
@cancel="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
<script setup>
|
||||
import {
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['edit', 'create'].includes(value),
|
||||
},
|
||||
selectedCategory: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
activeLocaleCode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showActionButtons: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
portalName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeLocaleName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const isCreating = useMapGetter('categories/isCreating');
|
||||
|
||||
const isUpdatingCategory = computed(() => {
|
||||
const id = props.selectedCategory?.id;
|
||||
if (id) return getters['categories/uiFlags'].value(id)?.isUpdating;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isEmojiPickerOpen = ref(false);
|
||||
|
||||
const state = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
locale: '',
|
||||
});
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
const rules = {
|
||||
name: { required, minLength: minLength(1) },
|
||||
slug: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
const nameError = computed(() =>
|
||||
v$.value.name.$error
|
||||
? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const slugError = computed(() =>
|
||||
v$.value.slug.$error
|
||||
? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const slugHelpText = computed(() => {
|
||||
const { portalSlug, locale } = route.params;
|
||||
return t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.HELP_TEXT', {
|
||||
portalSlug,
|
||||
localeCode: locale,
|
||||
categorySlug: state.slug,
|
||||
});
|
||||
});
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
state.icon = emoji;
|
||||
isEmojiPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormCorrect = await v$.value.$validate();
|
||||
if (!isFormCorrect) return;
|
||||
|
||||
emit('submit', { ...state });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => state.name,
|
||||
() => {
|
||||
if (!isEditMode.value) {
|
||||
state.slug = convertToCategorySlug(state.name);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedCategory,
|
||||
newCategory => {
|
||||
if (props.mode === 'edit' && newCategory) {
|
||||
const { id, name, icon, slug, description } = newCategory;
|
||||
Object.assign(state, { id, name, icon, slug, description });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.mode === 'create') {
|
||||
state.locale = props.activeLocaleCode;
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ state, isSubmitDisabled });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex items-center justify-start gap-8 px-4 py-2 border rounded-lg border-slate-50 dark:border-slate-700/50"
|
||||
>
|
||||
<div class="flex flex-col items-start w-full gap-2 py-2">
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
|
||||
</span>
|
||||
<span class="text-sm text-slate-800 dark:text-slate-100">
|
||||
{{ portalName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="justify-start w-px h-10 bg-slate-50 dark:bg-slate-700/50" />
|
||||
<div class="flex flex-col w-full gap-2 py-2">
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
|
||||
</span>
|
||||
<span
|
||||
:title="`${activeLocaleName} (${activeLocaleCode})`"
|
||||
class="text-sm line-clamp-1 text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{{ `${activeLocaleName} (${activeLocaleCode})` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="state.name"
|
||||
:label="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.LABEL')
|
||||
"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.PLACEHOLDER')
|
||||
"
|
||||
:message="nameError"
|
||||
:message-type="nameError ? 'error' : 'info'"
|
||||
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12 !bg-slate-25 dark:!bg-slate-900"
|
||||
>
|
||||
<template #prefix>
|
||||
<OnClickOutside @trigger="isEmojiPickerOpen = false">
|
||||
<Button
|
||||
:label="state.icon"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:icon="!state.icon ? 'emoji-add' : ''"
|
||||
class="!h-[38px] !w-[38px] absolute top-[31px] !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="left-0 top-16"
|
||||
show-remove-button
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.slug"
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.LABEL')"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.PLACEHOLDER')
|
||||
"
|
||||
:disabled="isEditMode"
|
||||
:message="slugError ? slugError : slugHelpText"
|
||||
:message-type="slugError ? 'error' : 'info'"
|
||||
custom-input-class="!h-10 !bg-slate-25 dark:!bg-slate-900 "
|
||||
/>
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:label="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.LABEL')
|
||||
"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
show-character-count
|
||||
custom-text-area-wrapper-class="!bg-slate-25 dark:!bg-slate-900"
|
||||
/>
|
||||
<div
|
||||
v-if="showActionButtons"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
|
||||
text-variant="default"
|
||||
class="w-full bg-n-alpha-2 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.${mode.toUpperCase()}`
|
||||
)
|
||||
"
|
||||
class="w-full"
|
||||
:disabled="isSubmitDisabled || isCreating || isUpdatingCategory"
|
||||
:is-loading="isCreating || isUpdatingCategory"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.emoji-dialog::before {
|
||||
@apply hidden;
|
||||
}
|
||||
</style>
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useStoreGetters } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
hasSelectedCategory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['localeChange']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isLocaleMenuOpen = ref(false);
|
||||
const isCreateCategoryDialogOpen = ref(false);
|
||||
const isEditCategoryDialogOpen = ref(false);
|
||||
|
||||
const currentPortalSlug = computed(() => {
|
||||
return route.params.portalSlug;
|
||||
});
|
||||
|
||||
const currentPortal = computed(() => {
|
||||
const slug = currentPortalSlug.value;
|
||||
if (slug) return getters['portals/portalBySlug'].value(slug);
|
||||
|
||||
return getters['portals/allPortals'].value[0];
|
||||
});
|
||||
|
||||
const currentPortalName = computed(() => {
|
||||
return currentPortal.value?.name;
|
||||
});
|
||||
|
||||
const activeLocale = computed(() => {
|
||||
return props.allowedLocales.find(
|
||||
locale => locale.code === route.params.locale
|
||||
);
|
||||
});
|
||||
|
||||
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
|
||||
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
|
||||
|
||||
const localeMenuItems = computed(() => {
|
||||
return props.allowedLocales.map(locale => ({
|
||||
label: locale.name,
|
||||
value: locale.code,
|
||||
action: 'filter',
|
||||
}));
|
||||
});
|
||||
|
||||
const selectedCategory = computed(() =>
|
||||
props.categories.find(category => category.slug === route.params.categorySlug)
|
||||
);
|
||||
|
||||
const selectedCategoryName = computed(() => {
|
||||
return selectedCategory.value?.name;
|
||||
});
|
||||
|
||||
const selectedCategoryCount = computed(
|
||||
() => selectedCategory.value?.meta?.articles_count || 0
|
||||
);
|
||||
|
||||
const selectedCategoryEmoji = computed(() => {
|
||||
return selectedCategory.value?.icon;
|
||||
});
|
||||
|
||||
const categoriesCount = computed(() => props.categories?.length);
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
label: t(
|
||||
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.CATEGORY_LOCALE',
|
||||
{ localeCode: activeLocaleCode.value }
|
||||
),
|
||||
link: '#',
|
||||
},
|
||||
];
|
||||
if (selectedCategory.value) {
|
||||
items.push({
|
||||
label: t(
|
||||
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.ACTIVE_CATEGORY',
|
||||
{
|
||||
categoryName: selectedCategoryName.value,
|
||||
categoryCount: selectedCategoryCount.value,
|
||||
}
|
||||
),
|
||||
emoji: selectedCategoryEmoji.value,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleLocaleAction = ({ value }) => {
|
||||
emit('localeChange', value);
|
||||
isLocaleMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = () => {
|
||||
const { categorySlug, ...otherParams } = route.params;
|
||||
router.push({
|
||||
name: 'portals_categories_index',
|
||||
params: otherParams,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div v-if="!hasSelectedCategory" class="flex items-center gap-4">
|
||||
<div class="relative group">
|
||||
<OnClickOutside @trigger="isLocaleMenuOpen = false">
|
||||
<Button
|
||||
:label="activeLocaleName"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="isLocaleMenuOpen"
|
||||
:menu-items="localeMenuItems"
|
||||
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
|
||||
@action="handleLocaleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" />
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{{
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
|
||||
n: categoriesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Breadcrumb
|
||||
v-else
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
/>
|
||||
<div v-if="!hasSelectedCategory" class="relative">
|
||||
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isCreateCategoryDialogOpen"
|
||||
mode="create"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isCreateCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="isEditCategoryDialogOpen = !isEditCategoryDialogOpen"
|
||||
/>
|
||||
<CategoryDialog
|
||||
v-if="isEditCategoryDialogOpen"
|
||||
:selected-category="selectedCategory"
|
||||
:portal-name="currentPortalName"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
@close="isEditCategoryDialogOpen = false"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
};
|
||||
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedCategory: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const categoryFormRef = ref(null);
|
||||
|
||||
const isUpdatingCategory = computed(() => {
|
||||
const id = props.selectedCategory?.id;
|
||||
if (id) return getters['categories/uiFlags'].value(id)?.isUpdating;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isInvalidForm = computed(() => {
|
||||
if (!categoryFormRef.value) return false;
|
||||
const { isSubmitDisabled } = categoryFormRef.value;
|
||||
return isSubmitDisabled;
|
||||
});
|
||||
|
||||
const activeLocale = computed(() => {
|
||||
return props.allowedLocales.find(
|
||||
locale => locale.code === route.params.locale
|
||||
);
|
||||
});
|
||||
|
||||
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
|
||||
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
|
||||
|
||||
const onUpdateCategory = async () => {
|
||||
if (!categoryFormRef.value) return;
|
||||
const { state } = categoryFormRef.value;
|
||||
const { id, name, slug, icon, description } = state;
|
||||
const categoryData = { name, icon, slug, description };
|
||||
categoryData.id = id;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
portalSlug: route.params.portalSlug,
|
||||
categoryObj: categoryData,
|
||||
categoryId: id,
|
||||
};
|
||||
|
||||
await store.dispatch(`categories/update`, payload);
|
||||
|
||||
const successMessage = t(
|
||||
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.SUCCESS_MESSAGE`
|
||||
);
|
||||
useAlert(successMessage);
|
||||
dialogRef.value.close();
|
||||
|
||||
const trackEvent = PORTALS_EVENTS.EDIT_CATEGORY;
|
||||
useTrack(trackEvent, { hasDescription: Boolean(description) });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
t(`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the dialogRef to the parent component
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.EDIT')"
|
||||
:description="
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.DESCRIPTION')
|
||||
"
|
||||
:is-loading="isUpdatingCategory"
|
||||
:disable-confirm-button="isUpdatingCategory || isInvalidForm"
|
||||
@confirm="onUpdateCategory"
|
||||
>
|
||||
<template #form>
|
||||
<CategoryForm
|
||||
ref="categoryFormRef"
|
||||
mode="edit"
|
||||
:selected-category="selectedCategory"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
:portal-name="route.params.portalSlug"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:show-action-buttons="false"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import allLocales from 'shared/constants/locales.js';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
return {
|
||||
value: key,
|
||||
label: `${allLocales[key]} (${key})`,
|
||||
};
|
||||
})
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal.slug,
|
||||
config: { allowed_locales: updatedLocales },
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
|
||||
localeAdded: selectedLocale.value,
|
||||
totalLocales: updatedLocales.length,
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.ERROR_MESSAGE')
|
||||
);
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the dialogRef to the parent component
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="locales"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
locales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
portal: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
};
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
let alertMessage = '';
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal.slug,
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
},
|
||||
});
|
||||
|
||||
alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`);
|
||||
} catch (error) {
|
||||
alertMessage =
|
||||
error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`);
|
||||
} finally {
|
||||
useAlert(alertMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
|
||||
newLocale: localeCode,
|
||||
from: route.name,
|
||||
});
|
||||
};
|
||||
|
||||
const deletePortalLocale = ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
|
||||
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
|
||||
deletedLocale: localeCode,
|
||||
from: route.name,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="w-full h-full space-y-4">
|
||||
<LocaleCard
|
||||
v-for="(locale, index) in locales"
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import LocalesPage from './LocalesPage.vue';
|
||||
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
isDefault: true,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
isDefault: false,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
isDefault: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Locales">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<LocalesPage :locales="locales" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
locales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const addLocaleDialogRef = ref(null);
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
const openAddLocaleDialog = () => {
|
||||
addLocaleDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="openAddLocaleDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isSwitchingPortal"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<LocaleList v-else :locales="locales" :portal="portal" />
|
||||
</template>
|
||||
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'add',
|
||||
},
|
||||
customDomain: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['addCustomDomain']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const formState = reactive({
|
||||
customDomain: props.customDomain,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.customDomain,
|
||||
newVal => {
|
||||
formState.customDomain = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const handleDialogConfirm = () => {
|
||||
emit('addCustomDomain', formState.customDomain);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="
|
||||
t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_HEADER`
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t(
|
||||
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_CONFIRM_BUTTON_LABEL`
|
||||
)
|
||||
"
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<template #form>
|
||||
<Input
|
||||
v-model="formState.customDomain"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
defineProps({
|
||||
activePortalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['deletePortal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const handleDialogConfirm = () => {
|
||||
emit('deletePortal');
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.HEADER',
|
||||
{
|
||||
portalName: activePortalName,
|
||||
}
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.DESCRIPTION'
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.CONFIRM_BUTTON_LABEL'
|
||||
)
|
||||
"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</template>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
customDomain: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const domain = computed(() => {
|
||||
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
|
||||
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
|
||||
});
|
||||
|
||||
const subdomainCNAME = computed(
|
||||
() => `${props.customDomain} CNAME ${domain.value}`
|
||||
);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const handleDialogConfirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
|
||||
)
|
||||
"
|
||||
:show-cancel-button="false"
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<template #description>
|
||||
<p class="mb-0 text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<span
|
||||
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
|
||||
>
|
||||
{{ subdomainCNAME }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
<script setup>
|
||||
import { reactive, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
headerText: '',
|
||||
pageTitle: '',
|
||||
slug: '',
|
||||
widgetColor: '',
|
||||
homePageLink: '',
|
||||
liveChatWidgetInboxId: '',
|
||||
logoUrl: '',
|
||||
avatarBlobId: '',
|
||||
});
|
||||
|
||||
const originalState = reactive({ ...state });
|
||||
|
||||
const liveChatWidgets = computed(() => {
|
||||
const inboxes = store.getters['inboxes/getInboxes'];
|
||||
return inboxes
|
||||
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
|
||||
.map(inbox => ({
|
||||
value: inbox.id,
|
||||
label: inbox.name,
|
||||
}));
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: { required, minLength: minLength(2) },
|
||||
slug: { required },
|
||||
homePageLink: { shouldBeUrl },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const nameError = computed(() =>
|
||||
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
||||
);
|
||||
|
||||
const slugError = computed(() =>
|
||||
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
|
||||
);
|
||||
|
||||
const homePageLinkError = computed(() =>
|
||||
v$.value.homePageLink.$error
|
||||
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const isUpdatingPortal = computed(() => {
|
||||
const slug = props.activePortal?.slug;
|
||||
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.activePortal,
|
||||
newVal => {
|
||||
if (newVal && !props.isFetching) {
|
||||
Object.assign(state, {
|
||||
name: newVal.name,
|
||||
headerText: newVal.header_text,
|
||||
pageTitle: newVal.page_title,
|
||||
widgetColor: newVal.color,
|
||||
homePageLink: newVal.homepage_link,
|
||||
slug: newVal.slug,
|
||||
liveChatWidgetInboxId: newVal.inbox?.id,
|
||||
});
|
||||
if (newVal.logo) {
|
||||
const {
|
||||
logo: { file_url: logoURL, blob_id: blobId },
|
||||
} = newVal;
|
||||
state.logoUrl = logoURL;
|
||||
state.avatarBlobId = blobId;
|
||||
} else {
|
||||
state.logoUrl = '';
|
||||
state.avatarBlobId = '';
|
||||
}
|
||||
Object.assign(originalState, state);
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return JSON.stringify(state) !== JSON.stringify(originalState);
|
||||
});
|
||||
|
||||
const handleUpdatePortal = () => {
|
||||
const portal = {
|
||||
id: props.activePortal?.id,
|
||||
slug: state.slug,
|
||||
name: state.name,
|
||||
color: state.widgetColor,
|
||||
page_title: state.pageTitle,
|
||||
header_text: state.headerText,
|
||||
homepage_link: state.homePageLink,
|
||||
blob_id: state.avatarBlobId,
|
||||
inbox_id: state.liveChatWidgetInboxId,
|
||||
};
|
||||
emit('updatePortal', portal);
|
||||
};
|
||||
|
||||
async function uploadLogoToStorage({ file }) {
|
||||
try {
|
||||
const { fileUrl, blobId } = await uploadFile(file);
|
||||
if (fileUrl) {
|
||||
state.logoUrl = fileUrl;
|
||||
state.avatarBlobId = blobId;
|
||||
}
|
||||
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_ERROR'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLogo() {
|
||||
try {
|
||||
const portalSlug = props.activePortal?.slug;
|
||||
await store.dispatch('portals/deleteLogo', {
|
||||
portalSlug,
|
||||
});
|
||||
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarUpload = file => {
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
uploadLogoToStorage(file);
|
||||
} else {
|
||||
const errorKey =
|
||||
'HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SIZE_ERROR';
|
||||
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = () => {
|
||||
state.logoUrl = '';
|
||||
state.avatarBlobId = '';
|
||||
deleteLogo();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
|
||||
</label>
|
||||
<EditableAvatar
|
||||
label="Avatar"
|
||||
:src="state.logoUrl"
|
||||
:name="state.name"
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="state.name"
|
||||
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||
class="w-[432px]"
|
||||
:message-type="nameError ? 'error' : 'info'"
|
||||
:message="nameError"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
@input="v$.name.$touch()"
|
||||
@blur="v$.name.$touch()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="state.headerText"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER')
|
||||
"
|
||||
class="w-[432px]"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="state.pageTitle"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER')
|
||||
"
|
||||
class="w-[432px]"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="state.homePageLink"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER')
|
||||
"
|
||||
class="w-[432px]"
|
||||
:message-type="homePageLinkError ? 'error' : 'info'"
|
||||
:message="homePageLinkError"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
@input="v$.homePageLink.$touch()"
|
||||
@blur="v$.homePageLink.$touch()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="state.slug"
|
||||
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.PLACEHOLDER')"
|
||||
class="w-[432px]"
|
||||
:message-type="slugError ? 'error' : 'info'"
|
||||
:message="slugError || buildPortalURL(state.slug)"
|
||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
||||
@input="v$.slug.$touch()"
|
||||
@blur="v$.slug.$touch()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.liveChatWidgetInboxId"
|
||||
:options="liveChatWidgets"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
|
||||
"
|
||||
:message="
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
|
||||
"
|
||||
class="[&>button]:w-[432px] !w-[432px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
|
||||
</label>
|
||||
<div class="w-[432px] justify-start">
|
||||
<ColorPicker v-model="state.widgetColor" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end w-full gap-2">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
|
||||
:disabled="!hasChanges || isUpdatingPortal || v$.$invalid"
|
||||
:is-loading="isUpdatingPortal"
|
||||
@click="handleUpdatePortal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
|
||||
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortalConfiguration']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const addCustomDomainDialogRef = ref(null);
|
||||
const dnsConfigurationDialogRef = ref(null);
|
||||
const updatedDomainAddress = ref('');
|
||||
|
||||
const customDomainAddress = computed(
|
||||
() => props.activePortal?.custom_domain || ''
|
||||
);
|
||||
|
||||
const updatePortalConfiguration = customDomain => {
|
||||
const portal = {
|
||||
id: props.activePortal?.id,
|
||||
custom_domain: customDomain,
|
||||
};
|
||||
emit('updatePortalConfiguration', portal);
|
||||
addCustomDomainDialogRef.value.dialogRef.close();
|
||||
if (customDomain) {
|
||||
updatedDomainAddress.value = customDomain;
|
||||
dnsConfigurationDialogRef.value.dialogRef.open();
|
||||
}
|
||||
};
|
||||
|
||||
const closeDNSConfigurationDialog = () => {
|
||||
updatedDomainAddress.value = '';
|
||||
dnsConfigurationDialogRef.value.dialogRef.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER'
|
||||
)
|
||||
}}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div
|
||||
v-if="customDomainAddress"
|
||||
class="flex items-center w-full h-8 gap-4"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ customDomainAddress }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-full">
|
||||
<Button
|
||||
v-if="customDomainAddress"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
@click="addCustomDomainDialogRef.dialogRef.open()"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
@click="addCustomDomainDialogRef.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddCustomDomainDialog
|
||||
ref="addCustomDomainDialogRef"
|
||||
:mode="customDomainAddress ? 'edit' : 'add'"
|
||||
:custom-domain="customDomainAddress"
|
||||
@add-custom-domain="updatePortalConfiguration"
|
||||
/>
|
||||
<DNSConfigurationDialog
|
||||
ref="dnsConfigurationDialogRef"
|
||||
:custom-domain="updatedDomainAddress || customDomainAddress"
|
||||
@confirm="closeDNSConfigurationDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import PortalSettings from './PortalSettings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
|
||||
<PortalSettings />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portals: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'updatePortal',
|
||||
'updatePortalConfiguration',
|
||||
'deletePortal',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const confirmDeletePortalDialogRef = ref(null);
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
|
||||
const activePortal = computed(() => {
|
||||
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
|
||||
});
|
||||
|
||||
const activePortalName = computed(() => activePortal.value?.name || '');
|
||||
|
||||
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
|
||||
|
||||
const handleUpdatePortal = portal => {
|
||||
emit('updatePortal', portal);
|
||||
};
|
||||
|
||||
const handleUpdatePortalConfiguration = portal => {
|
||||
emit('updatePortalConfiguration', portal);
|
||||
};
|
||||
|
||||
const openConfirmDeletePortalDialog = () => {
|
||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleDeletePortal = () => {
|
||||
emit('deletePortal', activePortal.value);
|
||||
confirmDeletePortalDialogRef.value.dialogRef.close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="activePortal"
|
||||
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
|
||||
>
|
||||
<PortalBaseSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal="handleUpdatePortal"
|
||||
/>
|
||||
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||
<PortalConfigurationSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
|
||||
)
|
||||
}}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
|
||||
{
|
||||
portalName: activePortalName,
|
||||
}
|
||||
)
|
||||
"
|
||||
variant="destructive"
|
||||
class="w-56"
|
||||
@click="openConfirmDeletePortalDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ConfirmDeletePortalDialog
|
||||
ref="confirmDeletePortalDialogRef"
|
||||
:active-portal-name="activePortalName"
|
||||
@delete-portal="handleDeletePortal"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const isCreatingPortal = useMapGetter('portals/isCreatingPortal');
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
domain: '',
|
||||
logoUrl: '',
|
||||
avatarBlobId: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: { required, minLength: minLength(2) },
|
||||
slug: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const nameError = computed(() =>
|
||||
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
||||
);
|
||||
|
||||
const slugError = computed(() =>
|
||||
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
|
||||
);
|
||||
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
watch(
|
||||
() => state.name,
|
||||
() => {
|
||||
state.slug = convertToCategorySlug(state.name);
|
||||
}
|
||||
);
|
||||
|
||||
const redirectToPortal = portal => {
|
||||
emit('create', { slug: portal.slug, locale: 'en' });
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
Object.keys(state).forEach(key => {
|
||||
state[key] = '';
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
const createPortal = async portal => {
|
||||
try {
|
||||
await store.dispatch('portals/create', portal);
|
||||
dialogRef.value.close();
|
||||
|
||||
const analyticsPayload = {
|
||||
has_custom_domain: Boolean(portal.custom_domain),
|
||||
};
|
||||
useTrack(PORTALS_EVENTS.ONBOARD_BASIC_INFORMATION, analyticsPayload);
|
||||
useTrack(PORTALS_EVENTS.CREATE_PORTAL, analyticsPayload);
|
||||
|
||||
useAlert(
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.SUCCESS_MESSAGE')
|
||||
);
|
||||
|
||||
resetForm();
|
||||
redirectToPortal(portal);
|
||||
} catch (error) {
|
||||
dialogRef.value.close();
|
||||
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
const isFormCorrect = await v$.value.$validate();
|
||||
if (!isFormCorrect) return;
|
||||
|
||||
const portal = {
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
custom_domain: state.domain,
|
||||
blob_id: state.avatarBlobId || null,
|
||||
color: '#2781F6', // The default color is set to Chatwoot brand color
|
||||
};
|
||||
await createPortal(portal);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.CREATE_PORTAL_DIALOG.TITLE')"
|
||||
:confirm-button-label="
|
||||
t('HELP_CENTER.CREATE_PORTAL_DIALOG.CONFIRM_BUTTON_LABEL')
|
||||
"
|
||||
:description="t('HELP_CENTER.CREATE_PORTAL_DIALOG.DESCRIPTION')"
|
||||
:disable-confirm-button="isSubmitDisabled || isCreatingPortal"
|
||||
:is-loading="isCreatingPortal"
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
v-model="state.name"
|
||||
type="text"
|
||||
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
|
||||
:message-type="nameError ? 'error' : 'info'"
|
||||
:message="
|
||||
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
v-model="state.slug"
|
||||
type="text"
|
||||
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
|
||||
:message-type="slugError ? 'error' : 'info'"
|
||||
:message="slugError || buildPortalURL(state.slug)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import PortalSwitcher from './PortalSwitcher.vue';
|
||||
|
||||
const portals = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot Help Center',
|
||||
articles: 67,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'help-center',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Chatwoot Handbook',
|
||||
articles: 42,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'handbook',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Developer Documentation',
|
||||
articles: 89,
|
||||
domain: 'dev.chatwoot.com',
|
||||
slug: 'docs',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/PortalSwitcher"
|
||||
:layout="{ type: 'grid', width: '510px' }"
|
||||
>
|
||||
<Variant title="Portal Switcher">
|
||||
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
|
||||
<PortalSwitcher
|
||||
:portals="portals"
|
||||
header="Choose a Portal"
|
||||
description="Select from available help center portals"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createPortal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const DEFAULT_ROUTE = 'portals_articles_index';
|
||||
const CATEGORY_ROUTE = 'portals_categories_index';
|
||||
const CATEGORY_SUB_ROUTES = [
|
||||
'portals_categories_articles_index',
|
||||
'portals_categories_articles_edit',
|
||||
];
|
||||
|
||||
const portals = useMapGetter('portals/allPortals');
|
||||
|
||||
const currentPortalSlug = computed(() => route.params.portalSlug);
|
||||
|
||||
const portalLink = computed(() => {
|
||||
return buildPortalURL(currentPortalSlug.value);
|
||||
});
|
||||
|
||||
const isPortalActive = portal => {
|
||||
return portal.slug === currentPortalSlug.value;
|
||||
};
|
||||
|
||||
const getPortalThumbnailSrc = portal => {
|
||||
return portal?.logo?.file_url || '';
|
||||
};
|
||||
|
||||
const fetchPortalAndItsCategories = async (slug, locale) => {
|
||||
await store.dispatch('portals/switchPortal', true);
|
||||
await store.dispatch('portals/index');
|
||||
const selectedPortalParam = {
|
||||
portalSlug: slug,
|
||||
locale,
|
||||
};
|
||||
await store.dispatch('portals/show', selectedPortalParam);
|
||||
await store.dispatch('categories/index', selectedPortalParam);
|
||||
await store.dispatch('agents/get');
|
||||
await store.dispatch('portals/switchPortal', false);
|
||||
};
|
||||
|
||||
const handlePortalChange = async portal => {
|
||||
if (isPortalActive(portal)) return;
|
||||
const {
|
||||
slug,
|
||||
meta: { default_locale: defaultLocale },
|
||||
} = portal;
|
||||
emit('close');
|
||||
await fetchPortalAndItsCategories(slug, defaultLocale);
|
||||
const targetRouteName = CATEGORY_SUB_ROUTES.includes(route.name)
|
||||
? CATEGORY_ROUTE
|
||||
: route.name || DEFAULT_ROUTE;
|
||||
router.push({
|
||||
name: targetRouteName,
|
||||
params: {
|
||||
portalSlug: slug,
|
||||
locale: defaultLocale,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openCreatePortalDialog = () => {
|
||||
emit('createPortal');
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onClickPreviewPortal = () => {
|
||||
window.open(portalLink.value, '_blank');
|
||||
};
|
||||
|
||||
const redirectToPortalHomePage = () => {
|
||||
router.push({
|
||||
name: 'portals_index',
|
||||
params: {
|
||||
navigationPath: DEFAULT_ROUTE,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2
|
||||
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
|
||||
@click="redirectToPortalHomePage"
|
||||
>
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
|
||||
</h2>
|
||||
<Button
|
||||
icon="arrow-up-right-lucide"
|
||||
variant="ghost"
|
||||
icon-lib="lucide"
|
||||
size="sm"
|
||||
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
|
||||
@click="onClickPreviewPortal"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreatePortalDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<Button
|
||||
v-for="(portal, index) in portals"
|
||||
:key="index"
|
||||
:label="portal.name"
|
||||
variant="ghost"
|
||||
:icon="isPortalActive(portal) ? 'checkmark-lucide' : ''"
|
||||
icon-lib="lucide"
|
||||
icon-position="right"
|
||||
class="!justify-start !px-2 !py-2 hover:!bg-n-alpha-2 [&>svg]:text-n-teal-10 [&>svg]:w-5 [&>svg]:h-5 h-9"
|
||||
size="sm"
|
||||
@click="handlePortalChange(portal)"
|
||||
>
|
||||
<template #leftPrefix>
|
||||
<Thumbnail
|
||||
v-if="portal"
|
||||
:author="portal"
|
||||
:name="portal.name"
|
||||
:size="20"
|
||||
:src="getPortalThumbnailSrc(portal)"
|
||||
:show-author-name="false"
|
||||
icon-name="building-lucide"
|
||||
/>
|
||||
</template>
|
||||
<template #rightPrefix>
|
||||
<span class="text-sm truncate text-n-slate-11">
|
||||
{{ portal.custom_domain || '' }}
|
||||
</span>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import Avatar from './Avatar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Default with upload">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
allow-upload
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Invalid or empty SRC">
|
||||
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
|
||||
<Avatar name="Bruce Wayne" allow-upload />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Rounded Full">
|
||||
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
allow-upload
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Sizes">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
|
||||
:size="48"
|
||||
class="bg-green-300 dark:bg-green-900"
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
:size="72"
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
|
||||
:size="96"
|
||||
class="bg-woot-300 dark:bg-woot-900"
|
||||
allow-upload
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 32,
|
||||
},
|
||||
allowUpload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
roundedFull: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => {
|
||||
if (!value) return true;
|
||||
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
|
||||
},
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['upload']);
|
||||
|
||||
const isImageValid = ref(true);
|
||||
|
||||
function invalidateCurrentImage() {
|
||||
isImageValid.value = false;
|
||||
}
|
||||
|
||||
const initials = computed(() => {
|
||||
const splitNames = props.name.split(' ');
|
||||
|
||||
if (splitNames.length > 1) {
|
||||
const firstName = splitNames[0];
|
||||
const lastName = splitNames[splitNames.length - 1];
|
||||
|
||||
return firstName[0] + lastName[0];
|
||||
}
|
||||
|
||||
const firstName = splitNames[0];
|
||||
return firstName[0];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
isImageValid.value = true;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="relative inline"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="badge" :size>
|
||||
<div
|
||||
class="rounded-full w-2.5 h-2.5 absolute z-20"
|
||||
:style="{
|
||||
top: `${size - 10}px`,
|
||||
left: `${size - 10}px`,
|
||||
}"
|
||||
:class="{
|
||||
'bg-n-teal-10': status === 'online',
|
||||
'bg-n-amber-10': status === 'busy',
|
||||
'bg-n-slate-10': status === 'offline',
|
||||
}"
|
||||
/>
|
||||
</slot>
|
||||
<span
|
||||
role="img"
|
||||
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
|
||||
:class="{
|
||||
'rounded-full': roundedFull,
|
||||
'rounded-xl': !roundedFull,
|
||||
}"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="src && isImageValid"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
@error="invalidateCurrentImage"
|
||||
/>
|
||||
<span v-else>
|
||||
{{ initials }}
|
||||
</span>
|
||||
<div
|
||||
v-if="allowUpload"
|
||||
role="button"
|
||||
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white dark:text-white size-4"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import EditableAvatar from './EditableAvatar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<EditableAvatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Sizes">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<EditableAvatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
|
||||
:size="48"
|
||||
class="bg-green-300 dark:bg-green-900"
|
||||
/>
|
||||
<EditableAvatar
|
||||
:size="72"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
/>
|
||||
<EditableAvatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
|
||||
:size="96"
|
||||
class="bg-woot-300 dark:bg-woot-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 72,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload', 'delete']);
|
||||
|
||||
const avatarSize = computed(() => `${props.size}px`);
|
||||
const iconSize = computed(() => `${props.size / 2}px`);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const imgError = ref(false);
|
||||
|
||||
const shouldShowImage = computed(() => props.src && !imgError.value);
|
||||
|
||||
const handleUploadAvatar = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleImageUpload = event => {
|
||||
const [file] = event.target.files;
|
||||
if (file) {
|
||||
emit('upload', {
|
||||
file,
|
||||
url: file ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAvatar = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = null;
|
||||
}
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
const handleDismiss = event => {
|
||||
event.stopPropagation();
|
||||
handleDeleteAvatar();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
|
||||
:style="{ width: avatarSize, height: avatarSize }"
|
||||
>
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
:src="src"
|
||||
:alt="name || 'avatar'"
|
||||
class="object-cover w-full h-full shadow-sm rounded-xl"
|
||||
@error="imgError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2"
|
||||
>
|
||||
<FluentIcon
|
||||
icon="building-lucide"
|
||||
icon-lib="lucide"
|
||||
:size="iconSize"
|
||||
class="dark:text-n-brand/50 text-n-brand/30"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="src"
|
||||
class="absolute z-20 flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
<FluentIcon icon="dismiss" :size="16" class="text-n-slate-11" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<FluentIcon
|
||||
icon="upload-lucide"
|
||||
icon-lib="lucide"
|
||||
:size="iconSize"
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user