Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5171c81384 |
+2
-10
@@ -26,12 +26,6 @@ jobs:
|
||||
override-ci-command: pnpm i
|
||||
- run: node --version
|
||||
- run: pnpm --version
|
||||
- run:
|
||||
name: Add PostgreSQL repository and update
|
||||
command: |
|
||||
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||
sudo apt-get update -y
|
||||
|
||||
- run:
|
||||
name: Install System Dependencies
|
||||
@@ -40,9 +34,7 @@ jobs:
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
||||
libpq-dev \
|
||||
redis-server \
|
||||
postgresql-common \
|
||||
postgresql-16 \
|
||||
postgresql-16-pgvector \
|
||||
postgresql \
|
||||
build-essential \
|
||||
git \
|
||||
curl \
|
||||
@@ -82,7 +74,7 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
rvm install "3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
gem install bundler -v 2.5.16
|
||||
gem install bundler
|
||||
|
||||
- run:
|
||||
name: Install Application Dependencies
|
||||
|
||||
@@ -51,12 +51,9 @@ exclude_patterns:
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
|
||||
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
|
||||
- 'app/javascript/dashboard/store/captain/storeFactory.js'
|
||||
- 'app/javascript/dashboard/i18n/index.js'
|
||||
- 'app/javascript/widget/i18n/index.js'
|
||||
- 'app/javascript/survey/i18n/index.js'
|
||||
- 'app/javascript/shared/constants/locales.js'
|
||||
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
|
||||
- '**/fixtures/**'
|
||||
- '**/*/fixtures.js'
|
||||
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
network_mode: service:db
|
||||
|
||||
db:
|
||||
image: pgvector/pgvector:pg16
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
@@ -12,7 +12,6 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -41,9 +40,7 @@ jobs:
|
||||
|
||||
- name: set docker tag
|
||||
run: |
|
||||
# Replace forward slashes with hyphens in the ref name
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: replace docker tag if master
|
||||
if: github.ref_name == 'master'
|
||||
@@ -51,7 +48,6 @@ jobs:
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -62,6 +58,6 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64, linux/arm64
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# #
|
||||
# # This action will strip the enterprise folder
|
||||
# # and run the spec.
|
||||
# # This is set to run against every PR.
|
||||
# #
|
||||
|
||||
name: Run Chatwoot CE spec
|
||||
on:
|
||||
push:
|
||||
@@ -12,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
image: postgres:15.3
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# #
|
||||
# # This workflow will run specs related to response bot
|
||||
# # This can only be activated in installations Where vector extension is available.
|
||||
# #
|
||||
|
||||
name: Run Response Bot spec
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
services:
|
||||
postgres:
|
||||
image: ankane/pgvector
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
# tmpfs makes DB faster by using RAM
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.3.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: pnpm
|
||||
run: pnpm install
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Enable ResponseBotService in installation
|
||||
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
|
||||
|
||||
# Run Response Bot specs
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
|
||||
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
|
||||
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
|
||||
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rails-log-folder
|
||||
path: log
|
||||
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
|
||||
# original gem isn't maintained actively
|
||||
# we wanted updated version of faraday which is a dependency for slack-ruby-client
|
||||
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
|
||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'image_processing'
|
||||
|
||||
##-- gems for database --#
|
||||
@@ -175,7 +175,8 @@ gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'ruby-openai'
|
||||
gem 'opensearch-ruby'
|
||||
gem 'searchkick'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
@@ -183,9 +184,6 @@ gem 'ruby-openai'
|
||||
group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
# for heroku autoscaling
|
||||
gem 'judoscale-rails', require: false
|
||||
gem 'judoscale-sidekiq', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
+94
-100
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actioncable (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activestorage (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actionmailbox (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
actionview (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actionmailer (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.7)
|
||||
actionview (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actionpack (7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activestorage (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actiontext (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actionview (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activejob (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activerecord (7.0.8.7)
|
||||
activemodel (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activemodel (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
activerecord (7.0.8.5)
|
||||
activemodel (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activestorage (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.7)
|
||||
activesupport (7.0.8.5)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -209,7 +209,7 @@ GEM
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 7.2)
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
digest-crc (0.6.4)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
@@ -231,7 +231,6 @@ GEM
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
execjs (2.8.1)
|
||||
facebook-messenger (2.0.1)
|
||||
httparty (~> 0.13, >= 0.13.7)
|
||||
@@ -285,39 +284,39 @@ GEM
|
||||
activesupport (>= 6.1)
|
||||
gmail_xoauth (0.4.3)
|
||||
oauth (>= 0.3.6)
|
||||
google-apis-core (0.15.1)
|
||||
google-apis-core (0.11.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
google-apis-iamcredentials_v1 (0.22.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.47.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.7.1)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-dialogflow-v2 (0.31.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-location (>= 0.4, < 2.a)
|
||||
google-cloud-env (2.2.1)
|
||||
faraday (>= 1.0, < 3.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-errors (~> 1.0)
|
||||
google-cloud-storage (1.52.0)
|
||||
google-cloud-storage (1.44.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (~> 0.13)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (~> 0.38)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.19.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
@@ -332,10 +331,10 @@ GEM
|
||||
grpc (~> 1.41)
|
||||
googleapis-common-protos-types (1.14.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
googleauth (1.5.2)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
@@ -398,13 +397,6 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
judoscale-ruby (1.8.2)
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
@@ -453,7 +445,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.23.1)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -463,6 +455,7 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
maxminddb (0.1.22)
|
||||
memoist (0.16.2)
|
||||
meta_request (0.8.3)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 8)
|
||||
@@ -472,15 +465,14 @@ GEM
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.25.1)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
activerecord (>= 5.2)
|
||||
net-http (0.4.1)
|
||||
@@ -503,14 +495,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.17.1)
|
||||
nokogiri (1.16.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.1-arm64-darwin)
|
||||
nokogiri (1.16.7-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.1-x86_64-darwin)
|
||||
nokogiri (1.16.7-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.17.1-x86_64-linux)
|
||||
nokogiri (1.16.7-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -540,6 +532,9 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -582,30 +577,30 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.8.7)
|
||||
actioncable (= 7.0.8.7)
|
||||
actionmailbox (= 7.0.8.7)
|
||||
actionmailer (= 7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
actiontext (= 7.0.8.7)
|
||||
actionview (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activemodel (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activestorage (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
rails (7.0.8.5)
|
||||
actioncable (= 7.0.8.5)
|
||||
actionmailbox (= 7.0.8.5)
|
||||
actionmailer (= 7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
actiontext (= 7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activemodel (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.7)
|
||||
railties (= 7.0.8.5)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -685,10 +680,6 @@ GEM
|
||||
rubocop-rspec (2.21.0)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-capybara (~> 2.17)
|
||||
ruby-openai (7.3.1)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
@@ -715,6 +706,9 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.4.0)
|
||||
activemodel (>= 6.1)
|
||||
hashie
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -829,6 +823,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.2)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -890,7 +885,7 @@ DEPENDENCIES
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
google-cloud-storage (>= 1.48.0)
|
||||
google-cloud-storage
|
||||
google-cloud-translate-v3 (>= 0.7.0)
|
||||
groupdate
|
||||
grpc
|
||||
@@ -902,8 +897,6 @@ DEPENDENCIES
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt
|
||||
kaminari
|
||||
koala
|
||||
@@ -923,6 +916,7 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -946,9 +940,9 @@ DEPENDENCIES
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
|
||||
+4
-4
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
|
||||
We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
|
||||
|
||||
If you have any questions about the process, contact security@chatwoot.com.
|
||||
|
||||
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible.
|
||||
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
|
||||
|
||||
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new)
|
||||
> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
|
||||
- Brute force attacks
|
||||
- DNSSEC
|
||||
|
||||
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new).
|
||||
If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
|
||||
|
||||
|
||||
## Thanks
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.1.0
|
||||
3.0.0
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"size": "basic"
|
||||
}
|
||||
},
|
||||
"stack": "heroku-24",
|
||||
"stack": "heroku-20",
|
||||
"image": "heroku/ruby",
|
||||
"addons": [
|
||||
{
|
||||
@@ -58,7 +58,7 @@
|
||||
"plan": "heroku-postgresql:essential-0"
|
||||
}
|
||||
],
|
||||
"stack": "heroku-24",
|
||||
"stack": "heroku-20",
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "heroku/nodejs"
|
||||
|
||||
@@ -54,7 +54,7 @@ class ContactMergeAction
|
||||
# attributes in base contact are given preference
|
||||
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
|
||||
|
||||
@mergee_contact.reload.destroy!
|
||||
@mergee_contact.destroy!
|
||||
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
|
||||
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
|
||||
@base_contact.update!(merged_attributes)
|
||||
|
||||
@@ -25,8 +25,6 @@ class NotificationBuilder
|
||||
def build_notification
|
||||
# Create conversation_creation notification only if user is subscribed to it
|
||||
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
|
||||
# skip notifications for blocked conversations except for user mentions
|
||||
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
|
||||
|
||||
user.notifications.create!(
|
||||
notification_type: notification_type,
|
||||
|
||||
@@ -2,38 +2,52 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
end
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def build_agent_stats(account_user)
|
||||
user_id = account_user.user_id
|
||||
{
|
||||
id: user_id,
|
||||
conversations_count: conversations_count[user_id] || 0,
|
||||
resolved_conversations_count: resolved_count[user_id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[user_id],
|
||||
avg_first_response_time: avg_first_response_time[user_id],
|
||||
avg_reply_time: avg_reply_time[user_id]
|
||||
}
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:user_id
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.each_with_object([]) do |account_user, arr|
|
||||
arr << {
|
||||
id: account_user.user_id,
|
||||
conversations_count: @grouped_conversations_count[account_user.user_id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
|
||||
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
load_data
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
end
|
||||
end
|
||||
|
||||
def build_inbox_stats(inbox)
|
||||
{
|
||||
id: inbox.id,
|
||||
conversations_count: conversations_count[inbox.id] || 0,
|
||||
resolved_conversations_count: resolved_count[inbox.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[inbox.id],
|
||||
avg_first_response_time: avg_first_response_time[inbox.id],
|
||||
avg_reply_time: avg_reply_time[inbox.id]
|
||||
}
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,37 +1,49 @@
|
||||
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
set_grouped_conversations_count
|
||||
set_grouped_avg_reply_time
|
||||
set_grouped_avg_first_response_time
|
||||
set_grouped_avg_resolution_time
|
||||
prepare_report
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
def set_grouped_conversations_count
|
||||
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
def set_grouped_avg_resolution_time
|
||||
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_first_response_time
|
||||
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
|
||||
end
|
||||
|
||||
def set_grouped_avg_reply_time
|
||||
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
end
|
||||
end
|
||||
|
||||
def build_team_stats(team)
|
||||
{
|
||||
id: team.id,
|
||||
conversations_count: conversations_count[team.id] || 0,
|
||||
resolved_conversations_count: resolved_count[team.id] || 0,
|
||||
avg_resolution_time: avg_resolution_time[team.id],
|
||||
avg_first_response_time: avg_first_response_time[team.id],
|
||||
avg_reply_time: avg_reply_time[team.id]
|
||||
}
|
||||
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
'conversations.team_id'
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.each_with_object([]) do |team, arr|
|
||||
arr << {
|
||||
id: team.id,
|
||||
conversations_count: @grouped_conversations_count[team.id],
|
||||
avg_resolution_time: @grouped_avg_resolution_time[team.id],
|
||||
avg_first_response_time: @grouped_avg_first_response_time[team.id],
|
||||
avg_reply_time: @grouped_avg_reply_time[team.id]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@@ -68,7 +68,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contacts = fetch_contacts(contacts)
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -46,7 +46,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
CustomExceptions::CustomFilter::InvalidOperator,
|
||||
CustomExceptions::CustomFilter::InvalidQueryOperator,
|
||||
CustomExceptions::CustomFilter::InvalidValue => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_hook
|
||||
|
||||
def sso_url
|
||||
params_string =
|
||||
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
|
||||
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
|
||||
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
|
||||
|
||||
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
|
||||
|
||||
sso_url = "#{installation_config.value}/sso?#{params_string}"
|
||||
render json: { sso_url: sso_url }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Current.account.hooks.find_by!(app_id: 'captain')
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
|
||||
def avatar
|
||||
@user.avatar.attachment.destroy! if @user.avatar.attached?
|
||||
@user.reload
|
||||
head :ok
|
||||
end
|
||||
|
||||
def auto_offline
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
|
||||
before_action :prepare_builder_params, only: [:agent, :team]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@@ -10,10 +10,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
@@ -30,7 +26,8 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
render json: builder.build
|
||||
data = builder.build
|
||||
render json: data
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -4,7 +4,6 @@ class Platform::Api::V1::AccountsController < PlatformController
|
||||
def create
|
||||
@resource = Account.create!(account_params)
|
||||
update_resource_features
|
||||
@resource.save!
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
end
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
|
||||
def index
|
||||
@articles = @portal.articles.published
|
||||
@articles_count = @articles.count
|
||||
search_articles
|
||||
order_by_sort_param
|
||||
@articles = @articles.page(list_params[:page]) if list_params[:page].present?
|
||||
@articles.page(list_params[:page]) if list_params[:page].present?
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -45,7 +44,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.permit(:query, :locale, :sort, :status, :page)
|
||||
params.permit(:query, :locale, :sort, :status)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -26,8 +26,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
conversations: CountField,
|
||||
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
|
||||
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
|
||||
account_users: Field::HasMany,
|
||||
custom_attributes: Field::String
|
||||
account_users: Field::HasMany
|
||||
}.merge(enterprise_attribute_types).freeze
|
||||
|
||||
# COLLECTION_ATTRIBUTES
|
||||
@@ -46,7 +45,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
|
||||
# SHOW_PAGE_ATTRIBUTES
|
||||
# an array of attributes that will be displayed on the model's show page.
|
||||
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
|
||||
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
|
||||
SHOW_PAGE_ATTRIBUTES = (%i[
|
||||
id
|
||||
name
|
||||
|
||||
@@ -22,5 +22,3 @@ class AsyncDispatcher < BaseDispatcher
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')
|
||||
|
||||
@@ -2,6 +2,6 @@ require 'administrate/field/base'
|
||||
|
||||
class Enterprise::AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,12 +81,4 @@ module FilterHelper
|
||||
def default_filter(query_hash, filter_operator_value)
|
||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
return if condition['query_operator'].nil?
|
||||
return if condition['query_operator'].empty?
|
||||
|
||||
operator = condition['query_operator'].upcase
|
||||
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainAssistant extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
@@ -1,20 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainDocument extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/documents', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
assistant_id: assistantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
@@ -1,26 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainInboxes extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ assistantId } = {}) {
|
||||
return axios.get(`${this.url}/${assistantId}/inboxes`);
|
||||
}
|
||||
|
||||
create(params = {}) {
|
||||
const { assistantId, inboxId } = params;
|
||||
return axios.post(`${this.url}/${assistantId}/inboxes`, {
|
||||
inbox: { inbox_id: inboxId },
|
||||
});
|
||||
}
|
||||
|
||||
delete(params = {}) {
|
||||
const { assistantId, inboxId } = params;
|
||||
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainInboxes();
|
||||
@@ -1,22 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainResponses extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistant_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainResponses();
|
||||
@@ -27,14 +27,6 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}?include_contact_inboxes=false`);
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
|
||||
}
|
||||
|
||||
getConversations(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
}
|
||||
|
||||
@@ -133,10 +133,6 @@ class ConversationApi extends ApiClient {
|
||||
getAllAttachments(conversationId) {
|
||||
return axios.get(`${this.url}/${conversationId}/attachments`);
|
||||
}
|
||||
|
||||
requestCopilot(conversationId, body) {
|
||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -32,6 +32,10 @@ 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();
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
|
||||
.mx-input {
|
||||
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none;
|
||||
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
|
||||
}
|
||||
|
||||
.mx-input:disabled,
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
|
||||
.mx-datepicker-main {
|
||||
@apply border-0 bg-n-solid-2 rounded-xl;
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
|
||||
.cell {
|
||||
&.disabled {
|
||||
@@ -53,14 +53,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mx-calendar+.mx-calendar {
|
||||
@apply border-l border-n-weak;
|
||||
}
|
||||
|
||||
.mx-datepicker-footer {
|
||||
@apply border border-n-weak;
|
||||
}
|
||||
|
||||
.mx-time {
|
||||
@apply border-0 bg-white dark:bg-slate-800;
|
||||
|
||||
|
||||
@@ -72,19 +72,6 @@
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
@@ -124,19 +111,6 @@
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
--gray-4: 232 232 232;
|
||||
--gray-5: 224 224 224;
|
||||
--gray-6: 217 217 217;
|
||||
--gray-7: 206 206 206;
|
||||
--gray-8: 187 187 187;
|
||||
--gray-9: 141 141 141;
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
@@ -148,7 +122,6 @@
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
@@ -156,10 +129,10 @@
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
|
||||
.dark {
|
||||
body.dark {
|
||||
/* slate */
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
@@ -174,19 +147,6 @@
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
@@ -226,19 +186,6 @@
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
--gray-4: 42 42 42;
|
||||
--gray-5: 49 49 49;
|
||||
--gray-6: 58 58 58;
|
||||
--gray-7: 72 72 72;
|
||||
--gray-8: 96 96 96;
|
||||
--gray-9: 110 110 110;
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
@@ -248,7 +195,6 @@
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.dropdown-pane {
|
||||
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
|
||||
@apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999];
|
||||
|
||||
&.dropdown-pane--open {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
|
||||
@apply bg-white absolute dark:bg-slate-800 block visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,7 @@ hr {
|
||||
ul,
|
||||
ol,
|
||||
dl {
|
||||
@apply list-disc list-outside leading-[1.65];
|
||||
}
|
||||
|
||||
ul:not(.reset-base),
|
||||
ol:not(.reset-base),
|
||||
dl:not(.reset-base) {
|
||||
@apply mb-0;
|
||||
@apply mb-2 list-disc list-outside leading-[1.65];
|
||||
}
|
||||
|
||||
// Form elements
|
||||
|
||||
@@ -31,7 +31,7 @@ button {
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
|
||||
@apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
|
||||
|
||||
.button__content {
|
||||
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
|
||||
@@ -42,10 +42,8 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.secondary):not(.success):not(.alert):not(.warning):not(
|
||||
.clear
|
||||
):not(.smooth):not(.hollow) {
|
||||
@apply bg-n-brand/80 dark:bg-n-brand/80;
|
||||
&:hover {
|
||||
@apply bg-woot-600 dark:bg-woot-600;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
@@ -54,23 +52,23 @@ button {
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply bg-n-teal-9 text-white dark:text-white;
|
||||
@apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
@apply bg-n-solid-3 text-white dark:text-white;
|
||||
@apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
@apply bg-n-brand text-white dark:text-white;
|
||||
@apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white;
|
||||
}
|
||||
|
||||
&.clear {
|
||||
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
|
||||
@apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply bg-n-ruby-9 text-white dark:text-white;
|
||||
@apply bg-red-500 dark:bg-red-500 text-white dark:text-white;
|
||||
|
||||
&.clear {
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
@@ -78,7 +76,7 @@ button {
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply bg-n-amber-9 text-white dark:text-white;
|
||||
@apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white;
|
||||
|
||||
&.clear {
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
@@ -98,7 +96,7 @@ button {
|
||||
}
|
||||
|
||||
// @TODDO - Remove after moving all buttons to woot-button
|
||||
.icon + .button__content {
|
||||
.icon+.button__content {
|
||||
@apply w-auto;
|
||||
}
|
||||
|
||||
@@ -117,74 +115,114 @@ button {
|
||||
}
|
||||
|
||||
&.hollow {
|
||||
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20;
|
||||
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
|
||||
|
||||
&.secondary {
|
||||
@apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5;
|
||||
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5;
|
||||
@apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5;
|
||||
@apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5;
|
||||
@apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
|
||||
|
||||
&.secondary {
|
||||
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth style
|
||||
&.smooth {
|
||||
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40;
|
||||
@apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900;
|
||||
|
||||
&.secondary {
|
||||
@apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5;
|
||||
@apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5;
|
||||
@apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5;
|
||||
@apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5;
|
||||
@apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200;
|
||||
}
|
||||
}
|
||||
|
||||
&.clear {
|
||||
@apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30;
|
||||
@apply text-woot-500 dark:text-woot-500;
|
||||
|
||||
&.secondary {
|
||||
@apply text-n-slate-12 hover:bg-n-slate-4;
|
||||
@apply text-slate-700 dark:text-slate-100;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply text-n-teal-10 hover:bg-n-teal-4;
|
||||
@apply text-green-700 dark:text-green-100;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply text-n-ruby-11 hover:bg-n-ruby-4;
|
||||
@apply text-red-700 dark:text-red-100;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply text-n-amber-11 hover:bg-n-amber-4;
|
||||
@apply text-yellow-700 dark:text-yellow-600;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100;
|
||||
|
||||
&.secondary {
|
||||
@apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
&.secondary {
|
||||
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
|
||||
@apply active:bg-slate-100 dark:active:bg-slate-900;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&.secondary {
|
||||
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
|
||||
@apply focus:bg-slate-50 dark:focus:bg-slate-700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
}
|
||||
|
||||
.tabs--container--with-border {
|
||||
@apply border-b border-n-weak;
|
||||
}
|
||||
|
||||
.tabs--container--compact.tab--chat-type {
|
||||
.tabs-title {
|
||||
a {
|
||||
@apply py-2 text-sm;
|
||||
}
|
||||
}
|
||||
@apply border-b border-slate-50 dark:border-slate-800/50;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@@ -42,7 +34,7 @@
|
||||
@apply flex-shrink-0 my-0 mx-2;
|
||||
|
||||
.badge {
|
||||
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
|
||||
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
@@ -56,22 +48,22 @@
|
||||
&:hover,
|
||||
&:focus {
|
||||
a {
|
||||
@apply text-n-slate-12;
|
||||
@apply text-slate-800 dark:text-slate-100;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
|
||||
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
|
||||
transition: border-color 0.15s $swift-ease-out-function;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
a {
|
||||
@apply border-b border-n-brand text-n-blue-text;
|
||||
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
|
||||
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,56 +82,60 @@ const inboxIcon = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout layout="row">
|
||||
<div class="flex flex-col items-start justify-between flex-1 min-w-0 gap-2">
|
||||
<div class="flex justify-between gap-3 w-fit">
|
||||
<span
|
||||
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ campaignStatus }}
|
||||
</span>
|
||||
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
|
||||
<template #header>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between gap-3 w-fit">
|
||||
<span
|
||||
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ campaignStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message)"
|
||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||
/>
|
||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||
<LiveChatCampaignDetails
|
||||
v-if="isLiveChatType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
:scheduled-at="scheduledAt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message, false, false, false)"
|
||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||
/>
|
||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||
<LiveChatCampaignDetails
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
icon="i-lucide-sliders-vertical"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
:scheduled-at="scheduledAt"
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
icon="i-lucide-sliders-vertical"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
+3
-5
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -34,11 +33,10 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Avatar
|
||||
<Thumbnail
|
||||
:author="sender || { name: senderName }"
|
||||
:name="senderName"
|
||||
:src="senderThumbnailSrc"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
|
||||
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto">
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
||||
<div class="w-full max-w-[900px] mx-auto py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ defineProps({
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ defineProps({
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
|
||||
@@ -27,6 +27,7 @@ const handleDelete = campaign => emit('delete', campaign);
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
|
||||
+9
-7
@@ -63,12 +63,14 @@ defineExpose({ dialogRef });
|
||||
overflow-y-auto
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<LiveChatCampaignForm
|
||||
ref="liveChatCampaignFormRef"
|
||||
mode="edit"
|
||||
:selected-campaign="selectedCampaign"
|
||||
:show-action-buttons="false"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
<template #form>
|
||||
<LiveChatCampaignForm
|
||||
ref="liveChatCampaignFormRef"
|
||||
mode="edit"
|
||||
:selected-campaign="selectedCampaign"
|
||||
:show-action-buttons="false"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'col',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
@@ -15,18 +13,11 @@ const handleClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-3 px-6 py-5"
|
||||
:class="
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
|
||||
"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<slot name="after" />
|
||||
<slot name="header" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, watch, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
|
||||
// Store the currently hovered label's ID
|
||||
// Using JS state management instead of CSS :hover / group hover
|
||||
// This will solve the flickering issue when hovering over the last label item
|
||||
const hoveredLabel = ref(null);
|
||||
|
||||
const allLabels = useMapGetter('labels/getLabels');
|
||||
const contactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
|
||||
const savedLabels = computed(() => {
|
||||
const availableContactLabels = contactLabels.value(props.contactId);
|
||||
return allLabels.value.filter(({ title }) =>
|
||||
availableContactLabels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return allLabels.value
|
||||
?.map(label => ({
|
||||
label: label.title,
|
||||
value: label.id,
|
||||
thumbnail: { name: label.title, color: label.color },
|
||||
isSelected: savedLabels.value.some(
|
||||
savedLabel => savedLabel.id === label.id
|
||||
),
|
||||
action: 'contactLabel',
|
||||
}))
|
||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||
});
|
||||
|
||||
const fetchLabels = async contactId => {
|
||||
if (!contactId) {
|
||||
return;
|
||||
}
|
||||
store.dispatch('contactLabels/get', contactId);
|
||||
};
|
||||
|
||||
const handleLabelAction = async ({ value }) => {
|
||||
try {
|
||||
// Get current label titles
|
||||
const currentLabels = savedLabels.value.map(label => label.title);
|
||||
|
||||
// Find the label title for the ID (value)
|
||||
const selectedLabel = allLabels.value.find(label => label.id === value);
|
||||
if (!selectedLabel) return;
|
||||
|
||||
let updatedLabels;
|
||||
|
||||
// If label is already selected, remove it (toggle behavior)
|
||||
if (currentLabels.includes(selectedLabel.title)) {
|
||||
updatedLabels = currentLabels.filter(
|
||||
labelTitle => labelTitle !== selectedLabel.title
|
||||
);
|
||||
} else {
|
||||
// Add the new label
|
||||
updatedLabels = [...currentLabels, selectedLabel.title];
|
||||
}
|
||||
|
||||
await store.dispatch('contactLabels/update', {
|
||||
contactId: props.contactId,
|
||||
labels: updatedLabels,
|
||||
});
|
||||
|
||||
showDropdown.value = false;
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLabel = labelId => {
|
||||
return handleLabelAction({ value: labelId });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
fetchLabels(newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
onMounted(() => {
|
||||
if (route.params.contactId) {
|
||||
fetchLabels(route.params.contactId);
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Reset hover state when mouse leaves the container
|
||||
// This ensures all labels return to their default state
|
||||
hoveredLabel.value = null;
|
||||
};
|
||||
|
||||
const handleLabelHover = labelId => {
|
||||
// Added this to prevent flickering on when showing remove button on hover
|
||||
// If the label item is at end of the line, it will show the remove button
|
||||
// when hovering over the last label item
|
||||
hoveredLabel.value = labelId;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
|
||||
<LabelItem
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
:is-hovered="hoveredLabel === label.id"
|
||||
@remove="handleRemoveLabel"
|
||||
@hover="handleLabelHover(label.id)"
|
||||
/>
|
||||
<AddLabel
|
||||
:label-menu-items="labelMenuItems"
|
||||
@update-label="handleLabelAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactsFormRef = ref(null);
|
||||
|
||||
const getInitialContactData = () => ({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
email: props.email,
|
||||
phoneNumber: props.phoneNumber,
|
||||
additionalAttributes: props.additionalAttributes,
|
||||
});
|
||||
|
||||
const contactData = ref(getInitialContactData());
|
||||
|
||||
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const countryDetails = computed(() => {
|
||||
const attributes = props.additionalAttributes || {};
|
||||
const { country, countryCode, city } = attributes;
|
||||
|
||||
if (!country && !countryCode) return null;
|
||||
|
||||
const activeCountry =
|
||||
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||
|
||||
if (!activeCountry) return null;
|
||||
|
||||
return {
|
||||
countryCode: activeCountry.id,
|
||||
city: city ? `${city},` : null,
|
||||
name: activeCountry.name,
|
||||
};
|
||||
});
|
||||
|
||||
const formattedLocation = computed(() => {
|
||||
if (!countryDetails.value) return '';
|
||||
|
||||
return [countryDetails.value.city, countryDetails.value.name]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const handleFormUpdate = updatedData => {
|
||||
Object.assign(contactData.value, updatedData);
|
||||
};
|
||||
|
||||
const handleUpdateContact = () => {
|
||||
emit('updateContact', contactData.value);
|
||||
};
|
||||
|
||||
const onClickExpand = () => {
|
||||
emit('toggle');
|
||||
contactData.value = getInitialContactData();
|
||||
};
|
||||
|
||||
const onClickViewDetails = () => emit('showContact', props.id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :key="id" layout="row">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<transition
|
||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
||||
>
|
||||
<div v-show="isExpanded" class="w-full">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ContactsCard from '../ContactsCard.vue';
|
||||
import contacts from './fixtures';
|
||||
|
||||
const expandedCardId = ref(null);
|
||||
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactsCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default with expandable function">
|
||||
<div class="flex flex-col p-4">
|
||||
<ContactsCard
|
||||
v-bind="contacts[0]"
|
||||
:is-expanded="expandedCardId === contacts[0].id"
|
||||
@toggle="toggleExpanded(contacts[0].id)"
|
||||
@update-contact="() => {}"
|
||||
@show-contact="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Company Name and without phone number">
|
||||
<div class="flex flex-col p-4">
|
||||
<ContactsCard
|
||||
v-bind="{ ...contacts[1], phoneNumber: '' }"
|
||||
:is-expanded="false"
|
||||
@toggle="() => {}"
|
||||
@update-contact="() => {}"
|
||||
@show-contact="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Expanded State">
|
||||
<div class="flex flex-col p-4">
|
||||
<ContactsCard
|
||||
v-bind="contacts[2]"
|
||||
is-expanded
|
||||
@toggle="() => {}"
|
||||
@update-contact="() => {}"
|
||||
@show-contact="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Without Email and Phone">
|
||||
<div class="flex flex-col p-4">
|
||||
<ContactsCard
|
||||
v-bind="{ ...contacts[3], email: '', phoneNumber: '' }"
|
||||
:is-expanded="false"
|
||||
@toggle="() => {}"
|
||||
@update-contact="() => {}"
|
||||
@show-contact="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,149 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
additionalAttributes: {
|
||||
socialProfiles: {},
|
||||
},
|
||||
availabilityStatus: null,
|
||||
email: 'johndoe@chatwoot.com',
|
||||
id: 370,
|
||||
name: 'John Doe',
|
||||
phoneNumber: '+918634322418',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Felix',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1731608270,
|
||||
createdAt: 1731586271,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'kerala',
|
||||
country: 'India',
|
||||
description: 'Curious about the web. ',
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: '',
|
||||
socialProfiles: {
|
||||
github: 'abozler',
|
||||
twitter: 'ozler',
|
||||
facebook: 'abozler',
|
||||
linkedin: 'abozler',
|
||||
instagram: 'ozler',
|
||||
},
|
||||
},
|
||||
availabilityStatus: null,
|
||||
email: 'ozler@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Abraham Ozlers',
|
||||
phoneNumber: '+246232222222',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Upload',
|
||||
customAttributes: {
|
||||
dateContact: '2024-02-01T00:00:00.000Z',
|
||||
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
|
||||
listContact: 'Not spam',
|
||||
numberContact: '12',
|
||||
},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Kerala',
|
||||
country: 'India',
|
||||
description:
|
||||
"I'm Candice developer focusing on building things for the web 🌍. Currently, I’m working as a Product Developer here at @chatwootapp ⚡️🔥",
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: 'IN',
|
||||
socialProfiles: {
|
||||
github: 'cmathersonj',
|
||||
twitter: 'cmather',
|
||||
facebook: 'cmathersonj',
|
||||
linkedin: 'cmathersonj',
|
||||
instagram: 'cmathersonjs',
|
||||
},
|
||||
},
|
||||
availabilityStatus: null,
|
||||
email: 'cmathersonj@va.test',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+917474774742',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Emery',
|
||||
customAttributes: {
|
||||
dateContact: '2024-11-12T03:23:06.963Z',
|
||||
linkContact: 'https://sd.sd',
|
||||
textContact: 'hey',
|
||||
numberContact: '12',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: '',
|
||||
country: '',
|
||||
description: '',
|
||||
companyName: '',
|
||||
countryCode: '',
|
||||
socialProfiles: {
|
||||
github: '',
|
||||
twitter: '',
|
||||
facebook: '',
|
||||
linkedin: '',
|
||||
instagram: '',
|
||||
},
|
||||
},
|
||||
availabilityStatus: null,
|
||||
email: 'ofolkardi@taobao.test',
|
||||
id: 21,
|
||||
name: 'Ophelia Folkard',
|
||||
phoneNumber: '',
|
||||
identifier: null,
|
||||
thumbnail:
|
||||
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBPZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--08dcac8eb72ef12b2cad92d58dddd04cd8a5f513/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/32.jpg',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
socialProfiles: {},
|
||||
},
|
||||
availabilityStatus: null,
|
||||
email: 'wcasteloth@exblog.jp',
|
||||
id: 20,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+919384',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Jade',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: '',
|
||||
country: '',
|
||||
description: '',
|
||||
companyName: '',
|
||||
countryCode: '',
|
||||
socialProfiles: {
|
||||
github: '',
|
||||
twitter: '',
|
||||
facebook: '',
|
||||
linkedin: '',
|
||||
instagram: '',
|
||||
},
|
||||
},
|
||||
availabilityStatus: null,
|
||||
email: 'ederingtong@printfriendly.test',
|
||||
id: 19,
|
||||
name: 'Elisabeth Derington',
|
||||
phoneNumber: '',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Jade',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
];
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
const props = defineProps({
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goToContactsList']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
|
||||
const contactId = computed(() => route.params.contactId);
|
||||
|
||||
const selectedContactName = computed(() => {
|
||||
return props.selectedContact?.name;
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.BREADCRUMB.CONTACTS'),
|
||||
link: '#',
|
||||
},
|
||||
];
|
||||
if (props.selectedContact) {
|
||||
items.push({
|
||||
label: selectedContactName.value,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleBreadcrumbClick = () => {
|
||||
emit('goToContactsList');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[650px]">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@click="handleBreadcrumbClick"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[650px]">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goToContactsList']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const deleteContact = async id => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('contacts/delete', id);
|
||||
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
emit('goToContactsList');
|
||||
await deleteContact(route.params.contactId || props.selectedContact.id);
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
||||
:description="
|
||||
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
|
||||
contactName: props.selectedContact.name,
|
||||
})
|
||||
"
|
||||
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</template>
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const emit = defineEmits(['export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const segments = useMapGetter('customViews/getContactCustomViews');
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isExportingContact = computed(() => uiFlags.value.isExporting);
|
||||
|
||||
const activeSegmentId = computed(() => route.params.segmentId);
|
||||
const activeSegment = computed(() =>
|
||||
activeSegmentId.value
|
||||
? segments.value.find(view => view.id === Number(activeSegmentId.value))
|
||||
: undefined
|
||||
);
|
||||
|
||||
const exportContacts = async () => {
|
||||
let query = { payload: [] };
|
||||
|
||||
if (activeSegmentId.value && activeSegment.value) {
|
||||
query = activeSegment.value.query;
|
||||
} else if (Object.keys(appliedFilters.value).length > 0) {
|
||||
query = filterQueryGenerator(appliedFilters.value);
|
||||
}
|
||||
|
||||
emit('export', {
|
||||
...query,
|
||||
label: route.params.label || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
await exportContacts();
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.TITLE')"
|
||||
:description="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.DESCRIPTION')
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.CONFIRM')
|
||||
"
|
||||
:is-loading="isExportingContact"
|
||||
:disable-confirm-button="isExportingContact"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</template>
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['import']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isImportingContact = computed(() => uiFlags.value.isImporting);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const fileInput = ref(null);
|
||||
|
||||
const hasSelectedFile = ref(null);
|
||||
const selectedFileName = ref('');
|
||||
|
||||
const csvUrl = '/downloads/import-contacts-sample.csv';
|
||||
|
||||
const handleFileClick = () => fileInput.value?.click();
|
||||
|
||||
const processFileName = fileName => {
|
||||
const lastDotIndex = fileName.lastIndexOf('.');
|
||||
const extension = fileName.slice(lastDotIndex);
|
||||
const baseName = fileName.slice(0, lastDotIndex);
|
||||
|
||||
return baseName.length > 20
|
||||
? `${baseName.slice(0, 20)}...${extension}`
|
||||
: fileName;
|
||||
};
|
||||
|
||||
const handleFileChange = () => {
|
||||
const file = fileInput.value?.files[0];
|
||||
hasSelectedFile.value = file;
|
||||
selectedFileName.value = file ? processFileName(file.name) : '';
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
hasSelectedFile.value = null;
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = null;
|
||||
}
|
||||
selectedFileName.value = '';
|
||||
};
|
||||
|
||||
const uploadFile = async () => {
|
||||
if (!hasSelectedFile.value) return;
|
||||
emit('import', hasSelectedFile.value);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.TITLE')"
|
||||
:confirm-button-label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.IMPORT')
|
||||
"
|
||||
:is-loading="isImportingContact"
|
||||
:disable-confirm-button="isImportingContact"
|
||||
@confirm="uploadFile"
|
||||
>
|
||||
<template #description>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DESCRIPTION') }}
|
||||
<a
|
||||
:href="csvUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download="import-contacts-sample.csv"
|
||||
class="text-n-blue-text"
|
||||
>
|
||||
{{
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
|
||||
}}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-n-slate-12 whitespace-nowrap">
|
||||
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
|
||||
</label>
|
||||
<div class="flex items-center justify-between w-full gap-2">
|
||||
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
|
||||
{{ selectedFileName }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="!hasSelectedFile"
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
|
||||
"
|
||||
icon="i-lucide-upload"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!w-fit"
|
||||
@click="handleFileClick"
|
||||
/>
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleFileClick"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleRemoveFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="text/csv"
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,112 +0,0 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
primaryContactId: {
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
primaryContactList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSearching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:primaryContactId', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between h-5 gap-2">
|
||||
<label class="text-sm text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
|
||||
</label>
|
||||
<span
|
||||
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
:model-value="primaryContactId"
|
||||
:options="primaryContactList"
|
||||
:empty-state="
|
||||
isSearching
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
|
||||
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
|
||||
"
|
||||
:search-placeholder="
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
|
||||
:has-error="hasError"
|
||||
:message="errorMessage"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
@update:model-value="value => emit('update:primaryContactId', value)"
|
||||
@search="query => emit('search', query)"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex justify-center gap-2 top-4">
|
||||
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
|
||||
<div
|
||||
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
|
||||
/>
|
||||
<div
|
||||
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between h-5 gap-2">
|
||||
<label class="text-sm text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
|
||||
</label>
|
||||
<span
|
||||
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
|
||||
>
|
||||
<Avatar
|
||||
:name="selectedContact.name || ''"
|
||||
:src="selectedContact.thumbnail || ''"
|
||||
:size="32"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col w-full min-w-0 gap-1">
|
||||
<span class="text-sm leading-4 truncate text-n-slate-11">
|
||||
{{ selectedContact.name }}
|
||||
</span>
|
||||
<span class="text-sm leading-4 truncate text-n-slate-11">
|
||||
{{ selectedContact.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,315 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, email, minLength } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { splitName } from '@chatwoot/utils';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactData: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isDetailsView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isNewContact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const FORM_CONFIG = {
|
||||
FIRST_NAME: { field: 'firstName' },
|
||||
LAST_NAME: { field: 'lastName' },
|
||||
EMAIL_ADDRESS: { field: 'email' },
|
||||
PHONE_NUMBER: { field: 'phoneNumber' },
|
||||
CITY: { field: 'additionalAttributes.city' },
|
||||
COUNTRY: { field: 'additionalAttributes.countryCode' },
|
||||
BIO: { field: 'additionalAttributes.description' },
|
||||
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
|
||||
};
|
||||
|
||||
const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
};
|
||||
|
||||
const defaultState = {
|
||||
id: 0,
|
||||
name: '',
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
additionalAttributes: {
|
||||
description: '',
|
||||
companyName: '',
|
||||
countryCode: '',
|
||||
country: '',
|
||||
city: '',
|
||||
socialProfiles: {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const state = reactive({ ...defaultState });
|
||||
|
||||
const validationRules = {
|
||||
firstName: { required, minLength: minLength(2) },
|
||||
email: { email },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isFormInvalid = computed(() => v$.value.$invalid);
|
||||
|
||||
const prepareStateBasedOnProps = () => {
|
||||
if (props.isNewContact) {
|
||||
return; // Added to prevent state update for new contact form
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
name = '',
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
additionalAttributes = {},
|
||||
} = props.contactData || {};
|
||||
const { firstName, lastName } = splitName(name || '');
|
||||
const {
|
||||
description = '',
|
||||
companyName = '',
|
||||
countryCode = '',
|
||||
country = '',
|
||||
city = '',
|
||||
socialProfiles = {},
|
||||
} = additionalAttributes || {};
|
||||
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
firstName,
|
||||
lastName,
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
additionalAttributes: {
|
||||
description,
|
||||
companyName,
|
||||
countryCode,
|
||||
country,
|
||||
city,
|
||||
socialProfiles,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const countryOptions = computed(() =>
|
||||
countries.map(({ name, id }) => ({ label: name, value: id }))
|
||||
);
|
||||
|
||||
const editDetailsForm = computed(() =>
|
||||
Object.keys(FORM_CONFIG).map(key => ({
|
||||
key,
|
||||
placeholder: t(
|
||||
`CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.${key}.PLACEHOLDER`
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
const socialProfilesForm = computed(() =>
|
||||
Object.entries(SOCIAL_CONFIG).map(([key, icon]) => ({
|
||||
key,
|
||||
placeholder: t(`CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.FORM.${key}.PLACEHOLDER`),
|
||||
icon,
|
||||
}))
|
||||
);
|
||||
|
||||
const isValidationField = key => {
|
||||
const field = FORM_CONFIG[key]?.field;
|
||||
return ['firstName', 'email'].includes(field);
|
||||
};
|
||||
|
||||
const getValidationKey = key => {
|
||||
return FORM_CONFIG[key]?.field;
|
||||
};
|
||||
|
||||
// Creates a computed property for two-way form field binding
|
||||
const getFormBinding = key => {
|
||||
const field = FORM_CONFIG[key]?.field;
|
||||
if (!field) return null;
|
||||
|
||||
return computed({
|
||||
get: () => {
|
||||
// Handle firstName/lastName fields
|
||||
if (field === 'firstName' || field === 'lastName') {
|
||||
return state[field]?.toString() || '';
|
||||
}
|
||||
|
||||
// Handle nested vs non-nested fields
|
||||
const [base, nested] = field.split('.');
|
||||
// Example: 'email' → state.email
|
||||
// Example: 'additionalAttributes.city' → state.additionalAttributes.city
|
||||
return (nested ? state[base][nested] : state[base])?.toString() || '';
|
||||
},
|
||||
|
||||
set: async value => {
|
||||
// Handle name fields specially to maintain the combined 'name' field
|
||||
if (field === 'firstName' || field === 'lastName') {
|
||||
state[field] = value;
|
||||
// Example: firstName="John", lastName="Doe" → name="John Doe"
|
||||
state.name = `${state.firstName} ${state.lastName}`.trim();
|
||||
} else {
|
||||
// Handle nested vs non-nested fields
|
||||
const [base, nested] = field.split('.');
|
||||
if (nested) {
|
||||
// Example: additionalAttributes.city = "New York"
|
||||
state[base][nested] = value;
|
||||
} else {
|
||||
// Example: email = "test@example.com"
|
||||
state[base] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (isFormValid) {
|
||||
const { firstName, lastName, ...stateWithoutNames } = state;
|
||||
emit('update', stateWithoutNames);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getMessageType = key => {
|
||||
return isValidationField(key) && v$.value[getValidationKey(key)]?.$error
|
||||
? 'error'
|
||||
: 'info';
|
||||
};
|
||||
|
||||
const handleCountrySelection = value => {
|
||||
const selectedCountry = countries.find(option => option.id === value);
|
||||
state.additionalAttributes.country = selectedCountry?.name || '';
|
||||
emit('update', state);
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(state, defaultState);
|
||||
};
|
||||
|
||||
watch(() => props.contactData, prepareStateBasedOnProps, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
});
|
||||
|
||||
// Expose state to parent component for avatar upload
|
||||
defineExpose({
|
||||
state,
|
||||
resetValidation,
|
||||
isFormInvalid,
|
||||
resetForm,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
|
||||
</span>
|
||||
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<template v-for="item in editDetailsForm" :key="item.key">
|
||||
<ComboBox
|
||||
v-if="item.key === 'COUNTRY'"
|
||||
v-model="state.additionalAttributes.countryCode"
|
||||
:options="countryOptions"
|
||||
:placeholder="item.placeholder"
|
||||
class="[&>div>button]:h-8"
|
||||
:class="{
|
||||
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
|
||||
!isDetailsView,
|
||||
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
|
||||
isDetailsView,
|
||||
}"
|
||||
@update:model-value="handleCountrySelection"
|
||||
/>
|
||||
<PhoneNumberInput
|
||||
v-else-if="item.key === 'PHONE_NUMBER'"
|
||||
v-model="getFormBinding(item.key).value"
|
||||
:placeholder="item.placeholder"
|
||||
:show-border="isDetailsView"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="getFormBinding(item.key).value"
|
||||
:placeholder="item.placeholder"
|
||||
:message-type="getMessageType(item.key)"
|
||||
:custom-input-class="`h-8 !pt-1 !pb-1 ${
|
||||
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
|
||||
}`"
|
||||
class="w-full"
|
||||
@input="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
@blur="
|
||||
isValidationField(item.key) &&
|
||||
v$[getValidationKey(item.key)].$touch()
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in socialProfilesForm"
|
||||
:key="item.key"
|
||||
class="flex items-center h-8 gap-2 px-2 rounded-lg"
|
||||
:class="{
|
||||
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
|
||||
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
|
||||
}"
|
||||
>
|
||||
<Icon
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-4"
|
||||
/>
|
||||
<input
|
||||
v-model="
|
||||
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
|
||||
"
|
||||
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
|
||||
:placeholder="item.placeholder"
|
||||
:size="item.placeholder.length"
|
||||
@input="emit('update', state)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const contactsFormRef = ref(null);
|
||||
const contact = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isCreatingContact = computed(() => uiFlags.value.isCreating);
|
||||
|
||||
const createNewContact = contactItem => {
|
||||
contact.value = contactItem;
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
if (!contact.value) return;
|
||||
emit('create', contact.value);
|
||||
};
|
||||
|
||||
const onSuccess = () => {
|
||||
contactsFormRef.value?.resetForm();
|
||||
dialogRef.value.close();
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
dialogRef.value.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
is-new-contact
|
||||
@update="createNewContact"
|
||||
/>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="link"
|
||||
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
|
||||
"
|
||||
color="blue"
|
||||
:is-loading="isCreatingContact"
|
||||
@click="handleDialogConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const FILTER_TYPE_CONTACT = 1;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const uiFlags = useMapGetter('customViews/getUIFlags');
|
||||
const isCreating = computed(() => uiFlags.value.isCreating);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const state = reactive({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const validationRules = {
|
||||
name: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
const isNameValid = await v$.value.$validate();
|
||||
if (!isNameValid) return;
|
||||
emit('create', {
|
||||
name: state.name,
|
||||
filter_type: FILTER_TYPE_CONTACT,
|
||||
});
|
||||
state.name = '';
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.TITLE')"
|
||||
:confirm-button-label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.CONFIRM')
|
||||
"
|
||||
:is-loading="isCreating"
|
||||
:disable-confirm-button="isCreating"
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<Input
|
||||
v-model="state.name"
|
||||
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.LABEL')"
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.PLACEHOLDER')
|
||||
"
|
||||
:message="
|
||||
v$.name.$error
|
||||
? t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR')
|
||||
: ''
|
||||
"
|
||||
:message-type="v$.name.$error ? 'error' : 'info'"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const FILTER_TYPE_CONTACT = 'contact';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const uiFlags = useMapGetter('customViews/getUIFlags');
|
||||
const isDeleting = computed(() => uiFlags.value.isDeleting);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
emit('delete', {
|
||||
filterType: FILTER_TYPE_CONTACT,
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.TITLE')"
|
||||
:description="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.DESCRIPTION')
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.CONFIRM')
|
||||
"
|
||||
:is-loading="isDeleting"
|
||||
:disable-confirm-button="isDeleting"
|
||||
@confirm="handleDialogConfirm"
|
||||
/>
|
||||
</template>
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
<script setup>
|
||||
import ContactMergeForm from '../ContactMergeForm.vue';
|
||||
import { contactData, primaryContactList } from './fixtures';
|
||||
|
||||
const handleSearch = query => {
|
||||
console.log('Searching for:', query);
|
||||
};
|
||||
|
||||
const handleUpdate = value => {
|
||||
console.log('Primary contact updated:', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactMergeForm"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:primary-contact-id="null"
|
||||
:is-searching="false"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Selected Primary Contact">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:primary-contact-id="1"
|
||||
:is-searching="false"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Error State">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:primary-contact-id="null"
|
||||
:is-searching="false"
|
||||
has-error
|
||||
error-message="Please select a primary contact"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty Primary Contact List">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactMergeForm
|
||||
:selected-contact="contactData"
|
||||
:primary-contact-list="[]"
|
||||
:primary-contact-id="null"
|
||||
:is-searching="false"
|
||||
@update:primary-contact-id="handleUpdate"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
<script setup>
|
||||
import ContactsForm from '../ContactsForm.vue';
|
||||
import { contactData } from './fixtures';
|
||||
|
||||
const handleUpdate = updatedData => {
|
||||
console.log('Form updated:', updatedData);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactsForm"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default without border">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactsForm :contact-data="contactData" @update="handleUpdate" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Details View with border">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactsForm
|
||||
:contact-data="contactData"
|
||||
is-details-view
|
||||
@update="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Minimal Data">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactsForm
|
||||
:contact-data="{
|
||||
id: 21,
|
||||
name: 'Ophelia Folkard',
|
||||
email: 'ofolkardi@taobao.test',
|
||||
phoneNumber: '',
|
||||
additionalAttributes: {
|
||||
city: '',
|
||||
country: '',
|
||||
description: '',
|
||||
companyName: '',
|
||||
countryCode: '',
|
||||
socialProfiles: {
|
||||
github: '',
|
||||
twitter: '',
|
||||
facebook: '',
|
||||
linkedin: '',
|
||||
instagram: '',
|
||||
},
|
||||
},
|
||||
}"
|
||||
@update="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With All Social Profiles">
|
||||
<div class="p-6 border rounded-lg border-n-strong">
|
||||
<ContactsForm
|
||||
:contact-data="{
|
||||
...contactData,
|
||||
additionalAttributes: {
|
||||
...contactData.additionalAttributes,
|
||||
socialProfiles: {
|
||||
github: 'cmathersonj',
|
||||
twitter: 'cmather',
|
||||
facebook: 'cmathersonj',
|
||||
linkedin: 'cmathersonj',
|
||||
instagram: 'cmathersonjs',
|
||||
},
|
||||
},
|
||||
}"
|
||||
@update="handleUpdate"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,47 +0,0 @@
|
||||
export const contactData = {
|
||||
id: 370,
|
||||
name: 'John Doe',
|
||||
email: 'johndoe@chatwoot.com',
|
||||
phoneNumber: '+918634322418',
|
||||
additionalAttributes: {
|
||||
city: 'Kerala',
|
||||
country: 'India',
|
||||
description: 'Curious about the web.',
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: 'IN',
|
||||
socialProfiles: {
|
||||
github: 'johndoe',
|
||||
twitter: 'johndoe',
|
||||
facebook: 'johndoe',
|
||||
linkedin: 'johndoe',
|
||||
instagram: 'johndoe',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const primaryContactList = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@chatwoot.com',
|
||||
thumbnail: '',
|
||||
label: '(ID: 1) Jane Smith',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Mike Johnson',
|
||||
email: 'mike@chatwoot.com',
|
||||
thumbnail: '',
|
||||
label: '(ID: 2) Mike Johnson',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Sarah Wilson',
|
||||
email: 'sarah@chatwoot.com',
|
||||
thumbnail: '',
|
||||
label: '(ID: 3) Sarah Wilson',
|
||||
value: 3,
|
||||
},
|
||||
];
|
||||
@@ -1,143 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ContactSortMenu from './components/ContactSortMenu.vue';
|
||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
searchValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'last_activity_at',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isSegmentsView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLabelView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'search',
|
||||
'filter',
|
||||
'update:sort',
|
||||
'add',
|
||||
'import',
|
||||
'export',
|
||||
'createSegment',
|
||||
'deleteSegment',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
type="search"
|
||||
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
||||
:custom-input-class="[
|
||||
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||
]"
|
||||
@input="emit('search', $event.target.value)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView" class="relative">
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="relative w-8"
|
||||
variant="ghost"
|
||||
@click="emit('filter')"
|
||||
>
|
||||
<div
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
|
||||
icon="i-lucide-save"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('createSegment')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isSegmentsView && !isLabelView"
|
||||
icon="i-lucide-trash"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="emit('deleteSegment')"
|
||||
/>
|
||||
<ContactSortMenu
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<ContactMoreActions
|
||||
@add="emit('add')"
|
||||
@import="emit('import')"
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, unref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
import countries from 'shared/constants/countries';
|
||||
import {
|
||||
useCamelCase,
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
|
||||
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
|
||||
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue';
|
||||
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
|
||||
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
|
||||
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue';
|
||||
import ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showSearch: { type: Boolean, default: true },
|
||||
searchValue: { type: String, default: '' },
|
||||
activeSort: { type: String, default: 'last_activity_at' },
|
||||
activeOrdering: { type: String, default: '' },
|
||||
headerTitle: { type: String, default: '' },
|
||||
segmentsId: { type: [String, Number], default: 0 },
|
||||
activeSegment: { type: Object, default: null },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
isLabelView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:sort',
|
||||
'search',
|
||||
'applyFilter',
|
||||
'clearFilters',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const createNewContactDialogRef = ref(null);
|
||||
const contactExportDialogRef = ref(null);
|
||||
const contactImportDialogRef = ref(null);
|
||||
const createSegmentDialogRef = ref(null);
|
||||
const deleteSegmentDialogRef = ref(null);
|
||||
|
||||
const showFiltersModal = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const segmentsQuery = ref({});
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
const hasActiveSegments = computed(
|
||||
() => props.activeSegment && props.segmentsId !== 0
|
||||
);
|
||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||
|
||||
const openCreateNewContactDialog = async () => {
|
||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
const openContactImportDialog = () =>
|
||||
contactImportDialogRef.value?.dialogRef.open();
|
||||
const openContactExportDialog = () =>
|
||||
contactExportDialogRef.value?.dialogRef.open();
|
||||
const openCreateSegmentDialog = () =>
|
||||
createSegmentDialogRef.value?.dialogRef.open();
|
||||
const openDeleteSegmentDialog = () =>
|
||||
deleteSegmentDialogRef.value?.dialogRef.open();
|
||||
|
||||
const onCreate = async contact => {
|
||||
try {
|
||||
await store.dispatch('contacts/create', contact);
|
||||
createNewContactDialogRef.value?.onSuccess();
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
const i18nPrefix = 'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION';
|
||||
if (error instanceof DuplicateContactException) {
|
||||
if (error.data.includes('email')) {
|
||||
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS_DUPLICATE`));
|
||||
} else if (error.data.includes('phone_number')) {
|
||||
useAlert(t(`${i18nPrefix}.PHONE_NUMBER_DUPLICATE`));
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = async file => {
|
||||
try {
|
||||
await store.dispatch('contacts/import', file);
|
||||
contactImportDialogRef.value?.dialogRef.close();
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.SUCCESS_MESSAGE')
|
||||
);
|
||||
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ??
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.ERROR_MESSAGE')
|
||||
);
|
||||
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
|
||||
}
|
||||
};
|
||||
|
||||
const onExport = async query => {
|
||||
try {
|
||||
await store.dispatch('contacts/export', query);
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateSegment = async payload => {
|
||||
try {
|
||||
const payloadData = {
|
||||
...payload,
|
||||
query: segmentsQuery.value,
|
||||
};
|
||||
const response = await store.dispatch('customViews/create', payloadData);
|
||||
createSegmentDialogRef.value?.dialogRef.close();
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
|
||||
);
|
||||
const segmentId = response?.data?.id;
|
||||
if (!segmentId) return;
|
||||
// Navigate to the created segment
|
||||
router.push({
|
||||
name: 'contacts_dashboard_segments_index',
|
||||
params: { segmentId },
|
||||
query: { page: 1 },
|
||||
});
|
||||
} catch {
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSegment = async payload => {
|
||||
try {
|
||||
await store.dispatch('customViews/delete', {
|
||||
id: Number(props.segmentsId),
|
||||
...payload,
|
||||
});
|
||||
router.push({
|
||||
name: 'contacts_dashboard_index',
|
||||
query: {
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
deleteSegmentDialogRef.value?.dialogRef.close();
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const closeAdvanceFiltersModal = () => {
|
||||
showFiltersModal.value = false;
|
||||
appliedFilter.value = [];
|
||||
};
|
||||
|
||||
const clearFilters = async () => {
|
||||
emit('clearFilters');
|
||||
};
|
||||
|
||||
const onApplyFilter = async payload => {
|
||||
payload = useSnakeCase(payload);
|
||||
segmentsQuery.value = filterQueryGenerator(payload);
|
||||
emit('applyFilter', filterQueryGenerator(payload));
|
||||
showFiltersModal.value = false;
|
||||
};
|
||||
|
||||
const onUpdateSegment = async (payload, segmentName) => {
|
||||
payload = useSnakeCase(payload);
|
||||
const payloadData = {
|
||||
...props.activeSegment,
|
||||
name: segmentName,
|
||||
query: filterQueryGenerator(payload),
|
||||
};
|
||||
await store.dispatch('customViews/update', payloadData);
|
||||
closeAdvanceFiltersModal();
|
||||
};
|
||||
|
||||
const setParamsForEditSegmentModal = () => {
|
||||
return {
|
||||
countries,
|
||||
filterTypes: contactFilterItems,
|
||||
allCustomAttributes: useSnakeCase(contactAttributes.value),
|
||||
};
|
||||
};
|
||||
|
||||
const initializeSegmentToFilterModal = segment => {
|
||||
const query = unref(segment)?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
const values = Array.isArray(transformed.values)
|
||||
? generateValuesForEditCustomViews(
|
||||
useSnakeCase(filter),
|
||||
setParamsForEditSegmentModal()
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values,
|
||||
};
|
||||
});
|
||||
|
||||
appliedFilter.value = [...appliedFilter.value, ...newFilters];
|
||||
};
|
||||
|
||||
const onToggleFilters = () => {
|
||||
appliedFilter.value = [];
|
||||
if (hasActiveSegments.value) {
|
||||
initializeSegmentToFilterModal(props.activeSegment);
|
||||
} else {
|
||||
appliedFilter.value = props.hasAppliedFilters
|
||||
? [...appliedFilters.value]
|
||||
: [
|
||||
{
|
||||
attributeKey: 'name',
|
||||
filterOperator: 'equal_to',
|
||||
values: '',
|
||||
queryOperator: 'and',
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
];
|
||||
}
|
||||
showFiltersModal.value = true;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
onToggleFilters,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContactsHeader
|
||||
:show-search="showSearch"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
:header-title="headerTitle"
|
||||
:is-segments-view="hasActiveSegments"
|
||||
:is-label-view="isLabelView"
|
||||
:has-active-filters="hasAppliedFilters"
|
||||
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
|
||||
@search="emit('search', $event)"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
@add="openCreateNewContactDialog"
|
||||
@import="openContactImportDialog"
|
||||
@export="openContactExportDialog"
|
||||
@filter="onToggleFilters"
|
||||
@create-segment="openCreateSegmentDialog"
|
||||
@delete-segment="openDeleteSegmentDialog"
|
||||
>
|
||||
<template #filter>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
</ContactsHeader>
|
||||
|
||||
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
|
||||
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
|
||||
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
|
||||
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
|
||||
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
|
||||
</template>
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactMenuItems = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
if (action === 'add') {
|
||||
emit('add');
|
||||
} else if (action === 'import') {
|
||||
emit('import');
|
||||
} else if (action === 'export') {
|
||||
emit('export');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
|
||||
@click="showActionsDropdown = !showActionsDropdown"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="contactMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
|
||||
@action="handleContactAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, toRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: 'last_activity_at',
|
||||
},
|
||||
activeOrdering: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:sort']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const sortMenus = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
|
||||
value: 'name',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
||||
value: 'email',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
|
||||
value: 'phone_number',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
||||
value: 'company_name',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
|
||||
value: 'country',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
|
||||
value: 'city',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
|
||||
value: 'last_activity_at',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
];
|
||||
|
||||
const orderingMenus = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
|
||||
value: '-',
|
||||
},
|
||||
];
|
||||
|
||||
// Converted the props to refs for better reactivity
|
||||
const activeSort = toRef(props, 'activeSort');
|
||||
|
||||
const activeOrdering = toRef(props, 'activeOrdering');
|
||||
|
||||
const activeSortLabel = computed(() => {
|
||||
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
|
||||
return (
|
||||
selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
|
||||
);
|
||||
});
|
||||
|
||||
const activeOrderingLabel = computed(() => {
|
||||
const selectedMenu = orderingMenus.find(
|
||||
menu => menu.value === activeOrdering.value
|
||||
);
|
||||
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL');
|
||||
});
|
||||
|
||||
const handleSortChange = value => {
|
||||
emit('update:sort', { sort: value, order: props.activeOrdering });
|
||||
};
|
||||
|
||||
const handleOrderChange = value => {
|
||||
emit('update:sort', { sort: props.activeSort, order: value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-lucide-arrow-down-up"
|
||||
color="slate"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
|
||||
@click="isMenuOpen = !isMenuOpen"
|
||||
/>
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
|
||||
</span>
|
||||
<SelectMenu
|
||||
:model-value="activeSort"
|
||||
:options="sortMenus"
|
||||
:label="activeSortLabel"
|
||||
@update:model-value="handleSortChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
|
||||
</span>
|
||||
<SelectMenu
|
||||
:model-value="activeOrdering"
|
||||
:options="orderingMenus"
|
||||
:label="activeOrderingLabel"
|
||||
@update:model-value="handleOrderChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeSegment: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['clearFilters', 'openFilter']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
const activeSegmentId = computed(() => route.params.segmentId);
|
||||
|
||||
const activeSegmentQuery = computed(() => {
|
||||
const query = props.activeSegment?.query?.payload;
|
||||
if (!Array.isArray(query)) return [];
|
||||
|
||||
const newFilters = query.map(filter => {
|
||||
const transformed = useCamelCase(filter);
|
||||
return {
|
||||
attributeKey: transformed.attributeKey,
|
||||
attributeModel: transformed.attributeModel,
|
||||
customAttributeType: transformed.customAttributeType,
|
||||
filterOperator: transformed.filterOperator,
|
||||
queryOperator: transformed.queryOperator ?? 'and',
|
||||
values: transformed.values,
|
||||
};
|
||||
});
|
||||
|
||||
return newFilters;
|
||||
});
|
||||
|
||||
const hasActiveSegments = computed(
|
||||
() => props.activeSegment && activeSegmentId.value !== 0
|
||||
);
|
||||
|
||||
const activeFilterQueryData = computed(() => {
|
||||
return hasActiveSegments.value
|
||||
? activeSegmentQuery.value
|
||||
: appliedFilters.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="activeFilterQueryData"
|
||||
:max-visible-filters="2"
|
||||
:more-filters-label="
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
|
||||
count: activeFilterQueryData.length - 2,
|
||||
})
|
||||
"
|
||||
:clear-button-label="
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||
"
|
||||
:show-clear-button="!hasActiveSegments"
|
||||
class="max-w-[960px] px-6"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
</template>
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ContactHeader from '../ContactHeader.vue';
|
||||
|
||||
// Base state controls
|
||||
const searchValue = ref('');
|
||||
const activeSort = ref('last_activity_at');
|
||||
const activeOrdering = ref('');
|
||||
|
||||
const onSearch = value => {
|
||||
searchValue.value = value;
|
||||
console.log('🔍 Search:', value);
|
||||
};
|
||||
|
||||
const onSort = ({ sort, order }) => {
|
||||
activeSort.value = sort;
|
||||
activeOrdering.value = order;
|
||||
console.log('🔄 Sort changed:', { sort, order });
|
||||
};
|
||||
|
||||
const onFilter = () => {
|
||||
console.log('🏷️ Filter clicked');
|
||||
};
|
||||
|
||||
const onMessage = () => {
|
||||
console.log('💬 Message clicked');
|
||||
};
|
||||
|
||||
const onAdd = () => {
|
||||
console.log('➕ Add contact clicked');
|
||||
};
|
||||
|
||||
const onImport = () => {
|
||||
console.log('📥 Import contacts clicked');
|
||||
};
|
||||
|
||||
const onExport = () => {
|
||||
console.log('📤 Export contacts clicked');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactHeader"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="w-full h-[400px]">
|
||||
<ContactHeader
|
||||
header-title="Contacts"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Empty State">
|
||||
<div class="w-full">
|
||||
<ContactHeader
|
||||
:show-search="false"
|
||||
header-title="Contacts"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Segment View">
|
||||
<div class="w-full">
|
||||
<ContactHeader
|
||||
:show-search="false"
|
||||
header-title="Segment: VIP Customers"
|
||||
button-label="Message"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
@search="onSearch"
|
||||
@filter="onFilter"
|
||||
@update:sort="onSort"
|
||||
@message="onMessage"
|
||||
@add="onAdd"
|
||||
@import="onImport"
|
||||
@export="onExport"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
|
||||
defineProps({
|
||||
searchValue: { type: String, default: '' },
|
||||
headerTitle: { type: String, default: '' },
|
||||
showPaginationFooter: { type: Boolean, default: true },
|
||||
currentPage: { type: Number, default: 1 },
|
||||
totalItems: { type: Number, default: 100 },
|
||||
itemsPerPage: { type: Number, default: 15 },
|
||||
activeSort: { type: String, default: '' },
|
||||
activeOrdering: { type: String, default: '' },
|
||||
activeSegment: { type: Object, default: null },
|
||||
segmentsId: { type: [String, Number], default: 0 },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
isFetchingList: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:currentPage',
|
||||
'update:sort',
|
||||
'search',
|
||||
'applyFilter',
|
||||
'clearFilters',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const contactListHeaderWrapper = ref(null);
|
||||
|
||||
const isNotSegmentView = computed(() => {
|
||||
return route.name !== 'contacts_dashboard_segments_index';
|
||||
});
|
||||
|
||||
const isLabelView = computed(
|
||||
() => route.name === 'contacts_dashboard_labels_index'
|
||||
);
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
};
|
||||
|
||||
const openFilter = () => {
|
||||
contactListHeaderWrapper.value?.onToggleFilters();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<ContactListHeaderWrapper
|
||||
ref="contactListHeaderWrapper"
|
||||
:show-search="isNotSegmentView"
|
||||
:search-value="searchValue"
|
||||
:active-sort="activeSort"
|
||||
:active-ordering="activeOrdering"
|
||||
:header-title="headerTitle"
|
||||
:active-segment="activeSegment"
|
||||
:segments-id="segmentsId"
|
||||
:has-applied-filters="hasAppliedFilters"
|
||||
:is-label-view="isLabelView"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
@search="emit('search', $event)"
|
||||
@apply-filter="emit('applyFilter', $event)"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[960px]">
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="
|
||||
(hasAppliedFilters || !isNotSegmentView) &&
|
||||
!isFetchingList &&
|
||||
!isLabelView
|
||||
"
|
||||
:active-segment="activeSegment"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||
>
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
|
||||
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
|
||||
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
|
||||
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await store.dispatch('contacts/deleteCustomAttributes', {
|
||||
id: route.params.contactId,
|
||||
customAttributes: [props.attribute.attributeKey],
|
||||
});
|
||||
useAlert(
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_SUCCESS_MESSAGE')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async value => {
|
||||
try {
|
||||
await store.dispatch('contacts/update', {
|
||||
id: route.params.contactId,
|
||||
customAttributes: {
|
||||
[props.attribute.attributeKey]: value,
|
||||
},
|
||||
});
|
||||
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.UPDATE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const componentMap = {
|
||||
list: ListAttribute,
|
||||
checkbox: CheckboxAttribute,
|
||||
date: DateAttribute,
|
||||
default: OtherAttribute,
|
||||
};
|
||||
|
||||
const CurrentAttributeComponent = computed(() => {
|
||||
return (
|
||||
componentMap[props.attribute.attributeDisplayType] || componentMap.default
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
|
||||
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="CurrentAttributeComponent"
|
||||
:attribute="attribute"
|
||||
:is-editing-view="isEditingView"
|
||||
@update="handleUpdate"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
|
||||
|
||||
const hasContactAttributes = computed(
|
||||
() => contactAttributes.value?.length > 0
|
||||
);
|
||||
|
||||
const processContactAttributes = (
|
||||
attributes,
|
||||
customAttributes,
|
||||
filterCondition
|
||||
) => {
|
||||
if (!attributes.length || !customAttributes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return attributes.reduce((result, attribute) => {
|
||||
const { attributeKey } = attribute;
|
||||
const meetsCondition = filterCondition(attributeKey, customAttributes);
|
||||
|
||||
if (meetsCondition) {
|
||||
result.push({
|
||||
...attribute,
|
||||
value: customAttributes[attributeKey] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const usedAttributes = computed(() => {
|
||||
return processContactAttributes(
|
||||
contactAttributes.value,
|
||||
props.selectedContact?.customAttributes,
|
||||
(key, custom) => key in custom
|
||||
);
|
||||
});
|
||||
|
||||
const unusedAttributes = computed(() => {
|
||||
return processContactAttributes(
|
||||
contactAttributes.value,
|
||||
props.selectedContact?.customAttributes,
|
||||
(key, custom) => !(key in custom)
|
||||
);
|
||||
});
|
||||
|
||||
const filteredUnusedAttributes = computed(() => {
|
||||
return unusedAttributes.value?.filter(attribute =>
|
||||
attribute.attributeDisplayName
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const unusedAttributesCount = computed(() => unusedAttributes.value?.length);
|
||||
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
|
||||
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
:key="attribute.id"
|
||||
is-editing-view
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
|
||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
||||
<span class="text-sm font-medium text-n-slate-10">{{
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||
count: unusedAttributesCount,
|
||||
})
|
||||
}}</span>
|
||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!hasNoUnusedAttributes" class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
|
||||
class="flex items-center justify-start h-11"
|
||||
>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in filteredUnusedAttributes"
|
||||
:key="attribute.id"
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -1,54 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const conversations = useMapGetter(
|
||||
'contactConversations/getAllConversationsByContactId'
|
||||
);
|
||||
const contactsById = useMapGetter('contacts/getContactById');
|
||||
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const accountLabelsValue = computed(() => accountLabels.value);
|
||||
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const contactConversations = computed(() =>
|
||||
conversations.value(route.params.contactId)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="contactConversations.length > 0"
|
||||
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in contactConversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="contactsById(conversation.meta.sender.id)"
|
||||
:state-inbox="stateInbox(conversation.inboxId)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goToContactsList', 'resetTab']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const state = reactive({
|
||||
primaryContactId: null,
|
||||
});
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const searchResults = ref([]);
|
||||
const isSearching = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
primaryContactId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isMergingContact = computed(() => uiFlags.value.isMerging);
|
||||
|
||||
const primaryContactList = computed(
|
||||
() =>
|
||||
searchResults.value?.map(item => ({
|
||||
value: item.id,
|
||||
label: `(ID: ${item.id}) ${item.name}`,
|
||||
})) ?? []
|
||||
);
|
||||
|
||||
const onContactSearch = debounce(
|
||||
async query => {
|
||||
isSearching.value = true;
|
||||
searchResults.value = [];
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(query);
|
||||
searchResults.value = payload.filter(
|
||||
contact => contact.id !== props.selectedContact.id
|
||||
);
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
},
|
||||
300,
|
||||
false
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
if (state.primaryContactId === null) {
|
||||
emit('resetTab');
|
||||
}
|
||||
state.primaryContactId = null;
|
||||
searchResults.value = [];
|
||||
isSearching.value = false;
|
||||
};
|
||||
|
||||
const onMergeContacts = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
|
||||
try {
|
||||
await store.dispatch('contacts/merge', {
|
||||
childId: props.selectedContact.id || route.params.contactId,
|
||||
parentId: state.primaryContactId,
|
||||
});
|
||||
emit('goToContactsList');
|
||||
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SUCCESS_MESSAGE'));
|
||||
resetState();
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 px-6 py-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-base text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<ContactMergeForm
|
||||
v-model:primary-contact-id="state.primaryContactId"
|
||||
:selected-contact="selectedContact"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:is-searching="isSearching"
|
||||
:has-error="!!v$.primaryContactId.$error"
|
||||
:error-message="
|
||||
v$.primaryContactId.$error
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
|
||||
: ''
|
||||
"
|
||||
@search="onContactSearch"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="resetState"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
:is-loading="isMergingContact"
|
||||
:disabled="isMergingContact"
|
||||
@click="onMergeContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactNoteItem from './components/ContactNoteItem.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const state = reactive({
|
||||
message: '',
|
||||
});
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const uiFlags = useMapGetter('contactNotes/getUIFlags');
|
||||
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
||||
const isCreatingNote = computed(() => uiFlags.value.isCreating);
|
||||
const notes = computed(() => notesByContact.value(route.params.contactId));
|
||||
|
||||
const getWrittenBy = note => {
|
||||
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||
return isCurrentUser
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: note?.user?.name || 'Bot';
|
||||
};
|
||||
|
||||
const onAdd = content => {
|
||||
if (!content) return;
|
||||
const { contactId } = route.params;
|
||||
store.dispatch('contactNotes/create', { content, contactId });
|
||||
state.message = '';
|
||||
};
|
||||
|
||||
const onDelete = noteId => {
|
||||
if (!noteId) return;
|
||||
const { contactId } = route.params;
|
||||
store.dispatch('contactNotes/delete', { noteId, contactId });
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'$mod+Enter': {
|
||||
action: () => onAdd(state.message),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 py-6">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
focus-on-mount
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="link"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
||||
class="hover:no-underline"
|
||||
:is-loading="isCreatingNote"
|
||||
:disabled="!state.message || isCreatingNote"
|
||||
@click="onAdd(state.message)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
<div
|
||||
v-if="isFetchingNotes"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="notes.length > 0">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
writtenBy: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.note.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
|
||||
<Avatar
|
||||
:name="note?.user?.name || 'Bot'"
|
||||
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0 truncate">
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span class="font-medium">{{ writtenBy }}</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
class="opacity-0 group-hover/note:opacity-100"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<script setup>
|
||||
import ContactNoteItem from '../ContactNoteItem.vue';
|
||||
import notes from './fixtures';
|
||||
|
||||
const controls = {
|
||||
writtenBy: {
|
||||
type: 'text',
|
||||
default: 'You',
|
||||
},
|
||||
};
|
||||
|
||||
// Example delete handler
|
||||
const onDelete = noteId => {
|
||||
console.log('Note deleted:', noteId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/ContactNoteItem"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Multiple Notes">
|
||||
<div class="flex flex-col border rounded-lg border-n-strong">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
:written-by="
|
||||
note.id === notes[1].id
|
||||
? controls.writtenBy.default
|
||||
: note.user.name
|
||||
"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 12,
|
||||
content:
|
||||
'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
|
||||
accountId: null,
|
||||
contactId: null,
|
||||
user: {
|
||||
id: 30,
|
||||
account_id: 2,
|
||||
availability_status: 'offline',
|
||||
auto_offline: true,
|
||||
confirmed: true,
|
||||
email: 'bruce@paperlayer.test',
|
||||
available_name: 'Bruce',
|
||||
name: 'Bruce',
|
||||
role: 'administrator',
|
||||
thumbnail:
|
||||
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
|
||||
custom_role_id: null,
|
||||
},
|
||||
createdAt: 1730786556,
|
||||
updatedAt: 1730786556,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
content:
|
||||
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
|
||||
accountId: null,
|
||||
contactId: null,
|
||||
user: {
|
||||
id: 1,
|
||||
account_id: 2,
|
||||
availability_status: 'online',
|
||||
auto_offline: false,
|
||||
confirmed: true,
|
||||
email: 'hillary@chatwoot.com',
|
||||
available_name: 'Hillary',
|
||||
name: 'Hillary',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
custom_role_id: null,
|
||||
},
|
||||
createdAt: 1730782566,
|
||||
updatedAt: 1730782566,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
content:
|
||||
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
|
||||
accountId: null,
|
||||
contactId: null,
|
||||
user: {
|
||||
id: 1,
|
||||
account_id: 2,
|
||||
availability_status: 'online',
|
||||
auto_offline: false,
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
available_name: 'John',
|
||||
name: 'John',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
custom_role_id: null,
|
||||
},
|
||||
createdAt: 1730782564,
|
||||
updatedAt: 1730782564,
|
||||
},
|
||||
];
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
<script setup>
|
||||
import ContactEmptyState from './ContactEmptyState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Contacts/EmptyState"
|
||||
:layout="{ type: 'grid', width: '900px' }"
|
||||
>
|
||||
<!-- Default Story -->
|
||||
<Variant title="Default">
|
||||
<ContactEmptyState
|
||||
title="No contacts found"
|
||||
subtitle="Create your first contact to get started"
|
||||
button-label="Add Contact"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Without Button -->
|
||||
<Variant title="Without Button">
|
||||
<ContactEmptyState
|
||||
title="No contacts"
|
||||
subtitle="These are your current contacts"
|
||||
:show-button="false"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
|
||||
const createNewContactDialogRef = ref(null);
|
||||
|
||||
const onClick = () => {
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ContactsCard
|
||||
v-for="contact in contactContent.slice(0, 5)"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:is-expanded="0 === contact.id"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="showButton">
|
||||
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
||||
<CreateNewContactDialog
|
||||
ref="createNewContactDialogRef"
|
||||
@create="emit('create', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Los Angeles',
|
||||
country: 'United States',
|
||||
description:
|
||||
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
|
||||
companyName: 'Chatwoot',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'candice-dev',
|
||||
twitter: 'candice_w_dev',
|
||||
facebook: 'candice.dev',
|
||||
linkedin: 'candice-matherson',
|
||||
instagram: 'candice.codes',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice.matherson@chatwoot.com',
|
||||
id: 22,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+14155552671',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
dateContact: '2024-11-11T11:53:09.299Z',
|
||||
linkContact: 'https://example.com',
|
||||
listContact: 'Follow-Up',
|
||||
textContact: 'Hi there!',
|
||||
numberContact: '42',
|
||||
checkboxContact: false,
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'San Francisco',
|
||||
country: 'United States',
|
||||
description: 'Passionate about design and user experience.',
|
||||
companyName: 'Designify',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'ophelia-folkard',
|
||||
twitter: 'oph_designs',
|
||||
facebook: 'ophelia.folkard',
|
||||
linkedin: 'ophelia-folkard',
|
||||
instagram: 'ophelia.design',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'ophelia.folkard@designify.com',
|
||||
id: 21,
|
||||
name: 'Ophelia Folkard',
|
||||
phoneNumber: '+14155552672',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
dateContact: '2024-10-05T10:12:34.567Z',
|
||||
linkContact: 'https://designify.com',
|
||||
listContact: 'Prospects',
|
||||
textContact: 'Looking forward to connecting!',
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Austin',
|
||||
country: 'United States',
|
||||
description: 'Avid coder and tech enthusiast.',
|
||||
companyName: 'CodeHub',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'willy_castelot',
|
||||
twitter: 'willy_code',
|
||||
facebook: 'willy.castelot',
|
||||
linkedin: 'willy-castelot',
|
||||
instagram: 'willy.coder',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy.castelot@codehub.io',
|
||||
id: 20,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+14155552673',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
textContact: 'Let’s collaborate!',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712123233,
|
||||
createdAt: 1712123233,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Seattle',
|
||||
country: 'United States',
|
||||
description: 'Product manager with a love for innovation.',
|
||||
companyName: 'InnovaTech',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'elisabeth-d',
|
||||
twitter: 'elisabeth_innova',
|
||||
facebook: 'elisabeth.derington',
|
||||
linkedin: 'elisabeth-derington',
|
||||
instagram: 'elisabeth.innovates',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'elisabeth.derington@innova.com',
|
||||
id: 19,
|
||||
name: 'Elisabeth Derington',
|
||||
phoneNumber: '+14155552674',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
textContact: 'Let’s schedule a call.',
|
||||
},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Chicago',
|
||||
country: 'United States',
|
||||
description: 'Marketing specialist and content creator.',
|
||||
companyName: 'Contently',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'olia-olenchenko',
|
||||
twitter: 'olia_content',
|
||||
facebook: 'olia.olenchenko',
|
||||
linkedin: 'olia-olenchenko',
|
||||
instagram: 'olia.creates',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'olia.olenchenko@contently.com',
|
||||
id: 18,
|
||||
name: 'Olia Olenchenko',
|
||||
phoneNumber: '+14155552675',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Boston',
|
||||
country: 'United States',
|
||||
description: 'SEO expert and analytics enthusiast.',
|
||||
companyName: 'OptiSearch',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'nate-vannuchi',
|
||||
twitter: 'nate_seo',
|
||||
facebook: 'nathaniel.vannuchi',
|
||||
linkedin: 'nathaniel-vannuchi',
|
||||
instagram: 'nate.optimizes',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'nathaniel.vannuchi@optisearch.com',
|
||||
id: 17,
|
||||
name: 'Nathaniel Vannuchi',
|
||||
phoneNumber: '+14155552676',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Denver',
|
||||
country: 'United States',
|
||||
description: 'UI/UX designer with a flair for minimalist designs.',
|
||||
companyName: 'Minimal Designs',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'merrile-petruk',
|
||||
twitter: 'merrile_ux',
|
||||
facebook: 'merrile.petruk',
|
||||
linkedin: 'merrile-petruk',
|
||||
instagram: 'merrile.designs',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'merrile.petruk@minimal.com',
|
||||
id: 16,
|
||||
name: 'Merrile Petruk',
|
||||
phoneNumber: '+14155552677',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
{
|
||||
additionalAttributes: {
|
||||
city: 'Miami',
|
||||
country: 'United States',
|
||||
description: 'Entrepreneur with a background in e-commerce.',
|
||||
companyName: 'Ecom Solutions',
|
||||
countryCode: 'US',
|
||||
socialProfiles: {
|
||||
github: 'cordell-d',
|
||||
twitter: 'cordell_entrepreneur',
|
||||
facebook: 'cordell.dalinder',
|
||||
linkedin: 'cordell-dalinder',
|
||||
instagram: 'cordell.ecom',
|
||||
},
|
||||
},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'cordell.dalinder@ecomsolutions.com',
|
||||
id: 15,
|
||||
name: 'Cordell Dalinder',
|
||||
phoneNumber: '+14155552678',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712123232,
|
||||
createdAt: 1712123232,
|
||||
},
|
||||
];
|
||||
@@ -1,200 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['goToContactsList']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
|
||||
const avatarFile = ref(null);
|
||||
const avatarUrl = ref('');
|
||||
|
||||
const contactsFormRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
|
||||
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const contactData = ref({});
|
||||
|
||||
const getInitialContactData = () => {
|
||||
if (!props.selectedContact) return {};
|
||||
return { ...props.selectedContact };
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
Object.assign(contactData.value, getInitialContactData());
|
||||
});
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return contactData.value?.createdAt
|
||||
? dynamicTime(contactData.value.createdAt)
|
||||
: '';
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
return contactData.value?.lastActivityAt
|
||||
? dynamicTime(contactData.value.lastActivityAt)
|
||||
: '';
|
||||
});
|
||||
|
||||
const avatarSrc = computed(() => {
|
||||
return avatarUrl.value ? avatarUrl.value : contactData.value?.thumbnail;
|
||||
});
|
||||
|
||||
const handleFormUpdate = updatedData => {
|
||||
Object.assign(contactData.value, updatedData);
|
||||
};
|
||||
|
||||
const updateContact = async () => {
|
||||
try {
|
||||
const { customAttributes, ...basicContactData } = contactData.value;
|
||||
await store.dispatch('contacts/update', basicContactData);
|
||||
await store.dispatch(
|
||||
'contacts/fetchContactableInbox',
|
||||
props.selectedContact.id
|
||||
);
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const openConfirmDeleteContactDialog = () => {
|
||||
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async ({ file, url }) => {
|
||||
avatarFile.value = file;
|
||||
avatarUrl.value = url;
|
||||
|
||||
try {
|
||||
await store.dispatch('contacts/update', {
|
||||
...contactsFormRef.value?.state,
|
||||
avatar: file,
|
||||
isFormData: true,
|
||||
});
|
||||
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.SUCCESS_MESSAGE'));
|
||||
} catch {
|
||||
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
try {
|
||||
if (props.selectedContact && props.selectedContact.id) {
|
||||
await store.dispatch('contacts/deleteAvatar', props.selectedContact.id);
|
||||
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.SUCCESS_MESSAGE'));
|
||||
}
|
||||
avatarFile.value = null;
|
||||
avatarUrl.value = '';
|
||||
contactData.value.thumbnail = null;
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-8 pb-6">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Avatar
|
||||
:src="avatarSrc || ''"
|
||||
:name="selectedContact?.name || ''"
|
||||
:size="72"
|
||||
allow-upload
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ selectedContact?.name }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span
|
||||
v-if="selectedContact?.identifier"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
|
||||
>
|
||||
<span class="i-ph-user-gear text-n-slate-10 size-4" />
|
||||
{{ selectedContact?.identifier }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span
|
||||
v-if="selectedContact?.identifier"
|
||||
class="i-ph-activity text-n-slate-10 size-4"
|
||||
/>
|
||||
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
|
||||
•
|
||||
{{
|
||||
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
||||
date: lastActivityAt,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ContactLabels :contact-id="selectedContact?.id" />
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-6">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
is-details-view
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
DuplicateContactException,
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
|
||||
defineProps({ contacts: { type: Array, required: true } });
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
const expandedCardId = ref(null);
|
||||
|
||||
const updateContact = async updatedData => {
|
||||
try {
|
||||
await store.dispatch('contacts/update', updatedData);
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const i18nPrefix = 'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM';
|
||||
if (error instanceof DuplicateContactException) {
|
||||
if (error.data.includes('email')) {
|
||||
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS.DUPLICATE`));
|
||||
} else if (error.data.includes('phone_number')) {
|
||||
useAlert(t(`${i18nPrefix}.PHONE_NUMBER.DUPLICATE`));
|
||||
}
|
||||
} else if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onClickViewDetails = async id => {
|
||||
const routeTypes = {
|
||||
contacts_dashboard_segments_index: ['contacts_edit_segment', 'segmentId'],
|
||||
contacts_dashboard_labels_index: ['contacts_edit_label', 'label'],
|
||||
};
|
||||
const [name, paramKey] = routeTypes[route.name] || ['contacts_edit'];
|
||||
const params = {
|
||||
contactId: id,
|
||||
...(paramKey && { [paramKey]: route.params[paramKey] }),
|
||||
};
|
||||
|
||||
await router.push({ name, params, query: route.query });
|
||||
};
|
||||
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<ContactsCard
|
||||
v-for="contact in contacts"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const WIDTH_CONFIG = Object.freeze({
|
||||
DEFAULT_WIDTH: 80,
|
||||
CHAR_WIDTH: {
|
||||
SHORT: 8, // For labels <= 5 chars
|
||||
LONG: 6, // For labels > 5 chars
|
||||
},
|
||||
BASE_WIDTH: 12, // dot + gap
|
||||
THRESHOLD: 5, // character length threshold
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const maxLabels = ref(1);
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
const labelSet = new Set(props.conversationLabels);
|
||||
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
|
||||
});
|
||||
|
||||
const calculateLabelWidth = ({ title = '' }) => {
|
||||
const charWidth =
|
||||
title.length > WIDTH_CONFIG.THRESHOLD
|
||||
? WIDTH_CONFIG.CHAR_WIDTH.LONG
|
||||
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
|
||||
|
||||
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
|
||||
};
|
||||
|
||||
const getAverageWidth = labels => {
|
||||
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
|
||||
|
||||
const totalWidth = labels.reduce(
|
||||
(sum, label) => sum + calculateLabelWidth(label),
|
||||
0
|
||||
);
|
||||
|
||||
return totalWidth / labels.length;
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() =>
|
||||
activeLabels.value?.slice(0, maxLabels.value)
|
||||
);
|
||||
|
||||
const updateVisibleLabels = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const containerWidth = containerRef.value.offsetWidth;
|
||||
const avgWidth = getAverageWidth(activeLabels.value);
|
||||
|
||||
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
v-resize="updateVisibleLabels"
|
||||
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
|
||||
>
|
||||
<template v-for="(label, index) in visibleLabels" :key="label.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
:class="[
|
||||
index !== visibleLabels.length - 1
|
||||
? 'flex-shrink-0 text-ellipsis'
|
||||
: 'flex-shrink',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: label.color }"
|
||||
class="size-1.5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-10 whitespace-nowrap"
|
||||
:class="{ truncate: index === visibleLabels.length - 1 }"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const slaCardLabelRef = ref(null);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return (
|
||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
||||
);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid items-center gap-2.5 h-7"
|
||||
:class="
|
||||
hasSlaThreshold
|
||||
? 'grid-cols-[auto_auto_1fr_20px]'
|
||||
: 'grid-cols-[1fr_20px]'
|
||||
"
|
||||
>
|
||||
<SLACardLabel
|
||||
v-show="hasSlaThreshold"
|
||||
ref="slaCardLabelRef"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="overflow-hidden">
|
||||
<CardLabels
|
||||
:conversation-labels="conversation.labels"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-207
@@ -1,207 +0,0 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
-477
@@ -1,477 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ConversationCard from './ConversationCard.vue';
|
||||
|
||||
// Base conversation object
|
||||
const conversationWithoutMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phone_number: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
last_activity_at: 1712127410,
|
||||
created_at: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 38,
|
||||
messages: [
|
||||
{
|
||||
id: 3597,
|
||||
content: 'Sivin set the priority to low',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 38,
|
||||
messageType: 2,
|
||||
createdAt: 1730885168,
|
||||
updatedAt: '2024-11-06T09:26:08.565Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
contentType: 'text',
|
||||
contentAttributes: {},
|
||||
senderType: null,
|
||||
senderId: null,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent: 'Sivin set the priority to low',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assigneeId: 1,
|
||||
unreadCount: 0,
|
||||
lastActivityAt: 1730885168,
|
||||
contactInbox: {
|
||||
sourceId: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'Test email',
|
||||
},
|
||||
agentLastSeenAt: 0,
|
||||
assigneeLastSeenAt: 0,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [],
|
||||
status: 'open',
|
||||
createdAt: 1730836533,
|
||||
timestamp: 1730885168,
|
||||
firstReplyCreatedAt: 1730836533,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3591,
|
||||
content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 38,
|
||||
message_type: 1,
|
||||
created_at: 1730836533,
|
||||
updated_at: '2024-11-05T19:55:37.158Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: ['test@gmail.com'],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885168,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885168,
|
||||
priority: 'low',
|
||||
waitingSince: 0,
|
||||
slaPolicyId: null,
|
||||
slaEvents: [],
|
||||
};
|
||||
|
||||
const conversationWithMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 37,
|
||||
messages: [
|
||||
{
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 37,
|
||||
messageType: 1,
|
||||
createdAt: 1730885428,
|
||||
updatedAt: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
sourceId:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
contentType: 'text',
|
||||
contentAttributes: {
|
||||
ccEmails: [],
|
||||
bccEmails: [],
|
||||
toEmails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
senderId: 1,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
availableName: 'Sivin',
|
||||
avatarUrl: '',
|
||||
type: 'user',
|
||||
availabilityStatus: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'we',
|
||||
},
|
||||
agentLastSeenAt: 1730885428,
|
||||
assigneeLastSeenAt: 1730885428,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [
|
||||
'billing',
|
||||
'delivery',
|
||||
'lead',
|
||||
'premium-customer',
|
||||
'software',
|
||||
'ops-handover',
|
||||
],
|
||||
muted: false,
|
||||
snoozedUntil: null,
|
||||
status: 'open',
|
||||
createdAt: 1722487645,
|
||||
timestamp: 1730885428,
|
||||
firstReplyCreatedAt: 1722487645,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 37,
|
||||
message_type: 1,
|
||||
created_at: 1730885428,
|
||||
updated_at: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 2,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'willy@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885428,
|
||||
priority: 'urgent',
|
||||
waitingSince: 1730885428,
|
||||
slaPolicyId: 3,
|
||||
appliedSla: {
|
||||
id: 4,
|
||||
sla_id: 3,
|
||||
sla_status: 'active_with_misses',
|
||||
created_at: 1712127410,
|
||||
updated_at: 1712127545,
|
||||
sla_description:
|
||||
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
|
||||
sla_name: 'Premium SLA',
|
||||
sla_first_response_time_threshold: 120,
|
||||
sla_next_response_time_threshold: 180,
|
||||
sla_only_during_business_hours: false,
|
||||
sla_resolution_time_threshold: 360,
|
||||
},
|
||||
slaEvents: [
|
||||
{
|
||||
id: 8,
|
||||
event_type: 'frt',
|
||||
meta: {},
|
||||
updated_at: 1712127545,
|
||||
created_at: 1712127545,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
event_type: 'rt',
|
||||
meta: {},
|
||||
updated_at: 1712127790,
|
||||
created_at: 1712127790,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contactForConversationWithoutMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
|
||||
customAttributes: {},
|
||||
last_activity_at: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const contactForConversationWithMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const webWidgetInbox = computed(() => ({
|
||||
phone_number: '+918585858585',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
}));
|
||||
|
||||
const accountLabels = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'billing',
|
||||
description: 'Label is used for tagging billing related conversations',
|
||||
color: '#28AD21',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'delivery',
|
||||
description: null,
|
||||
color: '#A2FDD5',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'lead',
|
||||
description: null,
|
||||
color: '#F161C8',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'ops-handover',
|
||||
description: null,
|
||||
color: '#A53326',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'premium-customer',
|
||||
description: null,
|
||||
color: '#6FD4EF',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'software',
|
||||
description: null,
|
||||
color: '#8F6EF2',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ConversationCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Conversation without meta">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="conversationWithoutMeta"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
priority: 'medium',
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation without meta (Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithoutMeta,
|
||||
unreadCount: 2,
|
||||
priority: 'high',
|
||||
}"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
unreadCount: 2,
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardMessagePreview from './CardMessagePreview.vue';
|
||||
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stateInbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const cardMessagePreviewWithMetaRef = ref(null);
|
||||
|
||||
const currentContact = computed(() => props.contact);
|
||||
|
||||
const currentContactName = computed(() => currentContact.value?.name);
|
||||
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
|
||||
const currentContactStatus = computed(
|
||||
() => currentContact.value?.availabilityStatus
|
||||
);
|
||||
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
const timestamp = props.conversation?.timestamp;
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { labels = [] } = props.conversation;
|
||||
return (
|
||||
!cardMessagePreviewWithMetaRef.value?.hasSlaThreshold && labels.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: props.conversation.id,
|
||||
})
|
||||
);
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
window.open(
|
||||
window.chatwootConfig.hostURL + path,
|
||||
'_blank',
|
||||
'noopener noreferrer nofollow'
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.push({ path });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
:size="24"
|
||||
:status="currentContactStatus"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col w-full gap-1 min-w-0">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<h4 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ currentContactName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<CardPriorityIcon :priority="conversation.priority || null" />
|
||||
<div
|
||||
v-tooltip.left="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return slaStatus.value?.threshold && appliedSLA.value?.id;
|
||||
});
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
chat: props.conversation,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
|
||||
// This expose is to provide context to the parent component, so that it can decided weather
|
||||
// a new row has to be added to the conversation card or not
|
||||
// SLACardLabel > CardMessagePreviewWithMeta > ConversationCard
|
||||
//
|
||||
// We need to do this becuase each SLA card has it's own SLA timer
|
||||
// and it's just convenient to have this logic in the SLACardLabel component
|
||||
// However this is a bit hacky, and we should change this in the future
|
||||
//
|
||||
// TODO: A better implementation would be to have the timer as a shared composable, just like the provider pattern
|
||||
// we use across the next components. Have the calculation be done on the top ConversationCard component
|
||||
// and then the value be injected to the SLACardLabel component
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center min-w-fit gap-0.5 h-6">
|
||||
<div class="flex items-center justify-center size-4">
|
||||
<svg
|
||||
width="10"
|
||||
height="13"
|
||||
viewBox="0 0 10 13"
|
||||
fill="none"
|
||||
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-sm truncate"
|
||||
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const attributeValue = ref(Boolean(props.attribute.value));
|
||||
|
||||
const handleChange = value => {
|
||||
emit('update', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<Switch v-model="attributeValue" @change="handleChange" />
|
||||
<Button
|
||||
v-if="isEditingView"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,148 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isEditingValue = ref(false);
|
||||
const editedValue = ref(props.attribute.value || '');
|
||||
|
||||
const rules = {
|
||||
editedValue: {
|
||||
required,
|
||||
isDate: value => new Date(value).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, { editedValue });
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return props.attribute.value
|
||||
? new Date(props.attribute.value).toLocaleDateString()
|
||||
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
|
||||
});
|
||||
|
||||
const hasError = computed(() => v$.value.$errors.length > 0);
|
||||
|
||||
const defaultDateValue = computed({
|
||||
get() {
|
||||
const existingDate = editedValue.value ?? props.attribute.value;
|
||||
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
|
||||
return isEditingValue.value && !hasError.value
|
||||
? new Date().toISOString().slice(0, 10)
|
||||
: '';
|
||||
},
|
||||
set(value) {
|
||||
editedValue.value = value ? new Date(value).toISOString() : value;
|
||||
},
|
||||
});
|
||||
|
||||
const toggleEditValue = value => {
|
||||
isEditingValue.value =
|
||||
typeof value === 'boolean' ? value : !isEditingValue.value;
|
||||
|
||||
if (isEditingValue.value && !editedValue.value) {
|
||||
v$.value.$reset();
|
||||
editedValue.value = new Date().toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputUpdate = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
emit('update', parseISO(editedValue.value));
|
||||
isEditingValue.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="!isEditingValue"
|
||||
class="min-w-0 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate': isEditingView,
|
||||
}"
|
||||
@click="toggleEditValue(!isEditingView)"
|
||||
>
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="isEditingView && !isEditingValue"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="toggleEditValue(true)"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditingValue"
|
||||
v-on-clickaway="() => toggleEditValue(false)"
|
||||
class="flex items-center w-full"
|
||||
>
|
||||
<Input
|
||||
v-model="defaultDateValue"
|
||||
type="date"
|
||||
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
|
||||
:message="
|
||||
hasError
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
|
||||
: ''
|
||||
"
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
autofocus
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
:color="hasError ? 'ruby' : 'blue'"
|
||||
size="sm"
|
||||
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click="handleInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
|
||||
|
||||
const attributeListMenuItems = computed(() => {
|
||||
return (
|
||||
props.attribute.attributeValues?.map(value => ({
|
||||
label: value,
|
||||
value,
|
||||
action: 'select',
|
||||
isSelected: value === props.attribute.value,
|
||||
})) || []
|
||||
);
|
||||
});
|
||||
|
||||
const handleAttributeAction = async action => {
|
||||
emit('update', action.value);
|
||||
toggleAttributeListDropdown(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="() => toggleAttributeListDropdown(false)"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate flex-1': isEditingView,
|
||||
}"
|
||||
@click="toggleAttributeListDropdown(!props.isEditingView)"
|
||||
>
|
||||
{{
|
||||
attribute.value ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
|
||||
}}
|
||||
</span>
|
||||
<DropdownMenu
|
||||
v-if="showAttributeListDropdown"
|
||||
:menu-items="attributeListMenuItems"
|
||||
show-search
|
||||
class="w-48 mt-2 top-full"
|
||||
:class="{
|
||||
'ltr:right-0 rtl:left-0': !isEditingView,
|
||||
'ltr:left-0 rtl:right-0': isEditingView,
|
||||
}"
|
||||
@action="handleAttributeAction($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditingView" class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="toggleAttributeListDropdown()"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,201 +0,0 @@
|
||||
<!-- Attribute type "Text, URL, Number" -->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { isValidURL } from 'dashboard/helper/URLHelper.js';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isEditingView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const isEditingValue = ref(false);
|
||||
const editedValue = ref(props.attribute.value || '');
|
||||
|
||||
const isAttributeTypeLink = computed(
|
||||
() => props.attribute.attributeDisplayType === 'link'
|
||||
);
|
||||
|
||||
const isAttributeTypeText = computed(
|
||||
() => props.attribute.attributeDisplayType === 'text'
|
||||
);
|
||||
|
||||
const isAttributeTypeNumber = computed(
|
||||
() => props.attribute.attributeDisplayType === 'number'
|
||||
);
|
||||
|
||||
const rules = computed(() => ({
|
||||
editedValue: {
|
||||
required,
|
||||
...(isAttributeTypeLink.value && {
|
||||
url: value => !value || isValidURL(value),
|
||||
}),
|
||||
...(isAttributeTypeText.value &&
|
||||
props.attribute.regexPattern && {
|
||||
regexValidation: value => {
|
||||
if (!value) return true;
|
||||
return getRegexp(props.attribute.regexPattern).test(value);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const v$ = useVuelidate(rules, { editedValue });
|
||||
|
||||
const hasError = computed(() => v$.value.$error);
|
||||
|
||||
const attributeErrorMessage = computed(() => {
|
||||
if (!hasError.value) return '';
|
||||
|
||||
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
|
||||
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
|
||||
}
|
||||
|
||||
if (
|
||||
isAttributeTypeText.value &&
|
||||
props.attribute.regexPattern &&
|
||||
v$.value.editedValue.regexValidation?.$invalid
|
||||
) {
|
||||
return (
|
||||
props.attribute.regexCue ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
|
||||
);
|
||||
}
|
||||
|
||||
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
|
||||
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
|
||||
}
|
||||
|
||||
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
});
|
||||
|
||||
const getInputType = computed(() => {
|
||||
switch (props.attribute.attributeDisplayType) {
|
||||
case 'link':
|
||||
return 'url';
|
||||
case 'number':
|
||||
return 'number';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
});
|
||||
|
||||
const toggleEditValue = value => {
|
||||
isEditingValue.value =
|
||||
typeof value === 'boolean' ? value : !isEditingValue.value;
|
||||
if (isEditingValue.value) {
|
||||
v$.value.$reset();
|
||||
editedValue.value = props.attribute.value || '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputUpdate = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
emit('update', editedValue.value);
|
||||
toggleEditValue(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2"
|
||||
:class="{
|
||||
'justify-start': isEditingView,
|
||||
'justify-end': !isEditingView,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="!isEditingValue"
|
||||
class="min-w-0 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
|
||||
'truncate hover:text-n-brand text-n-blue-text':
|
||||
isEditingView && isAttributeTypeLink,
|
||||
}"
|
||||
@click="toggleEditValue(!isEditingView)"
|
||||
>
|
||||
<a
|
||||
v-if="isAttributeTypeLink && attribute.value && isEditingView"
|
||||
:href="attribute.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
{{ attribute.value }}
|
||||
</a>
|
||||
<template v-else>
|
||||
{{
|
||||
attribute.value ||
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="isEditingView && !isEditingValue"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-pencil"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="toggleEditValue(true)"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
icon="i-lucide-trash"
|
||||
size="xs"
|
||||
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditingValue"
|
||||
v-on-clickaway="() => toggleEditValue(false)"
|
||||
class="flex items-center w-full"
|
||||
>
|
||||
<Input
|
||||
v-model="editedValue"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
|
||||
:type="getInputType"
|
||||
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
|
||||
autofocus
|
||||
:message="attributeErrorMessage"
|
||||
:message-type="hasError ? 'error' : 'info'"
|
||||
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
|
||||
@keyup.enter="handleInputUpdate"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-check"
|
||||
:color="hasError ? 'ruby' : 'blue'"
|
||||
size="sm"
|
||||
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click="handleInputUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
<script setup>
|
||||
import Attributes from './fixtures';
|
||||
|
||||
import OtherAttribute from '../OtherAttribute.vue';
|
||||
import ListAttribute from '../ListAttribute.vue';
|
||||
import DateAttribute from '../DateAttribute.vue';
|
||||
import CheckboxAttribute from '../CheckboxAttribute.vue';
|
||||
|
||||
const componentMap = {
|
||||
list: ListAttribute,
|
||||
checkbox: CheckboxAttribute,
|
||||
date: DateAttribute,
|
||||
default: OtherAttribute,
|
||||
};
|
||||
|
||||
const getCurrentComponent = type => {
|
||||
return componentMap[type] || componentMap.default;
|
||||
};
|
||||
|
||||
const handleUpdate = (type, value) => {
|
||||
console.log(`${type} updated:`, value);
|
||||
};
|
||||
|
||||
const handleDelete = type => {
|
||||
console.log(`${type} deleted`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/CustomAttributes"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Create View">
|
||||
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
|
||||
<div
|
||||
v-for="attribute in Attributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
<component
|
||||
:is="getCurrentComponent(attribute.attributeDisplayType)"
|
||||
:attribute="attribute"
|
||||
@update="
|
||||
value => handleUpdate(attribute.attributeDisplayType, value)
|
||||
"
|
||||
@delete="() => handleDelete(attribute.attributeDisplayType)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Saved View">
|
||||
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
|
||||
<div
|
||||
v-for="attribute in Attributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
|
||||
>
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
<component
|
||||
:is="getCurrentComponent(attribute.attributeDisplayType)"
|
||||
:attribute="attribute"
|
||||
is-editing-view
|
||||
@update="
|
||||
value => handleUpdate(attribute.attributeDisplayType, value)
|
||||
"
|
||||
@delete="() => handleDelete(attribute.attributeDisplayType)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
attributeKey: 'textContact',
|
||||
attributeDisplayName: 'Text Input',
|
||||
attributeDisplayType: 'text',
|
||||
value: 'Sample text value',
|
||||
},
|
||||
{
|
||||
attributeKey: 'linkContact',
|
||||
attributeDisplayName: 'URL Input',
|
||||
attributeDisplayType: 'link',
|
||||
value: 'https://www.chatwoot.com',
|
||||
},
|
||||
{
|
||||
attributeKey: 'numberContact',
|
||||
attributeDisplayName: 'Number Input',
|
||||
attributeDisplayType: 'number',
|
||||
value: '42',
|
||||
},
|
||||
{
|
||||
attributeKey: 'listContact',
|
||||
attributeDisplayName: 'List Input',
|
||||
attributeDisplayType: 'list',
|
||||
value: 'Option 2',
|
||||
attributeValues: ['Option 1', 'Option 2', 'Option 3'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'dateContact',
|
||||
attributeDisplayName: 'Date Input',
|
||||
attributeDisplayType: 'date',
|
||||
value: '2024-03-25T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
attributeKey: 'checkboxContact',
|
||||
attributeDisplayName: 'Checkbox Input',
|
||||
attributeDisplayType: 'checkbox',
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, useSlots } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
@@ -41,14 +41,10 @@ const props = defineProps({
|
||||
default: 'info',
|
||||
validator: value => ['info', 'error', 'success'].includes(value),
|
||||
},
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const isFocused = ref(false);
|
||||
|
||||
const characterCount = computed(() => props.modelValue.length);
|
||||
@@ -85,7 +81,7 @@ const handleBlur = () => {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
if (props.maxLength && props.showCharacterCount && !slots.actions) {
|
||||
if (props.maxLength && props.showCharacterCount) {
|
||||
if (characterCount.value >= props.maxLength) {
|
||||
emit('update:modelValue', newValue.slice(0, props.maxLength));
|
||||
}
|
||||
@@ -118,23 +114,17 @@ watch(
|
||||
:placeholder="placeholder"
|
||||
:focus-on-mount="focusOnMount"
|
||||
:disabled="disabled"
|
||||
:enable-variables="enableVariables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount || slots.actions"
|
||||
v-if="showCharacterCount"
|
||||
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
|
||||
>
|
||||
<span
|
||||
v-if="showCharacterCount && !slots.actions"
|
||||
class="text-xs tabular-nums text-n-slate-10"
|
||||
>
|
||||
<span class="text-xs tabular-nums text-n-slate-10">
|
||||
{{ characterCount }} / {{ maxLength }}
|
||||
</span>
|
||||
<slot v-else name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
@@ -154,7 +144,7 @@ watch(
|
||||
@apply gap-2 !important;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
|
||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply h-5 !important;
|
||||
@@ -173,7 +163,7 @@ watch(
|
||||
@apply m-0 !important;
|
||||
|
||||
&::before {
|
||||
@apply text-n-slate-11 dark:text-n-slate-11;
|
||||
@apply text-n-slate-11 dark:text-n-slate-11 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -101,7 +101,7 @@ const categoryName = computed(() => {
|
||||
});
|
||||
|
||||
const authorName = computed(() => {
|
||||
return props.author?.name || props.author?.availableName || '';
|
||||
return props.author?.name || props.author?.availableName || '-';
|
||||
});
|
||||
|
||||
const authorThumbnailSrc = computed(() => {
|
||||
@@ -124,72 +124,75 @@ const handleClick = id => {
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-1">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ statusText }}
|
||||
{{ title }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm truncate text-n-slate-11">
|
||||
{{ authorName || '-' }}
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ authorName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
{{
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
|
||||
count: views,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
{{
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
|
||||
count: views,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user