Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce276f888a |
+129
-109
@@ -1,87 +1,84 @@
|
|||||||
version: 2.1
|
# Ruby CircleCI 2.0 configuration file
|
||||||
orbs:
|
#
|
||||||
node: circleci/node@6.1.0
|
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
|
||||||
|
#
|
||||||
|
version: 2
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
machine:
|
docker:
|
||||||
image: ubuntu-2204:2024.05.1
|
# specify the version you desire here
|
||||||
resource_class: large
|
- image: cimg/ruby:3.3.3-browsers
|
||||||
|
|
||||||
|
# Specify service dependencies here if necessary
|
||||||
|
# CircleCI maintains a library of pre-built images
|
||||||
|
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||||
|
- image: cimg/postgres:15.3
|
||||||
|
- image: cimg/redis:6.2.6
|
||||||
environment:
|
environment:
|
||||||
RAILS_LOG_TO_STDOUT: false
|
- RAILS_LOG_TO_STDOUT: false
|
||||||
COVERAGE: true
|
- COVERAGE: true
|
||||||
LOG_LEVEL: warn
|
- LOG_LEVEL: warn
|
||||||
parallelism: 4
|
parallelism: 4
|
||||||
|
resource_class: large
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
|
||||||
node-version: '20.12'
|
|
||||||
- node/install-pnpm
|
|
||||||
- node/install-packages:
|
|
||||||
pkg-manager: pnpm
|
|
||||||
override-ci-command: pnpm i
|
|
||||||
- run: node --version
|
|
||||||
- run: pnpm --version
|
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install System Dependencies
|
name: Configure Bundler
|
||||||
command: |
|
command: |
|
||||||
sudo apt-get update
|
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
source $BASH_ENV
|
||||||
libpq-dev \
|
|
||||||
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
|
gem install bundler
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Install Application Dependencies
|
name: Which bundler?
|
||||||
|
command: bundle -v
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Swap node versions
|
||||||
command: |
|
command: |
|
||||||
source ~/.rvm/scripts/rvm
|
set +e
|
||||||
bundle install
|
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
|
||||||
# pnpm install
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
|
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||||
|
nvm install v20
|
||||||
|
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
|
||||||
|
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
|
||||||
|
|
||||||
|
# Run bundler
|
||||||
|
# Load installed gems from cache if possible, bundle install then save cache
|
||||||
|
# Multiple caches are used to increase the chance of a cache hit
|
||||||
|
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||||
|
|
||||||
|
- run: bundle install --frozen --path ~/.bundle
|
||||||
|
- save_cache:
|
||||||
|
paths:
|
||||||
|
- ~/.bundle
|
||||||
|
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||||
|
|
||||||
|
# Only necessary if app uses webpacker or yarn in some other way
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
|
||||||
|
- chatwoot-yarn-
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: yarn
|
||||||
|
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
|
||||||
|
|
||||||
|
# Store yarn / webpacker cache
|
||||||
|
- save_cache:
|
||||||
|
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
|
||||||
|
paths:
|
||||||
|
- ~/.cache/yarn
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Download cc-test-reporter
|
name: Download cc-test-reporter
|
||||||
@@ -89,8 +86,12 @@ jobs:
|
|||||||
mkdir -p ~/tmp
|
mkdir -p ~/tmp
|
||||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||||
chmod +x ~/tmp/cc-test-reporter
|
chmod +x ~/tmp/cc-test-reporter
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: ~/tmp
|
||||||
|
paths:
|
||||||
|
- cc-test-reporter
|
||||||
|
|
||||||
# Swagger verification
|
# verify swagger specification
|
||||||
- run:
|
- run:
|
||||||
name: Verify swagger API specification
|
name: Verify swagger API specification
|
||||||
command: |
|
command: |
|
||||||
@@ -103,62 +104,45 @@ jobs:
|
|||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
# we remove the FRONTED_URL from the .env before running the tests
|
|
||||||
- run:
|
|
||||||
name: Database Setup and Configure Environment Variables
|
|
||||||
command: |
|
|
||||||
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
|
|
||||||
sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql
|
|
||||||
chmod 644 ${PWD}/.circleci/setup_chatwoot.sql
|
|
||||||
mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/
|
|
||||||
sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql
|
|
||||||
cp .env.example .env
|
|
||||||
sed -i '/^FRONTEND_URL/d' .env
|
|
||||||
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
|
|
||||||
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
|
|
||||||
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
|
||||||
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
|
||||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
|
||||||
|
|
||||||
# Database setup
|
# Database setup
|
||||||
- run:
|
- run: bundle exec rake db:create
|
||||||
name: Run DB migrations
|
- run: bundle exec rake db:schema:load
|
||||||
command: bundle exec rails db:chatwoot_prepare
|
|
||||||
|
|
||||||
# Bundle audit
|
|
||||||
- run:
|
- run:
|
||||||
name: Bundle audit
|
name: Bundle audit
|
||||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||||
|
|
||||||
# Rubocop linting
|
|
||||||
- run:
|
- run:
|
||||||
name: Rubocop
|
name: Rubocop
|
||||||
command: bundle exec rubocop
|
command: bundle exec rubocop
|
||||||
|
|
||||||
# ESLint linting
|
# - run:
|
||||||
|
# name: Brakeman
|
||||||
|
# command: bundle exec brakeman
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: eslint
|
name: eslint
|
||||||
command: pnpm run eslint
|
command: yarn run eslint
|
||||||
|
|
||||||
|
# Run frontend tests
|
||||||
- run:
|
- run:
|
||||||
name: Run frontend tests
|
name: Run frontend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/build/coverage/frontend
|
mkdir -p ~/tmp/test-results/frontend_specs
|
||||||
~/tmp/cc-test-reporter before-build
|
~/tmp/cc-test-reporter before-build
|
||||||
pnpm run test:coverage
|
yarn test:coverage
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Code Climate Test Coverage (Frontend)
|
name: Code Climate Test Coverage
|
||||||
command: |
|
command: |
|
||||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||||
|
|
||||||
# Run backend tests
|
# Run rails tests
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/tmp/test-results/rspec
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
mkdir -p ~/tmp/test-artifacts
|
mkdir -p ~/tmp/test-artifacts
|
||||||
mkdir -p ~/build/coverage/backend
|
mkdir -p coverage
|
||||||
~/tmp/cc-test-reporter before-build
|
~/tmp/cc-test-reporter before-build
|
||||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||||
bundle exec rspec --format progress \
|
bundle exec rspec --format progress \
|
||||||
@@ -166,18 +150,54 @@ jobs:
|
|||||||
--out ~/tmp/test-results/rspec.xml \
|
--out ~/tmp/test-results/rspec.xml \
|
||||||
-- ${TESTFILES}
|
-- ${TESTFILES}
|
||||||
no_output_timeout: 30m
|
no_output_timeout: 30m
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Code Climate Test Coverage (Backend)
|
name: Code Climate Test Coverage
|
||||||
command: |
|
command: |
|
||||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
|
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||||
|
|
||||||
- run:
|
|
||||||
name: List coverage directory contents
|
|
||||||
command: |
|
|
||||||
ls -R ~/build/coverage
|
|
||||||
|
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
root: ~/build
|
root: coverage
|
||||||
paths:
|
paths:
|
||||||
- coverage
|
- codeclimate.*.json
|
||||||
|
# collect reports
|
||||||
|
- store_test_results:
|
||||||
|
path: ~/tmp/test-results
|
||||||
|
- store_artifacts:
|
||||||
|
path: ~/tmp/test-artifacts
|
||||||
|
- store_artifacts:
|
||||||
|
path: log
|
||||||
|
|
||||||
|
upload-coverage:
|
||||||
|
working_directory: ~/build
|
||||||
|
docker:
|
||||||
|
# specify the version you desire here
|
||||||
|
- image: circleci/ruby:3.0.2-node-browsers
|
||||||
|
environment:
|
||||||
|
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
|
||||||
|
steps:
|
||||||
|
- attach_workspace:
|
||||||
|
at: ~/build
|
||||||
|
- run:
|
||||||
|
name: Download cc-test-reporter
|
||||||
|
command: |
|
||||||
|
mkdir -p ~/tmp
|
||||||
|
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||||
|
chmod +x ~/tmp/cc-test-reporter
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: ~/tmp
|
||||||
|
paths:
|
||||||
|
- cc-test-reporter
|
||||||
|
- run:
|
||||||
|
name: Upload coverage results to Code Climate
|
||||||
|
command: |
|
||||||
|
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
commit:
|
||||||
|
jobs:
|
||||||
|
- build
|
||||||
|
- upload-coverage:
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE USER chatwoot CREATEDB;
|
|
||||||
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
|
|
||||||
ALTER ROLE chatwoot SUPERUSER;
|
|
||||||
|
|
||||||
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
|
|
||||||
DROP DATABASE template1;
|
|
||||||
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
|
|
||||||
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
|
|
||||||
|
|
||||||
\c template1;
|
|
||||||
VACUUM FREEZE;
|
|
||||||
+1
-2
@@ -27,8 +27,7 @@ checks:
|
|||||||
threshold: 50
|
threshold: 50
|
||||||
exclude_patterns:
|
exclude_patterns:
|
||||||
- 'spec/'
|
- 'spec/'
|
||||||
- '**/specs/**/**'
|
- '**/specs/'
|
||||||
- '**/spec/**/**'
|
|
||||||
- 'db/*'
|
- 'db/*'
|
||||||
- 'bin/**/*'
|
- 'bin/**/*'
|
||||||
- 'db/**/*'
|
- 'db/**/*'
|
||||||
|
|||||||
+1
-32
@@ -1,37 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: [
|
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/vue3-recommended'],
|
||||||
'airbnb-base/legacy',
|
|
||||||
'prettier',
|
|
||||||
'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'],
|
plugins: ['html', 'prettier'],
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 'latest',
|
|
||||||
sourceType: 'module',
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
'prettier/prettier': ['error'],
|
'prettier/prettier': ['error'],
|
||||||
camelcase: 'off',
|
camelcase: 'off',
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
name: Frontend Lint & Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
|
||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9.3.0
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run eslint
|
|
||||||
run: pnpm run eslint
|
|
||||||
|
|
||||||
- name: Run frontend tests with coverage
|
|
||||||
run: |
|
|
||||||
mkdir -p coverage
|
|
||||||
pnpm run test:coverage
|
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
image: postgres:15.3
|
image: postgres:15.3
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ''
|
POSTGRES_PASSWORD: ""
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
ports:
|
ports:
|
||||||
@@ -41,49 +41,46 @@ jobs:
|
|||||||
options: --entrypoint redis-server
|
options: --entrypoint redis-server
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 9
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
run: pnpm i
|
run: pnpm i
|
||||||
|
|
||||||
- name: Strip enterprise code
|
- name: Strip enterprise code
|
||||||
run: |
|
run: |
|
||||||
rm -rf enterprise
|
rm -rf enterprise
|
||||||
rm -rf spec/enterprise
|
rm -rf spec/enterprise
|
||||||
|
|
||||||
- name: Create database
|
- name: Create database
|
||||||
run: bundle exec rake db:create
|
run: bundle exec rake db:create
|
||||||
|
|
||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
- name: Run frontend tests
|
# Run rails tests
|
||||||
run: pnpm run test:coverage
|
- name: Run backend tests
|
||||||
|
run: |
|
||||||
|
bundle exec rspec --profile=10 --format documentation
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --openssl-legacy-provider
|
||||||
|
|
||||||
# Run rails tests
|
- name: Upload rails log folder
|
||||||
- name: Run backend tests
|
uses: actions/upload-artifact@v4
|
||||||
run: |
|
if: always()
|
||||||
bundle exec rspec --profile=10 --format documentation
|
with:
|
||||||
env:
|
name: rails-log-folder
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
path: log
|
||||||
|
|
||||||
- name: Upload rails log folder
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: rails-log-folder
|
|
||||||
path: log
|
|
||||||
|
|||||||
@@ -175,9 +175,6 @@ gem 'pgvector'
|
|||||||
# Convert Website HTML to Markdown
|
# Convert Website HTML to Markdown
|
||||||
gem 'reverse_markdown'
|
gem 'reverse_markdown'
|
||||||
|
|
||||||
gem 'opensearch-ruby'
|
|
||||||
gem 'searchkick'
|
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
##############################################################
|
##############################################################
|
||||||
|
|
||||||
|
|||||||
+59
-65
@@ -33,70 +33,70 @@ GIT
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (7.0.8.5)
|
actioncable (7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (7.0.8.5)
|
actionmailbox (7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
activejob (= 7.0.8.5)
|
activejob (= 7.0.8.4)
|
||||||
activerecord (= 7.0.8.5)
|
activerecord (= 7.0.8.4)
|
||||||
activestorage (= 7.0.8.5)
|
activestorage (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
actionmailer (7.0.8.5)
|
actionmailer (7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
actionview (= 7.0.8.5)
|
actionview (= 7.0.8.4)
|
||||||
activejob (= 7.0.8.5)
|
activejob (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (7.0.8.5)
|
actionpack (7.0.8.4)
|
||||||
actionview (= 7.0.8.5)
|
actionview (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
rack (~> 2.0, >= 2.2.4)
|
rack (~> 2.0, >= 2.2.4)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||||
actiontext (7.0.8.5)
|
actiontext (7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
activerecord (= 7.0.8.5)
|
activerecord (= 7.0.8.4)
|
||||||
activestorage (= 7.0.8.5)
|
activestorage (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (7.0.8.5)
|
actionview (7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||||
active_record_query_trace (1.8)
|
active_record_query_trace (1.8)
|
||||||
activejob (7.0.8.5)
|
activejob (7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (7.0.8.5)
|
activemodel (7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
activerecord (7.0.8.5)
|
activerecord (7.0.8.4)
|
||||||
activemodel (= 7.0.8.5)
|
activemodel (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
activerecord-import (1.4.1)
|
activerecord-import (1.4.1)
|
||||||
activerecord (>= 4.2)
|
activerecord (>= 4.2)
|
||||||
activestorage (7.0.8.5)
|
activestorage (7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
activejob (= 7.0.8.5)
|
activejob (= 7.0.8.4)
|
||||||
activerecord (= 7.0.8.5)
|
activerecord (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
mini_mime (>= 1.1.0)
|
mini_mime (>= 1.1.0)
|
||||||
activesupport (7.0.8.5)
|
activesupport (7.0.8.4)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
@@ -373,7 +373,7 @@ GEM
|
|||||||
mini_mime (>= 1.0.0)
|
mini_mime (>= 1.0.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
i18n (1.14.6)
|
i18n (1.14.5)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.12.2)
|
image_processing (1.12.2)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
@@ -479,7 +479,7 @@ GEM
|
|||||||
uri
|
uri
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.17)
|
net-imap (0.4.14)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -532,9 +532,6 @@ GEM
|
|||||||
omniauth-rails_csrf_protection (1.0.2)
|
omniauth-rails_csrf_protection (1.0.2)
|
||||||
actionpack (>= 4.2)
|
actionpack (>= 4.2)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
opensearch-ruby (3.4.0)
|
|
||||||
faraday (>= 1.0, < 3)
|
|
||||||
multi_json (>= 1.0)
|
|
||||||
openssl (3.2.0)
|
openssl (3.2.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
@@ -560,7 +557,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.10)
|
rack (2.2.9)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -577,20 +574,20 @@ GEM
|
|||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rack-timeout (0.6.3)
|
rack-timeout (0.6.3)
|
||||||
rails (7.0.8.5)
|
rails (7.0.8.4)
|
||||||
actioncable (= 7.0.8.5)
|
actioncable (= 7.0.8.4)
|
||||||
actionmailbox (= 7.0.8.5)
|
actionmailbox (= 7.0.8.4)
|
||||||
actionmailer (= 7.0.8.5)
|
actionmailer (= 7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
actiontext (= 7.0.8.5)
|
actiontext (= 7.0.8.4)
|
||||||
actionview (= 7.0.8.5)
|
actionview (= 7.0.8.4)
|
||||||
activejob (= 7.0.8.5)
|
activejob (= 7.0.8.4)
|
||||||
activemodel (= 7.0.8.5)
|
activemodel (= 7.0.8.4)
|
||||||
activerecord (= 7.0.8.5)
|
activerecord (= 7.0.8.4)
|
||||||
activestorage (= 7.0.8.5)
|
activestorage (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.0.8.5)
|
railties (= 7.0.8.4)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -598,9 +595,9 @@ GEM
|
|||||||
rails-html-sanitizer (1.6.0)
|
rails-html-sanitizer (1.6.0)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.21)
|
||||||
nokogiri (~> 1.14)
|
nokogiri (~> 1.14)
|
||||||
railties (7.0.8.5)
|
railties (7.0.8.4)
|
||||||
actionpack (= 7.0.8.5)
|
actionpack (= 7.0.8.4)
|
||||||
activesupport (= 7.0.8.5)
|
activesupport (= 7.0.8.4)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
@@ -636,7 +633,8 @@ GEM
|
|||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
reverse_markdown (2.1.1)
|
reverse_markdown (2.1.1)
|
||||||
nokogiri
|
nokogiri
|
||||||
rexml (3.3.9)
|
rexml (3.3.6)
|
||||||
|
strscan
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.2)
|
rspec-expectations (3.13.2)
|
||||||
@@ -706,9 +704,6 @@ GEM
|
|||||||
parser
|
parser
|
||||||
scss_lint (0.60.0)
|
scss_lint (0.60.0)
|
||||||
sass (~> 3.5, >= 3.5.5)
|
sass (~> 3.5, >= 3.5.5)
|
||||||
searchkick (5.4.0)
|
|
||||||
activemodel (>= 6.1)
|
|
||||||
hashie
|
|
||||||
seed_dump (3.3.1)
|
seed_dump (3.3.1)
|
||||||
activerecord (>= 4)
|
activerecord (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
@@ -769,6 +764,7 @@ GEM
|
|||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (8.5.0)
|
stripe (8.5.0)
|
||||||
|
strscan (3.1.0)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.3.1)
|
thor (1.3.1)
|
||||||
@@ -916,7 +912,6 @@ DEPENDENCIES
|
|||||||
omniauth-google-oauth2 (>= 1.1.3)
|
omniauth-google-oauth2 (>= 1.1.3)
|
||||||
omniauth-oauth2
|
omniauth-oauth2
|
||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
opensearch-ruby
|
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -942,7 +937,6 @@ DEPENDENCIES
|
|||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
searchkick
|
|
||||||
seed_dump
|
seed_dump
|
||||||
sentry-rails (>= 5.19.0)
|
sentry-rails (>= 5.19.0)
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
|
|||||||
setup:
|
setup:
|
||||||
gem install bundler
|
gem install bundler
|
||||||
bundle install
|
bundle install
|
||||||
pnpm install
|
yarn install
|
||||||
|
|
||||||
db_create:
|
db_create:
|
||||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
||||||
@@ -30,7 +30,7 @@ server:
|
|||||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
||||||
|
|
||||||
burn:
|
burn:
|
||||||
bundle && pnpm install
|
bundle && yarn
|
||||||
|
|
||||||
run:
|
run:
|
||||||
@if [ -f ./.overmind.sock ]; then \
|
@if [ -f ./.overmind.sock ]; then \
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@portal_articles = @portal.articles
|
@portal_articles = @portal.articles
|
||||||
|
@all_articles = @portal_articles.search(list_params)
|
||||||
set_article_count
|
@articles_count = @all_articles.count
|
||||||
|
|
||||||
@articles = @articles.search(list_params)
|
|
||||||
|
|
||||||
@articles = if list_params[:category_slug].present?
|
@articles = if list_params[:category_slug].present?
|
||||||
@articles.order_by_position.page(@current_page)
|
@all_articles.order_by_position.page(@current_page)
|
||||||
else
|
else
|
||||||
@articles.order_by_updated_at.page(@current_page)
|
@all_articles.order_by_updated_at.page(@current_page)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -45,19 +43,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_article_count
|
|
||||||
# Search the params without status and author_id, use this to
|
|
||||||
# compute mine count published draft etc
|
|
||||||
base_search_params = list_params.except(:status, :author_id)
|
|
||||||
@articles = @portal_articles.search(base_search_params)
|
|
||||||
|
|
||||||
@articles_count = @articles.count
|
|
||||||
@mine_articles_count = @articles.search_by_author(Current.user.id).count
|
|
||||||
@published_articles_count = @articles.published.count
|
|
||||||
@draft_articles_count = @articles.draft.count
|
|
||||||
@archived_articles_count = @articles.archived.count
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_article
|
def fetch_article
|
||||||
@article = @portal.articles.find(params[:id])
|
@article = @portal.articles.find(params[:id])
|
||||||
end
|
end
|
||||||
@@ -68,10 +53,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def article_params
|
def article_params
|
||||||
params.require(:article).permit(
|
params.require(:article).permit(
|
||||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
|
||||||
:locale, meta: [:title,
|
:description,
|
||||||
:description,
|
{ tags: [] }]
|
||||||
{ tags: [] }]
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
|
@portal = Current.account.portals.build(portal_params)
|
||||||
@portal.custom_domain = parsed_custom_domain
|
@portal.custom_domain = parsed_custom_domain
|
||||||
@portal.save!
|
@portal.save!
|
||||||
process_attached_logo
|
process_attached_logo
|
||||||
@@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
|
@portal.update!(portal_params) if params[:portal].present?
|
||||||
# @portal.custom_domain = parsed_custom_domain
|
# @portal.custom_domain = parsed_custom_domain
|
||||||
process_attached_logo if params[:blob_id].present?
|
process_attached_logo if params[:blob_id].present?
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@@ -70,21 +70,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def portal_params
|
def portal_params
|
||||||
params.require(:portal).permit(
|
params.require(:portal).permit(
|
||||||
:account_id, :color, :custom_domain, :header_text, :homepage_link,
|
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
|
||||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
{ allowed_locales: [] }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def live_chat_widget_params
|
|
||||||
permitted_params = params.permit(:inbox_id)
|
|
||||||
return {} if permitted_params[:inbox_id].blank?
|
|
||||||
|
|
||||||
inbox = Inbox.find(permitted_params[:inbox_id])
|
|
||||||
return {} unless inbox.web_widget?
|
|
||||||
|
|
||||||
{ channel_web_widget_id: inbox.channel.id }
|
|
||||||
end
|
|
||||||
|
|
||||||
def portal_member_params
|
def portal_member_params
|
||||||
params.require(:portal).permit(:account_id, member_ids: [])
|
params.require(:portal).permit(:account_id, member_ids: [])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
class Google::CallbacksController < OauthCallbackController
|
class Google::CallbacksController < OauthCallbackController
|
||||||
include GoogleConcern
|
include GoogleConcern
|
||||||
|
|
||||||
def find_channel_by_email
|
|
||||||
# find by imap_login first, and then by email
|
|
||||||
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
|
|
||||||
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
|
|
||||||
return imap_channel if imap_channel
|
|
||||||
|
|
||||||
Channel::Email.find_by(email: users_data['email'], account: account)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def provider_name
|
def provider_name
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_inbox
|
def find_or_create_inbox
|
||||||
channel_email = find_channel_by_email
|
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
|
||||||
# we need this value to know where to redirect on sucessful processing of the callback
|
# we need this value to know where to redirect on sucessful processing of the callback
|
||||||
channel_exists = channel_email.present?
|
channel_exists = channel_email.present?
|
||||||
|
|
||||||
@@ -39,10 +39,6 @@ class OauthCallbackController < ApplicationController
|
|||||||
[channel_email.inbox, channel_exists]
|
[channel_email.inbox, channel_exists]
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_channel_by_email
|
|
||||||
Channel::Email.find_by(email: users_data['email'], account: account)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_channel(channel_email)
|
def update_channel(channel_email)
|
||||||
channel_email.update!({
|
channel_email.update!({
|
||||||
imap_login: users_data['email'], imap_address: imap_address,
|
imap_login: users_data['email'], imap_address: imap_address,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def set_article
|
def set_article
|
||||||
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
|
||||||
@article.increment_view_count if @article.published?
|
@article.increment_view_count
|
||||||
@parsed_content = render_article_content(@article.content)
|
@parsed_content = render_article_content(@article.content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useStore } from 'dashboard/composables/store';
|
|||||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||||
import { setColorTheme } from './helper/themeHelper';
|
import { setColorTheme } from './helper/themeHelper';
|
||||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
|
||||||
import {
|
import {
|
||||||
registerSubscription,
|
registerSubscription,
|
||||||
verifyServiceWorkerExistence,
|
verifyServiceWorkerExistence,
|
||||||
@@ -36,9 +35,8 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { accountId } = useAccount();
|
|
||||||
|
|
||||||
return { router, store, currentAccountId: accountId };
|
return { router, store };
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -54,6 +52,7 @@ export default {
|
|||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
|
currentAccountId: 'getCurrentAccountId',
|
||||||
}),
|
}),
|
||||||
hasAccounts() {
|
hasAccounts() {
|
||||||
const { accounts = [] } = this.currentUser || {};
|
const { accounts = [] } = this.currentUser || {};
|
||||||
@@ -70,13 +69,10 @@ export default {
|
|||||||
this.showAddAccountModal = true;
|
this.showAddAccountModal = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
currentAccountId: {
|
currentAccountId() {
|
||||||
immediate: true,
|
if (this.currentAccountId) {
|
||||||
handler() {
|
this.initializeAccount();
|
||||||
if (this.currentAccountId) {
|
}
|
||||||
this.initializeAccount();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -52,13 +52,12 @@ class ArticlesAPI extends PortalsAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createArticle({ portalSlug, articleObj }) {
|
createArticle({ portalSlug, articleObj }) {
|
||||||
const { content, title, authorId, categoryId, locale } = articleObj;
|
const { content, title, author_id, category_id } = articleObj;
|
||||||
return axios.post(`${this.url}/${portalSlug}/articles`, {
|
return axios.post(`${this.url}/${portalSlug}/articles`, {
|
||||||
content,
|
content,
|
||||||
title,
|
title,
|
||||||
author_id: authorId,
|
author_id,
|
||||||
category_id: categoryId,
|
category_id,
|
||||||
locale,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,168 +46,8 @@
|
|||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.n-blue-border {
|
|
||||||
@apply border-n-blue-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.n-blue-text {
|
|
||||||
@apply text-n-blue-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// scss-lint:disable PropertySortOrder
|
|
||||||
@layer base {
|
@layer base {
|
||||||
/* NEXT COLORS START */
|
// scss-lint:disable PropertySortOrder
|
||||||
: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;
|
|
||||||
--text-blue: 8 109 224;
|
|
||||||
--border-container: 236 236 236;
|
|
||||||
--border-strong: 235 235 235;
|
|
||||||
--border-weak: 234 234 234;
|
|
||||||
--solid-1: 255 255 255;
|
|
||||||
--solid-2: 255 255 255;
|
|
||||||
--solid-3: 255 255 255;
|
|
||||||
--solid-active: 255 255 255;
|
|
||||||
--solid-amber: 252 232 193;
|
|
||||||
--solid-blue: 218 236 255;
|
|
||||||
|
|
||||||
--alpha-1: 67, 67, 67, 0.06;
|
|
||||||
--alpha-2: 201, 202, 207, 0.15;
|
|
||||||
--alpha-3: 255, 255, 255, 0.96;
|
|
||||||
--black-alpha-1: 0, 0, 0, 0.12;
|
|
||||||
--black-alpha-2: 0, 0, 0, 0.04;
|
|
||||||
--border-blue: 39, 129, 246, 0.5;
|
|
||||||
--white-alpha: 255, 255, 255, 0.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-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;
|
|
||||||
|
|
||||||
--alpha-1: 36, 36, 36, 0.8;
|
|
||||||
--alpha-2: 139, 147, 182, 0.15;
|
|
||||||
--alpha-3: 36, 38, 45, 0.9;
|
|
||||||
--black-alpha-1: 0, 0, 0, 0.3;
|
|
||||||
--black-alpha-2: 0, 0, 0, 0.2;
|
|
||||||
--border-blue: 39, 129, 246, 0.5;
|
|
||||||
--border-container: 236, 236, 236, 0;
|
|
||||||
--white-alpha: 255, 255, 255, 0.1;
|
|
||||||
}
|
|
||||||
/* NEXT COLORS END */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-amber-25: 254 253 251;
|
--color-amber-25: 254 253 251;
|
||||||
--color-amber-50: 255 249 237;
|
--color-amber-50: 255 249 237;
|
||||||
@@ -550,15 +390,3 @@
|
|||||||
--color-orange-900: 255 224 194;
|
--color-orange-900: 255 224 194;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
|
||||||
.no-scrollbar {
|
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
|
||||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
|
||||||
|
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
|
|
||||||
import SMSCampaignDetails from './SMSCampaignDetails.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isLiveChatType: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isEnabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
inbox: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
scheduledAt: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const STATUS_COMPLETED = 'completed';
|
|
||||||
|
|
||||||
const { formatMessage } = useMessageFormatter();
|
|
||||||
|
|
||||||
const isActive = computed(() =>
|
|
||||||
props.isLiveChatType ? props.isEnabled : props.status !== STATUS_COMPLETED
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusTextColor = computed(() => ({
|
|
||||||
'text-n-teal-11': isActive.value,
|
|
||||||
'text-n-slate-12': !isActive.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const campaignStatus = computed(() => {
|
|
||||||
if (props.isLiveChatType) {
|
|
||||||
return props.isEnabled
|
|
||||||
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
|
|
||||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.status === STATUS_COMPLETED
|
|
||||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
|
||||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
|
||||||
});
|
|
||||||
|
|
||||||
const inboxName = computed(() => props.inbox?.name || '');
|
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
|
||||||
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
|
|
||||||
return getInboxIconByType(type, phoneNumber);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
|
|
||||||
<template #header>
|
|
||||||
<div class="flex flex-col items-start gap-2">
|
|
||||||
<div class="flex justify-between gap-3 w-fit">
|
|
||||||
<span
|
|
||||||
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
|
||||||
:class="statusTextColor"
|
|
||||||
>
|
|
||||||
{{ campaignStatus }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-dompurify-html="formatMessage(message)"
|
|
||||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
|
||||||
<LiveChatCampaignDetails
|
|
||||||
v-if="isLiveChatType"
|
|
||||||
:sender="sender"
|
|
||||||
:inbox-name="inboxName"
|
|
||||||
:inbox-icon="inboxIcon"
|
|
||||||
/>
|
|
||||||
<SMSCampaignDetails
|
|
||||||
v-else
|
|
||||||
:inbox-name="inboxName"
|
|
||||||
:inbox-icon="inboxIcon"
|
|
||||||
:scheduled-at="scheduledAt"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex items-center justify-end w-20 gap-2">
|
|
||||||
<Button
|
|
||||||
v-if="isLiveChatType"
|
|
||||||
variant="faded"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-sliders-vertical"
|
|
||||||
@click="emit('edit')"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="faded"
|
|
||||||
color="ruby"
|
|
||||||
size="sm"
|
|
||||||
icon="i-lucide-trash"
|
|
||||||
@click="emit('delete')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</CardLayout>
|
|
||||||
</template>
|
|
||||||
-54
@@ -1,54 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
sender: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
inboxName: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
inboxIcon: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const senderName = computed(
|
|
||||||
() => props.sender?.name || t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.BOT')
|
|
||||||
);
|
|
||||||
|
|
||||||
const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
|
||||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
<Thumbnail
|
|
||||||
:author="sender || { name: senderName }"
|
|
||||||
:name="senderName"
|
|
||||||
:src="senderThumbnailSrc"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ senderName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
|
||||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ inboxName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
|
||||||
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
defineProps({
|
|
||||||
inboxName: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
inboxIcon: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
scheduledAt: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
|
||||||
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.SENT_FROM') }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ inboxName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
|
||||||
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.ON') }}
|
|
||||||
</span>
|
|
||||||
<span class="flex-1 text-sm font-medium truncate text-n-slate-12">
|
|
||||||
{{ messageStamp(new Date(scheduledAt), 'LLL d, h:mm a') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
headerTitle: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
buttonLabel: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'close']);
|
|
||||||
|
|
||||||
const handleButtonClick = () => {
|
|
||||||
emit('click');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
|
||||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
|
||||||
<div class="w-full max-w-[900px] mx-auto">
|
|
||||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
|
||||||
<span class="text-xl font-medium text-n-slate-12">
|
|
||||||
{{ headerTitle }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
v-on-clickaway="() => emit('close')"
|
|
||||||
class="relative group/campaign-button"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
:label="buttonLabel"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
size="sm"
|
|
||||||
class="group-hover/campaign-button:brightness-110"
|
|
||||||
@click="handleButtonClick"
|
|
||||||
/>
|
|
||||||
<slot name="action" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
|
||||||
<div class="w-full max-w-[900px] mx-auto py-4">
|
|
||||||
<slot name="default" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
-212
@@ -1,212 +0,0 @@
|
|||||||
export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Chatbot Assistance',
|
|
||||||
inbox: {
|
|
||||||
id: 2,
|
|
||||||
name: 'PaperLayer Website',
|
|
||||||
channel_type: 'Channel::WebWidget',
|
|
||||||
phone_number: '',
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Alexa Rivera',
|
|
||||||
},
|
|
||||||
message: 'Hello! 👋 Need help with our chatbot features? Feel free to ask!',
|
|
||||||
campaign_status: 'active',
|
|
||||||
enabled: true,
|
|
||||||
campaign_type: 'ongoing',
|
|
||||||
trigger_rules: {
|
|
||||||
url: 'https://www.chatwoot.com/features/chatbot/',
|
|
||||||
time_on_page: 10,
|
|
||||||
},
|
|
||||||
trigger_only_during_business_hours: true,
|
|
||||||
created_at: '2024-10-24T13:10:26.636Z',
|
|
||||||
updated_at: '2024-10-24T13:10:26.636Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Pricing Information Support',
|
|
||||||
inbox: {
|
|
||||||
id: 2,
|
|
||||||
name: 'PaperLayer Website',
|
|
||||||
channel_type: 'Channel::WebWidget',
|
|
||||||
phone_number: '',
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Jamie Lee',
|
|
||||||
},
|
|
||||||
message: 'Hello! 👋 Any questions on pricing? I’m here to help!',
|
|
||||||
campaign_status: 'active',
|
|
||||||
enabled: false,
|
|
||||||
campaign_type: 'ongoing',
|
|
||||||
trigger_rules: {
|
|
||||||
url: 'https://www.chatwoot.com/pricings',
|
|
||||||
time_on_page: 10,
|
|
||||||
},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-24T13:11:08.763Z',
|
|
||||||
updated_at: '2024-10-24T13:11:08.763Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Product Setup Assistance',
|
|
||||||
inbox: {
|
|
||||||
id: 2,
|
|
||||||
name: 'PaperLayer Website',
|
|
||||||
channel_type: 'Channel::WebWidget',
|
|
||||||
phone_number: '',
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Chatwoot',
|
|
||||||
},
|
|
||||||
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
|
|
||||||
campaign_status: 'active',
|
|
||||||
enabled: false,
|
|
||||||
campaign_type: 'ongoing',
|
|
||||||
trigger_rules: {
|
|
||||||
url: 'https://{*.}?chatwoot.com/apps/account/*/settings/inboxes/new/',
|
|
||||||
time_on_page: 10,
|
|
||||||
},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-24T13:11:44.285Z',
|
|
||||||
updated_at: '2024-10-24T13:11:44.285Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: 'General Assistance Campaign',
|
|
||||||
inbox: {
|
|
||||||
id: 2,
|
|
||||||
name: 'PaperLayer Website',
|
|
||||||
channel_type: 'Channel::WebWidget',
|
|
||||||
phone_number: '',
|
|
||||||
},
|
|
||||||
sender: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Chris Barlow',
|
|
||||||
},
|
|
||||||
message:
|
|
||||||
'Hi there! 👋 I’m here for any questions you may have. Let’s chat!',
|
|
||||||
campaign_status: 'active',
|
|
||||||
enabled: true,
|
|
||||||
campaign_type: 'ongoing',
|
|
||||||
trigger_rules: {
|
|
||||||
url: 'https://siv.com',
|
|
||||||
time_on_page: 200,
|
|
||||||
},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-29T19:54:33.741Z',
|
|
||||||
updated_at: '2024-10-29T19:56:26.296Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Customer Feedback Request',
|
|
||||||
inbox: {
|
|
||||||
id: 6,
|
|
||||||
name: 'PaperLayer Mobile',
|
|
||||||
channel_type: 'Channel::Sms',
|
|
||||||
phone_number: '+29818373149903',
|
|
||||||
provider: 'default',
|
|
||||||
},
|
|
||||||
message:
|
|
||||||
'Hello! Enjoying our product? Share your feedback on G2 and earn a $25 Amazon coupon: https://chwt.app/g2-review',
|
|
||||||
campaign_status: 'active',
|
|
||||||
enabled: true,
|
|
||||||
campaign_type: 'one_off',
|
|
||||||
scheduled_at: 1729775588,
|
|
||||||
audience: [
|
|
||||||
{ id: 4, type: 'Label' },
|
|
||||||
{ id: 5, type: 'Label' },
|
|
||||||
{ id: 6, type: 'Label' },
|
|
||||||
],
|
|
||||||
trigger_rules: {},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-24T13:13:08.496Z',
|
|
||||||
updated_at: '2024-10-24T13:15:38.698Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Welcome New Customer',
|
|
||||||
inbox: {
|
|
||||||
id: 6,
|
|
||||||
name: 'PaperLayer Mobile',
|
|
||||||
channel_type: 'Channel::Sms',
|
|
||||||
phone_number: '+29818373149903',
|
|
||||||
provider: 'default',
|
|
||||||
},
|
|
||||||
message: 'Welcome aboard! 🎉 Let us know if you have any questions.',
|
|
||||||
campaign_status: 'completed',
|
|
||||||
enabled: true,
|
|
||||||
campaign_type: 'one_off',
|
|
||||||
scheduled_at: 1729732500,
|
|
||||||
audience: [
|
|
||||||
{ id: 1, type: 'Label' },
|
|
||||||
{ id: 6, type: 'Label' },
|
|
||||||
{ id: 5, type: 'Label' },
|
|
||||||
{ id: 2, type: 'Label' },
|
|
||||||
{ id: 4, type: 'Label' },
|
|
||||||
],
|
|
||||||
trigger_rules: {},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-24T13:14:00.168Z',
|
|
||||||
updated_at: '2024-10-24T13:15:38.707Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'New Business Welcome',
|
|
||||||
inbox: {
|
|
||||||
id: 6,
|
|
||||||
name: 'PaperLayer Mobile',
|
|
||||||
channel_type: 'Channel::Sms',
|
|
||||||
phone_number: '+29818373149903',
|
|
||||||
provider: 'default',
|
|
||||||
},
|
|
||||||
message: 'Hello! We’re excited to have your business with us!',
|
|
||||||
campaign_status: 'active',
|
|
||||||
enabled: true,
|
|
||||||
campaign_type: 'one_off',
|
|
||||||
scheduled_at: 1730368440,
|
|
||||||
audience: [
|
|
||||||
{ id: 1, type: 'Label' },
|
|
||||||
{ id: 3, type: 'Label' },
|
|
||||||
{ id: 6, type: 'Label' },
|
|
||||||
{ id: 4, type: 'Label' },
|
|
||||||
{ id: 2, type: 'Label' },
|
|
||||||
{ id: 5, type: 'Label' },
|
|
||||||
],
|
|
||||||
trigger_rules: {},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-30T07:54:49.915Z',
|
|
||||||
updated_at: '2024-10-30T07:54:49.915Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: 'New Member Onboarding',
|
|
||||||
inbox: {
|
|
||||||
id: 6,
|
|
||||||
name: 'PaperLayer Mobile',
|
|
||||||
channel_type: 'Channel::Sms',
|
|
||||||
phone_number: '+29818373149903',
|
|
||||||
provider: 'default',
|
|
||||||
},
|
|
||||||
message: 'Welcome to the team! Reach out if you have questions.',
|
|
||||||
campaign_status: 'completed',
|
|
||||||
enabled: true,
|
|
||||||
campaign_type: 'one_off',
|
|
||||||
scheduled_at: 1730304840,
|
|
||||||
audience: [
|
|
||||||
{ id: 1, type: 'Label' },
|
|
||||||
{ id: 3, type: 'Label' },
|
|
||||||
{ id: 6, type: 'Label' },
|
|
||||||
],
|
|
||||||
trigger_rules: {},
|
|
||||||
trigger_only_during_business_hours: false,
|
|
||||||
created_at: '2024-10-29T16:14:10.374Z',
|
|
||||||
updated_at: '2024-10-30T16:15:03.157Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
|
||||||
|
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
|
||||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
|
||||||
<template #empty-state-item>
|
|
||||||
<div class="flex flex-col gap-4 p-px">
|
|
||||||
<CampaignCard
|
|
||||||
v-for="campaign in ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT"
|
|
||||||
:key="campaign.id"
|
|
||||||
:title="campaign.title"
|
|
||||||
:message="campaign.message"
|
|
||||||
:is-enabled="campaign.enabled"
|
|
||||||
:status="campaign.campaign_status"
|
|
||||||
:trigger-rules="campaign.trigger_rules"
|
|
||||||
:sender="campaign.sender"
|
|
||||||
:inbox="campaign.inbox"
|
|
||||||
:scheduled-at="campaign.scheduled_at"
|
|
||||||
is-live-chat-type
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EmptyStateLayout>
|
|
||||||
</template>
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
|
||||||
|
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
|
||||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
|
||||||
<template #empty-state-item>
|
|
||||||
<div class="flex flex-col gap-4 p-px">
|
|
||||||
<CampaignCard
|
|
||||||
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
|
|
||||||
:key="campaign.id"
|
|
||||||
:title="campaign.title"
|
|
||||||
:message="campaign.message"
|
|
||||||
:is-enabled="campaign.enabled"
|
|
||||||
:status="campaign.campaign_status"
|
|
||||||
:trigger-rules="campaign.trigger_rules"
|
|
||||||
:sender="campaign.sender"
|
|
||||||
:inbox="campaign.inbox"
|
|
||||||
:scheduled-at="campaign.scheduled_at"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EmptyStateLayout>
|
|
||||||
</template>
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
campaigns: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isLiveChatType: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['edit', 'delete']);
|
|
||||||
|
|
||||||
const handleEdit = campaign => emit('edit', campaign);
|
|
||||||
const handleDelete = campaign => emit('delete', campaign);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<CampaignCard
|
|
||||||
v-for="campaign in campaigns"
|
|
||||||
:key="campaign.id"
|
|
||||||
:title="campaign.title"
|
|
||||||
:message="campaign.message"
|
|
||||||
:is-enabled="campaign.enabled"
|
|
||||||
:status="campaign.campaign_status"
|
|
||||||
:trigger-rules="campaign.trigger_rules"
|
|
||||||
:sender="campaign.sender"
|
|
||||||
:inbox="campaign.inbox"
|
|
||||||
:scheduled-at="campaign.scheduled_at"
|
|
||||||
:is-live-chat-type="isLiveChatType"
|
|
||||||
@edit="handleEdit(campaign)"
|
|
||||||
@delete="handleDelete(campaign)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useStore } from 'dashboard/composables/store';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
selectedCampaign: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
|
|
||||||
const deleteCampaign = async id => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await store.dispatch('campaigns/delete', id);
|
|
||||||
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.ERROR_MESSAGE'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogConfirm = async () => {
|
|
||||||
await deleteCampaign(props.selectedCampaign.id);
|
|
||||||
dialogRef.value?.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({ dialogRef });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
ref="dialogRef"
|
|
||||||
type="alert"
|
|
||||||
:title="t('CAMPAIGN.CONFIRM_DELETE.TITLE')"
|
|
||||||
:description="t('CAMPAIGN.CONFIRM_DELETE.DESCRIPTION')"
|
|
||||||
:confirm-button-label="t('CAMPAIGN.CONFIRM_DELETE.CONFIRM')"
|
|
||||||
@confirm="handleDialogConfirm"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
-76
@@ -1,76 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue';
|
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|
||||||
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
selectedCampaign: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
const liveChatCampaignFormRef = ref(null);
|
|
||||||
|
|
||||||
const uiFlags = useMapGetter('campaigns/getUIFlags');
|
|
||||||
const isUpdatingCampaign = computed(() => uiFlags.value.isUpdating);
|
|
||||||
|
|
||||||
const isInvalidForm = computed(
|
|
||||||
() => liveChatCampaignFormRef.value?.isSubmitDisabled
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedCampaignId = computed(() => props.selectedCampaign.id);
|
|
||||||
|
|
||||||
const updateCampaign = async campaignDetails => {
|
|
||||||
try {
|
|
||||||
await store.dispatch('campaigns/update', {
|
|
||||||
id: selectedCampaignId.value,
|
|
||||||
...campaignDetails,
|
|
||||||
});
|
|
||||||
|
|
||||||
useAlert(t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.SUCCESS_MESSAGE'));
|
|
||||||
dialogRef.value.close();
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error?.response?.message ||
|
|
||||||
t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.ERROR_MESSAGE');
|
|
||||||
useAlert(errorMessage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
updateCampaign(liveChatCampaignFormRef.value.prepareCampaignDetails());
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({ dialogRef });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog
|
|
||||||
ref="dialogRef"
|
|
||||||
type="edit"
|
|
||||||
:title="t('CAMPAIGN.LIVE_CHAT.EDIT.TITLE')"
|
|
||||||
:is-loading="isUpdatingCampaign"
|
|
||||||
:disable-confirm-button="isUpdatingCampaign || isInvalidForm"
|
|
||||||
overflow-y-auto
|
|
||||||
@confirm="handleSubmit"
|
|
||||||
>
|
|
||||||
<template #form>
|
|
||||||
<LiveChatCampaignForm
|
|
||||||
ref="liveChatCampaignFormRef"
|
|
||||||
mode="edit"
|
|
||||||
:selected-campaign="selectedCampaign"
|
|
||||||
:show-action-buttons="false"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
-54
@@ -1,54 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useStore } from 'dashboard/composables/store';
|
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
|
||||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
|
||||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
|
||||||
|
|
||||||
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
|
||||||
|
|
||||||
const store = useStore();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const addCampaign = async campaignDetails => {
|
|
||||||
try {
|
|
||||||
await store.dispatch('campaigns/create', campaignDetails);
|
|
||||||
|
|
||||||
// tracking this here instead of the store to track the type of campaign
|
|
||||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
|
||||||
type: CAMPAIGN_TYPES.ONGOING,
|
|
||||||
});
|
|
||||||
|
|
||||||
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error?.response?.message ||
|
|
||||||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
|
|
||||||
useAlert(errorMessage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => emit('close');
|
|
||||||
|
|
||||||
const handleSubmit = campaignDetails => {
|
|
||||||
addCampaign(campaignDetails);
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
|
||||||
>
|
|
||||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
|
||||||
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
|
|
||||||
</h3>
|
|
||||||
<LiveChatCampaignForm
|
|
||||||
mode="create"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
@cancel="handleClose"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-323
@@ -1,323 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { reactive, computed, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import { URLPattern } from 'urlpattern-polyfill';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
mode: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
validator: value => ['edit', 'create'].includes(value),
|
|
||||||
},
|
|
||||||
selectedCampaign: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
showActionButtons: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const formState = {
|
|
||||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
|
||||||
inboxes: useMapGetter('inboxes/getWebsiteInboxes'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const senderList = ref([]);
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
inboxId: null,
|
|
||||||
senderId: 0,
|
|
||||||
enabled: true,
|
|
||||||
triggerOnlyDuringBusinessHours: false,
|
|
||||||
endPoint: '',
|
|
||||||
timeOnPage: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
|
||||||
|
|
||||||
const urlValidators = {
|
|
||||||
shouldBeAValidURLPattern: value => {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
new URLPattern(value);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shouldStartWithHTTP: value =>
|
|
||||||
value ? value.startsWith('https://') || value.startsWith('http://') : false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationRules = {
|
|
||||||
title: { required, minLength: minLength(1) },
|
|
||||||
message: { required, minLength: minLength(1) },
|
|
||||||
inboxId: { required },
|
|
||||||
senderId: { required },
|
|
||||||
endPoint: { required, ...urlValidators },
|
|
||||||
timeOnPage: { required },
|
|
||||||
};
|
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
|
||||||
|
|
||||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
|
||||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
|
||||||
|
|
||||||
const mapToOptions = (items, valueKey, labelKey) =>
|
|
||||||
items?.map(item => ({
|
|
||||||
value: item[valueKey],
|
|
||||||
label: item[labelKey],
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
const inboxOptions = computed(() =>
|
|
||||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendersAndBotList = computed(() => [
|
|
||||||
{ value: 0, label: 'Bot' },
|
|
||||||
...mapToOptions(senderList.value, 'id', 'name'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getErrorMessage = (field, errorKey) => {
|
|
||||||
const baseKey = 'CAMPAIGN.LIVE_CHAT.CREATE.FORM';
|
|
||||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formErrors = computed(() => ({
|
|
||||||
title: getErrorMessage('title', 'TITLE'),
|
|
||||||
message: getErrorMessage('message', 'MESSAGE'),
|
|
||||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
|
||||||
endPoint: getErrorMessage('endPoint', 'END_POINT'),
|
|
||||||
timeOnPage: getErrorMessage('timeOnPage', 'TIME_ON_PAGE'),
|
|
||||||
sender: getErrorMessage('senderId', 'SENT_BY'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const resetState = () => Object.assign(state, initialState);
|
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
|
||||||
|
|
||||||
const handleInboxChange = async inboxId => {
|
|
||||||
if (!inboxId) {
|
|
||||||
senderList.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await store.dispatch('inboxMembers/get', { inboxId });
|
|
||||||
senderList.value = response?.data?.payload ?? [];
|
|
||||||
} catch (error) {
|
|
||||||
senderList.value = [];
|
|
||||||
useAlert(
|
|
||||||
error?.response?.message ??
|
|
||||||
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareCampaignDetails = () => ({
|
|
||||||
title: state.title,
|
|
||||||
message: state.message,
|
|
||||||
inbox_id: state.inboxId,
|
|
||||||
sender_id: state.senderId || null,
|
|
||||||
enabled: state.enabled,
|
|
||||||
trigger_only_during_business_hours: state.triggerOnlyDuringBusinessHours,
|
|
||||||
trigger_rules: {
|
|
||||||
url: state.endPoint,
|
|
||||||
time_on_page: state.timeOnPage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const isFormValid = await v$.value.$validate();
|
|
||||||
if (!isFormValid) return;
|
|
||||||
|
|
||||||
emit('submit', prepareCampaignDetails());
|
|
||||||
if (props.mode === 'create') {
|
|
||||||
resetState();
|
|
||||||
handleCancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStateFromCampaign = campaign => {
|
|
||||||
if (!campaign) return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
inbox: { id: inboxId },
|
|
||||||
sender,
|
|
||||||
enabled,
|
|
||||||
trigger_only_during_business_hours: triggerOnlyDuringBusinessHours,
|
|
||||||
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
|
|
||||||
} = campaign;
|
|
||||||
|
|
||||||
Object.assign(state, {
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
inboxId,
|
|
||||||
senderId: sender?.id ?? 0,
|
|
||||||
enabled,
|
|
||||||
triggerOnlyDuringBusinessHours,
|
|
||||||
endPoint,
|
|
||||||
timeOnPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => state.inboxId,
|
|
||||||
newInboxId => {
|
|
||||||
if (newInboxId) {
|
|
||||||
handleInboxChange(newInboxId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.selectedCampaign,
|
|
||||||
newCampaign => {
|
|
||||||
if (props.mode === 'edit' && newCampaign) {
|
|
||||||
updateStateFromCampaign(newCampaign);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
defineExpose({ prepareCampaignDetails, isSubmitDisabled });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
|
||||||
<Input
|
|
||||||
v-model="state.title"
|
|
||||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.LABEL')"
|
|
||||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.PLACEHOLDER')"
|
|
||||||
:message="formErrors.title"
|
|
||||||
:message-type="formErrors.title ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Editor
|
|
||||||
v-model="state.message"
|
|
||||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.LABEL')"
|
|
||||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.PLACEHOLDER')"
|
|
||||||
:message="formErrors.message"
|
|
||||||
:message-type="formErrors.message ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<ComboBox
|
|
||||||
id="inbox"
|
|
||||||
v-model="state.inboxId"
|
|
||||||
:options="inboxOptions"
|
|
||||||
:has-error="!!formErrors.inbox"
|
|
||||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.PLACEHOLDER')"
|
|
||||||
:message="formErrors.inbox"
|
|
||||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label for="sentBy" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<ComboBox
|
|
||||||
id="sentBy"
|
|
||||||
v-model="state.senderId"
|
|
||||||
:options="sendersAndBotList"
|
|
||||||
:has-error="!!formErrors.sender"
|
|
||||||
:disabled="!state.inboxId"
|
|
||||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.PLACEHOLDER')"
|
|
||||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
|
||||||
:message="formErrors.sender"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="state.endPoint"
|
|
||||||
type="url"
|
|
||||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.LABEL')"
|
|
||||||
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.PLACEHOLDER')"
|
|
||||||
:message="formErrors.endPoint"
|
|
||||||
:message-type="formErrors.endPoint ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="state.timeOnPage"
|
|
||||||
type="number"
|
|
||||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="formErrors.timeOnPage"
|
|
||||||
:message-type="formErrors.timeOnPage ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<fieldset class="flex flex-col gap-2.5">
|
|
||||||
<legend class="mb-2.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TITLE') }}
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.enabled" type="checkbox" />
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.ENABLED') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.triggerOnlyDuringBusinessHours" type="checkbox" />
|
|
||||||
<span class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
'CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TRIGGER_ONLY_BUSINESS_HOURS'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showActionButtons"
|
|
||||||
class="flex items-center justify-between w-full gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="faded"
|
|
||||||
color="slate"
|
|
||||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
|
|
||||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
|
||||||
@click="handleCancel"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:label="
|
|
||||||
t(`CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.${mode.toUpperCase()}`)
|
|
||||||
"
|
|
||||||
class="w-full"
|
|
||||||
:is-loading="isCreating"
|
|
||||||
:disabled="isCreating || isSubmitDisabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useStore } from 'dashboard/composables/store';
|
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
|
||||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
|
||||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
|
||||||
|
|
||||||
import SMSCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
|
||||||
|
|
||||||
const store = useStore();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const addCampaign = async campaignDetails => {
|
|
||||||
try {
|
|
||||||
await store.dispatch('campaigns/create', campaignDetails);
|
|
||||||
|
|
||||||
// tracking this here instead of the store to track the type of campaign
|
|
||||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
|
||||||
type: CAMPAIGN_TYPES.ONE_OFF,
|
|
||||||
});
|
|
||||||
|
|
||||||
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error?.response?.message ||
|
|
||||||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
|
|
||||||
useAlert(errorMessage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = campaignDetails => {
|
|
||||||
addCampaign(campaignDetails);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => emit('close');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
|
||||||
>
|
|
||||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
|
||||||
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
|
|
||||||
</h3>
|
|
||||||
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-189
@@ -1,189 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { reactive, computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
|
||||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const formState = {
|
|
||||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
|
||||||
labels: useMapGetter('labels/getLabels'),
|
|
||||||
inboxes: useMapGetter('inboxes/getSMSInboxes'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
inboxId: null,
|
|
||||||
scheduledAt: null,
|
|
||||||
selectedAudience: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
title: { required, minLength: minLength(1) },
|
|
||||||
message: { required, minLength: minLength(1) },
|
|
||||||
inboxId: { required },
|
|
||||||
scheduledAt: { required },
|
|
||||||
selectedAudience: { required },
|
|
||||||
};
|
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, state);
|
|
||||||
|
|
||||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
|
||||||
|
|
||||||
const currentDateTime = computed(() => {
|
|
||||||
// Added to disable the scheduled at field from being set to the current time
|
|
||||||
const now = new Date();
|
|
||||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
|
||||||
return localTime.toISOString().slice(0, 16);
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapToOptions = (items, valueKey, labelKey) =>
|
|
||||||
items?.map(item => ({
|
|
||||||
value: item[valueKey],
|
|
||||||
label: item[labelKey],
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
const audienceList = computed(() =>
|
|
||||||
mapToOptions(formState.labels.value, 'id', 'title')
|
|
||||||
);
|
|
||||||
|
|
||||||
const inboxOptions = computed(() =>
|
|
||||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
|
||||||
);
|
|
||||||
|
|
||||||
const getErrorMessage = (field, errorKey) => {
|
|
||||||
const baseKey = 'CAMPAIGN.SMS.CREATE.FORM';
|
|
||||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formErrors = computed(() => ({
|
|
||||||
title: getErrorMessage('title', 'TITLE'),
|
|
||||||
message: getErrorMessage('message', 'MESSAGE'),
|
|
||||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
|
||||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
|
||||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
|
||||||
|
|
||||||
const formatToUTCString = localDateTime =>
|
|
||||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
|
||||||
|
|
||||||
const resetState = () => {
|
|
||||||
Object.assign(state, initialState);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
|
||||||
|
|
||||||
const prepareCampaignDetails = () => ({
|
|
||||||
title: state.title,
|
|
||||||
message: state.message,
|
|
||||||
inbox_id: state.inboxId,
|
|
||||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
|
||||||
audience: state.selectedAudience?.map(id => ({
|
|
||||||
id,
|
|
||||||
type: 'Label',
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const isFormValid = await v$.value.$validate();
|
|
||||||
if (!isFormValid) return;
|
|
||||||
|
|
||||||
emit('submit', prepareCampaignDetails());
|
|
||||||
resetState();
|
|
||||||
handleCancel();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
|
||||||
<Input
|
|
||||||
v-model="state.title"
|
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.LABEL')"
|
|
||||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.PLACEHOLDER')"
|
|
||||||
:message="formErrors.title"
|
|
||||||
:message-type="formErrors.title ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
v-model="state.message"
|
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.LABEL')"
|
|
||||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.PLACEHOLDER')"
|
|
||||||
show-character-count
|
|
||||||
:message="formErrors.message"
|
|
||||||
:message-type="formErrors.message ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.SMS.CREATE.FORM.INBOX.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<ComboBox
|
|
||||||
id="inbox"
|
|
||||||
v-model="state.inboxId"
|
|
||||||
:options="inboxOptions"
|
|
||||||
:has-error="!!formErrors.inbox"
|
|
||||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.INBOX.PLACEHOLDER')"
|
|
||||||
:message="formErrors.inbox"
|
|
||||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<TagMultiSelectComboBox
|
|
||||||
v-model="state.selectedAudience"
|
|
||||||
:options="audienceList"
|
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL')"
|
|
||||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
|
||||||
:has-error="!!formErrors.audience"
|
|
||||||
:message="formErrors.audience"
|
|
||||||
class="[&>div>button]:bg-n-alpha-black2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="state.scheduledAt"
|
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
|
||||||
type="datetime-local"
|
|
||||||
:min="currentDateTime"
|
|
||||||
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
|
||||||
:message="formErrors.scheduledAt"
|
|
||||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
|
||||||
<Button
|
|
||||||
variant="faded"
|
|
||||||
color="slate"
|
|
||||||
type="button"
|
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
|
|
||||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
|
||||||
@click="handleCancel"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CREATE')"
|
|
||||||
class="w-full"
|
|
||||||
type="submit"
|
|
||||||
:is-loading="isCreating"
|
|
||||||
:disabled="isCreating || isSubmitDisabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
layout: {
|
|
||||||
type: String,
|
|
||||||
default: 'col',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(['click']);
|
|
||||||
const handleClick = () => {
|
|
||||||
emit('click');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
|
||||||
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<slot name="header" />
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
|
|
||||||
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
focusOnMount: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
type: Number,
|
|
||||||
default: 200,
|
|
||||||
},
|
|
||||||
showCharacterCount: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
messageType: {
|
|
||||||
type: String,
|
|
||||||
default: 'info',
|
|
||||||
validator: value => ['info', 'error', 'success'].includes(value),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
|
||||||
|
|
||||||
const isFocused = ref(false);
|
|
||||||
|
|
||||||
const characterCount = computed(() => props.modelValue.length);
|
|
||||||
|
|
||||||
const messageClass = computed(() => {
|
|
||||||
switch (props.messageType) {
|
|
||||||
case 'error':
|
|
||||||
return 'text-n-ruby-9 dark:text-n-ruby-9';
|
|
||||||
case 'success':
|
|
||||||
return 'text-green-500 dark:text-green-400';
|
|
||||||
default:
|
|
||||||
return 'text-n-slate-11 dark:text-n-slate-11';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleInput = value => {
|
|
||||||
if (!props.disabled) {
|
|
||||||
emit('update:modelValue', value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (!props.disabled) {
|
|
||||||
isFocused.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
if (!props.disabled) {
|
|
||||||
isFocused.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
newValue => {
|
|
||||||
if (props.maxLength && props.showCharacterCount) {
|
|
||||||
if (characterCount.value >= props.maxLength) {
|
|
||||||
emit('update:modelValue', newValue.slice(0, props.maxLength));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col min-w-0 gap-1">
|
|
||||||
<label v-if="label" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ label }}
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="flex flex-col w-full gap-2 px-3 py-3 transition-all duration-500 ease-in-out border rounded-lg editor-wrapper bg-n-alpha-black2"
|
|
||||||
:class="[
|
|
||||||
{
|
|
||||||
'cursor-not-allowed opacity-50 pointer-events-none !bg-n-alpha-black2 disabled:border-n-weak dark:disabled:border-n-weak':
|
|
||||||
disabled,
|
|
||||||
'border-n-brand dark:border-n-brand': isFocused,
|
|
||||||
'hover:border-n-slate-6 dark:hover:border-n-slate-6 border-n-weak dark:border-n-weak':
|
|
||||||
!isFocused && messageType !== 'error',
|
|
||||||
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9':
|
|
||||||
messageType === 'error' && !isFocused,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<WootEditor
|
|
||||||
:model-value="modelValue"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:focus-on-mount="focusOnMount"
|
|
||||||
:disabled="disabled"
|
|
||||||
@input="handleInput"
|
|
||||||
@focus="handleFocus"
|
|
||||||
@blur="handleBlur"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="showCharacterCount"
|
|
||||||
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
|
|
||||||
>
|
|
||||||
<span class="text-xs tabular-nums text-n-slate-10">
|
|
||||||
{{ characterCount }} / {{ maxLength }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="message"
|
|
||||||
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
|
||||||
:class="messageClass"
|
|
||||||
>
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.editor-wrapper {
|
|
||||||
::v-deep {
|
|
||||||
.ProseMirror-menubar-wrapper {
|
|
||||||
@apply gap-2 !important;
|
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
|
||||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
|
|
||||||
|
|
||||||
.ProseMirror-menuitem {
|
|
||||||
@apply h-5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon {
|
|
||||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ProseMirror.ProseMirror-woot-style {
|
|
||||||
p {
|
|
||||||
@apply first:mt-0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-node {
|
|
||||||
@apply m-0 !important;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
@apply text-n-slate-11 dark:text-n-slate-11 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<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-xl 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>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useToggle } from '@vueuse/core';
|
|
||||||
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 Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
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';
|
|
||||||
|
|
||||||
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 [showActionsDropdown, toggleDropdown] = useToggle();
|
|
||||||
|
|
||||||
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 }) => {
|
|
||||||
toggleDropdown(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 underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
|
||||||
@click="handleClick(id)"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
|
||||||
:class="statusTextColor"
|
|
||||||
>
|
|
||||||
{{ statusText }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
|
||||||
class="relative flex items-center group"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-ellipsis-vertical"
|
|
||||||
color="slate"
|
|
||||||
size="xs"
|
|
||||||
class="rounded-md group-hover:bg-n-alpha-2"
|
|
||||||
@click="toggleDropdown()"
|
|
||||||
/>
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="showActionsDropdown"
|
|
||||||
: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)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<Icon icon="i-lucide-eye" class="size-4" />
|
|
||||||
<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
@@ -1,48 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useToggle } from '@vueuse/core';
|
|
||||||
|
|
||||||
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 [showActionsDropdown, toggleDropdown] = useToggle();
|
|
||||||
|
|
||||||
const categoryMenuItems = [
|
|
||||||
{
|
|
||||||
label: 'Edit',
|
|
||||||
action: 'edit',
|
|
||||||
value: 'edit',
|
|
||||||
icon: 'i-lucide-pencil',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
action: 'delete',
|
|
||||||
value: 'delete',
|
|
||||||
icon: 'i-lucide-trash',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 });
|
|
||||||
toggleDropdown(false);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CardLayout>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="flex justify-between w-full gap-2">
|
|
||||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
|
||||||
<span
|
|
||||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
|
||||||
@click="handleClick(slug)"
|
|
||||||
>
|
|
||||||
{{ categoryTitleWithIcon }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
|
|
||||||
count: articlesCount,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
|
||||||
class="relative group"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-ellipsis-vertical"
|
|
||||||
color="slate"
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
class="rounded-md group-hover:bg-n-alpha-2"
|
|
||||||
@click="toggleDropdown()"
|
|
||||||
/>
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="showActionsDropdown"
|
|
||||||
: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"
|
|
||||||
/>
|
|
||||||
</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
@@ -1,16 +0,0 @@
|
|||||||
<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>
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
<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 p-px 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 :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EmptyStateLayout>
|
|
||||||
</template>
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
<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 p-px">
|
|
||||||
<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
@@ -1,142 +0,0 @@
|
|||||||
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
@@ -1,16 +0,0 @@
|
|||||||
<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>
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
<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 p-px">
|
|
||||||
<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
|
|
||||||
:label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
@click="openDialog"
|
|
||||||
/>
|
|
||||||
<CreatePortalDialog
|
|
||||||
ref="createPortalDialogRef"
|
|
||||||
@create="onPortalCreate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</EmptyStateLayout>
|
|
||||||
</template>
|
|
||||||
-172
@@ -1,172 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<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-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ activePortalName }}
|
|
||||||
</span>
|
|
||||||
<div v-if="activePortalName" class="relative group">
|
|
||||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
|
|
||||||
@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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useToggle } from '@vueuse/core';
|
|
||||||
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, toggleDropdown] = useToggle();
|
|
||||||
|
|
||||||
const localeMenuItems = computed(() =>
|
|
||||||
LOCALE_MENU_ITEMS.map(item => ({
|
|
||||||
...item,
|
|
||||||
label: t(item.label),
|
|
||||||
disabled: props.isDefault,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
|
||||||
emit('action', { action, value });
|
|
||||||
toggleDropdown(false);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<CardLayout>
|
|
||||||
<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-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
|
|
||||||
>
|
|
||||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-4">
|
|
||||||
<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
|
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
|
||||||
class="relative group"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon="i-lucide-ellipsis-vertical"
|
|
||||||
color="slate"
|
|
||||||
size="xs"
|
|
||||||
class="rounded-md group-hover:bg-n-alpha-2"
|
|
||||||
@click="toggleDropdown()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</CardLayout>
|
|
||||||
</template>
|
|
||||||
-154
@@ -1,154 +0,0 @@
|
|||||||
<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>
|
|
||||||
-264
@@ -1,264 +0,0 @@
|
|||||||
<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: props.article?.author?.id
|
|
||||||
? 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
|
|
||||||
variant="ghost"
|
|
||||||
class="!px-0 font-normal hover:!bg-transparent"
|
|
||||||
text-variant="info"
|
|
||||||
@click="openAgentsList = !openAgentsList"
|
|
||||||
>
|
|
||||||
<Thumbnail
|
|
||||||
:author="author"
|
|
||||||
:name="authorName"
|
|
||||||
:size="20"
|
|
||||||
:src="authorThumbnailSrc"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-if="author"
|
|
||||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ author.available_name }}
|
|
||||||
</span>
|
|
||||||
</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')
|
|
||||||
"
|
|
||||||
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
|
|
||||||
variant="ghost"
|
|
||||||
class="!px-2 font-normal hover:!bg-transparent"
|
|
||||||
@click="openCategoryList = !openCategoryList"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="selectedCategory"
|
|
||||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<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="i-lucide-plus"
|
|
||||||
variant="ghost"
|
|
||||||
:disabled="isNewArticle"
|
|
||||||
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
|
|
||||||
@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>
|
|
||||||
-177
@@ -1,177 +0,0 @@
|
|||||||
<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="i-lucide-chevron-left"
|
|
||||||
variant="link"
|
|
||||||
color="slate"
|
|
||||||
size="sm"
|
|
||||||
class="ltr:pl-3 rtl:pr-3"
|
|
||||||
@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')"
|
|
||||||
color="slate"
|
|
||||||
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="i-lucide-chevron-down"
|
|
||||||
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
@@ -1,134 +0,0 @@
|
|||||||
<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 outline outline-1 outline-n-container 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="i-lucide-x"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
class="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-[220px]"
|
|
||||||
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>
|
|
||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
<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="i-lucide-chevron-down"
|
|
||||||
color="slate"
|
|
||||||
trailing-icon
|
|
||||||
@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"
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
trailing-icon
|
|
||||||
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="i-lucide-plus"
|
|
||||||
size="sm"
|
|
||||||
@click="handleNewArticle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
-190
@@ -1,190 +0,0 @@
|
|||||||
<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
@@ -1,72 +0,0 @@
|
|||||||
<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
@@ -1,190 +0,0 @@
|
|||||||
<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
@@ -1,209 +0,0 @@
|
|||||||
<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
@@ -1,139 +0,0 @@
|
|||||||
<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
@@ -1,112 +0,0 @@
|
|||||||
<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>
|
|
||||||
-271
@@ -1,271 +0,0 @@
|
|||||||
<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-n-strong"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-start w-full gap-2 py-2">
|
|
||||||
<span class="text-sm font-medium text-n-slate-11">
|
|
||||||
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-n-slate-12">
|
|
||||||
{{ portalName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="justify-start w-px h-10 bg-n-strong" />
|
|
||||||
<div class="flex flex-col w-full gap-2 py-2">
|
|
||||||
<span class="text-sm font-medium text-n-slate-11">
|
|
||||||
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
:title="`${activeLocaleName} (${activeLocaleCode})`"
|
|
||||||
class="text-sm line-clamp-1 text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ `${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"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<OnClickOutside @trigger="isEmojiPickerOpen = false">
|
|
||||||
<Button
|
|
||||||
:label="state.icon"
|
|
||||||
color="slate"
|
|
||||||
size="sm"
|
|
||||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
|
||||||
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !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"
|
|
||||||
/>
|
|
||||||
<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
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="showActionButtons"
|
|
||||||
class="flex items-center justify-between w-full gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="faded"
|
|
||||||
color="slate"
|
|
||||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
|
|
||||||
class="w-full bg-n-alpha-2 n-blue-text 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>
|
|
||||||
-202
@@ -1,202 +0,0 @@
|
|||||||
<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"
|
|
||||||
trailing-icon
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
color="slate"
|
|
||||||
@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="min-w-0 text-sm font-medium truncate 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="i-lucide-plus"
|
|
||||||
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')"
|
|
||||||
color="slate"
|
|
||||||
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
@@ -1,37 +0,0 @@
|
|||||||
<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
@@ -1,112 +0,0 @@
|
|||||||
<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
@@ -1,101 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<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
@@ -1,52 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<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="i-lucide-plus"
|
|
||||||
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
@@ -1,74 +0,0 @@
|
|||||||
<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
@@ -1,51 +0,0 @@
|
|||||||
<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
@@ -1,79 +0,0 @@
|
|||||||
<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>
|
|
||||||
-327
@@ -1,327 +0,0 @@
|
|||||||
<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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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')"
|
|
||||||
: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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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')
|
|
||||||
"
|
|
||||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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')
|
|
||||||
"
|
|
||||||
custom-input-class="!bg-transparent dark:!bg-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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')
|
|
||||||
"
|
|
||||||
: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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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')"
|
|
||||||
: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="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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="[&>div>button]:!outline-n-weak"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start justify-between w-full gap-2">
|
|
||||||
<label
|
|
||||||
class="text-sm font-medium whitespace-nowrap 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
@@ -1,118 +0,0 @@
|
|||||||
<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"
|
|
||||||
color="slate"
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
@click="addCustomDomainDialogRef.dialogRef.open()"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
v-else
|
|
||||||
:label="
|
|
||||||
t(
|
|
||||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
color="slate"
|
|
||||||
@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
@@ -1,13 +0,0 @@
|
|||||||
<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
@@ -1,130 +0,0 @@
|
|||||||
<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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
"
|
|
||||||
color="ruby"
|
|
||||||
class="max-w-56 !w-fit"
|
|
||||||
@click="openConfirmDeletePortalDialog"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<ConfirmDeletePortalDialog
|
|
||||||
ref="confirmDeletePortalDialogRef"
|
|
||||||
:active-portal-name="activePortalName"
|
|
||||||
@delete-portal="handleDeletePortal"
|
|
||||||
/>
|
|
||||||
</HelpCenterLayout>
|
|
||||||
</template>
|
|
||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
<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
@@ -1,44 +0,0 @@
|
|||||||
<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>
|
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
<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="i-lucide-arrow-up-right"
|
|
||||||
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')"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
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"
|
|
||||||
trailing-icon
|
|
||||||
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
|
|
||||||
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
|
|
||||||
size="sm"
|
|
||||||
@click="handlePortalChange(portal)"
|
|
||||||
>
|
|
||||||
<div v-if="portal.custom_domain" class="flex items-center gap-1">
|
|
||||||
<span class="i-lucide-link size-3" />
|
|
||||||
<span class="text-sm truncate text-n-slate-11">
|
|
||||||
{{ portal.custom_domain || '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
|
||||||
{{ portal.name || '' }}
|
|
||||||
</span>
|
|
||||||
<Thumbnail
|
|
||||||
v-if="portal"
|
|
||||||
:author="portal"
|
|
||||||
:name="portal.name"
|
|
||||||
:size="20"
|
|
||||||
:src="getPortalThumbnailSrc(portal)"
|
|
||||||
:show-author-name="false"
|
|
||||||
icon-name="i-lucide-building-2"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.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 outline outline-1 outline-n-container 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"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-building-2"
|
|
||||||
class="text-n-brand/50"
|
|
||||||
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="src"
|
|
||||||
class="absolute z-20 outline outline-1 outline-n-container 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"
|
|
||||||
>
|
|
||||||
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-upload"
|
|
||||||
class="text-white"
|
|
||||||
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleImageUpload"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import Breadcrumb from './Breadcrumb.vue';
|
|
||||||
|
|
||||||
const singleItem = ref([{ label: 'Home', link: '#' }]);
|
|
||||||
const twoItems = ref([
|
|
||||||
{ label: 'Home', link: '#' },
|
|
||||||
{ label: 'Categories', link: '#' },
|
|
||||||
]);
|
|
||||||
const threeItems = ref([
|
|
||||||
{ label: 'Home', link: '#' },
|
|
||||||
{ label: 'Categories', link: '#' },
|
|
||||||
{ label: 'Marketing', count: 6, emoji: '📊' },
|
|
||||||
]);
|
|
||||||
const longBreadcrumb = ref([
|
|
||||||
{ label: 'Home', link: '#' },
|
|
||||||
{ label: 'Categories', link: '#', emoji: '📁' },
|
|
||||||
{ label: 'Marketing', link: '#' },
|
|
||||||
{ label: 'Digital', link: '#', emoji: '💻' },
|
|
||||||
{ label: 'Social Media', count: 12, emoji: '📱' },
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
|
||||||
<!-- eslint-disable vue/no-undef-components -->
|
|
||||||
<template>
|
|
||||||
<Story
|
|
||||||
title="Components/Breadcrumb"
|
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
|
||||||
>
|
|
||||||
<Variant title="Single Item">
|
|
||||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Breadcrumb :items="singleItem" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Two Items">
|
|
||||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Breadcrumb :items="twoItems" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Three Items with Count">
|
|
||||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Breadcrumb :items="threeItems" count-label="articles" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Long Breadcrumb">
|
|
||||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="RTL Support">
|
|
||||||
<div dir="rtl">
|
|
||||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Breadcrumb :items="threeItems" count-label="articles" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
items: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
validator: value => {
|
|
||||||
return value.every(
|
|
||||||
item =>
|
|
||||||
typeof item.label === 'string' &&
|
|
||||||
(item.link === undefined || typeof item.link === 'string') &&
|
|
||||||
(item.count === undefined || typeof item.count === 'number')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['click']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const onClick = event => {
|
|
||||||
emit('click', event);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
|
||||||
<ol class="flex items-center mb-0">
|
|
||||||
<li v-for="(item, index) in items" :key="index" class="flex items-center">
|
|
||||||
<button
|
|
||||||
v-if="index === 0"
|
|
||||||
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
|
|
||||||
@click="onClick"
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
|
||||||
</button>
|
|
||||||
<template v-else>
|
|
||||||
<Icon
|
|
||||||
icon="i-lucide-chevron-right"
|
|
||||||
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56"
|
|
||||||
>
|
|
||||||
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Button from './Button.vue';
|
|
||||||
|
|
||||||
// Constants for documentation
|
|
||||||
const VARIANTS = ['solid', 'outline', 'faded', 'link', 'ghost'];
|
|
||||||
const COLORS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
|
|
||||||
const SIZES = ['default', 'sm', 'lg'];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story title="Components/Button" :layout="{ type: 'grid', width: '800px' }">
|
|
||||||
<!-- Basic Variants -->
|
|
||||||
<Variant title="Basic Variants">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Button
|
|
||||||
v-for="variant in VARIANTS"
|
|
||||||
:key="variant"
|
|
||||||
:label="variant"
|
|
||||||
:variant="variant"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Colors -->
|
|
||||||
<Variant title="Color Variants">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Button
|
|
||||||
v-for="color in COLORS"
|
|
||||||
:key="color"
|
|
||||||
:label="color"
|
|
||||||
:color="color"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Sizes -->
|
|
||||||
<Variant title="Size Variants">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2 p-4 bg-white dark:bg-slate-900"
|
|
||||||
>
|
|
||||||
<Button v-for="size in SIZES" :key="size" :label="size" :size="size" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Icons -->
|
|
||||||
<Variant title="Icons">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Button label="Leading Icon" icon="i-lucide-plus" />
|
|
||||||
<Button label="Trailing Icon" icon="i-lucide-plus" trailing-icon />
|
|
||||||
<Button icon="i-lucide-plus" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<Variant title="Loading State">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Button label="Loading" is-loading />
|
|
||||||
<Button label="Loading" variant="outline" is-loading />
|
|
||||||
<Button is-loading icon="i-lucide-plus" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Disabled State -->
|
|
||||||
<Variant title="Disabled State">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Button label="Disabled" disabled />
|
|
||||||
<Button label="Disabled Outline" variant="outline" disabled />
|
|
||||||
<Button label="Disabled Icon" icon="delete" disabled />
|
|
||||||
<Button
|
|
||||||
label="Disabled Destructive"
|
|
||||||
color="ruby"
|
|
||||||
disabled
|
|
||||||
icon="delete"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Color Combinations -->
|
|
||||||
<Variant title="Color & Variant Combinations">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<template v-for="color in COLORS" :key="color">
|
|
||||||
<Button
|
|
||||||
v-for="variant in VARIANTS"
|
|
||||||
:key="`${color}-${variant}`"
|
|
||||||
:label="`${color} ${variant}`"
|
|
||||||
:color="color"
|
|
||||||
:variant="variant"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Icon Positions -->
|
|
||||||
<Variant title="Icon Positions & Sizes">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<template v-for="size in SIZES" :key="size">
|
|
||||||
<Button
|
|
||||||
:label="`${size} Leading`"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
:size="size"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
:label="`${size} Trailing`"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
trailing-icon
|
|
||||||
:size="size"
|
|
||||||
/>
|
|
||||||
<Button icon="i-lucide-plus" :size="size" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<!-- Ghost & Link Variants -->
|
|
||||||
<Variant title="Ghost & Link Variants">
|
|
||||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
|
||||||
<Button label="Ghost Button" variant="ghost" />
|
|
||||||
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
|
|
||||||
<Button label="Link Button" variant="link" />
|
|
||||||
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, useSlots } from 'vue';
|
|
||||||
|
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
variant: {
|
|
||||||
type: String,
|
|
||||||
default: 'solid',
|
|
||||||
validator: value =>
|
|
||||||
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: 'blue',
|
|
||||||
validator: value =>
|
|
||||||
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'md',
|
|
||||||
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
trailingIcon: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const slots = useSlots();
|
|
||||||
|
|
||||||
const STYLE_CONFIG = {
|
|
||||||
colors: {
|
|
||||||
blue: {
|
|
||||||
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
|
|
||||||
faded:
|
|
||||||
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
|
|
||||||
outline: 'text-n-blue-text outline-n-blue-border',
|
|
||||||
link: 'text-n-brand hover:underline outline-transparent',
|
|
||||||
},
|
|
||||||
ruby: {
|
|
||||||
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
|
|
||||||
faded:
|
|
||||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
|
|
||||||
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
|
|
||||||
link: 'text-n-ruby-9 hover:underline outline-transparent',
|
|
||||||
},
|
|
||||||
amber: {
|
|
||||||
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
|
|
||||||
faded:
|
|
||||||
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
|
|
||||||
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
|
|
||||||
link: 'text-n-amber-9 hover:underline outline-transparent',
|
|
||||||
},
|
|
||||||
slate: {
|
|
||||||
solid:
|
|
||||||
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
|
||||||
faded:
|
|
||||||
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
|
|
||||||
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
|
|
||||||
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
|
|
||||||
},
|
|
||||||
teal: {
|
|
||||||
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
|
|
||||||
faded:
|
|
||||||
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
|
|
||||||
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
|
|
||||||
link: 'text-n-teal-9 hover:underline outline-transparent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sizes: {
|
|
||||||
regular: {
|
|
||||||
xs: 'h-6 px-2',
|
|
||||||
sm: 'h-8 px-3',
|
|
||||||
md: 'h-10 px-4',
|
|
||||||
lg: 'h-12 px-5',
|
|
||||||
},
|
|
||||||
iconOnly: {
|
|
||||||
xs: 'h-6 w-6 p-0',
|
|
||||||
sm: 'h-8 w-8 p-0',
|
|
||||||
md: 'h-10 w-10 p-0',
|
|
||||||
lg: 'h-12 w-12 p-0',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
xs: 'p-0',
|
|
||||||
sm: 'p-0',
|
|
||||||
md: 'p-0',
|
|
||||||
lg: 'p-0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontSize: {
|
|
||||||
xs: 'text-xs',
|
|
||||||
sm: 'text-sm',
|
|
||||||
md: 'text-sm font-medium',
|
|
||||||
lg: 'text-base',
|
|
||||||
},
|
|
||||||
base: 'inline-flex items-center justify-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
};
|
|
||||||
|
|
||||||
const variantClasses = computed(() => {
|
|
||||||
const variantMap = {
|
|
||||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
|
|
||||||
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
|
|
||||||
outline: STYLE_CONFIG.colors[props.color].outline,
|
|
||||||
faded: STYLE_CONFIG.colors[props.color].faded,
|
|
||||||
solid: STYLE_CONFIG.colors[props.color].solid,
|
|
||||||
};
|
|
||||||
|
|
||||||
return variantMap[props.variant];
|
|
||||||
});
|
|
||||||
|
|
||||||
const isIconOnly = computed(() => !props.label && !slots.default);
|
|
||||||
const isLink = computed(() => props.variant === 'link');
|
|
||||||
|
|
||||||
const buttonClasses = computed(() => {
|
|
||||||
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
|
|
||||||
const classes = [
|
|
||||||
variantClasses.value,
|
|
||||||
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return classes.join(' ');
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkButtonClasses = computed(() => {
|
|
||||||
const classes = [
|
|
||||||
variantClasses.value,
|
|
||||||
STYLE_CONFIG.sizes.link[props.size],
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return classes.join(' ');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
:class="{
|
|
||||||
[STYLE_CONFIG.base]: true,
|
|
||||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
|
||||||
[STYLE_CONFIG.fontSize[size]]: true,
|
|
||||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<slot v-if="(icon || $slots.icon) && !isLoading" name="icon">
|
|
||||||
<Icon :icon="icon" class="flex-shrink-0" />
|
|
||||||
</slot>
|
|
||||||
|
|
||||||
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
|
|
||||||
|
|
||||||
<slot v-if="label || $slots.default" name="default">
|
|
||||||
<span class="min-w-0 truncate">{{ label }}</span>
|
|
||||||
</slot>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, defineProps, defineEmits } from 'vue';
|
|
||||||
import { Chrome } from '@lk77/vue3-color';
|
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
|
||||||
|
|
||||||
const isPickerOpen = ref(false);
|
|
||||||
|
|
||||||
const toggleColorPicker = () => {
|
|
||||||
isPickerOpen.value = !isPickerOpen.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeTogglePicker = () => {
|
|
||||||
if (isPickerOpen.value) {
|
|
||||||
toggleColorPicker();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateColor = e => {
|
|
||||||
emit('update:modelValue', e.hex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickerRef = ref(null);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="pickerRef" class="relative w-fit">
|
|
||||||
<OnClickOutside @trigger="closeTogglePicker">
|
|
||||||
<Button
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-pipette"
|
|
||||||
trailing-icon
|
|
||||||
class="!px-3 !py-3 [&>svg]:w-4 [&>svg]:h-4"
|
|
||||||
@click="toggleColorPicker"
|
|
||||||
>
|
|
||||||
<div class="flex items-center flex-grow gap-2">
|
|
||||||
<span
|
|
||||||
class="rounded-md size-4"
|
|
||||||
:style="{ backgroundColor: modelValue }"
|
|
||||||
/>
|
|
||||||
<span class="min-w-0 truncate">{{ modelValue }}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Chrome
|
|
||||||
v-if="isPickerOpen"
|
|
||||||
disable-alpha
|
|
||||||
:model-value="modelValue"
|
|
||||||
class="colorpicker--chrome"
|
|
||||||
@update:model-value="updateColor"
|
|
||||||
/>
|
|
||||||
</OnClickOutside>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.colorpicker--chrome.vc-chrome {
|
|
||||||
@apply shadow-lg absolute bg-n-background z-[9999] border border-n-weak dark:border-n-weak rounded-[8px];
|
|
||||||
|
|
||||||
:deep() {
|
|
||||||
.vc-chrome-saturation-wrap {
|
|
||||||
@apply rounded-t-[7px];
|
|
||||||
|
|
||||||
.vc-saturation {
|
|
||||||
@apply rounded-t-[8px];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vc-chrome-body {
|
|
||||||
@apply rounded-b-[7px] bg-n-alpha-3;
|
|
||||||
|
|
||||||
.vc-chrome-toggle-btn {
|
|
||||||
.vc-chrome-toggle-icon svg {
|
|
||||||
@apply [&>path]:fill-n-slate-10 dark:[&>path]:fill-n-slate-10 left-3 relative;
|
|
||||||
}
|
|
||||||
.vc-chrome-toggle-icon-highlight {
|
|
||||||
@apply bg-n-background;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
.vc-input__input {
|
|
||||||
@apply bg-n-background text-slate-900 dark:text-slate-50 rounded-md shadow-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vc-input__label {
|
|
||||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import ComboBox from './ComboBox.vue';
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: 1, label: 'Option 1' },
|
|
||||||
{ value: 2, label: 'Option 2' },
|
|
||||||
{ value: 3, label: 'Option 3' },
|
|
||||||
{ value: 4, label: 'Option 4' },
|
|
||||||
{ value: 5, label: 'Option 5' },
|
|
||||||
];
|
|
||||||
const selectedValue = ref('');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story title="Components/ComboBox" :layout="{ type: 'grid', width: '300px' }">
|
|
||||||
<Variant title="Default">
|
|
||||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
|
||||||
<ComboBox v-model="selectedValue" :options="options" />
|
|
||||||
<p class="mt-2">Selected value: {{ selectedValue }}</p>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Disabled">
|
|
||||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
|
||||||
<ComboBox :options="options" disabled />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
options: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
validator: value =>
|
|
||||||
value.every(option => 'value' in option && 'label' in option),
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
searchPlaceholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
hasError: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const selectedValue = ref(props.modelValue);
|
|
||||||
const open = ref(false);
|
|
||||||
const search = ref('');
|
|
||||||
const dropdownRef = ref(null);
|
|
||||||
const comboboxRef = ref(null);
|
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
|
||||||
const searchTerm = search.value.toLowerCase();
|
|
||||||
return props.options.filter(option =>
|
|
||||||
option.label.toLowerCase().includes(searchTerm)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const selectPlaceholder = computed(() => {
|
|
||||||
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
|
|
||||||
});
|
|
||||||
const selectedLabel = computed(() => {
|
|
||||||
const selected = props.options.find(
|
|
||||||
option => option.value === selectedValue.value
|
|
||||||
);
|
|
||||||
return selected?.label ?? selectPlaceholder.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectOption = option => {
|
|
||||||
selectedValue.value = option.value;
|
|
||||||
emit('update:modelValue', option.value);
|
|
||||||
open.value = false;
|
|
||||||
search.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
if (props.disabled) return;
|
|
||||||
open.value = !open.value;
|
|
||||||
if (open.value) {
|
|
||||||
search.value = '';
|
|
||||||
nextTick(() => dropdownRef.value?.focus());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
newValue => {
|
|
||||||
selectedValue.value = newValue;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="comboboxRef"
|
|
||||||
class="relative w-full min-w-0"
|
|
||||||
:class="{
|
|
||||||
'cursor-not-allowed': disabled,
|
|
||||||
'group/combobox': !disabled,
|
|
||||||
}"
|
|
||||||
@click.prevent
|
|
||||||
>
|
|
||||||
<OnClickOutside @trigger="open = false">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
:color="hasError && !open ? 'ruby' : open ? 'blue' : 'slate'"
|
|
||||||
:label="selectedLabel"
|
|
||||||
trailing-icon
|
|
||||||
:disabled="disabled"
|
|
||||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
|
|
||||||
:class="{ focused: open }"
|
|
||||||
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
@click="toggleDropdown"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ComboBoxDropdown
|
|
||||||
ref="dropdownRef"
|
|
||||||
:open="open"
|
|
||||||
:options="filteredOptions"
|
|
||||||
:search-value="search"
|
|
||||||
:search-placeholder="searchPlaceholder"
|
|
||||||
:empty-state="emptyState"
|
|
||||||
:selected-values="selectedValue"
|
|
||||||
@update:search-value="search = $event"
|
|
||||||
@select="selectOption"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="message"
|
|
||||||
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
|
||||||
:class="{
|
|
||||||
'text-n-ruby-9': hasError,
|
|
||||||
'text-n-slate-11': !hasError,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
</OnClickOutside>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
open: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
searchValue: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
searchPlaceholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
multiple: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
selectedValues: {
|
|
||||||
type: [String, Number, Array],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:searchValue', 'select']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const searchInput = ref(null);
|
|
||||||
|
|
||||||
const isSelected = option => {
|
|
||||||
if (Array.isArray(props.selectedValues)) {
|
|
||||||
return props.selectedValues.includes(option.value);
|
|
||||||
}
|
|
||||||
return option.value === props.selectedValues;
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
focus: () => searchInput.value?.focus(),
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-show="open"
|
|
||||||
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
|
|
||||||
>
|
|
||||||
<div class="relative border-b border-n-strong">
|
|
||||||
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
|
|
||||||
<input
|
|
||||||
ref="searchInput"
|
|
||||||
:value="searchValue"
|
|
||||||
type="search"
|
|
||||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
|
||||||
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
|
|
||||||
@input="emit('update:searchValue', $event.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ul
|
|
||||||
class="py-1 mb-0 overflow-auto max-h-60"
|
|
||||||
role="listbox"
|
|
||||||
:aria-multiselectable="multiple"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-2': isSelected(option),
|
|
||||||
}"
|
|
||||||
role="option"
|
|
||||||
:aria-selected="isSelected(option)"
|
|
||||||
@click="emit('select', option)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="{
|
|
||||||
'font-medium': isSelected(option),
|
|
||||||
}"
|
|
||||||
class="text-n-slate-12"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isSelected(option)"
|
|
||||||
class="flex-shrink-0 i-lucide-check size-4 text-n-slate-11"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
v-if="options.length === 0"
|
|
||||||
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
|
|
||||||
>
|
|
||||||
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
options: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
validator: value =>
|
|
||||||
value.every(option => 'value' in option && 'label' in option),
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
searchPlaceholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
hasError: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const selectedValues = ref(props.modelValue);
|
|
||||||
const open = ref(false);
|
|
||||||
const search = ref('');
|
|
||||||
const dropdownRef = ref(null);
|
|
||||||
const comboboxRef = ref(null);
|
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
|
||||||
const searchTerm = search.value.toLowerCase();
|
|
||||||
return props.options.filter(option =>
|
|
||||||
option.label?.toLowerCase().includes(searchTerm)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectPlaceholder = computed(() => {
|
|
||||||
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedTags = computed(() => {
|
|
||||||
return selectedValues.value.map(value => {
|
|
||||||
const option = props.options.find(opt => opt.value === value);
|
|
||||||
return option || { value, label: value };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleOption = option => {
|
|
||||||
const index = selectedValues.value.indexOf(option.value);
|
|
||||||
if (index === -1) {
|
|
||||||
selectedValues.value.push(option.value);
|
|
||||||
} else {
|
|
||||||
selectedValues.value.splice(index, 1);
|
|
||||||
}
|
|
||||||
emit('update:modelValue', selectedValues.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTag = value => {
|
|
||||||
const index = selectedValues.value.indexOf(value);
|
|
||||||
if (index !== -1) {
|
|
||||||
selectedValues.value.splice(index, 1);
|
|
||||||
emit('update:modelValue', selectedValues.value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
if (props.disabled) return;
|
|
||||||
open.value = !open.value;
|
|
||||||
if (open.value) {
|
|
||||||
search.value = '';
|
|
||||||
nextTick(() => dropdownRef.value?.focus());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
newValue => {
|
|
||||||
selectedValues.value = newValue;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
toggleDropdown,
|
|
||||||
open,
|
|
||||||
disabled: props.disabled,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="comboboxRef"
|
|
||||||
class="relative w-full min-w-0"
|
|
||||||
:class="{
|
|
||||||
'cursor-not-allowed': disabled,
|
|
||||||
'group/combobox': !disabled,
|
|
||||||
}"
|
|
||||||
@click.prevent
|
|
||||||
>
|
|
||||||
<OnClickOutside @trigger="open = false">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap w-full gap-2 px-3 py-2.5 border rounded-lg cursor-pointer bg-n-alpha-black2 min-h-[42px] transition-all duration-500 ease-in-out"
|
|
||||||
:class="{
|
|
||||||
'border-n-ruby-8': hasError,
|
|
||||||
'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6':
|
|
||||||
!hasError && !open,
|
|
||||||
'border-n-brand': open,
|
|
||||||
'cursor-not-allowed pointer-events-none opacity-50': disabled,
|
|
||||||
}"
|
|
||||||
@click="toggleDropdown"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="tag in selectedTags"
|
|
||||||
:key="tag.value"
|
|
||||||
class="flex items-center justify-center max-w-full gap-1 px-2 py-0.5 rounded-lg bg-n-alpha-black1"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
|
|
||||||
{{ tag.label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex-shrink-0 cursor-pointer i-lucide-x size-3 text-n-slate-11"
|
|
||||||
@click="removeTag(tag.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-if="selectedTags.length === 0"
|
|
||||||
class="flex items-center text-sm text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ selectPlaceholder }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ComboBoxDropdown
|
|
||||||
ref="dropdownRef"
|
|
||||||
:open="open"
|
|
||||||
:options="filteredOptions"
|
|
||||||
:search-value="search"
|
|
||||||
:search-placeholder="searchPlaceholder"
|
|
||||||
:empty-state="emptyState"
|
|
||||||
multiple
|
|
||||||
:selected-values="selectedValues"
|
|
||||||
@update:search-value="search = $event"
|
|
||||||
@select="toggleOption"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="message"
|
|
||||||
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
|
||||||
:class="{
|
|
||||||
'text-n-ruby-9': hasError,
|
|
||||||
'text-n-slate-11': !hasError,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
</OnClickOutside>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import TagMultiSelectComboBox from './TagMultiSelectComboBox.vue';
|
|
||||||
|
|
||||||
const options = [
|
|
||||||
{ value: 1, label: 'Option 1' },
|
|
||||||
{ value: 2, label: 'Option 2' },
|
|
||||||
{ value: 3, label: 'Option 3' },
|
|
||||||
{ value: 4, label: 'Option 4' },
|
|
||||||
{ value: 5, label: 'Option 5' },
|
|
||||||
];
|
|
||||||
const selectedValues = ref([]);
|
|
||||||
|
|
||||||
const preselectedValues = ref([1, 2]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story
|
|
||||||
title="Components/TagMultiSelectComboBox"
|
|
||||||
:layout="{ type: 'grid', width: '300px' }"
|
|
||||||
>
|
|
||||||
<Variant title="Default">
|
|
||||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
|
||||||
<TagMultiSelectComboBox v-model="selectedValues" :options="options" />
|
|
||||||
<p class="mt-2">Selected values: {{ selectedValues }}</p>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="With Preselected Values">
|
|
||||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
|
||||||
<TagMultiSelectComboBox
|
|
||||||
v-model="preselectedValues"
|
|
||||||
:options="options"
|
|
||||||
placeholder="Select multiple options"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Disabled">
|
|
||||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
|
||||||
<TagMultiSelectComboBox :options="options" disabled />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="With Error">
|
|
||||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
|
||||||
<TagMultiSelectComboBox
|
|
||||||
:options="options"
|
|
||||||
has-error
|
|
||||||
message="This field is required"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import Dialog from './Dialog.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
|
|
||||||
const alertDialog = ref(null);
|
|
||||||
const editDialog = ref(null);
|
|
||||||
const confirmDialog = ref(null);
|
|
||||||
|
|
||||||
const openAlertDialog = () => {
|
|
||||||
alertDialog.value.open();
|
|
||||||
};
|
|
||||||
const openEditDialog = () => {
|
|
||||||
editDialog.value.open();
|
|
||||||
};
|
|
||||||
const openConfirmDialog = () => {
|
|
||||||
confirmDialog.value.open();
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const onConfirm = dialog => {};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story title="Components/Dialog" :layout="{ type: 'grid', width: '100%' }">
|
|
||||||
<Variant title="Alert Dialog">
|
|
||||||
<Button label="Open Alert Dialog" @click="openAlertDialog" />
|
|
||||||
<Dialog
|
|
||||||
ref="alertDialog"
|
|
||||||
type="alert"
|
|
||||||
title="Alert"
|
|
||||||
description="This is an alert message."
|
|
||||||
/>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Edit Dialog">
|
|
||||||
<Button label="Open Edit Dialog" @click="openEditDialog" />
|
|
||||||
<Dialog
|
|
||||||
ref="editDialog"
|
|
||||||
type="edit"
|
|
||||||
description="You can create a new portal here, by providing a name and a slug."
|
|
||||||
title="Create Portal"
|
|
||||||
confirm-button-label="Save"
|
|
||||||
@confirm="onConfirm()"
|
|
||||||
>
|
|
||||||
<template #form>
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<Input
|
|
||||||
id="portal-name"
|
|
||||||
type="text"
|
|
||||||
placeholder="User Guide | Chatwoot"
|
|
||||||
label="Name"
|
|
||||||
message="This will be the name of your public facing portal"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="portal-slug"
|
|
||||||
type="text"
|
|
||||||
placeholder="user-guide"
|
|
||||||
label="Slug"
|
|
||||||
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Confirm Dialog">
|
|
||||||
<Button label="Open Confirm Dialog" @click="openConfirmDialog" />
|
|
||||||
<Dialog
|
|
||||||
ref="confirmDialog"
|
|
||||||
type="confirm"
|
|
||||||
title="Confirm Action"
|
|
||||||
description="Are you sure you want to perform this action?"
|
|
||||||
confirm-button-label="Yes, I'm sure"
|
|
||||||
cancel-button-label="No, cancel"
|
|
||||||
@confirm="onConfirm()"
|
|
||||||
/>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: 'edit',
|
|
||||||
validator: value => ['alert', 'edit'].includes(value),
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
cancelButtonLabel: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
confirmButtonLabel: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
disableConfirmButton: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showCancelButton: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
showConfirmButton: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
overflowYAuto: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['confirm', 'close']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const isRTL = useMapGetter('accounts/isRTL');
|
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
|
||||||
const dialogContentRef = ref(null);
|
|
||||||
|
|
||||||
const open = () => {
|
|
||||||
dialogRef.value?.showModal();
|
|
||||||
};
|
|
||||||
const close = () => {
|
|
||||||
emit('close');
|
|
||||||
dialogRef.value?.close();
|
|
||||||
};
|
|
||||||
const confirm = () => {
|
|
||||||
emit('confirm');
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({ open, close });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<dialog
|
|
||||||
ref="dialogRef"
|
|
||||||
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
|
||||||
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
|
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
|
||||||
@close="close"
|
|
||||||
>
|
|
||||||
<OnClickOutside @trigger="close">
|
|
||||||
<div
|
|
||||||
ref="dialogContentRef"
|
|
||||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<slot name="description">
|
|
||||||
<p v-if="description" class="mb-0 text-sm text-n-slate-11">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
<slot name="form">
|
|
||||||
<!-- Form content will be injected here -->
|
|
||||||
</slot>
|
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
|
||||||
<Button
|
|
||||||
v-if="showCancelButton"
|
|
||||||
variant="faded"
|
|
||||||
color="slate"
|
|
||||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
|
||||||
class="w-full"
|
|
||||||
@click="close"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
v-if="showConfirmButton"
|
|
||||||
:color="type === 'edit' ? 'blue' : 'ruby'"
|
|
||||||
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
|
||||||
class="w-full"
|
|
||||||
:is-loading="isLoading"
|
|
||||||
:disabled="disableConfirmButton || isLoading"
|
|
||||||
@click="confirm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</OnClickOutside>
|
|
||||||
</dialog>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
dialog::backdrop {
|
|
||||||
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import DropdownMenu from './DropdownMenu.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
const menuItems = [
|
|
||||||
{ label: 'Edit', action: 'edit', icon: 'edit' },
|
|
||||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
|
||||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
|
||||||
{ label: 'Delete', action: 'delete', icon: 'delete' },
|
|
||||||
];
|
|
||||||
const isOpen = ref(false);
|
|
||||||
const toggleDropdown = () => {
|
|
||||||
isOpen.value = !isOpen.value;
|
|
||||||
};
|
|
||||||
const handleAction = () => {
|
|
||||||
isOpen.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
|
|
||||||
<Variant title="Default">
|
|
||||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
|
||||||
<div class="relative">
|
|
||||||
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="isOpen"
|
|
||||||
:menu-items="menuItems"
|
|
||||||
class="absolute left-0 top-10"
|
|
||||||
@action="handleAction"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Always Open">
|
|
||||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
|
||||||
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Custom Items">
|
|
||||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
|
||||||
<DropdownMenu
|
|
||||||
:menu-items="[
|
|
||||||
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
|
|
||||||
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
|
|
||||||
{ label: 'Danger', action: 'delete', icon: 'delete' },
|
|
||||||
]"
|
|
||||||
@action="handleAction"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
menuItems: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
validator: value => {
|
|
||||||
return value.every(item => item.action && item.value && item.label);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thumbnailSize: {
|
|
||||||
type: Number,
|
|
||||||
default: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['action']);
|
|
||||||
|
|
||||||
const handleAction = (action, value) => {
|
|
||||||
emit('action', { action, value });
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="item in menuItems"
|
|
||||||
:key="item.action"
|
|
||||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
|
||||||
'text-n-ruby-11': item.action === 'delete',
|
|
||||||
'text-n-slate-12': item.action !== 'delete',
|
|
||||||
}"
|
|
||||||
:disabled="item.disabled"
|
|
||||||
@click="handleAction(item.action, item.value)"
|
|
||||||
>
|
|
||||||
<Thumbnail
|
|
||||||
v-if="item.thumbnail"
|
|
||||||
:author="item.thumbnail"
|
|
||||||
:name="item.thumbnail.name"
|
|
||||||
:size="thumbnailSize"
|
|
||||||
:src="item.thumbnail.src"
|
|
||||||
/>
|
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
|
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
|
||||||
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
|
|
||||||
item.label
|
|
||||||
}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { h, isVNode } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
icon: { type: [String, Object, Function], required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderIcon = () => {
|
|
||||||
if (!props.icon) return null;
|
|
||||||
if (typeof props.icon === 'function' || isVNode(props.icon)) {
|
|
||||||
return props.icon;
|
|
||||||
}
|
|
||||||
return h('span', { class: props.icon });
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component :is="renderIcon" />
|
|
||||||
</template>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg
|
|
||||||
v-once
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g clip-path="url(#woot-logo-clip-2342424e23u32098)">
|
|
||||||
<path
|
|
||||||
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
|
|
||||||
fill="#2781F6"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M11.4172 11.4172H7.70831C5.66383 11.4172 4 9.75328 4 7.70828C4 5.66394 5.66383 4 7.70835 4C9.75339 4 11.4172 5.66394 11.4172 7.70828V11.4172Z"
|
|
||||||
fill="white"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="0.1875"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="woot-logo-clip-2342424e23u32098">
|
|
||||||
<rect width="16" height="16" fill="white" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import InlineInput from './InlineInput.vue';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story
|
|
||||||
title="Components/InlineInput"
|
|
||||||
:layout="{ type: 'grid', width: '400' }"
|
|
||||||
>
|
|
||||||
<Variant title="Default">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<InlineInput id="inline-input-1" placeholder="Default InlineInput" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="With Label">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-2"
|
|
||||||
label="Username"
|
|
||||||
placeholder="Enter your username"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Disabled">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-3"
|
|
||||||
label="Disabled InlineInput"
|
|
||||||
placeholder="Can't type here"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="With Custom Classes">
|
|
||||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-4"
|
|
||||||
label="Custom Input Class"
|
|
||||||
placeholder="Custom input style"
|
|
||||||
custom-input-class="placeholder:text-green-200 dark:placeholder:text-green-800"
|
|
||||||
/>
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-5"
|
|
||||||
label="Custom Label Class"
|
|
||||||
placeholder="Custom label style"
|
|
||||||
custom-label-class="text-green-600 dark:text-green-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Different Types">
|
|
||||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-6"
|
|
||||||
label="Text"
|
|
||||||
placeholder="Text input"
|
|
||||||
/>
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-7"
|
|
||||||
label="Number"
|
|
||||||
placeholder="Number input"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-8"
|
|
||||||
label="Password"
|
|
||||||
placeholder="Password input"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<InlineInput
|
|
||||||
id="inline-input-9"
|
|
||||||
label="Email"
|
|
||||||
placeholder="Email input"
|
|
||||||
type="email"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: 'text',
|
|
||||||
},
|
|
||||||
customInputClass: {
|
|
||||||
type: [String, Object, Array],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
customLabelClass: {
|
|
||||||
type: [String, Object, Array],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'enterPress']);
|
|
||||||
|
|
||||||
const onEnterPress = () => {
|
|
||||||
emit('enterPress');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="relative flex items-center justify-between w-full gap-3 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-if="label"
|
|
||||||
:for="id"
|
|
||||||
:class="customLabelClass"
|
|
||||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</label>
|
|
||||||
<!-- Added prefix slot to allow adding custom labels to the input -->
|
|
||||||
<slot name="prefix" />
|
|
||||||
<input
|
|
||||||
:id="id"
|
|
||||||
:value="modelValue"
|
|
||||||
:type="type"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:disabled="disabled"
|
|
||||||
:class="customInputClass"
|
|
||||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
|
|
||||||
@input="$emit('update:modelValue', $event.target.value)"
|
|
||||||
@keydown.enter.prevent="onEnterPress"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Input from './Input.vue';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Story title="Components/Input" :layout="{ type: 'grid', width: '400' }">
|
|
||||||
<Variant title="Default">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<Input placeholder="Default Input" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="With Label">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<Input label="Username" placeholder="Enter your username" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Disabled">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<Input label="Disabled Input" placeholder="Can't type here" disabled />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="With Message">
|
|
||||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
placeholder="Enter your email"
|
|
||||||
message="We'll never share your email."
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
message="Password is incorrect"
|
|
||||||
message-type="error"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Verification Code"
|
|
||||||
placeholder="Enter the code"
|
|
||||||
message="Code verified successfully"
|
|
||||||
message-type="success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Different Types">
|
|
||||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
|
||||||
<Input label="Text" type="text" placeholder="Text input" />
|
|
||||||
<Input label="Number" type="number" placeholder="Number input" />
|
|
||||||
<Input label="Password" type="password" placeholder="Password input" />
|
|
||||||
<Input label="Email" type="email" placeholder="Email input" />
|
|
||||||
<Input label="Date" type="date" />
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
|
|
||||||
<Variant title="Custom Input Class">
|
|
||||||
<div class="p-4 bg-white dark:bg-slate-800">
|
|
||||||
<Input
|
|
||||||
label="Custom Style"
|
|
||||||
placeholder="Custom input class"
|
|
||||||
custom-input-class="border-yellow-500 dark:border-yellow-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Variant>
|
|
||||||
</Story>
|
|
||||||
</template>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user