Compare commits

..
2916 changed files with 73652 additions and 90361 deletions
+129 -109
View File
@@ -1,87 +1,84 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
# Ruby CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
defaults: &defaults
working_directory: ~/build
machine:
image: ubuntu-2204:2024.05.1
resource_class: large
docker:
# specify the version you desire here
- image: cimg/ruby:3.3.3-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment:
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
jobs:
build:
<<: *defaults
steps:
- checkout
- node/install:
node-version: '20.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Install System Dependencies
name: Configure Bundler
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
redis-server \
postgresql \
build-essential \
git \
curl \
libssl-dev \
zlib1g-dev \
libreadline-dev \
libyaml-dev \
openjdk-11-jdk \
jq \
software-properties-common \
ca-certificates \
imagemagick \
libxml2-dev \
libxslt1-dev \
file \
g++ \
gcc \
autoconf \
gnupg2 \
patch \
ruby-dev \
liblzma-dev \
libgmp-dev \
libncurses5-dev \
libffi-dev \
libgdbm6 \
libgdbm-dev \
libvips
- run:
name: Install RVM and Ruby 3.3.3
command: |
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
source $BASH_ENV
gem install bundler
- run:
name: Install Application Dependencies
name: Which bundler?
command: bundle -v
- run:
name: Swap node versions
command: |
source ~/.rvm/scripts/rvm
bundle install
# pnpm install
set +e
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler
# Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit
- restore_cache:
keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- run: bundle install --frozen --path ~/.bundle
- save_cache:
paths:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- chatwoot-yarn-
- run:
name: yarn
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
# Store yarn / webpacker cache
- save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
- run:
name: Download cc-test-reporter
@@ -89,8 +86,12 @@ jobs:
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
# Swagger verification
# verify swagger specification
- run:
name: Verify swagger API specification
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
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
- run:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
- run: bundle exec rake db:create
- run: bundle exec rake db:schema:load
# Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop
# ESLint linting
# - run:
# name: Brakeman
# command: bundle exec brakeman
- run:
name: eslint
command: pnpm run eslint
command: yarn run eslint
# Run frontend tests
- run:
name: Run frontend tests
command: |
mkdir -p ~/build/coverage/frontend
mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
yarn test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
name: Code Climate Test Coverage
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:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
mkdir -p coverage
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
@@ -166,18 +150,54 @@ jobs:
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage (Backend)
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
- persist_to_workspace:
root: ~/build
root: coverage
paths:
- coverage
- codeclimate.*.json
# collect reports
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/tmp/test-artifacts
- store_artifacts:
path: log
upload-coverage:
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:3.0.2-node-browsers
environment:
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
steps:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
- run:
name: Upload coverage results to Code Climate
command: |
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build
-11
View File
@@ -1,11 +0,0 @@
CREATE USER chatwoot CREATEDB;
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
ALTER ROLE chatwoot SUPERUSER;
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
DROP DATABASE template1;
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
\c template1;
VACUUM FREEZE;
+1 -2
View File
@@ -27,8 +27,7 @@ checks:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/**/**'
- '**/spec/**/**'
- '**/specs/'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
-1
View File
@@ -257,4 +257,3 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
+13 -180
View File
@@ -2,36 +2,16 @@ module.exports = {
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
'plugin:vue/recommended',
'plugin:storybook/recommended',
'plugin:cypress/recommended',
],
overrides: [
{
files: ['**/*.spec.{j,t}s?(x)'],
env: {
'vitest-globals/env': true,
},
},
{
files: ['**/*.story.vue'],
rules: {
'vue/no-undef-components': [
'error',
{
ignorePatterns: ['Variant', 'Story'],
},
],
// Story files can have static strings, it doesn't need to handle i18n always.
'vue/no-bare-strings-in-template': 'off',
'no-console': 'off',
},
},
],
plugins: ['html', 'prettier'],
parserOptions: {
ecmaVersion: 'latest',
parser: '@babel/eslint-parser',
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['html', 'prettier', 'babel'],
rules: {
'prettier/prettier': ['error'],
camelcase: 'off',
@@ -47,160 +27,6 @@ module.exports = {
'import/no-unresolved': 'off',
'vue/html-indent': 'off',
'vue/multi-word-component-names': 'off',
'vue/next-tick-style': ['error', 'callback'],
'vue/block-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/component-options-name-casing': ['error', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-emits-declaration': ['error'],
'vue/define-macros-order': [
'error',
{
order: ['defineProps', 'defineEmits'],
defineExposeLast: false,
},
],
'vue/define-props-declaration': ['error', 'runtime'],
'vue/match-component-import-name': ['error'],
'vue/no-bare-strings-in-template': [
'error',
{
allowlist: [
'(',
')',
',',
'.',
'&',
'+',
'-',
'=',
'*',
'/',
'#',
'%',
'!',
'?',
':',
'[',
']',
'{',
'}',
'<',
'>',
'⌘',
'📄',
'🎉',
'💬',
'👥',
'📥',
'🔖',
'❌',
'✅',
'\u00b7',
'\u2022',
'\u2010',
'\u2013',
'\u2014',
'\u2212',
'|',
],
attributes: {
'/.+/': [
'title',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext',
],
input: ['placeholder'],
},
directives: ['v-text'],
},
],
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-root-v-if': 'warn',
'vue/no-static-inline-styles': [
'error',
{
allowBinding: false,
},
],
'vue/no-template-target-blank': [
'error',
{
allowReferrer: false,
enforceDynamicLinks: 'always',
},
],
'vue/no-required-prop-with-default': [
'error',
{
autofix: false,
},
],
'vue/no-this-in-before-route-enter': 'error',
'vue/no-undef-components': [
'error',
{
ignorePatterns: [
'^woot-',
'^fluent-',
'^multiselect',
'^router-link',
'^router-view',
'^ninja-keys',
'^FormulateForm',
'^FormulateInput',
'^highlightjs',
],
},
],
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/no-useless-v-bind': [
'error',
{
ignoreIncludesComment: false,
ignoreStringEscape: false,
},
],
'vue/no-v-text': 'error',
'vue/padding-line-between-blocks': ['error', 'always'],
'vue/prefer-separate-static-class': 'error',
'vue/require-explicit-slots': 'error',
'vue/require-macro-variable-name': [
'error',
{
defineProps: 'props',
defineEmits: 'emit',
defineSlots: 'slots',
useSlots: 'slots',
useAttrs: 'attrs',
},
],
'vue/no-unused-properties': [
'error',
{
groups: ['props'],
deepData: false,
ignorePublicMembers: false,
unreferencedOptions: [],
},
],
'vue/max-attributes-per-line': [
'error',
{
@@ -230,6 +56,13 @@ module.exports = {
'import/extensions': ['off'],
'no-console': 'error',
},
settings: {
'import/resolver': {
webpack: {
config: 'config/webpack/resolve.js',
},
},
},
env: {
browser: true,
node: true,
-43
View File
@@ -1,43 +0,0 @@
name: Frontend Lint & Test
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile
- name: Run eslint
run: pnpm run eslint
- name: Run frontend tests with coverage
run: |
mkdir -p coverage
pnpm run test:coverage
+8
View File
@@ -25,5 +25,13 @@ jobs:
with:
issue-inactive-days: '30'
issue-lock-reason: 'resolved'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-inactive-days: '30'
pr-lock-reason: 'resolved'
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
+36 -38
View File
@@ -21,7 +21,7 @@ jobs:
image: postgres:15.3
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
@@ -41,49 +41,47 @@ jobs:
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn
- name: Install pnpm dependencies
run: pnpm i
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run frontend tests
run: pnpm run test:coverage
- name: yarn check-files
run: yarn install --check-files
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+3 -7
View File
@@ -49,17 +49,13 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache: yarn
- name: pnpm
run: pnpm install
- name: yarn
run: yarn install
- name: Create database
run: bundle exec rake db:create
+5 -15
View File
@@ -19,33 +19,23 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache: 'yarn'
- name: pnpm
run: pnpm install
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
RAILS_ENV: production
NODE_OPTIONS: --openssl-legacy-provider
- name: Size Check
run: pnpm run size
run: yarn run size
+1 -15
View File
@@ -32,16 +32,6 @@ master.key
public/uploads
public/packs*
public/assets/administrate*
public/assets/action*.js
public/assets/activestorage*.js
public/assets/trix*
public/assets/belongs_to*.js
public/assets/manifest*.js
public/assets/manifest*.js
public/assets/*.js.gz
public/assets/secretField*
public/assets/.sprockets-manifest-*.json
# VIM files
*.swp
@@ -85,8 +75,4 @@ yalc.lock
yarn-debug.log*
.yarn-integrity
# Vite Ruby
/public/vite*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
/storybook-static
+8 -8
View File
@@ -1,11 +1,11 @@
# #!/bin/sh
# . "$(dirname "$0")/_/husky.sh"
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# # lint js and vue files
# npx --no-install lint-staged
# lint js and vue files
npx --no-install lint-staged
# # lint only staged ruby files
# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# lint only staged ruby files
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# # stage rubocop changes to files
# git diff --name-only --cached | xargs git add
# stage rubocop changes to files
git diff --name-only --cached | xargs git add
+56
View File
@@ -0,0 +1,56 @@
const path = require('path');
const resolve = require('../config/webpack/resolve');
// Chatwoot's webpack.config.js
process.env.NODE_ENV = 'development';
const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{
name: '@storybook/addon-docs',
options: {
vueDocgenOptions: {
alias: {
'@': path.resolve(__dirname, '../'),
},
},
},
},
'@storybook/addon-links',
'@storybook/addon-essentials',
{
/**
* Fix Storybook issue with PostCSS@8
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
*/
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
webpackFinal: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
modules: custom.resolvedModules.map(i => i.value),
},
};
newConfig.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};
+48
View File
@@ -0,0 +1,48 @@
import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function() {
this.$root._i18n = this.$i18n;
},
}));
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
+11 -9
View File
@@ -64,7 +64,7 @@ gem 'activerecord-import'
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma'
gem 'vite_rails'
gem 'webpacker'
# metrics on heroku
gem 'barnes'
@@ -96,12 +96,12 @@ gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3', '>= 0.7.0'
gem 'google-cloud-translate-v3'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -111,12 +111,12 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.19.0', require: false
gem 'sentry-rails', '>= 5.18.1', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
gem 'sentry-sidekiq', '>= 5.18.1', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.1'
gem 'sidekiq', '>= 7.3.0'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
@@ -200,10 +200,12 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
gem 'meta_request', '>= 0.8.0'
end
group :test do
# Cypress in rails.
gem 'cypress-on-rails'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
@@ -226,7 +228,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.5'
gem 'rspec-rails', '>= 6.1.3'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+119 -118
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailbox (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.5)
actionpack (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailer (7.0.8.4)
actionpack (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activesupport (= 7.0.8.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionpack (7.0.8.4)
actionview (= 7.0.8.4)
activesupport (= 7.0.8.4)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.5)
actionpack (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actiontext (7.0.8.4)
actionpack (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
actionview (7.0.8.4)
activesupport (= 7.0.8.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.5)
activesupport (= 7.0.8.5)
activejob (7.0.8.4)
activesupport (= 7.0.8.4)
globalid (>= 0.3.6)
activemodel (7.0.8.5)
activesupport (= 7.0.8.5)
activerecord (7.0.8.5)
activemodel (= 7.0.8.5)
activesupport (= 7.0.8.5)
activemodel (7.0.8.4)
activesupport (= 7.0.8.4)
activerecord (7.0.8.4)
activemodel (= 7.0.8.4)
activesupport (= 7.0.8.4)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activesupport (= 7.0.8.5)
activestorage (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activesupport (= 7.0.8.4)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.5)
activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -169,7 +169,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.4)
concurrent-ruby (1.3.3)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
@@ -178,6 +178,8 @@ GEM
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
cypress-on-rails (1.16.0)
rack
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0)
@@ -220,7 +222,6 @@ GEM
railties (>= 6.1)
down (5.4.0)
addressable (~> 2.8)
dry-cli (1.1.0)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.6.2)
@@ -229,7 +230,7 @@ GEM
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.13.0)
et-orbi (1.2.11)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@@ -256,7 +257,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday-retry (2.1.0)
faraday (~> 2.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
@@ -267,10 +268,10 @@ GEM
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
fugit (1.9.0)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
gapic-common (0.20.0)
gapic-common (0.18.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
@@ -300,15 +301,15 @@ GEM
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-dialogflow-v2 (0.23.0)
gapic-common (>= 0.18.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-location (0.4.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
@@ -318,17 +319,17 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.5)
google-protobuf (3.25.5-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux)
googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
google-protobuf (3.25.3)
google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
@@ -373,7 +374,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.6)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -456,7 +457,7 @@ GEM
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.3)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
@@ -466,7 +467,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.25.1)
minitest (5.24.1)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -479,7 +480,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.17)
net-imap (0.4.12)
date
net-protocol
net-pop (0.1.2)
@@ -495,14 +496,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.7)
nokogiri (1.16.6)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.7-arm64-darwin)
nokogiri (1.16.6-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-darwin)
nokogiri (1.16.6-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.16.6-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -521,7 +522,7 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.3)
omniauth-google-oauth2 (1.1.2)
jwt (>= 2.0)
oauth2 (~> 2.0)
omniauth (~> 2.0)
@@ -551,13 +552,13 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.0)
puma (6.4.3)
puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.10)
racc (1.8.0)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -569,25 +570,25 @@ GEM
rack-protection (3.2.0)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.5)
actioncable (= 7.0.8.5)
actionmailbox (= 7.0.8.5)
actionmailer (= 7.0.8.5)
actionpack (= 7.0.8.5)
actiontext (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activemodel (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
rails (7.0.8.4)
actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.4)
actiontext (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activemodel (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
bundler (>= 1.15.0)
railties (= 7.0.8.5)
railties (= 7.0.8.4)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -595,9 +596,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
railties (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -633,20 +634,20 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.6)
rexml (3.3.2)
strscan
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.0.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-rails (6.1.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
@@ -708,19 +709,20 @@ GEM
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
sentry-rails (5.19.0)
semantic_range (3.0.0)
sentry-rails (5.18.1)
railties (>= 5.0)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
sentry-ruby (~> 5.18.1)
sentry-ruby (5.18.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.19.0)
sentry-ruby (~> 5.19.0)
sentry-sidekiq (5.18.1)
sentry-ruby (~> 5.18.1)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.1)
sidekiq (7.3.0)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
logger
@@ -794,17 +796,10 @@ GEM
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (5.2.6)
valid_email2 (4.0.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2)
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -819,7 +814,12 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
webpacker (5.4.4)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -827,7 +827,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.17)
zeitwerk (2.6.16)
PLATFORMS
arm64-darwin-20
@@ -862,6 +862,7 @@ DEPENDENCIES
climate_control
commonmarker
csv-safe
cypress-on-rails
database_cleaner
ddtrace
debug (~> 1.8)
@@ -880,9 +881,9 @@ DEPENDENCIES
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-dialogflow-v2
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
google-cloud-translate-v3
groupdate
grpc
haikunator
@@ -902,14 +903,14 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.3)
meta_request (>= 0.8.0)
mock_redis
neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
@@ -929,7 +930,7 @@ DEPENDENCIES
responders (>= 3.1.1)
rest-client
reverse_markdown
rspec-rails (>= 6.1.5)
rspec-rails (>= 6.1.3)
rspec_junit_formatter
rubocop
rubocop-performance
@@ -938,11 +939,11 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.19.0)
sentry-rails (>= 5.18.1)
sentry-ruby
sentry-sidekiq (>= 5.19.0)
sentry-sidekiq (>= 5.18.1)
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq (>= 7.3.0)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
@@ -959,10 +960,10 @@ DEPENDENCIES
tzinfo-data
uglifier
valid_email2
vite_rails
web-console (>= 4.2.1)
web-push (>= 3.0.1)
webmock
webpacker
wisper (= 2.0.0)
working_hours
+2 -2
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup:
gem install bundler
bundle install
pnpm install
yarn install
db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
@@ -30,7 +30,7 @@ server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn:
bundle && pnpm install
bundle && yarn
run:
@if [ -f ./.overmind.sock ]; then \
+1 -1
View File
@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev
+1 -1
View File
@@ -1,3 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050
vite: bin/vite dev
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
+2 -5
View File
@@ -1,12 +1,9 @@
## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
___
# Chatwoot
# Chatwoot
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
@@ -101,7 +98,7 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
### Other deployment options
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security
+1 -1
View File
@@ -1 +1 @@
3.13.0
3.9.0
+1 -1
View File
@@ -1 +1 @@
3.0.0
2.8.0
+1
View File
@@ -2,4 +2,5 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js
+55
View File
@@ -0,0 +1,55 @@
// eslint-disable-next-line
function prepareData(data) {
var labels = [];
var dataSet = [];
data.forEach(item => {
labels.push(item[0]);
dataSet.push(item[1]);
});
return { labels, dataSet };
}
function getChartOptions() {
var fontFamily =
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
return {
responsive: true,
legend: { labels: { fontFamily } },
scales: {
xAxes: [
{
barPercentage: 1.26,
ticks: { fontFamily },
gridLines: { display: false },
},
],
yAxes: [
{
ticks: { fontFamily },
gridLines: { display: false },
},
],
},
};
}
// eslint-disable-next-line
function drawSuperAdminDashboard(data) {
var ctx = document.getElementById('dashboard-chart').getContext('2d');
var chartData = prepareData(data);
// eslint-disable-next-line
new Chart(ctx, {
type: 'bar',
data: {
labels: chartData.labels,
datasets: [
{
label: 'Conversations',
data: chartData.dataSet,
backgroundColor: '#1f93ff',
},
],
},
options: getChartOptions(),
});
}
@@ -86,5 +86,8 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
+2 -21
View File
@@ -32,13 +32,11 @@ class AccountBuilder
end
def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable?
if address.valid? # && !address.disposable?
true
else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
raise InvalidEmail.new(valid: address.valid?)
end
end
@@ -81,21 +79,4 @@ class AccountBuilder
@user.confirm if @confirmed
@user.save!
end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end
@@ -27,7 +27,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end
def destroy
@@ -67,16 +67,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id])
end
def account_user_attributes
[:role, :availability, :auto_offline]
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params
params.require(:agent).permit(allowed_agent_params)
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end
def new_agent_params
@@ -109,5 +101,3 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy
ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
message.attachments.destroy_all
end
end
@@ -10,7 +10,7 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
@apps = Integrations::App.all.select(&:active?)
end
def fetch_app
@@ -1,22 +0,0 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
end
private
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
end
end
@@ -11,17 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def process_event
response = @hook.process_event(params[:event])
# for cases like an invalid event, or when conversation does not have enough messages
# for a label suggestion, the response is nil
if response.nil?
render json: { message: nil }
elsif response[:error]
render json: { error: response[:error] }, status: :unprocessable_entity
else
render json: { message: response[:message] }
end
render json: { message: @hook.process_event(params[:event]) }
end
def destroy
@@ -1,68 +1,13 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
result = if params[:attachment].present?
create_from_file
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
end
private
def create_from_file
attachment = params[:attachment]
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
ActiveStorage::Blob.create_and_upload!(
io: io,
filename: filename,
content_type: content_type
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
end
file_blob.save!
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
def render_error(message, status)
render json: { error: message }, status: status
end
end
@@ -11,8 +11,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
process_update_contact
@conversation = create_conversation
conversation.messages.create!(message_params)
# TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer
conversation.reload
end
end
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def check_authorization
return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
def common_params
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
+1 -1
View File
@@ -72,7 +72,7 @@ class DashboardController < ActionController::Base
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app'
else
'dashboard'
'application'
end
end
@@ -1,15 +1,6 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
def find_channel_by_email
# find by imap_login first, and then by email
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
return imap_channel if imap_channel
Channel::Email.find_by(email: users_data['email'], account: account)
end
private
def provider_name
+1 -5
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
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
channel_exists = channel_email.present?
@@ -39,10 +39,6 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists]
end
def find_channel_by_email
Channel::Email.find_by(email: users_data['email'], account: account)
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
@@ -6,7 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
@articles = @portal.articles.published
@articles = @portal.articles
search_articles
order_by_sort_param
@articles.page(list_params[:page]) if list_params[:page].present?
+43 -64
View File
@@ -1,5 +1,33 @@
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<pending-email-verification-banner v-if="hideOnOnboardingView" />
<payment-pending-banner v-if="hideOnOnboardingView" />
<upgrade-banner />
</template>
<transition name="fade" mode="out-in">
<router-view />
</transition>
<add-account-modal
:show="showAddAccountModal"
:has-accounts="hasAccounts"
/>
<woot-snackbar-box />
<network-notification />
</div>
<loading-state v-else />
</template>
<script>
import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
@@ -8,12 +36,10 @@ import UpgradeBanner from './components/app/UpgradeBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -33,13 +59,9 @@ export default {
UpgradeBanner,
PendingEmailVerificationBanner,
},
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store, currentAccountId: accountId };
},
mixins: [rtlMixin],
data() {
return {
showAddAccountModal: false,
@@ -47,13 +69,15 @@ export default {
reconnectService: null,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -70,13 +94,10 @@ export default {
this.showAddAccountModal = true;
}
},
currentAccountId: {
immediate: true,
handler() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
mounted() {
@@ -84,7 +105,7 @@ export default {
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
},
unmounted() {
beforeDestroy() {
if (this.reconnectService) {
this.reconnectService.disconnect();
}
@@ -109,10 +130,10 @@ export default {
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(this.store, pubsubToken);
this.reconnectService = new ReconnectService(this.store, this.router);
window.reconnectService = this.reconnectService;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
@@ -126,50 +147,8 @@ export default {
};
</script>
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox />
<NetworkNotification />
</div>
<LoadingState v-else />
</template>
<style lang="scss">
@import './assets/scss/app';
.v-popper--theme-tooltip .v-popper__inner {
background: black !important;
font-size: 0.75rem;
padding: 4px 8px !important;
border-radius: 6px;
font-weight: 400;
}
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
@@ -1,9 +0,0 @@
import ApiClient from './ApiClient';
class CustomRole extends ApiClient {
constructor() {
super('custom_roles', { accountScoped: true });
}
}
export default new CustomRole();
@@ -32,10 +32,6 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
}
}
export default new IntegrationsAPI();
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 76 KiB

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

Before

Width:  |  Height:  |  Size: 262 B

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

Before

Width:  |  Height:  |  Size: 246 B

@@ -31,6 +31,23 @@
transform: translateX($space-medium);
}
.menu-list-enter-active,
.menu-list-leave-active {
transition: opacity 0.3s var(--ease-out-cubic),
transform 0.2s var(--ease-out-cubic);
}
.menu-list-leave-to {
opacity: 0;
position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
}
.slide-up-enter-active {
transition: all 0.3s var(--ease-in-cubic);
}
@@ -48,8 +65,7 @@
.menu-slide-enter-active,
.menu-slide-leave-active {
transform: translateY(0);
transition:
transform 0.25s var(--ease-in-cubic),
transition: transform 0.25s var(--ease-in-cubic),
opacity 0.15s var(--ease-in-cubic);
}
@@ -1,4 +1,4 @@
@import 'vue-datepicker-next/scss/index';
@import '~vue2-datepicker/scss/index';
.date-picker {
// To be removed one SLA reports date picker is created
@@ -1,4 +1,4 @@
@import 'dashboard/assets/scss/variables';
@import '~dashboard/assets/scss/variables';
.formulate-input {
.formulate-input-errors {
@@ -1,11 +1,10 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'shared/assets/fonts/inter';
// Inter,
html,
body {
font-family:
'Inter',
'PlusJakarta',
-apple-system,
system-ui,
BlinkMacSystemFont,
@@ -24,7 +23,7 @@ body {
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
@apply h-full flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
@@ -1,5 +1,5 @@
@import 'dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins';
@import '~dashboard/assets/scss/variables';
@import '~widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
@@ -89,6 +89,9 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
+4 -160
View File
@@ -2,9 +2,7 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@@ -33,171 +31,18 @@
@import 'plugins/multiselect';
@import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons';
.tooltip {
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
}
#app {
@apply h-full w-full;
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900;
}
.hide {
@apply hidden;
}
// scss-lint:disable PropertySortOrder
@layer base {
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--background-color: 248 248 248;
--solid-1: 255 255 255;
--solid-2: 252 252 252;
--solid-3: 255 255 255;
--solid-active: 250 251 251;
--white-alpha: 255 255 255 0.1;
--border-weak: 231 231 231;
--border-strong: 235 235 235;
--amber-solid: 252 232 193;
--blue-solid: 218 236 255;
--blue: 39 129 246;
/* alpha is added by default */
--alpha-1: 36, 38, 48, 0.06;
--alpha-2: 130, 134, 150, 0.12;
--alpha-3: 255, 255, 255, 0.9;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
}
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;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 36 38 48;
--solid-active: 51 53 64;
--border-weak: 34 34 37;
--border-strong: 46 47 49;
--amber-solid: 42 37 30;
--blue-solid: 16 49 91;
--blue: 126 182 255;
/* alpha is added by default */
--alpha-1: 35, 37, 45, 0.8;
--alpha-2: 130, 134, 150, 0.15;
--alpha-3: 32, 33, 37, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
@@ -368,7 +213,6 @@
--color-orange-800: 204 78 0;
--color-orange-900: 88 45 29;
}
// scss-lint:disable QualifyingElement
body.dark {
--color-amber-25: 31 19 0;
@@ -222,7 +222,6 @@
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0px !important;
}
.multiselect__single {
@@ -0,0 +1,39 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import 'vue-multiselect/dist/vue-multiselect.min.css';
@import '~shared/assets/stylesheets/ionicons';
@import 'mixins';
@import 'helper-classes';
@import 'typography';
@import 'layout';
@import 'animations';
@import 'widgets/buttons';
@import 'widgets/base';
@import 'plugins/multiselect';
@import 'widget/assets/scss/reset';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'widget/assets/scss/utilities';
html,
body {
font-family: 'PlusJakarta', sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
}
@@ -1,10 +1,6 @@
table {
@apply border-spacing-0 text-sm w-full;
}
.woot-table {
thead {
th {
@apply font-semibold tracking-[1px] text-left px-2.5 uppercase text-slate-900 dark:text-slate-200;
@@ -20,7 +16,9 @@ table {
@apply p-2.5 text-slate-700 dark:text-slate-100;
}
}
}
.woot-table {
tr {
.show-if-hover {
transition: opacity 0.2s $swift-ease-out-function;
@@ -1,16 +0,0 @@
<script setup>
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div
class="relative flex flex-col w-full gap-3 px-6 py-5 group/cardLayout rounded-2xl bg-slate-25 dark:bg-slate-800/50"
@click="handleClick"
>
<slot name="header" />
<slot name="footer" />
</div>
</template>
@@ -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 min-h-screen p-4 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-9 bg-gradient-to-t from-white dark:from-slate-900 to-transparent font-interDisplay"
>
<div class="flex flex-col items-center justify-center gap-6">
<div class="flex flex-col items-center justify-center gap-2">
<h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white"
>
{{ title }}
</h2>
<p
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300"
>
{{ subtitle }}
</p>
</div>
<slot name="actions" />
</div>
</div>
</div>
</section>
</template>
@@ -1,56 +0,0 @@
<script setup>
import ArticleCard from './ArticleCard.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: 400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 1400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 4300,
},
];
</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
:title="article.title"
:status="article.status"
:author="article.author"
:category="article.category"
:views="article.views"
:updated-at="article.updatedAt"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,142 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
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 FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
title: {
type: String,
required: true,
},
status: {
type: String,
required: true,
},
author: {
type: String,
required: true,
},
category: {
type: String,
required: true,
},
views: {
type: Number,
required: true,
},
updatedAt: {
type: String,
required: true,
},
});
const isOpen = ref(false);
const menuItems = computed(() => {
const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }];
const menuOptions = {
archived: [
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Draft', action: 'draft', icon: 'draft' },
],
draft: [
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
],
'': [
// Empty string represents published status
{ label: 'Draft', action: 'draft', icon: 'draft' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
],
};
return [...(menuOptions[props.status] || menuOptions['']), ...baseItems];
});
const statusTextColor = computed(() => {
switch (props.status) {
case 'archived':
return '!text-slate-600 dark:!text-slate-200';
case 'draft':
return '!text-amber-700 dark:!text-amber-400';
default:
return '!text-teal-700 dark:!text-teal-400';
}
});
const statusText = computed(() => {
switch (props.status) {
case 'archived':
return 'Archived';
case 'draft':
return 'Draft';
default:
return 'Published';
}
});
const handleAction = () => {
isOpen.value = false;
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-1">
<span class="text-base text-slate-900 dark:text-slate-50 line-clamp-1">
{{ title }}
</span>
<div class="relative group">
<Button
variant="ghost"
size="sm"
class="text-xs bg-slate-50 !font-normal group-hover:bg-slate-100/50 dark:group-hover:bg-slate-700/50 !h-6 dark:bg-slate-800 rounded-md border-0 !px-2 !py-0.5"
:label="statusText"
:class="statusTextColor"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false">
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="right-0 mt-2 xl:left-0 top-full"
@action="handleAction"
/>
</OnClickOutside>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
<span class="text-sm text-slate-500 dark:text-slate-400">
{{ author }}
</span>
</div>
<span
class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400"
>
{{ category }}
</span>
<div
class="inline-flex items-center gap-1 text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
<FluentIcon icon="eye-show" size="18" />
<span class="text-sm"> {{ views }} views </span>
</div>
</div>
<span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1">
{{ updatedAt }}
</span>
</div>
</template>
</CardLayout>
</template>
@@ -1,41 +0,0 @@
<script setup>
import CategoryCard from './CategoryCard.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: '5',
},
{
id: 'marketing',
title: '📈 Marketing',
description: '',
articlesCount: '4',
},
];
</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
:title="category.title"
:description="category.description"
:articles-count="category.articlesCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,115 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
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: String,
required: true,
},
title: {
type: String,
required: true,
},
articlesCount: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
});
const emit = defineEmits(['click']);
const isOpen = ref(false);
const menuItems = [
{
label: 'Edit',
action: 'edit',
icon: 'edit',
},
{
label: 'Delete',
action: 'delete',
icon: 'delete',
},
];
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
const hasDescription = computed(() => {
return props.description.length > 0;
});
const handleClick = id => {
emit('click', id);
};
// eslint-disable-next-line no-unused-vars
const handleAction = action => {
// TODO: Implement action
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<CardLayout @click="handleClick(id)">
<template #header>
<div class="flex gap-2">
<div class="flex justify-between w-full">
<div class="flex items-center justify-start gap-2">
<span
class="text-base cursor-pointer group-hover/cardLayout:underline text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ title }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
>
{{ articlesCount }} articles
</span>
</div>
<div class="relative group" @click.stop>
<Button
variant="ghost"
size="icon"
icon="more-vertical"
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false">
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="right-0 mt-1 xl:left-0 top-full z-60"
@action="handleAction"
/>
</OnClickOutside>
</div>
</div>
</div>
</template>
<template #footer>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
>
{{ description }}
</span>
</template>
</CardLayout>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
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';
defineProps({
header: {
type: String,
default: 'Chatwoot Help Center',
},
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 showPortalSwitcher = ref(false);
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-white dark:bg-slate-900"
>
<header
class="sticky top-0 z-10 px-6 pb-3 bg-white lg:px-0 dark:bg-slate-900"
>
<div class="w-full max-w-[900px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span class="text-xl font-medium text-slate-900 dark:text-white">
{{ header }}
</span>
<div class="relative group">
<Button
icon="more-vertical"
variant="ghost"
size="sm"
class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="togglePortalSwitcher"
/>
<OnClickOutside @trigger="showPortalSwitcher = false">
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute left-0 top-9"
/>
</OnClickOutside>
</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 pt-3 pb-4 bg-white dark:bg-slate-900"
>
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</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,103 +0,0 @@
<script setup>
import { ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
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';
defineProps({
locale: {
type: String,
required: true,
},
isDefault: {
type: Boolean,
required: true,
},
articleCount: {
type: Number,
required: true,
},
categoryCount: {
type: Number,
required: true,
},
});
const isOpen = ref(false);
const menuItems = [
{
label: 'Make default',
action: 'default',
icon: 'star-emphasis',
},
{
label: 'Delete',
action: 'delete',
icon: 'delete',
},
];
// eslint-disable-next-line no-unused-vars
const handleAction = action => {
// TODO: Implement action
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<CardLayout class="ltr:pr-2 rtl:pl-2">
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ locale }}
</span>
<span
v-if="isDefault"
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
>
Default
</span>
</div>
<div class="flex items-center justify-end gap-1">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ articleCount }} articles
</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"
>
{{ categoryCount }} categories
</span>
</div>
<div class="relative group">
<Button
variant="ghost"
size="icon"
icon="more-vertical"
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<OnClickOutside @trigger="isOpen = false">
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]"
@action="handleAction"
/>
</OnClickOutside>
</div>
</div>
</div>
</template>
</CardLayout>
</template>
@@ -1,25 +0,0 @@
<script setup>
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
defineProps({
articles: {
type: Array,
required: true,
},
});
</script>
<template>
<ul role="list" class="w-full h-full space-y-4">
<ArticleCard
v-for="article in articles"
:key="article.title"
:title="article.title"
:status="article.status"
:author="article.author"
:category="article.category"
:views="article.views"
:updated-at="article.updatedAt"
/>
</ul>
</template>
@@ -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>
@@ -1,74 +0,0 @@
<script setup>
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
defineProps({
articles: {
type: Array,
required: true,
},
});
const tabs = [
{ label: 'All articles', count: 24 },
{ label: 'Mine', count: 13 },
{ label: 'Draft', count: 5 },
{ label: 'Archived', count: 11 },
];
// TODO: remove comments
// eslint-disable-next-line no-unused-vars
const handleTabChange = tab => {
// TODO: Implement tab change logic
};
// eslint-disable-next-line no-unused-vars
const handlePageChange = page => {
// TODO: Implement page change logic
};
</script>
<template>
<HelpCenterLayout
:current-page="1"
:total-items="100"
:items-per-page="10"
@update:current-page="handlePageChange"
>
<template #header-actions>
<div class="flex items-end justify-between">
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
<TabBar
:tabs="tabs"
:initial-active-tab="1"
@tab-changed="handleTabChange"
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<Button
label="English"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
<Button
label="All categories"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
</div>
<Button label="New article" icon="add" size="sm" />
</div>
</div>
</div>
</template>
<template #content>
<ArticleList :articles="articles" />
</template>
</HelpCenterLayout>
</template>
@@ -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>
@@ -1,101 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
// import { OnClickOutside } from '@vueuse/components';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
// import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue';
const props = defineProps({
categories: {
type: Array,
required: true,
},
});
const selectedCategory = ref(null);
// const showEditCategory = ref(false);
// const openEditCategory = () => {
// showEditCategory.value = true;
// };
// const closeEditCategory = () => {
// showEditCategory.value = false;
// };
const breadcrumbItems = computed(() => {
const items = [{ label: 'Categories (en-US)', link: '#' }];
if (selectedCategory.value) {
items.push({
label: selectedCategory.value.title,
count: selectedCategory.value.articles.length,
});
}
return items;
});
const openCategoryArticles = id => {
selectedCategory.value = props.categories.find(
category => category.id === id
);
};
const resetCategory = () => {
selectedCategory.value = null;
};
const displayedArticles = computed(() => {
return selectedCategory.value ? selectedCategory.value.articles : [];
});
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div v-if="!selectedCategory" class="flex items-center gap-4">
<Button
label="English"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
/>
<div
class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ categories.length }} categories
</span>
</div>
<Breadcrumb v-else :items="breadcrumbItems" @click="resetCategory" />
<Button
v-if="!selectedCategory"
label="New category"
icon="add"
size="sm"
/>
<div v-else class="relative">
<Button
label="Edit category"
variant="secondary"
size="sm"
@click="openEditCategory"
/>
<!-- <OnClickOutside @trigger="closeEditCategory">
<EditCategory v-if="showEditCategory" @close="closeEditCategory" />
</OnClickOutside> -->
</div>
</div>
</template>
<template #content>
<CategoryList
v-if="!selectedCategory"
:categories="categories"
@click="openCategoryArticles"
/>
<ArticleList v-else :articles="displayedArticles" />
</template>
</HelpCenterLayout>
</template>
@@ -1,30 +0,0 @@
<script setup>
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['click']);
const handleClick = id => {
emit('click', id);
};
</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.title"
:title="category.title"
:description="category.description"
:articles-count="category.articlesCount"
@click="handleClick(category.id)"
/>
</ul>
</template>
@@ -1,146 +0,0 @@
<script setup>
import { computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
const { article } = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle']);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => article.title,
set: title => {
saveArticle({ title });
},
});
const articleContent = computed({
get: () => article.content,
set: content => {
saveArticle({ content });
},
});
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between h-20">
<Button
label="Back to articles"
icon="chevron-lucide-left"
icon-lib="lucide"
variant="link"
text-variant="info"
size="sm"
/>
<div class="flex items-center gap-4">
<span class="text-xs font-medium text-slate-500 dark:text-slate-400">
Saved
</span>
<div class="flex items-center gap-2">
<Button label="Preview" variant="secondary" size="sm" />
<Button
label="Publish"
icon="chevron-lucide-down"
icon-position="right"
icon-lib="lucide"
size="sm"
/>
</div>
</div>
</div>
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
<TextArea
v-model="articleTitle"
class="h-12"
custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]"
placeholder="Title"
/>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" />
<span class="text-sm text-slate-500 dark:text-slate-400">
John Doe
</span>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<Button
label="Uncategorized"
icon="play-shape"
variant="ghost"
class="!px-2 font-normal"
text-variant="info"
/>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<Button
label="More properties"
icon="add"
variant="ghost"
class="!px-2 font-normal"
/>
</div>
</div>
<FullEditor
v-model="articleContent"
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
placeholder="Write something"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
/>
</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>
@@ -1,23 +0,0 @@
<script setup>
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
defineProps({
locales: {
type: Array,
required: true,
},
});
</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="locale.isDefault"
:article-count="locale.articleCount"
:category-count="locale.categoryCount"
/>
</ul>
</template>
@@ -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,47 +0,0 @@
<script setup>
import { computed } from 'vue';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
const props = defineProps({
locales: {
type: Array,
required: true,
},
});
const localeCount = computed(() => props.locales?.length);
// TODO: remove comments
// eslint-disable-next-line no-unused-vars
const handleTabChange = tab => {
// TODO: Implement tab change logic
};
// eslint-disable-next-line no-unused-vars
const handlePageChange = page => {
// TODO: Implement page change logic
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="add"
size="sm"
/>
</div>
</template>
<template #content>
<LocaleList :locales="locales" />
</template>
</HelpCenterLayout>
</template>
@@ -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>
@@ -1,113 +0,0 @@
<script setup>
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const handleUploadAvatar = () => {};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #content>
<div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8">
<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"
>
Avatar
</label>
<Avatar
label="Avatar"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-400"
@upload="handleUploadAvatar"
/>
</div>
<div class="flex flex-col w-full gap-2">
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Name
</label>
<Input placeholder="Name" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Header text
</label>
<Input placeholder="Header text" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Page title
</label>
<Input placeholder="Page title" class="w-[432px]" />
</div>
<div class="flex justify-between w-full h-10 gap-2 py-1">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
Widget color
</label>
</div>
<div class="flex justify-end w-full gap-2 py-2">
<Button label="Save changes" size="sm" />
</div>
</div>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
</div>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col w-full gap-6">
<h6 class="text-base font-medium text-slate-900 dark:text-slate-50">
Configuration
</h6>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Slug"
label="Slug:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Custom domain"
label="Custom domain:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-1">
<InlineInput
placeholder="Home page link"
label="Home page link:"
custom-label-class="min-w-[100px]"
custom-input-class="!w-[430px]"
/>
</div>
</div>
</div>
<div class="flex justify-end w-full gap-3 py-4">
<Button label="Edit configuration" size="sm" variant="secondary" />
<Button
label="Delete Test-Help Center"
size="sm"
variant="destructive"
/>
</div>
</div>
</div>
</template>
</HelpCenterLayout>
</template>
@@ -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>
@@ -1,111 +0,0 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
portals: {
type: Array,
default: () => [
{
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',
},
],
},
header: {
type: String,
default: 'Portals',
},
description: {
type: String,
default: 'Create and manage multiple portals',
},
});
const selectedPortal = ref(1);
const handlePortalChange = id => {
selectedPortal.value = id;
};
</script>
<!-- TODO: Add i18n -->
<!-- eslint-disable vue/no-bare-strings-in-template -->
<template>
<div
class="pt-5 pb-3 bg-white z-50 dark:bg-slate-800 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
>
<div class="flex items-center justify-between gap-4 px-6 pb-2">
<div class="flex flex-col gap-1">
<h2 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ header }}
</h2>
<p class="text-sm text-slate-600 dark:text-slate-300">
{{ description }}
</p>
</div>
<Button label="New portal" variant="secondary" icon="add" size="sm" />
</div>
<div v-if="portals.length > 0" class="flex flex-col gap-3">
<template v-for="(portal, index) in portals" :key="portal.id">
<div class="flex flex-col gap-2 px-6 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
:id="portal.id"
v-model="selectedPortal"
type="radio"
:value="portal.id"
class="mr-3"
@change="handlePortalChange(portal.id)"
/>
<label
:for="portal.id"
class="text-sm font-medium text-slate-900 dark:text-slate-100"
>
{{ portal.name }}
</label>
</div>
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
</div>
<div class="inline-flex items-center gap-2 py-1 text-sm">
<span class="text-slate-600 dark:text-slate-400">
articles:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.articles }}
</span>
</span>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
<span class="text-slate-600 dark:text-slate-400">
domain:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.domain }}
</span>
</span>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
<span class="text-slate-600 dark:text-slate-400">
slug:
<span class="text-slate-800 dark:text-slate-200">
{{ portal.slug }}
</span>
</span>
</div>
</div>
<div
v-if="index < portals.length - 1 && portals.length > 1"
class="w-full h-px bg-slate-50 dark:bg-slate-700/50"
/>
</template>
</div>
</div>
</template>
@@ -1,36 +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/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">
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
/>
<Avatar
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,52 +0,0 @@
<script setup>
import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
});
const emit = defineEmits(['upload']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const handleUploadAvatar = () => {
emit('upload');
};
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
:style="{
width: avatarSize,
height: avatarSize,
}"
>
<img
v-if="src"
:src="props.src"
alt="avatar"
class="w-full h-full shadow-sm rounded-xl"
/>
<div
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="handleUploadAvatar"
>
<FluentIcon
icon="upload-lucide"
icon-lib="lucide"
:size="iconSize"
class="text-white dark:text-white"
/>
</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 },
]);
const longBreadcrumb = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
{ label: 'Marketing', link: '#' },
{ label: 'Digital', link: '#' },
{ label: 'Social Media', count: 12 },
]);
</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,60 +0,0 @@
<script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.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')
);
},
},
countLabel: {
type: String,
default: '',
},
});
const { t } = useI18n();
</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 gap-3"
>
<template v-if="index === items.length - 1">
<span class="text-sm text-slate-900 dark:text-slate-50">
{{
`${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}`
}}
</span>
</template>
<a
v-else
:href="item.link"
class="text-sm transition-colors duration-200 text-slate-300 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-100"
>
{{ item.label }}
</a>
<FluentIcon
v-if="index < items.length - 1"
icon="chevron-lucide-right"
size="18"
icon-lib="lucide"
class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3"
/>
</li>
</ol>
</nav>
</template>
@@ -1,126 +0,0 @@
<script setup>
import Button from './Button.vue';
</script>
<template>
<Story title="Components/Button" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<Button label="Default Button" />
</div>
</Variant>
<Variant title="Disabled">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Disabled" disabled />
<Button label="Disabled" variant="outline" disabled />
<Button label="Disabled" disabled icon="delete" variant="outline" />
<Button
label="Disabled"
disabled
icon="delete"
variant="destructive"
size="sm"
/>
<Button
label="Disabled"
disabled
icon="delete"
variant="ghost"
size="sm"
/>
<Button
label="Disabled"
disabled
icon="delete"
variant="link"
size="sm"
/>
</div>
</Variant>
<Variant title="Disabled with icon">
<div class="p-4 bg-white dark:bg-slate-900">
<Button label="Disabled Button" icon="emoji-add" disabled />
</div>
</Variant>
<Variant title="Different variant">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" variant="default" />
<Button label="Destructive" variant="destructive" />
<Button label="Outline" variant="outline" />
<Button label="Secondary" variant="secondary" />
<Button label="Ghost" variant="ghost" />
<Button label="Link" variant="link" />
</div>
</Variant>
<Variant title="Different variant with icon only">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button icon="emoji-add" variant="default" />
<Button icon="emoji-add" variant="destructive" />
<Button icon="emoji-add" variant="outline" />
<Button icon="emoji-add" variant="secondary" />
<Button icon="emoji-add" variant="ghost" />
<Button icon="emoji-add" variant="link" />
</div>
</Variant>
<Variant title="Different size">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" />
<Button label="Large" size="lg" />
<Button label="Small" size="sm" />
</div>
</Variant>
<Variant title="Different text variant">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" text-variant="default" variant="outline" />
<Button label="Success" text-variant="success" variant="outline" />
<Button label="Warning" text-variant="warning" variant="outline" />
<Button label="Danger" text-variant="danger" variant="outline" />
<Button label="Info" text-variant="info" variant="outline" />
</div>
</Variant>
<Variant title="Button with left icon with different sizes and icon only">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" icon="emoji-add" icon-position="left" />
<Button
label="Default LG"
icon="emoji-add"
icon-position="left"
size="lg"
/>
<Button
label="Default SM"
icon="emoji-add"
icon-position="left"
size="sm"
/>
<Button icon="emoji-add" size="icon" />
</div>
</Variant>
<Variant title="Button with right icon with different sizes and icon only">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Default" icon="emoji-add" icon-position="right" />
<Button
label="Default LG"
icon="emoji-add"
icon-position="right"
size="lg"
/>
<Button
label="Default SM"
icon="emoji-add"
icon-position="right"
size="sm"
/>
<Button icon="emoji-add" size="icon" />
</div>
</Variant>
</Story>
</template>
@@ -1,130 +0,0 @@
<script setup>
import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'default',
validator: value =>
[
'default',
'destructive',
'outline',
'secondary',
'ghost',
'link',
].includes(value),
},
textVariant: {
type: String,
default: '',
validator: value =>
['', 'default', 'success', 'warning', 'danger', 'info'].includes(value),
},
size: {
type: String,
default: 'default',
validator: value => ['default', 'sm', 'lg', 'icon'].includes(value),
},
icon: {
type: String,
default: '',
},
iconPosition: {
type: String,
default: 'left',
validator: value => ['left', 'right'].includes(value),
},
iconLib: {
type: String,
default: 'fluent',
},
});
const emit = defineEmits(['click']);
const buttonVariants = {
variant: {
default:
'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600',
destructive:
'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800',
outline:
'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600',
secondary:
'bg-slate-50 text-slate-900 dark:bg-slate-700/50 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-600',
ghost:
'text-slate-900 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800',
link: 'text-woot-500 underline-offset-4 hover:underline dark:hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-8 px-3',
lg: 'h-11 px-4',
icon: 'h-auto w-auto px-2',
},
text: {
default:
'!text-woot-500 dark:!text-woot-500 hover:!text-woot-600 dark:hover:!text-woot-600',
success:
'!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600',
warning:
'!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600',
danger:
'!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800',
info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500',
},
};
const buttonClasses = computed(() => {
const classes = [
buttonVariants.variant[props.variant],
buttonVariants.size[props.size],
];
if (props.textVariant && buttonVariants.text[props.textVariant]) {
classes.push(buttonVariants.text[props.textVariant]);
}
return classes.join(' ');
});
const iconSize = computed(() => {
if (props.size === 'sm') return 16;
if (props.size === 'lg') return 20;
return 18;
});
const handleClick = () => {
emit('click');
};
</script>
<template>
<button
:class="buttonClasses"
class="inline-flex items-center justify-center h-10 min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
@click="handleClick"
>
<FluentIcon
v-if="icon && iconPosition === 'left'"
:icon="icon"
:size="iconSize"
:icon-lib="iconLib"
class="flex-shrink-0"
/>
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
<FluentIcon
v-if="icon && iconPosition === 'right'"
:icon="icon"
:size="iconSize"
:icon-lib="iconLib"
class="flex-shrink-0"
/>
</button>
</template>
@@ -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,163 +0,0 @@
<script setup>
import { nextTick, ref, computed, watch } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import Button from 'dashboard/components-next/button/Button.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: '',
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValue = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const searchInput = 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 = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => searchInput.value.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValue.value = newValue;
}
);
onClickOutside(comboboxRef, () => {
open.value = false;
});
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
>
<Button
variant="outline"
:label="selectedLabel"
icon-position="right"
size="sm"
:disabled="disabled"
class="justify-between w-full text-slate-900 dark:text-slate-100 group-hover/combobox:border-slate-300 dark:group-hover/combobox:border-slate-600"
:icon="open ? 'chevron-up' : 'chevron-down'"
@click="toggleDropdown"
/>
<div
v-show="open"
class="absolute z-50 w-full mt-1 transition-opacity duration-200 bg-white border rounded-md shadow-lg border-slate-200 dark:bg-slate-900 dark:border-slate-700/50"
>
<div class="relative border-b border-slate-100 dark:border-slate-700/50">
<FluentIcon
icon="search"
:size="14"
class="absolute text-gray-400 dark:text-slate-500 top-3 left-3"
aria-hidden="true"
/>
<input
ref="searchInput"
v-model="search"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm bg-white border-none rounded-t-md dark:bg-slate-900 text-slate-900 dark:text-slate-50"
/>
</div>
<ul
class="py-1 overflow-auto max-h-60"
role="listbox"
:aria-activedescendant="selectedValue"
>
<li
v-for="option in filteredOptions"
: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-slate-50 dark:hover:bg-slate-800/50"
:class="{
'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue,
}"
role="option"
:aria-selected="option.value === selectedValue"
@click="selectOption(option)"
>
<span :class="{ 'font-medium': option.value === selectedValue }">
{{ option.label }}
</span>
<FluentIcon
v-if="option.value === selectedValue"
icon="checkmark"
:size="16"
class="flex-shrink-0"
aria-hidden="true"
/>
</li>
<li
v-if="filteredOptions.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
</div>
</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,109 +0,0 @@
<script setup>
import { ref } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
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: '',
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const open = () => {
dialogRef.value?.showModal();
};
const close = () => {
dialogRef.value?.close();
};
const confirm = () => {
emit('confirm');
};
defineExpose({ open });
onClickOutside(dialogContentRef, event => {
if (
dialogRef.value &&
dialogRef.value.open &&
event.target === dialogRef.value
) {
close();
}
});
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full max-w-lg overflow-visible shadow-xl bg-modal-backdrop-light dark:bg-modal-backdrop-dark rounded-xl"
@close="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-white shadow-xl dark:bg-slate-800 rounded-xl"
@click.stop
>
<div class="flex flex-col gap-2">
<h3
class="text-base font-medium leading-6 text-gray-900 dark:text-white"
>
{{ title }}
</h3>
<p
v-if="description"
class="mb-0 text-sm text-slate-500 dark:text-slate-400"
>
{{ description }}
</p>
</div>
<slot name="form">
<!-- Form content will be injected here -->
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="secondary"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
size="sm"
@click="close"
/>
<Button
v-if="type !== 'alert'"
:variant="type === 'edit' ? 'default' : 'destructive'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
size="sm"
@click="confirm"
/>
</div>
</div>
</dialog>
</Teleport>
</template>
@@ -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,35 +0,0 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
menuItems: {
type: Array,
required: true,
},
});
const emit = defineEmits(['action']);
const handleAction = action => {
emit('action', action);
};
</script>
<template>
<div
class="bg-white dark:bg-slate-800 absolute rounded-xl z-50 py-3 px-1 gap-2 flex flex-col min-w-[136px] shadow-lg"
>
<Button
v-for="item in menuItems"
:key="item.action"
:label="item.label"
:icon="item.icon"
variant="ghost"
size="sm"
class="!justify-start w-full hover:bg-white dark:hover:bg-slate-800 z-60 font-normal"
:text-variant="item.action === 'delete' ? 'danger' : ''"
@click="handleAction(item.action)"
/>
</div>
</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,65 +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,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<div
class="relative flex items-center justify-between w-full gap-2 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)"
/>
</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>
@@ -1,92 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
});
defineEmits(['update:modelValue']);
const messageClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'text-red-500 dark:text-red-400';
case 'success':
return 'text-green-500 dark:text-green-400';
default:
return 'text-slate-500 dark:text-slate-400';
}
});
const inputBorderClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'border-red-500 dark:border-red-400';
default:
return 'border-slate-100 dark:border-slate-700/50';
}
});
</script>
<template>
<div class="relative flex flex-col gap-1">
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:class="[customInputClass, inputBorderClass]"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
class="flex w-full reset-base text-sm h-8 pl-3 pr-2 rtl:pr-3 rtl:pl-2 py-1.5 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium 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)"
/>
<p
v-if="message"
class="mt-1 mb-0 text-xs transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>
@@ -1,80 +0,0 @@
<script setup>
import { ref } from 'vue';
import PaginationFooter from './PaginationFooter.vue';
const createPaginationState = (initialPage, totalItems, itemsPerPage) => {
const currentPage = ref(initialPage);
const handlePageChange = newPage => {
currentPage.value = newPage;
};
return { currentPage, totalItems, itemsPerPage, handlePageChange };
};
const defaultState = createPaginationState(1, 100, 16);
const middlePageState = createPaginationState(3, 100, 16);
const lastPageState = createPaginationState(7, 100, 16);
const customItemsState = createPaginationState(2, 100, 10);
const singlePageState = createPaginationState(1, 10, 16);
</script>
<template>
<Story
title="Components/PaginationFooter"
:layout="{ type: 'grid', width: '957' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="defaultState.currentPage.value"
:total-items="defaultState.totalItems"
:items-per-page="defaultState.itemsPerPage"
@update:current-page="defaultState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Middle Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="middlePageState.currentPage.value"
:total-items="middlePageState.totalItems"
:items-per-page="middlePageState.itemsPerPage"
@update:current-page="middlePageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Last Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="lastPageState.currentPage.value"
:total-items="lastPageState.totalItems"
:items-per-page="lastPageState.itemsPerPage"
@update:current-page="lastPageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Custom Items Per Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="customItemsState.currentPage.value"
:total-items="customItemsState.totalItems"
:items-per-page="customItemsState.itemsPerPage"
@update:current-page="customItemsState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Single Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="singlePageState.currentPage.value"
:total-items="singlePageState.totalItems"
:items-per-page="singlePageState.itemsPerPage"
@update:current-page="singlePageState.handlePageChange"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,113 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
currentPage: {
type: Number,
required: true,
},
totalItems: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
default: 16,
},
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
);
const startItem = computed(
() => (props.currentPage - 1) * props.itemsPerPage + 1
);
const endItem = computed(() =>
Math.min(startItem.value + props.itemsPerPage - 1, props.totalItems)
);
const isFirstPage = computed(() => props.currentPage === 1);
const isLastPage = computed(() => props.currentPage === totalPages.value);
const changePage = newPage => {
if (newPage >= 1 && newPage <= totalPages.value) {
emit('update:currentPage', newPage);
}
};
const currentPageInformation = computed(() => {
return t('PAGINATION_FOOTER.SHOWING', {
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
});
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
});
</script>
<template>
<div
class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-slate-25 dark:bg-slate-800/50 rounded-xl py-2 px-3 items-center"
>
<div class="flex items-center gap-3">
<span
class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300"
>
{{ currentPageInformation }}
</span>
</div>
<div class="flex items-center gap-2">
<Button
icon="chevrons-lucide-left"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isFirstPage"
@click="changePage(1)"
/>
<Button
icon="chevron-lucide-left"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isFirstPage"
@click="changePage(currentPage - 1)"
/>
<div
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"
>
<span
class="px-3 tabular-nums py-0.5 bg-white dark:bg-slate-900 rounded-md"
>
{{ currentPage }}
</span>
<span>{{ pageInfo }}</span>
</div>
<Button
icon="chevron-lucide-right"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isLastPage"
@click="changePage(currentPage + 1)"
/>
<Button
icon="chevrons-lucide-right"
icon-lib="lucide"
variant="ghost"
size="sm"
:disabled="isLastPage"
@click="changePage(totalPages)"
/>
</div>
</div>
</template>
@@ -1,15 +0,0 @@
<script setup>
import Spinner from './Spinner.vue';
</script>
<template>
<Story title="Components/Spinner" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<Spinner size="24" class="text-n-slate-10" />
<Spinner size="32" class="text-n-ruby-10" />
<Spinner size="40" class="text-n-amber-10" />
<Spinner size="48" class="text-n-teal-10" />
<Spinner size="64" class="text-n-brand" />
</Variant>
</Story>
</template>
@@ -1,25 +0,0 @@
<script setup>
defineProps({
size: {
type: Number,
default: 24,
},
});
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeLinejoin="round"
class="animate-spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</template>
@@ -1,61 +0,0 @@
<script setup>
import TabBar from './TabBar.vue';
const defaultTabs = [
{ label: 'All articles', count: 24 },
{ label: 'Mine', count: 13 },
{ label: 'Draft', count: 5 },
{ label: 'Archived', count: 11 },
{ label: 'Unpublished', count: 23 },
{ label: 'Published', count: 100 },
{ label: 'Scheduled', count: 49 },
];
const customTabs = [
{ label: 'Active', count: 10 },
{ label: 'Pending', count: 5 },
{ label: 'Completed', count: 15 },
];
const handleTabChanged = tab => {
console.log('Tab changed:', tab);
};
</script>
<template>
<Story title="Components/TabBar" :layout="{ type: 'grid', width: '920px' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar :tabs="defaultTabs" @tab-changed="handleTabChanged" />
</div>
</Variant>
<Variant title="Custom Tabs with Initial Tab">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar
:tabs="customTabs"
:initial-active-tab="1"
@tab-changed="handleTabChanged"
/>
</div>
</Variant>
<Variant title="No Counts">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar
:tabs="[{ label: 'Tab 1' }, { label: 'Tab 2' }, { label: 'Tab 3' }]"
@tab-changed="handleTabChanged"
/>
</div>
</Variant>
<Variant title="Single Tab">
<div class="p-4 bg-white dark:bg-slate-900">
<TabBar
:tabs="[{ label: 'Single Tab', count: 42 }]"
@tab-changed="handleTabChanged"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,58 +0,0 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
initialActiveTab: {
type: Number,
default: 0,
},
tabs: {
type: Array,
required: true,
validator: value => {
return value.every(
tab => typeof tab.label === 'string' && typeof tab.count === 'number'
);
},
},
});
const emit = defineEmits(['tabChanged']);
const activeTab = ref(props.initialActiveTab);
const selectTab = index => {
activeTab.value = index;
emit('tabChanged', props.tabs[index]);
};
const showDivider = index => {
return (
// Show dividers after the active tab, but not after the last tab
(index > activeTab.value && index < props.tabs.length - 1) ||
// Show dividers before the active tab, but not immediately before it and not before the first tab
(index < activeTab.value - 1 && index > -1)
);
};
</script>
<template>
<div class="flex h-8 rounded-lg bg-slate-25 dark:bg-slate-800/50 w-fit">
<template v-for="(tab, index) in tabs" :key="index">
<button
class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out"
:class="[
activeTab === index
? 'text-woot-500 bg-woot-500/10 dark:bg-woot-500/10'
: 'text-slate-500 dark:text-slate-400 hover:text-woot-500 dark:hover:text-woot-400',
]"
@click="selectTab(index)"
>
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button>
<div
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class="
showDivider(index)
? 'bg-slate-75 dark:bg-slate-800'
: 'bg-transparent dark:bg-transparent'
"
/>
</template>
</div>
</template>

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