Merge branch 'release/4.0.2'

This commit is contained in:
Sojan
2025-02-21 17:04:31 -08:00
1044 changed files with 31329 additions and 12147 deletions

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '20.12' node-version: '23.7'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm

View File

@@ -5,30 +5,30 @@
version: '3' version: '3'
services: services:
base: base:
build: build:
context: .. context: ..
dockerfile: .devcontainer/Dockerfile.base dockerfile: .devcontainer/Dockerfile.base
args: args:
VARIANT: "ubuntu-22.04" VARIANT: 'ubuntu-22.04'
NODE_VERSION: "20.9.0" NODE_VERSION: '23.7.0'
RUBY_VERSION: "3.3.3" RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000" USER_UID: '1000'
USER_GID: "1000" USER_GID: '1000'
image: base:latest image: base:latest
app: app:
build: build:
context: .. context: ..
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
VARIANT: "ubuntu-22.04" VARIANT: 'ubuntu-22.04'
NODE_VERSION: "20.9.0" NODE_VERSION: '23.7.0'
RUBY_VERSION: "3.3.3" RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000" USER_UID: '1000'
USER_GID: "1000" USER_GID: '1000'
volumes: volumes:
- ..:/workspace:cached - ..:/workspace:cached

View File

@@ -23,12 +23,10 @@ jobs:
bundler-cache: true bundler-cache: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies

140
.github/workflows/publish_ee_docker.yml vendored Normal file
View File

@@ -0,0 +1,140 @@
# #
# # This action will publish Chatwoot EE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot EE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
- name: Set Docker Tags
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG

View File

@@ -5,6 +5,7 @@
# # # #
name: Publish Chatwoot CE docker images name: Publish Chatwoot CE docker images
on: on:
push: push:
branches: branches:
@@ -12,23 +13,32 @@ on:
- master - master
tags: tags:
- v* - v*
# pull_request:
workflow_dispatch: workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs: jobs:
build: build:
runs-on: ubuntu-latest strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env: env:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches GIT_REF: ${{ github.head_ref || github.ref_name }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Prepare
uses: docker/setup-qemu-action@v1 run: |
platform=${{ matrix.platform }}
- name: Set up Docker Buildx echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
@@ -39,29 +49,97 @@ jobs:
run: | run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag - name: Set Docker Tags
run: | run: |
# Replace forward slashes with hyphens in the ref name
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
fi
- name: replace docker tag if master - name: Set up QEMU
if: github.ref_name == 'master' uses: docker/setup-qemu-action@v3
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push - name: Build and push by digest
uses: docker/build-push-action@v2 id: build
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
platforms: linux/amd64, linux/arm64 platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
tags: ${{ env.DOCKER_TAG }} outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG

View File

@@ -38,7 +38,6 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
version: 9
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -48,7 +47,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies

View File

@@ -19,13 +19,11 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: pnpm - name: pnpm
@@ -39,7 +37,7 @@ jobs:
- name: setup env - name: setup env
run: | run: |
cp .env.example .env cp .env.example .env
- name: Run asset compile - name: Run asset compile
run: bundle exec rake assets:precompile run: bundle exec rake assets:precompile
env: env:
@@ -47,5 +45,3 @@ jobs:
- name: Size Check - name: Size Check
run: pnpm run size run: pnpm run size

40
.github/workflows/test_docker_build.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Test Docker Build
on:
pull_request:
branches:
- develop
- master
workflow_dispatch:
jobs:
test-build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: false
load: false
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,11 +1,11 @@
# #!/bin/sh #!/bin/sh
# . "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
# # lint js and vue files # lint js and vue files
# npx --no-install lint-staged npx --no-install lint-staged
# # lint only staged ruby files # lint only staged ruby files
# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# # stage rubocop changes to files # stage rubocop changes to files
# git diff --name-only --cached | xargs git add git diff --name-only --cached | xargs git add

View File

@@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.2.0' gem 'slack-ruby-client', '~> 2.5.1'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc' gem 'grpc'
@@ -138,9 +138,7 @@ gem 'procore-sift'
# parse email # parse email
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10. gem 'html2text'
# and this gem hasn't been updated for a while.
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
# to calculate working hours # to calculate working hours
gem 'working_hours' gem 'working_hours'

View File

@@ -22,14 +22,6 @@ GIT
devise (>= 4.0.0, < 5.0.0) devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 8.0.0) railties (>= 5.0.0, < 8.0.0)
GIT
remote: https://github.com/chatwoot/html2text_ruby
revision: cdbdbbbf898d846d0136d69d688a003c6b26074b
branch: chatwoot
specs:
html2text (0.3.1)
nokogiri (>= 1.13.6)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
@@ -186,7 +178,7 @@ GEM
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
datadog-ci (0.8.3) datadog-ci (0.8.3)
msgpack msgpack
date (3.3.4) date (3.4.1)
ddtrace (1.23.2) ddtrace (1.23.2)
datadog-ci (~> 0.8.1) datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1) debase-ruby_core_source (= 3.3.1)
@@ -280,7 +272,8 @@ GEM
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.21.1) gli (2.22.2)
ostruct
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
gmail_xoauth (0.4.3) gmail_xoauth (0.4.3)
@@ -361,6 +354,8 @@ GEM
hana (1.3.7) hana (1.3.7)
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.0.0) hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1) http (5.1.1)
addressable (~> 2.8) addressable (~> 2.8)
http-cookie (~> 1.0) http-cookie (~> 1.0)
@@ -487,7 +482,7 @@ GEM
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.17) net-imap (0.4.19)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -503,14 +498,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.17.1) nokogiri (1.18.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.1-arm64-darwin) nokogiri (1.18.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.1-x86_64-darwin) nokogiri (1.18.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.1-x86_64-linux) nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -543,6 +538,7 @@ GEM
openssl (3.2.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.1)
parallel (1.23.0) parallel (1.23.0)
parser (3.2.2.1) parser (3.2.2.1)
ast (~> 2.4.1) ast (~> 2.4.1)
@@ -565,7 +561,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.10) rack (2.2.11)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -751,12 +747,13 @@ GEM
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.2) simplecov-html (0.10.2)
slack-ruby-client (2.2.0) slack-ruby-client (2.5.1)
faraday (>= 2.0) faraday (>= 2.0)
faraday-mashify faraday-mashify
faraday-multipart faraday-multipart
gli gli
hashie hashie
logger
snaky_hash (2.0.1) snaky_hash (2.0.1)
hashie hashie
version_gem (~> 1.1, >= 1.1.1) version_gem (~> 1.1, >= 1.1.1)
@@ -782,7 +779,7 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.4.1) timeout (0.4.3)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (5.77.0) twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@@ -897,7 +894,7 @@ DEPENDENCIES
haikunator haikunator
hairtrigger hairtrigger
hashie hashie
html2text! html2text
image_processing image_processing
jbuilder jbuilder
json_refs json_refs
@@ -957,7 +954,7 @@ DEPENDENCIES
sidekiq (>= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0) slack-ruby-client (~> 2.5.1)
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher

View File

@@ -120,4 +120,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2024, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.

View File

@@ -1 +1 @@
3.1.0 3.2.0

View File

@@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end end
def object_scope def object_scope
scope.reporting_events.where(name: event_name, created_at: range) scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end end
def reporting_events def reporting_events

View File

@@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
def index def index
@conversations = Current.account.conversations.includes( @conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20) ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end end
private private

View File

@@ -10,7 +10,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
def create def create
authorize @inbox, :create? authorize @inbox, :create?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) } @inbox.add_members(agents_to_be_added_ids)
end end
fetch_updated_agents fetch_updated_agents
end end
@@ -24,7 +24,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
def destroy def destroy
authorize @inbox, :destroy? authorize @inbox, :destroy?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @inbox.remove_member(user_id) } @inbox.remove_members(params[:user_ids])
end end
head :ok head :ok
end end
@@ -41,8 +41,8 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
# the missing ones are the agents which are to be deleted from the inbox # the missing ones are the agents which are to be deleted from the inbox
# the new ones are the agents which are to be added to the inbox # the new ones are the agents which are to be added to the inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } @inbox.add_members(agents_to_be_added_ids)
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } @inbox.remove_members(agents_to_be_removed_ids)
end end
end end

View File

@@ -9,14 +9,14 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) } @team_members = @team.add_members(members_to_be_added_ids)
end end
end end
def update def update
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
members_to_be_added_ids.each { |user_id| @team.add_member(user_id) } @team.add_members(members_to_be_added_ids)
members_to_be_removed_ids.each { |user_id| @team.remove_member(user_id) } @team.remove_members(members_to_be_removed_ids)
end end
@team_members = @team.members @team_members = @team.members
render action: 'create' render action: 'create'
@@ -24,7 +24,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @team.remove_member(user_id) } @team.remove_members(params[:user_ids])
end end
head :ok head :ok
end end

View File

@@ -7,13 +7,17 @@ class DashboardController < ActionController::Base
around_action :switch_locale around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index] before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index] before_action :render_hc_if_custom_domain, only: [:index]
before_action :ensure_html_format
layout 'vueapp' layout 'vueapp'
def index; end def index; end
private private
def ensure_html_format
head :not_acceptable unless request.format.html?
end
def set_global_config def set_global_config
@global_config = GlobalConfig.get( @global_config = GlobalConfig.get(
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
@@ -32,7 +36,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE', 'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV', 'DEPLOYMENT_ENV',
'CSML_EDITOR_HOST' 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
).merge(app_config) ).merge(app_config)
end end

View File

@@ -2,6 +2,6 @@ require 'administrate/field/base'
class Enterprise::AccountLimitsField < Administrate::Field::Base class Enterprise::AccountLimitsField < Administrate::Field::Base
def to_s def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
end end
end end

View File

@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
end end
def since_timestamp(date) def since_timestamp(date)
(date - 6.days).to_i.to_s number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
(date - number_of_days).to_i.to_s
end end
def until_timestamp(date) def until_timestamp(date)

View File

@@ -1,58 +1,70 @@
module Api::V2::Accounts::ReportsHelper module Api::V2::Accounts::ReportsHelper
def generate_agents_report def generate_agents_report
reports = V2::Reports::AgentSummaryBuilder.new(
account: Current.account,
params: build_params(type: :agent)
).build
Current.account.users.map do |agent| Current.account.users.map do |agent|
agent_report = report_builder({ type: :agent, id: agent.id }).summary report = reports.find { |r| r[:id] == agent.id }
[agent.name] + generate_readable_report_metrics(agent_report) [agent.name] + generate_readable_report_metrics(report)
end end
end end
def generate_inboxes_report def generate_inboxes_report
reports = V2::Reports::InboxSummaryBuilder.new(
account: Current.account,
params: build_params(type: :inbox)
).build
Current.account.inboxes.map do |inbox| Current.account.inboxes.map do |inbox|
inbox_report = generate_report({ type: :inbox, id: inbox.id }) report = reports.find { |r| r[:id] == inbox.id }
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report) [inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report)
end end
end end
def generate_teams_report def generate_teams_report
reports = V2::Reports::TeamSummaryBuilder.new(
account: Current.account,
params: build_params(type: :team)
).build
Current.account.teams.map do |team| Current.account.teams.map do |team|
team_report = report_builder({ type: :team, id: team.id }).summary report = reports.find { |r| r[:id] == team.id }
[team.name] + generate_readable_report_metrics(team_report) [team.name] + generate_readable_report_metrics(report)
end end
end end
def generate_labels_report def generate_labels_report
Current.account.labels.map do |label| Current.account.labels.map do |label|
label_report = generate_report({ type: :label, id: label.id }) label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report) [label.title] + generate_readable_report_metrics(label_report)
end end
end end
def report_builder(report_params) private
V2::ReportBuilder.new(
Current.account, def build_params(base_params)
report_params.merge( base_params.merge(
{ {
since: params[:since], since: params[:since],
until: params[:until], until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours]) business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
} }
)
) )
end end
def generate_report(report_params) def report_builder(report_params)
report_builder(report_params).short_summary V2::ReportBuilder.new(Current.account, build_params(report_params))
end end
private def generate_readable_report_metrics(report)
def generate_readable_report_metrics(report_metric)
[ [
report_metric[:conversations_count], report[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format, Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format, Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format, Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format,
report_metric[:resolutions_count] report[:resolved_conversations_count]
] ]
end end
end end

View File

@@ -5,8 +5,7 @@ module PortalHelper
end end
def generate_portal_bg(portal_color, theme) def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' generate_portal_bg_color(portal_color, theme)
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end end
def generate_gradient_to_bottom(theme) def generate_gradient_to_bottom(theme)

View File

@@ -6,4 +6,47 @@ module SuperAdmin::AccountFeaturesHelper
def self.account_premium_features def self.account_premium_features
account_features.filter { |feature| feature['premium'] }.pluck('name') account_features.filter { |feature| feature['premium'] }.pluck('name')
end end
# Returns a hash mapping feature names to their display names
def self.feature_display_names
account_features.each_with_object({}) do |feature, hash|
hash[feature['name']] = feature['display_name']
end
end
def self.filter_internal_features(features)
return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
features.except(*internal_features)
end
def self.filter_deprecated_features(features)
deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name')
features.except(*deprecated_features)
end
def self.sort_and_transform_features(features, display_names)
features.sort_by { |key, _| display_names[key] || key }
.to_h
.transform_keys { |key| [key, display_names[key]] }
end
def self.partition_features(features)
filtered = filter_internal_features(features)
filtered = filter_deprecated_features(filtered)
display_names = feature_display_names
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
[
sort_and_transform_features(regular, display_names),
sort_and_transform_features(premium, display_names)
]
end
def self.filtered_features(features)
regular, premium = partition_features(features)
regular.merge(premium)
end
end end

View File

@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
}); });
} }
getConversationTrafficCSV() { getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, { return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() }, params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
}); });
} }

View File

@@ -14,26 +14,29 @@ class SearchAPI extends ApiClient {
}); });
} }
contacts({ q }) { contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, { return axios.get(`${this.url}/contacts`, {
params: { params: {
q, q,
page: page,
}, },
}); });
} }
conversations({ q }) { conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, { return axios.get(`${this.url}/conversations`, {
params: { params: {
q, q,
page: page,
}, },
}); });
} }
messages({ q }) { messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, { return axios.get(`${this.url}/messages`, {
params: { params: {
q, q,
page: page,
}, },
}); });
} }

View File

@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();

View File

@@ -6,3 +6,209 @@
body { body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
} }
@layer base {
// FIXME: Use a common color file for all packs
// scss-lint:disable PropertySortOrder
:root {
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--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;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--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;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--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;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--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;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}

View File

@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
</span> </span>
</div> </div>
<div <div
v-dompurify-html="formatMessage(message)" v-dompurify-html="formatMessage(message, false, false, false)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6" 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"> <div class="flex items-center w-full h-6 gap-2 overflow-hidden">

View File

@@ -21,11 +21,11 @@ const addCampaign = async campaignDetails => {
type: CAMPAIGN_TYPES.ONGOING, type: CAMPAIGN_TYPES.ONGOING,
}); });
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE')); useAlert(t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error?.response?.message || error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE'); t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage); useAlert(errorMessage);
} }
}; };

View File

@@ -8,17 +8,17 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({ const props = defineProps({
buttonLabel: {
type: String,
default: '',
},
selectedContact: { selectedContact: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
isUpdating: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['goToContactsList']); const emit = defineEmits(['goToContactsList', 'toggleBlock']);
const { t } = useI18n(); const { t } = useI18n();
const slots = useSlots(); const slots = useSlots();
@@ -45,9 +45,17 @@ const breadcrumbItems = computed(() => {
return items; return items;
}); });
const isContactBlocked = computed(() => {
return props.selectedContact?.blocked;
});
const handleBreadcrumbClick = () => { const handleBreadcrumbClick = () => {
emit('goToContactsList'); emit('goToContactsList');
}; };
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
</script> </script>
<template> <template>
@@ -64,11 +72,29 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems" :items="breadcrumbItems"
@click="handleBreadcrumbClick" @click="handleBreadcrumbClick"
/> />
<ComposeConversation :contact-id="contactId"> <div class="flex items-center gap-2">
<template #trigger="{ toggle }"> <Button
<Button :label="buttonLabel" size="sm" @click="toggle" /> :label="
</template> !isContactBlocked
</ComposeConversation> ? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</div> </div>
</div> </div>
</header> </header>

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed, reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators'; import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils'; import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js'; import countries from 'shared/constants/countries.js';
@@ -35,7 +35,7 @@ const FORM_CONFIG = {
EMAIL_ADDRESS: { field: 'email' }, EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' }, PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' }, CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' }, COUNTRY: { field: 'additionalAttributes.countryCode' },
BIO: { field: 'additionalAttributes.description' }, BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' }, COMPANY_NAME: { field: 'additionalAttributes.companyName' },
}; };
@@ -74,7 +74,7 @@ const defaultState = {
const state = reactive({ ...defaultState }); const state = reactive({ ...defaultState });
const validationRules = { const validationRules = {
firstName: { required, minLength: minLength(2) }, firstName: { required },
email: { email }, email: { email },
}; };
@@ -123,7 +123,7 @@ const prepareStateBasedOnProps = () => {
}; };
const countryOptions = computed(() => const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name })) countries.map(({ name, id }) => ({ label: name, value: id }))
); );
const editDetailsForm = computed(() => const editDetailsForm = computed(() =>
@@ -205,8 +205,8 @@ const getMessageType = key => {
}; };
const handleCountrySelection = value => { const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.name === value); const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.countryCode = selectedCountry?.id || ''; state.additionalAttributes.country = selectedCountry?.name || '';
emit('update', state); emit('update', state);
}; };
@@ -242,7 +242,7 @@ defineExpose({
<template v-for="item in editDetailsForm" :key="item.key"> <template v-for="item in editDetailsForm" :key="item.key">
<ComboBox <ComboBox
v-if="item.key === 'COUNTRY'" v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country" v-model="state.additionalAttributes.countryCode"
:options="countryOptions" :options="countryOptions"
:placeholder="item.placeholder" :placeholder="item.placeholder"
class="[&>div>button]:h-8" class="[&>div>button]:h-8"

View File

@@ -59,6 +59,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT') t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
" "
color="blue" color="blue"
:disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact" :is-loading="isCreatingContact"
@click="handleDialogConfirm" @click="handleDialogConfirm"
/> />

View File

@@ -2,6 +2,7 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue'; import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
@@ -14,6 +15,8 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const { uiSettings } = useUISettings();
const searchQuery = ref(''); const searchQuery = ref('');
const contactAttributes = useMapGetter('attributes/getContactAttributes') || []; const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
@@ -46,20 +49,49 @@ const processContactAttributes = (
}, []); }, []);
}; };
const sortAttributesOrder = computed(
() =>
uiSettings.value.conversation_elements_order_conversation_contact_panel ??
[]
);
const sortByUISettings = attributes => {
// Get saved order from UI settings
// Same as conversation panel contact attribute order
const order = sortAttributesOrder.value;
// If no order defined, return original array
if (!order?.length) return attributes;
const orderMap = new Map(order.map((key, index) => [key, index]));
// Sort attributes based on their position in saved order
return [...attributes].sort((a, b) => {
// Get positions, use Infinity if not found in order (pushes to end)
const aPos = orderMap.get(a.attributeKey) ?? Infinity;
const bPos = orderMap.get(b.attributeKey) ?? Infinity;
return aPos - bPos;
});
};
const usedAttributes = computed(() => { const usedAttributes = computed(() => {
return processContactAttributes( const attributes = processContactAttributes(
contactAttributes.value, contactAttributes.value,
props.selectedContact?.customAttributes, props.selectedContact?.customAttributes,
(key, custom) => key in custom (key, custom) => key in custom
); );
return sortByUISettings(attributes);
}); });
const unusedAttributes = computed(() => { const unusedAttributes = computed(() => {
return processContactAttributes( const attributes = processContactAttributes(
contactAttributes.value, contactAttributes.value,
props.selectedContact?.customAttributes, props.selectedContact?.customAttributes,
(key, custom) => !(key in custom) (key, custom) => !(key in custom)
); );
return sortByUISettings(attributes);
}); });
const filteredUnusedAttributes = computed(() => { const filteredUnusedAttributes = computed(() => {

View File

@@ -1,4 +1,6 @@
<script setup> <script setup>
import Policy from 'dashboard/components/policy.vue';
defineProps({ defineProps({
title: { title: {
type: String, type: String,
@@ -8,6 +10,10 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
actionPerms: {
type: Array,
default: () => [],
},
}); });
</script> </script>
@@ -16,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden" class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
> >
<div <div
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]" class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
> >
<div <div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none" class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
@@ -39,7 +45,9 @@ defineProps({
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>
<slot name="actions" /> <Policy :permissions="actionPerms">
<slot name="actions" />
</Policy>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0"> <header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto"> <div class="w-full max-w-[960px] mx-auto lg:px-6">
<div <div
v-if="showHeaderTitle" v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2" class="flex items-center justify-start h-20 gap-2"
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0"> <main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3"> <div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
<slot name="content" /> <slot name="content" />
</div> </div>
</main> </main>

View File

@@ -36,6 +36,8 @@ const emit = defineEmits([
const { t } = useI18n(); const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => { const saveAndSync = value => {
emit('saveArticle', value); emit('saveArticle', value);
}; };
@@ -52,21 +54,32 @@ const quickSave = debounce(
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause // 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data // so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data // this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false); const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
};
const articleTitle = computed({ const articleTitle = computed({
get: () => props.article.title, get: () => props.article.title,
set: value => { set: value => {
quickSave({ title: value }); handleSave({ title: value });
saveAndSyncDebounced({ title: value });
}, },
}); });
const articleContent = computed({ const articleContent = computed({
get: () => props.article.content, get: () => props.article.content,
set: content => { set: content => {
quickSave({ content }); handleSave({ content });
saveAndSyncDebounced({ content });
}, },
}); });

View File

@@ -200,7 +200,8 @@ onMounted(() => {
<DropdownMenu <DropdownMenu
v-if="openAgentsList && hasAgentList" v-if="openAgentsList && hasAgentList"
:menu-items="agentList" :menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52" show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction" @action="handleArticleAction"
/> />
</OnClickOutside> </OnClickOutside>
@@ -231,7 +232,8 @@ onMounted(() => {
<DropdownMenu <DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems" v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList" :menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52" show-search
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction" @action="handleArticleAction"
/> />
</OnClickOutside> </OnClickOutside>

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { import {
ARTICLE_TABS, ARTICLE_TABS,
CATEGORY_ALL, CATEGORY_ALL,
@@ -37,6 +38,7 @@ const emit = defineEmits([
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const { updateUISettings } = useUISettings();
const isCategoryMenuOpen = ref(false); const isCategoryMenuOpen = ref(false);
const isLocaleMenuOpen = ref(false); const isLocaleMenuOpen = ref(false);
@@ -111,13 +113,12 @@ const localeMenuItems = computed(() => {
})); }));
}); });
const hasMoreThanOneLocaleMenuItems = computed(() => {
return localeMenuItems.value?.length > 1;
});
const handleLocaleAction = ({ value }) => { const handleLocaleAction = ({ value }) => {
emit('localeChange', value); emit('localeChange', value);
isLocaleMenuOpen.value = false; isLocaleMenuOpen.value = false;
updateUISettings({
last_active_locale_code: value,
});
}; };
const handleCategoryAction = ({ value }) => { const handleCategoryAction = ({ value }) => {
@@ -143,7 +144,7 @@ const handleTabChange = value => {
/> />
<div class="flex items-start justify-between w-full gap-2"> <div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="hasMoreThanOneLocaleMenuItems" class="relative group"> <div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false"> <OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button <Button
:label="activeLocaleName" :label="activeLocaleName"
@@ -157,6 +158,7 @@ const handleTabChange = value => {
<DropdownMenu <DropdownMenu
v-if="isLocaleMenuOpen" v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems" :menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60" class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction" @action="handleLocaleAction"
/> />
@@ -177,6 +179,7 @@ const handleTabChange = value => {
<DropdownMenu <DropdownMenu
v-if="isCategoryMenuOpen" v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems" :menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60" class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction" @action="handleCategoryAction"
/> />

View File

@@ -99,11 +99,19 @@ const getStatusMessage = (status, isSuccess) => {
: ''; : '';
}; };
const updateMeta = () => { const updatePortalMeta = () => {
const { portalSlug, locale } = route.params; const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { portalSlug, locale }); return store.dispatch('portals/show', { portalSlug, locale });
}; };
const updateArticlesMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('articles/updateArticleMeta', {
portalSlug,
locale,
});
};
const handleArticleAction = async (action, { status, id }) => { const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params; const { portalSlug } = route.params;
try { try {
@@ -127,7 +135,8 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE); useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
} }
} }
await updateMeta(); await updateArticlesMeta();
await updatePortalMeta();
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error?.message || error?.message ||

View File

@@ -91,10 +91,7 @@ const articlesCount = computed(() => {
}); });
const showArticleHeaderControls = computed( const showArticleHeaderControls = computed(
() => () => !props.isCategoryArticles && !isSwitchingPortal.value
!hasNoArticlesInPortal.value &&
!props.isCategoryArticles &&
!isSwitchingPortal.value
); );
const showCategoryHeaderControls = computed( const showCategoryHeaderControls = computed(

View File

@@ -141,6 +141,7 @@ const handleBreadcrumbClick = () => {
<DropdownMenu <DropdownMenu
v-if="isLocaleMenuOpen" v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems" :menu-items="localeMenuItems"
show-search
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60" class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction" @action="handleLocaleAction"
/> />

View File

@@ -49,8 +49,11 @@ const onCreate = async () => {
try { try {
await store.dispatch('portals/update', { await store.dispatch('portals/update', {
portalSlug: props.portal.slug, portalSlug: props.portal?.slug,
config: { allowed_locales: updatedLocales }, config: {
allowed_locales: updatedLocales,
default_locale: props.portal?.meta?.default_locale,
},
}); });
useTrack(PORTALS_EVENTS.CREATE_LOCALE, { useTrack(PORTALS_EVENTS.CREATE_LOCALE, {

View File

@@ -2,6 +2,7 @@
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue'; import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables'; import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -20,6 +21,7 @@ const props = defineProps({
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isLocaleDefault = code => { const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code; return props.portal?.meta?.default_locale === code;
@@ -56,26 +58,40 @@ const changeDefaultLocale = ({ localeCode }) => {
defaultLocale: localeCode, defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE', messageKey: 'CHANGE_DEFAULT_LOCALE',
}); });
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, { useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode, newLocale: localeCode,
from: route.name, from: route.name,
}); });
}; };
const deletePortalLocale = ({ localeCode }) => { const updateLastActivePortal = async localeCode => {
const { last_active_locale_code: lastActiveLocaleCode } =
uiSettings.value || {};
const defaultLocale = props.portal.meta.default_locale;
// Update UI settings only if deleting locale matches the last active locale in UI settings.
if (localeCode === lastActiveLocaleCode) {
await updateUISettings({
last_active_locale_code: defaultLocale,
});
}
};
const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode) .filter(locale => locale.code !== localeCode)
.map(locale => locale.code); .map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale; const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({ await updatePortalLocales({
newAllowedLocales: updatedLocales, newAllowedLocales: updatedLocales,
defaultLocale, defaultLocale,
messageKey: 'DELETE_LOCALE', messageKey: 'DELETE_LOCALE',
}); });
await updateLastActivePortal(localeCode);
useTrack(PORTALS_EVENTS.DELETE_LOCALE, { useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode, deletedLocale: localeCode,
from: route.name, from: route.name,

View File

@@ -171,6 +171,10 @@ watch(
{ immediate: true, deep: true } { immediate: true, deep: true }
); );
const handleClickOutside = () => {
showComposeNewConversation.value = false;
};
onMounted(() => resetContacts()); onMounted(() => resetContacts());
const keyboardEvents = { const keyboardEvents = {
@@ -188,7 +192,12 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<div <div
v-on-click-outside="() => (showComposeNewConversation = false)" v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative" class="relative"
:class="{ :class="{
'z-40': showComposeNewConversation, 'z-40': showComposeNewConversation,

View File

@@ -77,7 +77,7 @@ const toggleMessageSignature = () => {
setSignature(); setSignature();
}; };
// Added this watch to dynamically set signature. // Added this watch to dynamically set signature on target inbox change.
// Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox) // Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox)
// Set the signature only if the inbox based flag is true // Set the signature only if the inbox based flag is true
watch( watch(
@@ -86,7 +86,8 @@ watch(
nextTick(() => { nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature(); if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
}); });
} },
{ immediate: true }
); );
const onClickInsertEmoji = emoji => { const onClickInsertEmoji = emoji => {

View File

@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
color: {
type: String,
default: 'slate',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
},
actionLabel: {
type: String,
default: null,
},
});
const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
});
const buttonClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
});
const triggerAction = () => {
emit('action');
};
</script>
<template>
<div
class="text-sm rounded-xl flex items-center justify-between gap-2 border"
:class="[
bannerClass,
{
'py-2 px-3': !actionLabel,
'pl-3 p-2': actionLabel,
},
]"
>
<div>
<slot />
</div>
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
:class="buttonClass"
@click="triggerAction"
>
{{ actionLabel }}
</button>
</div>
</div>
</template>

View File

@@ -1,8 +1,12 @@
<script setup> <script setup>
import { computed } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({ const props = defineProps({
currentPage: { currentPage: {
type: Number, type: Number,
default: 1, default: 1,
@@ -19,10 +23,26 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
buttonPolicy: {
type: Array,
default: () => [],
},
buttonLabel: { buttonLabel: {
type: String, type: String,
default: '', default: '',
}, },
featureFlag: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
showPaginationFooter: { showPaginationFooter: {
type: Boolean, type: Boolean,
default: true, default: true,
@@ -30,6 +50,11 @@ defineProps({
}); });
const emit = defineEmits(['click', 'close', 'update:currentPage']); const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { shouldShowPaywall } = usePolicy();
const showPaywall = computed(() => {
return shouldShowPaywall(props.featureFlag);
});
const handleButtonClick = () => { const handleButtonClick = () => {
emit('click'); emit('click');
@@ -52,16 +77,19 @@ const handlePageChange = event => {
<slot name="headerTitle" /> <slot name="headerTitle" />
</span> </span>
<div <div
v-if="!showPaywall"
v-on-clickaway="() => emit('close')" v-on-clickaway="() => emit('close')"
class="relative group/campaign-button" class="relative group/campaign-button"
> >
<Button <Policy :permissions="buttonPolicy">
:label="buttonLabel" <Button
icon="i-lucide-plus" :label="buttonLabel"
size="sm" icon="i-lucide-plus"
class="group-hover/campaign-button:brightness-110" size="sm"
@click="handleButtonClick" class="group-hover/campaign-button:brightness-110"
/> @click="handleButtonClick"
/>
</Policy>
<slot name="action" /> <slot name="action" />
</div> </div>
</div> </div>
@@ -69,7 +97,21 @@ const handlePageChange = event => {
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4"> <div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" /> <slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="showPaywall">
<slot name="paywall" />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
<slot />
</div> </div>
</main> </main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4"> <footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">

View File

@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useToggle } from '@vueuse/core'; import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper'; import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -28,31 +29,41 @@ const props = defineProps({
}); });
const emit = defineEmits(['action']); const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n(); const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle(); const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [ const menuItems = computed(() => {
{ const allOptions = [
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'), {
value: 'viewConnectedInboxes', label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
action: 'viewConnectedInboxes', value: 'viewConnectedInboxes',
icon: 'i-lucide-link', action: 'viewConnectedInboxes',
}, icon: 'i-lucide-link',
{ },
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'), ];
value: 'edit',
action: 'edit', if (checkPermissions(['administrator'])) {
icon: 'i-lucide-pencil-line', allOptions.push(
}, {
{ label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'), value: 'edit',
value: 'delete', action: 'edit',
action: 'delete', icon: 'i-lucide-pencil-line',
icon: 'i-lucide-trash', },
}, {
]); label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
}
);
}
return allOptions;
});
const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt)); const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt));

View File

@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useToggle } from '@vueuse/core'; import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper'; import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -32,25 +33,33 @@ const props = defineProps({
}); });
const emit = defineEmits(['action']); const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n(); const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle(); const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [ const menuItems = computed(() => {
{ const allOptions = [
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'), {
value: 'viewRelatedQuestions', label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
action: 'viewRelatedQuestions', value: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone', action: 'viewRelatedQuestions',
}, icon: 'i-ph-tree-view-duotone',
{ },
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'), ];
value: 'delete',
action: 'delete', if (checkPermissions(['administrator'])) {
icon: 'i-lucide-trash', allOptions.push({
}, label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
]); value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
});
}
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt)); const createdAt = computed(() => dynamicTime(props.createdAt));

View File

@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox'; import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
const props = defineProps({ const props = defineProps({
@@ -76,8 +77,9 @@ const handleAction = ({ action, value }) => {
{{ inboxName }} {{ inboxName }}
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <Policy
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group" class="relative flex items-center group"
> >
<Button <Button
@@ -93,7 +95,7 @@ const handleAction = ({ action, value }) => {
class="mt-1 ltr:right-0 rtl:left-0 top-full" class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)" @action="handleAction($event)"
/> />
</div> </Policy>
</div> </div>
</div> </div>
</CardLayout> </CardLayout>

View File

@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -107,8 +108,9 @@ const handleDocumentableClick = () => {
{{ question }} {{ question }}
</span> </span>
<div v-if="!compact" class="flex items-center gap-2"> <div v-if="!compact" class="flex items-center gap-2">
<div <Policy
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group" class="relative flex items-center group"
> >
<Button <Button
@@ -124,7 +126,7 @@ const handleDocumentableClick = () => {
class="mt-1 ltr:right-0 rtl:right-0 top-full" class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAssistantAction($event)" @action="handleAssistantAction($event)"
/> />
</div> </Policy>
</div> </div>
</div> </div>
<span class="text-n-slate-11 text-sm line-clamp-5"> <span class="text-n-slate-11 text-sm line-clamp-5">

View File

@@ -20,6 +20,8 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const deleteDialogRef = ref(null); const deleteDialogRef = ref(null);
@@ -30,6 +32,7 @@ const deleteEntity = async payload => {
try { try {
await store.dispatch(`captain${props.type}/delete`, payload); await store.dispatch(`captain${props.type}/delete`, payload);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.SUCCESS_MESSAGE`)); useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.SUCCESS_MESSAGE`));
} catch (error) { } catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.ERROR_MESSAGE`)); useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.ERROR_MESSAGE`));

View File

@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const { accountId, isOnChatwootCloud } = useAccount();
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
feature-prefix="CAPTAIN"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="openBilling"
/>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useRouter } from 'vue-router';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const router = useRouter();
const { accountId } = useAccount();
const { documentLimits, fetchLimits } = useCaptain();
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const showBanner = computed(() => {
if (!documentLimits.value) return false;
const { currentAvailable } = documentLimits.value;
return currentAvailable === 0;
});
onMounted(fetchLimits);
</script>
<template>
<Banner
v-show="showBanner"
color="amber"
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@action="openBilling"
>
{{ $t('CAPTAIN.BANNER.DOCUMENTS') }}
</Banner>
</template>

View File

@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')" :subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">

View File

@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')" :subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">

View File

@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')" :subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">

View File

@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')" :subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
> >
<template #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">

View File

@@ -0,0 +1,42 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useRouter } from 'vue-router';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const router = useRouter();
const { accountId } = useAccount();
const { responseLimits, fetchLimits } = useCaptain();
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const showBanner = computed(() => {
if (!responseLimits.value) return false;
const { consumed, totalCount } = responseLimits.value;
if (!consumed || !totalCount) return false;
return consumed / totalCount > 0.8;
});
onMounted(fetchLimits);
</script>
<template>
<Banner
v-show="showBanner"
color="amber"
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@action="openBilling"
>
{{ $t('CAPTAIN.BANNER.RESPONSES') }}
</Banner>
</template>

View File

@@ -1,9 +1,13 @@
<script setup> <script setup>
import { nextTick, ref, watch } from 'vue';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import CopilotInput from './CopilotInput.vue'; import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue'; import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue'; import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue'; import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import { nextTick, ref, watch } from 'vue'; import Icon from '../icon/Icon.vue';
const props = defineProps({ const props = defineProps({
supportAgent: { supportAgent: {
@@ -24,13 +28,24 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['sendMessage']); const emit = defineEmits(['sendMessage', 'reset']);
const COPILOT_USER_ROLES = ['assistant', 'system']; const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
emit('sendMessage', message); emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
}; };
const useSuggestion = opt => {
emit('sendMessage', opt.prompt);
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null); const chatContainer = ref(null);
const scrollToBottom = async () => { const scrollToBottom = async () => {
@@ -40,6 +55,21 @@ const scrollToBottom = async () => {
} }
}; };
const promptOptions = [
{
label: 'Summarize this conversation',
prompt: `Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent`,
},
{
label: 'Suggest an answer',
prompt: `Analyze the customers inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information.`,
},
{
label: 'Rate this conversation',
prompt: `Review the conversation to see how well it meets the customers needs. Share a rating out of 5 based on tone, clarity, and effectiveness.`,
},
];
watch( watch(
[() => props.messages, () => props.isCaptainTyping], [() => props.messages, () => props.isCaptainTyping],
() => { () => {
@@ -50,7 +80,7 @@ watch(
</script> </script>
<template> <template>
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto"> <div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id"> <template v-for="message in messages" :key="message.id">
<CopilotAgentMessage <CopilotAgentMessage
@@ -67,7 +97,32 @@ watch(
<CopilotLoader v-if="isCaptainTyping" /> <CopilotLoader v-if="isCaptainTyping" />
</div> </div>
<div>
<CopilotInput class="mx-3 mt-px mb-4" @send="sendMessage" /> <div v-if="!messages.length" class="flex-1 px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ prompt.label }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
<div class="mx-3 mt-px mb-2 flex flex-col items-end flex-1">
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
<CopilotInput class="mb-1 flex-1 w-full" @send="sendMessage" />
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -1,8 +1,12 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useTrack } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue'; import Avatar from '../avatar/Avatar.vue';
@@ -18,6 +22,11 @@ const props = defineProps({
}, },
}); });
const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content);
return formatter.formattedMessage;
});
const insertIntoRichEditor = computed(() => { const insertIntoRichEditor = computed(() => {
return [INBOX_TYPES.WEB, INBOX_TYPES.EMAIL].includes( return [INBOX_TYPES.WEB, INBOX_TYPES.EMAIL].includes(
props.conversationInboxType props.conversationInboxType
@@ -30,6 +39,7 @@ const useCopilotResponse = () => {
} else { } else {
emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, props.message?.content);
} }
useTrack(COPILOT_EVENTS.USE_CAPTAIN_RESPONSE);
}; };
</script> </script>
@@ -43,9 +53,7 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-col gap-1 text-n-slate-12"> <div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div> <div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<div class="break-words"> <div v-dompurify-html="messageContent" class="prose-sm break-words" />
{{ message.content }}
</div>
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"

View File

@@ -1,6 +1,21 @@
<script setup>
import { useAttrs } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const attrs = useAttrs();
const globalConfig = useMapGetter('globalConfig/get');
</script>
<template> <template>
<img
v-if="globalConfig.logoThumbnail"
v-bind="attrs"
:src="globalConfig.logoThumbnail"
/>
<svg <svg
v-else
v-once v-once
v-bind="attrs"
width="16" width="16"
height="16" height="16"
viewBox="0 0 16 16" viewBox="0 0 16 16"

View File

@@ -34,6 +34,8 @@ import UnsupportedBubble from './bubbles/Unsupported.vue';
import ContactBubble from './bubbles/Contact.vue'; import ContactBubble from './bubbles/Contact.vue';
import DyteBubble from './bubbles/Dyte.vue'; import DyteBubble from './bubbles/Dyte.vue';
import LocationBubble from './bubbles/Location.vue'; import LocationBubble from './bubbles/Location.vue';
import CSATBubble from './bubbles/CSAT.vue';
import FormBubble from './bubbles/Form.vue';
import MessageError from './MessageError.vue'; import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
@@ -260,6 +262,16 @@ const componentToRender = computed(() => {
if (emailInboxTypes.includes(props.messageType)) return EmailBubble; if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
} }
if (props.contentType === CONTENT_TYPES.INPUT_CSAT) {
return CSATBubble;
}
if (
[CONTENT_TYPES.INPUT_SELECT, CONTENT_TYPES.FORM].includes(props.contentType)
) {
return FormBubble;
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) { if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble; return EmailBubble;
} }
@@ -402,6 +414,11 @@ const avatarInfo = computed(() => {
}; };
}); });
const avatarTooltip = computed(() => {
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
const setupHighlightTimer = () => { const setupHighlightTimer = () => {
if (Number(route.query.messageId) !== Number(props.id)) { if (Number(route.query.messageId) !== Number(props.id)) {
return; return;
@@ -460,6 +477,7 @@ provideMessageContext({
> >
<div <div
v-if="!shouldGroupWithNext && shouldShowAvatar" v-if="!shouldGroupWithNext && shouldShowAvatar"
v-tooltip.right-end="avatarTooltip"
class="[grid-area:avatar] flex items-end" class="[grid-area:avatar] flex items-end"
> >
<Avatar v-bind="avatarInfo" :size="24" /> <Avatar v-bind="avatarInfo" :size="24" />

View File

@@ -15,18 +15,14 @@ import { useCamelCase } from 'dashboard/composables/useTransformKeys';
* @property {Array} messages - Array of all messages [These are not in camelcase] * @property {Array} messages - Array of all messages [These are not in camelcase]
*/ */
const props = defineProps({ const props = defineProps({
readMessages: {
type: Array,
default: () => [],
},
unReadMessages: {
type: Array,
default: () => [],
},
currentUserId: { currentUserId: {
type: Number, type: Number,
required: true, required: true,
}, },
firstUnreadId: {
type: Number,
default: null,
},
isAnEmailChannel: { isAnEmailChannel: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -41,12 +37,8 @@ const props = defineProps({
}, },
}); });
const unread = computed(() => { const allMessages = computed(() => {
return useCamelCase(props.unReadMessages, { deep: true }); return useCamelCase(props.messages, { deep: true });
});
const read = computed(() => {
return useCamelCase(props.readMessages, { deep: true });
}); });
/** /**
@@ -108,26 +100,18 @@ const getInReplyToMessage = parentMessage => {
<template> <template>
<ul class="px-4 bg-n-background"> <ul class="px-4 bg-n-background">
<slot name="beforeAll" /> <slot name="beforeAll" />
<template v-for="(message, index) in read" :key="message.id"> <template v-for="(message, index) in allMessages" :key="message.id">
<Message <slot
v-bind="message" v-if="firstUnreadId && message.id === firstUnreadId"
:is-email-inbox="isAnEmailChannel" name="unreadBadge"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, read)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
/> />
</template>
<slot name="beforeUnread" />
<template v-for="(message, index) in unread" :key="message.id">
<Message <Message
v-bind="message" v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)" :in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unread)" :group-with-next="shouldGroupWithNext(index, allMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo" :inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId" :current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True" data-clarity-mask="True"
/> />
</template> </template>

View File

@@ -10,6 +10,7 @@ defineProps({
iconBgColor: { type: String, default: 'bg-n-alpha-3' }, iconBgColor: { type: String, default: 'bg-n-alpha-3' },
senderTranslationKey: { type: String, required: true }, senderTranslationKey: { type: String, required: true },
content: { type: String, required: true }, content: { type: String, required: true },
title: { type: String, default: '' }, // Title can be any name, description, etc
action: { action: {
type: Object, type: Object,
required: true, required: true,
@@ -23,14 +24,14 @@ const { sender } = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const senderName = computed(() => { const senderName = computed(() => {
return sender?.value.name; return sender?.value?.name || '';
}); });
</script> </script>
<template> <template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment"> <BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
<div class="grid gap-4 min-w-64"> <div class="grid gap-4 min-w-64">
<div class="grid gap-3 z-20"> <div class="grid gap-3">
<div <div
class="size-8 rounded-lg grid place-content-center" class="size-8 rounded-lg grid place-content-center"
:class="iconBgColor" :class="iconBgColor"
@@ -48,6 +49,9 @@ const senderName = computed(() => {
}} }}
</div> </div>
<slot> <slot>
<div v-if="title" class="truncate text-sm text-n-slate-12">
{{ title }}
</div>
<div v-if="content" class="truncate text-sm text-n-slate-11"> <div v-if="content" class="truncate text-sm text-n-slate-11">
{{ content }} {{ content }}
</div> </div>

View File

@@ -0,0 +1,45 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
const { contentAttributes } = useMessageContext();
const { t } = useI18n();
const response = computed(() => {
return contentAttributes.value?.submittedValues?.csatSurveyResponse ?? {};
});
const isRatingSubmitted = computed(() => {
return !!response.value.rating;
});
const rating = computed(() => {
if (isRatingSubmitted.value) {
return CSAT_RATINGS.find(
csatOption => csatOption.value === response.value.rating
);
}
return null;
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
</dt>
<dd>{{ t(rating.translationKey) }}</dd>
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
</dt>
<dd>{{ response.feedbackMessage }}</dd>
</dl>
</BaseBubble>
</template>

View File

@@ -11,7 +11,7 @@ import {
ExceptionWithMessage, ExceptionWithMessage,
} from 'shared/helpers/CustomErrors'; } from 'shared/helpers/CustomErrors';
const { content, attachments } = useMessageContext(); const { attachments } = useMessageContext();
const $store = useStore(); const $store = useStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -24,6 +24,12 @@ const phoneNumber = computed(() => {
return attachment.value.fallbackTitle; return attachment.value.fallbackTitle;
}); });
const contactName = computed(() => {
const { meta } = attachment.value ?? {};
const { firstName, lastName } = meta ?? {};
return `${firstName ?? ''} ${lastName ?? ''}`.trim();
});
const formattedPhoneNumber = computed(() => { const formattedPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, ''); return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, '');
}); });
@@ -32,13 +38,9 @@ const rawPhoneNumber = computed(() => {
return phoneNumber.value.replace(/\D/g, ''); return phoneNumber.value.replace(/\D/g, '');
}); });
const name = computed(() => {
return content.value;
});
function getContactObject() { function getContactObject() {
const contactItem = { const contactItem = {
name: name.value, name: contactName.value,
phone_number: `+${rawPhoneNumber.value}`, phone_number: `+${rawPhoneNumber.value}`,
}; };
return contactItem; return contactItem;
@@ -99,6 +101,7 @@ const action = computed(() => ({
icon="i-teenyicons-user-circle-solid" icon="i-teenyicons-user-circle-solid"
icon-bg-color="bg-[#D6409F]" icon-bg-color="bg-[#D6409F]"
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT" sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT"
:title="contactName"
:content="phoneNumber" :content="phoneNumber"
:action="formattedPhoneNumber ? action : null" :action="formattedPhoneNumber ? action : null"
/> />

View File

@@ -2,34 +2,30 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import DyteAPI from 'dashboard/api/integrations/dyte'; import DyteAPI from 'dashboard/api/integrations/dyte';
import { buildDyteURL } from 'shared/helpers/IntegrationHelper'; import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import BaseAttachmentBubble from './BaseAttachment.vue'; import BaseAttachmentBubble from './BaseAttachment.vue';
const { contentAttributes } = useMessageContext(); const { content, sender, id } = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const meetingData = computed(() => {
return useCamelCase(contentAttributes.value.data);
});
const isLoading = ref(false); const isLoading = ref(false);
const dyteAuthToken = ref(''); const dyteAuthToken = ref('');
const meetingLink = computed(() => { const meetingLink = computed(() => {
return buildDyteURL(meetingData.value.roomName, dyteAuthToken.value); return buildDyteURL(dyteAuthToken.value);
}); });
const joinTheCall = async () => { const joinTheCall = async () => {
isLoading.value = true; isLoading.value = true;
try { try {
const { data: { authResponse: { authToken } = {} } = {} } = const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
await DyteAPI.addParticipantToMeeting(meetingData.value.messageId); id.value
dyteAuthToken.value = authToken; );
dyteAuthToken.value = token;
} catch (err) { } catch (err) {
useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR')); useAlert(t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
} finally { } finally {
@@ -38,7 +34,7 @@ const joinTheCall = async () => {
}; };
const leaveTheRoom = () => { const leaveTheRoom = () => {
this.dyteAuthToken = ''; dyteAuthToken.value = '';
}; };
const action = computed(() => ({ const action = computed(() => ({
label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'), label: t('INTEGRATION_SETTINGS.DYTE.CLICK_HERE_TO_JOIN'),
@@ -53,13 +49,18 @@ const action = computed(() => ({
sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING" sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING"
:action="action" :action="action"
> >
<div v-if="!sender" class="text-sm truncate text-n-slate-12">
<!-- Added as a fallback, where the sender is not available (Deleted) -->
<!-- Will show the content, if senderName in BaseAttachment.vue is empty -->
{{ content }}
</div>
<div v-if="dyteAuthToken" class="video-call--container"> <div v-if="dyteAuthToken" class="video-call--container">
<iframe <iframe
:src="meetingLink" :src="meetingLink"
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;" allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
/> />
<button <button
class="bg-n-solid-3 px-4 py-2 rounded-lg text-sm" class="px-4 py-2 text-sm rounded-lg bg-n-solid-3 mt-3"
@click="leaveTheRoom" @click="leaveTheRoom"
> >
{{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }} {{ $t('INTEGRATION_SETTINGS.DYTE.LEAVE_THE_ROOM') }}

View File

@@ -26,7 +26,18 @@ const ccEmail = computed(() => {
}); });
const senderName = computed(() => { const senderName = computed(() => {
return sender.value.name ?? ''; const fromEmailAddress = fromEmail.value[0] ?? '';
const senderEmail = sender.value.email ?? '';
if (!fromEmailAddress && !senderEmail) return null;
// if the sender of the conversation and the sender of this particular
// email are the same, only then we return the sender name
if (fromEmailAddress === senderEmail) {
return sender.value.name;
}
return null;
}); });
const bccEmail = computed(() => { const bccEmail = computed(() => {
@@ -59,11 +70,19 @@ const showMeta = computed(() => {
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'" :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
> >
<template v-if="showMeta"> <template v-if="showMeta">
<div v-if="fromEmail[0]"> <div
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"> v-if="fromEmail[0]"
{{ senderName }} :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
</span> >
&lt;{{ fromEmail[0] }}&gt; <template v-if="senderName">
<span>
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
</template>
<template v-else>
{{ fromEmail[0] }}
</template>
</div> </div>
<div v-if="toEmail.length"> <div v-if="toEmail.length">
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }} {{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, useTemplateRef, ref, onMounted } from 'vue'; import { computed, useTemplateRef, ref, onMounted } from 'vue';
import { Letter } from 'vue-letter'; import { Letter } from 'vue-letter';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js'; import { EmailQuoteExtractor } from './removeReply.js';
@@ -29,8 +30,15 @@ const isOutgoing = computed(() => {
}); });
const isIncoming = computed(() => !isOutgoing.value); const isIncoming = computed(() => !isOutgoing.value);
const textToShow = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
// Use TextContent as the default to fullHTML
const fullHTML = computed(() => { const fullHTML = computed(() => {
return contentAttributes?.value?.email?.htmlContent?.full ?? content.value; return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
}); });
const unquotedHTML = computed(() => { const unquotedHTML = computed(() => {
@@ -40,12 +48,6 @@ const unquotedHTML = computed(() => {
const hasQuotedMessage = computed(() => { const hasQuotedMessage = computed(() => {
return EmailQuoteExtractor.hasQuotes(fullHTML.value); return EmailQuoteExtractor.hasQuotes(fullHTML.value);
}); });
const textToShow = computed(() => {
const text =
contentAttributes?.value?.email?.textContent?.full ?? content.value;
return text?.replace(/\n/g, '<br>');
});
</script> </script>
<template> <template>
@@ -92,6 +94,11 @@ const textToShow = computed(() => {
<Letter <Letter
v-if="showQuotedMessage" v-if="showQuotedMessage"
class-name="prose prose-bubble !max-w-none" class-name="prose prose-bubble !max-w-none"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:html="fullHTML" :html="fullHTML"
:text="textToShow" :text="textToShow"
/> />
@@ -99,6 +106,11 @@ const textToShow = computed(() => {
v-else v-else
class-name="prose prose-bubble !max-w-none" class-name="prose prose-bubble !max-w-none"
:html="unquotedHTML" :html="unquotedHTML"
:allowed-css-properties="[
...allowedCssProperties,
'transform',
'transform-origin',
]"
:text="textToShow" :text="textToShow"
/> />
</template> </template>

View File

@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CONTENT_TYPES } from '../constants.js';
import { useMessageContext } from '../provider.js';
const { content, contentAttributes, contentType } = useMessageContext();
const { t } = useI18n();
const formValues = computed(() => {
if (contentType.value === CONTENT_TYPES.FORM) {
const { items, submittedValues = [] } = contentAttributes.value;
if (submittedValues.length) {
return submittedValues.map(submittedValue => {
const item = items.find(
formItem => formItem.name === submittedValue.name
);
return {
title: submittedValue.value,
value: submittedValue.value,
label: item?.label,
};
});
}
return [];
}
if (contentType.value === CONTENT_TYPES.INPUT_SELECT) {
const [item] = contentAttributes.value?.submittedValues ?? [];
if (!item) return [];
return [
{
title: item.title,
value: item.value,
label: '',
},
];
}
return [];
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<span v-dompurify-html="content" :title="content" />
<dl v-if="formValues.length" class="mt-4">
<template v-for="item in formValues" :key="item.title">
<dt class="text-n-slate-11 italic mt-2">
{{ item.label || t('CONVERSATION.RESPONSE') }}
</dt>
<dd>{{ item.title }}</dd>
</template>
</dl>
<div v-else class="my-2 font-medium">
{{ t('CONVERSATION.NO_RESPONSE') }}
</div>
</BaseBubble>
</template>

View File

@@ -1,13 +1,19 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import BaseBubble from './Base.vue'; import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue'; import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys'; import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue'; import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const emit = defineEmits(['error']); const emit = defineEmits(['error']);
const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext(); const { filteredCurrentChatAttachments, attachments } = useMessageContext();
const attachment = computed(() => { const attachment = computed(() => {
@@ -16,6 +22,7 @@ const attachment = computed(() => {
const hasError = ref(false); const hasError = ref(false);
const showGallery = ref(false); const showGallery = ref(false);
const isDownloading = ref(false);
const handleError = () => { const handleError = () => {
hasError.value = true; hasError.value = true;
@@ -23,16 +30,15 @@ const handleError = () => {
}; };
const downloadAttachment = async () => { const downloadAttachment = async () => {
const response = await fetch(attachment.value.dataUrl); const { fileType, dataUrl, extension } = attachment.value;
const blob = await response.blob(); try {
const url = window.URL.createObjectURL(blob); isDownloading.value = true;
const a = document.createElement('a'); await downloadFile({ url: dataUrl, type: fileType, extension });
a.href = url; } catch (error) {
a.download = `attachment${attachment.value.extension || ''}`; useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
document.body.appendChild(a); } finally {
a.click(); isDownloading.value = false;
window.URL.revokeObjectURL(url); }
document.body.removeChild(a);
}; };
</script> </script>
@@ -66,7 +72,9 @@ const downloadAttachment = async () => {
slate slate
icon="i-lucide-download" icon="i-lucide-download"
class="opacity-60" class="opacity-60"
@click="downloadAttachment" :is-loading="isDownloading"
:disabled="isDownloading"
@click.stop="downloadAttachment"
/> />
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, useTemplateRef, ref } from 'vue'; import { computed, onMounted, useTemplateRef, ref } from 'vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper'; import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
const { attachment } = defineProps({ const { attachment } = defineProps({
attachment: { attachment: {
@@ -24,22 +25,29 @@ const isPlaying = ref(false);
const isMuted = ref(false); const isMuted = ref(false);
const currentTime = ref(0); const currentTime = ref(0);
const duration = ref(0); const duration = ref(0);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration; duration.value = audioPlayer.value?.duration;
}; };
const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`;
});
// There maybe a chance that the audioPlayer ref is not available // There maybe a chance that the audioPlayer ref is not available
// When the onLoadMetadata is called, so we need to set the duration // When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted // value when the component is mounted
onMounted(() => { onMounted(() => {
duration.value = audioPlayer.value?.duration; duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
}); });
const formatTime = time => { const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60); const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60); const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}; };
const toggleMute = () => { const toggleMute = () => {
@@ -48,7 +56,7 @@ const toggleMute = () => {
}; };
const onTimeUpdate = () => { const onTimeUpdate = () => {
currentTime.value = audioPlayer.value.currentTime; currentTime.value = audioPlayer.value?.currentTime;
}; };
const seek = event => { const seek = event => {
@@ -70,20 +78,21 @@ const playOrPause = () => {
const onEnd = () => { const onEnd = () => {
isPlaying.value = false; isPlaying.value = false;
currentTime.value = 0; currentTime.value = 0;
playbackSpeed.value = 1;
audioPlayer.value.playbackRate = 1;
};
const changePlaybackSpeed = () => {
const speeds = [1, 1.5, 2];
const currentIndex = speeds.indexOf(playbackSpeed.value);
const nextIndex = (currentIndex + 1) % speeds.length;
playbackSpeed.value = speeds[nextIndex];
audioPlayer.value.playbackRate = playbackSpeed.value;
}; };
const downloadAudio = async () => { const downloadAudio = async () => {
const response = await fetch(timeStampURL.value); const { fileType, dataUrl, extension } = attachment;
const blob = await response.blob(); downloadFile({ url: dataUrl, type: fileType, extension });
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
const filename = timeStampURL.value.split('/').pop().split('?')[0] || 'audio';
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
}; };
</script> </script>
@@ -113,7 +122,7 @@ const downloadAudio = async () => {
<div class="tabular-nums text-xs"> <div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} {{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div> </div>
<div class="flex items-center px-2"> <div class="flex-1 items-center flex px-2">
<input <input
type="range" type="range"
min="0" min="0"
@@ -123,6 +132,14 @@ const downloadAudio = async () => {
@input="seek" @input="seek"
/> />
</div> </div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button <button
class="p-0 border-0 size-8 grid place-content-center" class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute" @click="toggleMute"

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { getFileInfo } from '@chatwoot/utils';
import FileIcon from 'next/icon/FileIcon.vue'; import FileIcon from 'next/icon/FileIcon.vue';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
@@ -14,17 +15,20 @@ const { attachment } = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const fileName = computed(() => { const fileDetails = computed(() => {
const url = attachment.dataUrl; return getFileInfo(attachment?.dataUrl || '');
if (url) {
const filename = url.substring(url.lastIndexOf('/') + 1);
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
}
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
}); });
const fileType = computed(() => { const displayFileName = computed(() => {
return fileName.value.split('.').pop(); const { base, type } = fileDetails.value;
const truncatedName = (str, maxLength, hasExt) =>
str.length > maxLength
? `${str.substring(0, maxLength).trimEnd()}${hasExt ? '..' : '...'}`
: str;
return type
? `${truncatedName(base, 12, true)}.${type}`
: truncatedName(base, 14, false);
}); });
const textColorClass = computed(() => { const textColorClass = computed(() => {
@@ -47,21 +51,25 @@ const textColorClass = computed(() => {
zip: 'dark:text-[#EDEEF0] text-[#2F265F]', zip: 'dark:text-[#EDEEF0] text-[#2F265F]',
}; };
return colorMap[fileType.value] || 'text-n-slate-12'; return colorMap[fileDetails.value.type] || 'text-n-slate-12';
}); });
</script> </script>
<template> <template>
<div <div
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-container" class="h-9 bg-n-alpha-white gap-2 overflow-hidden items-center flex px-2 rounded-lg border border-n-container"
> >
<FileIcon class="flex-shrink-0" :file-type="fileType" /> <FileIcon class="flex-shrink-0" :file-type="fileDetails.type" />
<span class="mr-1 max-w-32 truncate" :class="textColorClass"> <span
{{ fileName }} class="flex-1 min-w-0 text-sm max-w-36"
:title="fileDetails.name"
:class="textColorClass"
>
{{ displayFileName }}
</span> </span>
<a <a
v-tooltip="t('CONVERSATION.DOWNLOAD')" v-tooltip="t('CONVERSATION.DOWNLOAD')"
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11" class="flex-shrink-0 size-9 grid place-content-center cursor-pointer text-n-slate-11 hover:text-n-slate-12 transition-colors"
:href="attachment.dataUrl" :href="attachment.dataUrl"
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
target="_blank" target="_blank"

View File

@@ -63,7 +63,7 @@ const pageInfo = computed(() => {
<template> <template>
<div <div
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center" class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11"> <span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">

View File

@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
} }
}; };
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn); useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar // We're using localStorage to store the expanded item in the sidebar
@@ -77,6 +90,59 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name)) inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
); );
const newReportRoutes = [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports_index'),
activeOn: ['agent_reports_show'],
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports_index'),
activeOn: ['inbox_reports_show'],
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports_index'),
activeOn: ['team_reports_show'],
},
];
const oldReportRoutes = [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes : oldReportRoutes
);
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
{ {
@@ -85,6 +151,9 @@ const menuItems = computed(() => {
icon: 'i-lucide-inbox', icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'), to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'], activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
badge: 'notifications/getHasUnreadNotifications',
},
}, },
{ {
name: 'Conversation', name: 'Conversation',
@@ -261,31 +330,12 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_CONVERSATION'), label: t('SIDEBAR.REPORTS_CONVERSATION'),
to: accountScopedRoute('conversation_reports'), to: accountScopedRoute('conversation_reports'),
}, },
...reportRoutes.value,
{ {
name: 'Reports CSAT', name: 'Reports CSAT',
label: t('SIDEBAR.CSAT'), label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'), to: accountScopedRoute('csat_reports'),
}, },
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
{ {
name: 'Reports SLA', name: 'Reports SLA',
label: t('SIDEBAR.REPORTS_SLA'), label: t('SIDEBAR.REPORTS_SLA'),
@@ -470,7 +520,7 @@ const menuItems = computed(() => {
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2"> <div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center"> <div class="grid flex-shrink-0 size-6 place-content-center">
<Logo /> <Logo class="size-4" />
</div> </div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" /> <div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher <SidebarAccountSwitcher

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -19,6 +20,12 @@ const { accountId, currentAccount } = useAccount();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const userAccounts = useMapGetter('getUserAccounts');
const showAccountSwitcher = computed(
() => userAccounts.value.length > 1 && currentAccount.value.name
);
const onChangeAccount = newId => { const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`; const accountUrl = `/app/accounts/${newId}/dashboard`;
window.location.href = accountUrl; window.location.href = accountUrl;
@@ -37,9 +44,14 @@ const emitNewAccount = () => {
:data-account-id="accountId" :data-account-id="accountId"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-controls="account-options" aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2" class="flex items-center gap-2 justify-between w-full rounded-lg px-2"
:class="{ 'bg-n-alpha-1': isOpen }" :class="[
@click="toggle" isOpen && 'bg-n-alpha-1',
showAccountSwitcher
? 'hover:bg-n-alpha-1 cursor-pointer'
: 'cursor-default',
]"
@click="() => showAccountSwitcher && toggle()"
> >
<span <span
class="text-sm font-medium leading-5 text-n-slate-12 truncate" class="text-sm font-medium leading-5 text-n-slate-12 truncate"
@@ -49,13 +61,14 @@ const emitNewAccount = () => {
</span> </span>
<span <span
v-if="showAccountSwitcher"
aria-hidden="true" aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0" class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/> />
</button> </button>
</template> </template>
<DropdownBody class="min-w-80 z-50"> <DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')"> <DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem <DropdownItem
v-for="account in currentUser.accounts" v-for="account in currentUser.accounts"
:id="`account-${account.id}`" :id="`account-${account.id}`"

View File

@@ -15,6 +15,7 @@ const props = defineProps({
to: { type: Object, default: null }, to: { type: Object, default: null },
activeOn: { type: Array, default: () => [] }, activeOn: { type: Array, default: () => [] },
children: { type: Array, default: undefined }, children: { type: Array, default: undefined },
getterKeys: { type: Object, default: () => ({}) },
}); });
const { const {
@@ -141,6 +142,7 @@ onMounted(async () => {
:name :name
:label :label
:to :to
:getter-keys="getterKeys"
:is-active="isActive" :is-active="isActive"
:has-active-child="hasActiveChild" :has-active-child="hasActiveChild"
:expandable="hasChildren" :expandable="hasChildren"
@@ -162,7 +164,7 @@ onMounted(async () => {
:active-child="activeChild" :active-child="activeChild"
/> />
<SidebarGroupLeaf <SidebarGroupLeaf
v-else v-else-if="isAllowed(child.to)"
v-show="isExpanded || activeChild?.name === child.name" v-show="isExpanded || activeChild?.name === child.name"
v-bind="child" v-bind="child"
:active="activeChild?.name === child.name" :active="activeChild?.name === child.name"

View File

@@ -1,7 +1,8 @@
<script setup> <script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
defineProps({ const props = defineProps({
to: { type: [Object, String], default: '' }, to: { type: [Object, String], default: '' },
label: { type: String, default: '' }, label: { type: String, default: '' },
icon: { type: [String, Object], default: '' }, icon: { type: [String, Object], default: '' },
@@ -9,9 +10,12 @@ defineProps({
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isActive: { type: Boolean, default: false }, isActive: { type: Boolean, default: false },
hasActiveChild: { type: Boolean, default: false }, hasActiveChild: { type: Boolean, default: false },
getterKeys: { type: Object, default: () => ({}) },
}); });
const emit = defineEmits(['toggle']); const emit = defineEmits(['toggle']);
const showBadge = useMapGetter(props.getterKeys.badge);
</script> </script>
<template> <template>
@@ -28,7 +32,13 @@ const emit = defineEmits(['toggle']);
}" }"
@click.stop="emit('toggle')" @click.stop="emit('toggle')"
> >
<Icon v-if="icon" :icon="icon" class="size-4" /> <div v-if="icon" class="relative flex items-center gap-2">
<Icon v-if="icon" :icon="icon" class="size-4" />
<span
v-if="showBadge"
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<span class="text-sm font-medium leading-5 flex-grow"> <span class="text-sm font-medium leading-5 flex-grow">
{{ label }} {{ label }}
</span> </span>

View File

@@ -19,6 +19,7 @@ const shouldRenderComponent = computed(() => {
}); });
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template> <template>
<Policy <Policy
:permissions="resolvePermissions(to)" :permissions="resolvePermissions(to)"

View File

@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue'; import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { import {
DropdownContainer, DropdownContainer,
@@ -21,14 +22,27 @@ defineOptions({
const { t } = useI18n(); const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability'); const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const accountId = useMapGetter('getCurrentAccountId');
const globalConfig = useMapGetter('globalConfig/get');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONTACT_CHATWOOT_SUPPORT_TEAM
) && globalConfig.value.chatwootInboxToken
);
});
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
{ {
show: !!globalConfig.value.chatwootInboxToken, show: showChatSupport.value,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'), label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy', icon: 'i-lucide-life-buoy',
click: () => { click: () => {

View File

@@ -2,7 +2,6 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue'; import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue'; import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
import { useSidebarContext } from './provider'; import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
@@ -19,15 +18,12 @@ const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null); const scrollableContainer = ref(null);
const accessibleItems = computed(() => const accessibleItems = computed(() =>
props.children.filter(child => isAllowed(child.to)) props.children.filter(child => {
return child.to && isAllowed(child.to);
})
); );
const hasAccessibleItems = computed(() => { const hasAccessibleItems = computed(() => {
if (props.children.length === 0) {
// cases like segment, folder and labels where users can create new items
return true;
}
return accessibleItems.value.length > 0; return accessibleItems.value.length > 0;
}); });
@@ -52,7 +48,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon :icon
class="my-1" class="my-1"
/> />
<ul class="m-0 list-none reset-base relative group"> <ul v-if="children.length" class="m-0 list-none reset-base relative group">
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end, <!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item --> which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div <div
@@ -61,16 +57,13 @@ useEventListener(scrollableContainer, 'scroll', () => {
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable, 'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}" }"
> >
<template v-if="children.length"> <SidebarGroupLeaf
<SidebarGroupLeaf v-for="child in children"
v-for="child in children" v-show="isExpanded || activeChild?.name === child.name"
v-show="isExpanded || activeChild?.name === child.name" v-bind="child"
v-bind="child" :key="child.name"
:key="child.name" :active="activeChild?.name === child.name"
:active="activeChild?.name === child.name" />
/>
</template>
<SidebarGroupEmptyLeaf v-else v-show="isExpanded" class="ml-3 rtl:mr-3" />
</div> </div>
<div <div
v-if="isScrollable && isExpanded" v-if="isScrollable && isExpanded"

View File

@@ -11,7 +11,8 @@ export function useSidebarContext() {
} }
const router = useRouter(); const router = useRouter();
const { checkFeatureAllowed, checkPermissions } = usePolicy();
const { shouldShow } = usePolicy();
const resolvePath = to => { const resolvePath = to => {
if (to) return router.resolve(to)?.path || '/'; if (to) return router.resolve(to)?.path || '/';
@@ -28,11 +29,17 @@ export function useSidebarContext() {
return ''; return '';
}; };
const resolveInstallationType = to => {
if (to) return router.resolve(to)?.meta?.installationTypes || [];
return [];
};
const isAllowed = to => { const isAllowed = to => {
const permissions = resolvePermissions(to); const permissions = resolvePermissions(to);
const featureFlag = resolveFeatureFlag(to); const featureFlag = resolveFeatureFlag(to);
const installationType = resolveInstallationType(to);
return checkPermissions(permissions) && checkFeatureAllowed(featureFlag); return shouldShow(featureFlag, permissions, installationType);
}; };
return { return {

View File

@@ -57,7 +57,7 @@ useEventListener(document.body, 'mouseup', onMouseUp);
useEventListener(document, 'keydown', onKeydown); useEventListener(document, 'keydown', onKeydown);
onMounted(() => { onMounted(() => {
if (onClose && typeof onClose === 'function') { if (import.meta.env.DEV && onClose && typeof onClose === 'function') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
"[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead." "[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead."

View File

@@ -28,10 +28,12 @@ export default {
}, },
}, },
created() { created() {
// eslint-disable-next-line if (import.meta.env.DEV) {
console.warn( // eslint-disable-next-line
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead' console.warn(
); '[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
);
}
}, },
}; };
</script> </script>

View File

@@ -18,6 +18,10 @@ const messages = ref([]);
const isCaptainTyping = ref(false); const isCaptainTyping = ref(false);
const handleReset = () => {
messages.value = [];
};
const sendMessage = async message => { const sendMessage = async message => {
// Add user message // Add user message
messages.value.push({ messages.value.push({
@@ -62,5 +66,6 @@ const sendMessage = async message => {
:is-captain-typing="isCaptainTyping" :is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType" :conversation-inbox-type="conversationInboxType"
@send-message="sendMessage" @send-message="sendMessage"
@reset="handleReset"
/> />
</template> </template>

View File

@@ -11,6 +11,7 @@ const reports = accountId => ({
'agent_reports', 'agent_reports',
'label_reports', 'label_reports',
'inbox_reports', 'inbox_reports',
'inbox_reports_show',
'team_reports', 'team_reports',
'sla_reports', 'sla_reports',
], ],

View File

@@ -4,6 +4,7 @@ import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue'; import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
export default { export default {
components: { components: {
@@ -28,6 +29,7 @@ export default {
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get', globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}), }),
showChangeAccountOption() { showChangeAccountOption() {
if (this.globalConfig.createNewAccountFromDashboard) { if (this.globalConfig.createNewAccountFromDashboard) {
@@ -37,6 +39,14 @@ export default {
const { accounts = [] } = this.currentUser; const { accounts = [] } = this.currentUser;
return accounts.length > 1; return accounts.length > 1;
}, },
showChatSupport() {
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CONTACT_CHATWOOT_SUPPORT_TEAM
) && this.globalConfig.chatwootInboxToken
);
},
}, },
methods: { methods: {
handleProfileSettingClick(e, navigate) { handleProfileSettingClick(e, navigate) {
@@ -82,7 +92,7 @@ export default {
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }} {{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</woot-button> </woot-button>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem v-if="globalConfig.chatwootInboxToken"> <WootDropdownItem v-if="showChatSupport">
<woot-button <woot-button
variant="clear" variant="clear"
color-scheme="secondary" color-scheme="secondary"

View File

@@ -15,17 +15,22 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
installationTypes: {
type: Array,
default: null,
},
}); });
const { checkFeatureAllowed, checkPermissions } = usePolicy(); const { shouldShow } = usePolicy();
const isFeatureAllowed = computed(() => checkFeatureAllowed(props.featureFlag)); const show = computed(() =>
const hasPermission = computed(() => checkPermissions(props.permissions)); shouldShow(props.featureFlag, props.permissions, props.installationTypes)
);
</script> </script>
<!-- eslint-disable vue/no-root-v-if --> <!-- eslint-disable vue/no-root-v-if -->
<template> <template>
<component :is="as" v-if="isFeatureAllowed && hasPermission"> <component :is="as" v-if="show">
<slot /> <slot />
</component> </component>
</template> </template>

View File

@@ -40,7 +40,7 @@ const headerClass = computed(() =>
:style="{ :style="{
width: `${header.getSize()}px`, width: `${header.getSize()}px`,
}" }"
class="text-left py-3 px-5 font-normal text-sm" class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
:class="headerClass" :class="headerClass"
@click="header.column.getCanSort() && header.column.toggleSorting()" @click="header.column.getCanSort() && header.column.toggleSorting()"
> >

View File

@@ -1,34 +1,67 @@
<script setup> <script setup>
import { ref, computed, onMounted, nextTick, defineEmits } from 'vue'; import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
const { x, y } = defineProps({ const props = defineProps({
x: { type: Number, default: 0 }, x: { type: Number, default: 0 },
y: { type: Number, default: 0 }, y: { type: Number, default: 0 },
}); });
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const left = ref(x); const menuRef = useTemplateRef('menuRef');
const top = ref(y);
const style = computed(() => ({ const { width: windowWidth, height: windowHeight } = useWindowSize();
top: top.value + 'px', const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
left: left.value + 'px',
})); const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
return {
left: Math.max(0, left),
top: Math.max(0, top),
};
};
const position = computed(() => {
if (!menuRef.value) return { top: `${props.y}px`, left: `${props.x}px` };
const { left, top } = calculatePosition(
props.x,
props.y,
menuWidth.value,
menuHeight.value,
windowWidth.value,
windowHeight.value
);
return {
top: `${top}px`,
left: `${left}px`,
};
});
const target = ref();
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => menuRef.value?.focus());
target.value.focus();
});
}); });
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div <div
ref="target" ref="menuRef"
class="fixed outline-none z-[9999] cursor-pointer" class="fixed outline-none z-[9999] cursor-pointer"
:style="style" :style="position"
tabindex="0" tabindex="0"
@blur="emit('close')" @blur="emit('close')"
> >

View File

@@ -37,8 +37,10 @@ const buttonStyleClass = props.compact
> >
<Icon <Icon
icon="i-lucide-chevron-left" icon="i-lucide-chevron-left"
class="size-5 ltr:-ml-1 rtl:-mr-1" class="ltr:-ml-1 rtl:-mr-1"
:class="props.compact ? 'text-n-slate-11' : 'text-n-blue-text'" :class="
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
"
/> />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }} {{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button> </button>

View File

@@ -156,13 +156,14 @@ export default {
</slot> </slot>
<img <img
v-if="badgeSrc" v-if="badgeSrc"
class="source-badge" class="source-badge z-20"
:style="badgeStyle" :style="badgeStyle"
:src="`/integrations/channels/badges/${badgeSrc}.png`" :src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge" alt="Badge"
/> />
<div <div
v-if="showStatusIndicator" v-if="showStatusIndicator"
class="z-20"
:class="`source-badge user-online-status user-online-status--${status}`" :class="`source-badge user-online-status user-online-status--${status}`"
:style="statusStyle" :style="statusStyle"
/> />

View File

@@ -95,9 +95,6 @@ export default {
activeInbox: 'getSelectedInbox', activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
}), }),
bulkActionCheck() {
return !this.hideThumbnail && !this.hovered && !this.selected;
},
chatMetadata() { chatMetadata() {
return this.chat.meta || {}; return this.chat.meta || {};
}, },
@@ -182,10 +179,10 @@ export default {
router.push({ path }); router.push({ path });
}, },
onCardHover() { onThumbnailHover() {
this.hovered = !this.hideThumbnail; this.hovered = !this.hideThumbnail;
}, },
onCardLeave() { onThumbnailLeave() {
this.hovered = false; this.hovered = false;
}, },
onSelectConversation(checked) { onSelectConversation(checked) {
@@ -249,28 +246,36 @@ export default {
'has-inbox-name': showInboxName, 'has-inbox-name': showInboxName,
'conversation-selected': selected, 'conversation-selected': selected,
}" }"
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="onCardClick" @click="onCardClick"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
> >
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop> <div
<input class="relative"
:value="selected" @mouseenter="onThumbnailHover"
:checked="selected" @mouseleave="onThumbnailLeave"
class="checkbox" >
type="checkbox" <label
@change="onSelectConversation($event.target.checked)" v-if="hovered || selected"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="checkbox"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
</label>
<Thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
/> />
</label> </div>
<Thumbnail
v-if="bulkActionCheck"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="40px"
/>
<div <div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]" class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
> >
@@ -400,7 +405,7 @@ export default {
} }
.checkbox-wrapper { .checkbox-wrapper {
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4 hover:bg-woot-100 dark:hover:bg-woot-800; @apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] { input[type='checkbox'] {
@apply m-0 cursor-pointer; @apply m-0 cursor-pointer;

View File

@@ -243,6 +243,15 @@ export default {
unreadMessageCount() { unreadMessageCount() {
return this.currentChat.unread_count || 0; return this.currentChat.unread_count || 0;
}, },
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
isInstagramDM() { isInstagramDM() {
return this.conversationType === 'instagram_direct_message'; return this.conversationType === 'instagram_direct_message';
}, },
@@ -492,12 +501,11 @@ export default {
<NextMessageList <NextMessageList
v-if="showNextBubbles" v-if="showNextBubbles"
class="conversation-panel" class="conversation-panel"
:read-messages="readMessages"
:un-read-messages="unReadMessages"
:current-user-id="currentUserId" :current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel" :is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo" :inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="currentChat ? currentChat.messages : []" :messages="getMessages"
> >
<template #beforeAll> <template #beforeAll>
<transition name="slide-up"> <transition name="slide-up">
@@ -507,15 +515,10 @@ export default {
</li> </li>
</transition> </transition>
</template> </template>
<template #beforeUnread> <template #unreadBadge>
<li v-show="unreadMessageCount != 0" class="unread--toast"> <li v-show="unreadMessageCount != 0" class="unread--toast">
<span> <span>
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }} {{ unreadMessageLabel }}
{{
unreadMessageCount > 1
? $t('CONVERSATION.UNREAD_MESSAGES')
: $t('CONVERSATION.UNREAD_MESSAGE')
}}
</span> </span>
</li> </li>
</template> </template>

View File

@@ -30,7 +30,7 @@ import {
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce } from '@chatwoot/utils'; import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
@@ -326,7 +326,8 @@ export default {
this.isAnEmailChannel || this.isAnEmailChannel ||
this.isAWebWidgetInbox || this.isAWebWidgetInbox ||
this.isAPIInbox || this.isAPIInbox ||
this.isAWhatsAppChannel this.isAWhatsAppChannel ||
this.isATelegramChannel
); );
}, },
isSignatureEnabledForInbox() { isSignatureEnabledForInbox() {
@@ -388,7 +389,6 @@ export default {
watch: { watch: {
currentChat(conversation) { currentChat(conversation) {
const { can_reply: canReply } = conversation; const { can_reply: canReply } = conversation;
this.setCCAndToEmailsFromLastChat(); this.setCCAndToEmailsFromLastChat();
if (this.isOnPrivateNote) { if (this.isOnPrivateNote) {
@@ -403,6 +403,19 @@ export default {
this.fetchAndSetReplyTo(); this.fetchAndSetReplyTo();
}, },
// When moving from one conversation to another, the store may not have the
// list of all the messages. A fetch is subsequently made to get the messages.
// However, this update does not trigger the `currentChat` watcher.
// We can add a deep watcher to it, but then, that would be too broad of a net to cast
// And would impact performance too. So we watch the messages directly.
// The watcher here is `deep` too, because the messages array is mutated and
// not replaced. So, a shallow watcher would not catch the change.
'currentChat.messages': {
handler() {
this.setCCAndToEmailsFromLastChat();
},
deep: true,
},
conversationIdByRoute(conversationId, oldConversationId) { conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) { if (conversationId !== oldConversationId) {
this.setToDraft(oldConversationId, this.replyType); this.setToDraft(oldConversationId, this.replyType);
@@ -989,45 +1002,20 @@ export default {
this.ccEmails = value.ccEmails; this.ccEmails = value.ccEmails;
}, },
setCCAndToEmailsFromLastChat() { setCCAndToEmailsFromLastChat() {
if (!this.lastEmail) return;
const {
content_attributes: { email: emailAttributes = {} },
} = this.lastEmail;
// Retrieve the email of the current conversation's sender
const conversationContact = this.currentChat?.meta?.sender?.email || ''; const conversationContact = this.currentChat?.meta?.sender?.email || '';
let cc = emailAttributes.cc ? [...emailAttributes.cc] : []; const { email: inboxEmail, forward_to_email: forwardToEmail } =
let to = []; this.inbox;
// there might be a situation where the current conversation will include a message from a third person, const { cc, bcc, to } = getRecipients(
// and the current conversation contact is in CC. this.lastEmail,
// This is an edge-case, reported here: CW-1511 [ONLY FOR INTERNAL REFERENCE] conversationContact,
// So we remove the current conversation contact's email from the CC list if present inboxEmail,
if (cc.includes(conversationContact)) { forwardToEmail
cc = cc.filter(email => email !== conversationContact);
}
// If the last incoming message sender is different from the conversation contact, add them to the "to"
// and add the conversation contact to the CC
if (!emailAttributes.from.includes(conversationContact)) {
to.push(...emailAttributes.from);
cc.push(conversationContact);
}
// Remove the conversation contact's email from the BCC list if present
let bcc = (emailAttributes.bcc || []).filter(
email => email !== conversationContact
); );
// Ensure only unique email addresses are in the CC list this.toEmails = to.join(', ');
bcc = [...new Set(bcc)];
cc = [...new Set(cc)];
to = [...new Set(to)];
this.ccEmails = cc.join(', '); this.ccEmails = cc.join(', ');
this.bccEmails = bcc.join(', '); this.bccEmails = bcc.join(', ');
this.toEmails = to.join(', ');
}, },
fetchAndSetReplyTo() { fetchAndSetReplyTo() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO; const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;

View File

@@ -9,26 +9,23 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
meetingData: {
type: Object,
default: () => ({}),
},
}, },
data() { data() {
return { isLoading: false, dyteAuthToken: '', isSDKMounted: false }; return { isLoading: false, dyteAuthToken: '', isSDKMounted: false };
}, },
computed: { computed: {
meetingLink() { meetingLink() {
return buildDyteURL(this.meetingData.room_name, this.dyteAuthToken); return buildDyteURL(this.dyteAuthToken);
}, },
}, },
methods: { methods: {
async joinTheCall() { async joinTheCall() {
this.isLoading = true; this.isLoading = true;
try { try {
const { data: { authResponse: { authToken } = {} } = {} } = const { data: { token } = {} } = await DyteAPI.addParticipantToMeeting(
await DyteAPI.addParticipantToMeeting(this.messageId); this.messageId
this.dyteAuthToken = authToken; );
this.dyteAuthToken = token;
} catch (err) { } catch (err) {
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR')); useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.JOIN_ERROR'));
} finally { } finally {

View File

@@ -1,9 +1,14 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { messageTimestamp } from 'shared/helpers/timeHelper'; import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
const props = defineProps({ const props = defineProps({
@@ -20,6 +25,8 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false }); const show = defineModel('show', { type: Boolean, default: false });
const { t } = useI18n();
const getters = useStoreGetters(); const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = { const ALLOWED_FILE_TYPES = {
@@ -32,6 +39,7 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2; const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1; const MIN_ZOOM_LEVEL = 1;
const isDownloading = ref(false);
const zoomScale = ref(1); const zoomScale = ref(1);
const activeAttachment = ref({}); const activeAttachment = ref({});
const activeFileType = ref(''); const activeFileType = ref('');
@@ -116,15 +124,20 @@ const onClickChangeAttachment = (attachment, index) => {
zoomScale.value = 1; zoomScale.value = 1;
}; };
const onClickDownload = () => { const onClickDownload = async () => {
const { file_type: type, data_url: url } = activeAttachment.value; const { file_type: type, data_url: url, extension } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) { if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return; return;
} }
const link = document.createElement('a');
link.href = url; try {
link.download = `attachment.${type}`; isDownloading.value = true;
link.click(); await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
} finally {
isDownloading.value = false;
}
}; };
const onRotate = type => { const onRotate = type => {
@@ -164,6 +177,12 @@ const onZoom = scale => {
}; };
const onClickZoomImage = () => { const onClickZoomImage = () => {
// If already at max zoom, clicking should zoom out to minimum
if (zoomScale.value >= MAX_ZOOM_LEVEL) {
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// Otherwise zoom in
onZoom(0.1); onZoom(0.1);
}; };
@@ -213,7 +232,6 @@ onMounted(() => {
:on-close="onClose" :on-close="onClose"
> >
<div <div
v-on-clickaway="onClose"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden" class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
@click="onClose" @click="onClose"
> >
@@ -258,63 +276,54 @@ onMounted(() => {
<div <div
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]" class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
> >
<woot-button <NextButton
v-if="isImage" v-if="isImage"
size="large" icon="i-lucide-zoom-in"
color-scheme="secondary" slate
variant="clear" ghost
icon="zoom-in"
@click="onZoom(0.1)" @click="onZoom(0.1)"
/> />
<woot-button <NextButton
v-if="isImage" v-if="isImage"
size="large" icon="i-lucide-zoom-out"
color-scheme="secondary" slate
variant="clear" ghost
icon="zoom-out"
@click="onZoom(-0.1)" @click="onZoom(-0.1)"
/> />
<woot-button <NextButton
v-if="isImage" v-if="isImage"
size="large" icon="i-lucide-rotate-ccw"
color-scheme="secondary" slate
variant="clear" ghost
icon="arrow-rotate-counter-clockwise"
@click="onRotate('counter-clockwise')" @click="onRotate('counter-clockwise')"
/> />
<woot-button <NextButton
v-if="isImage" v-if="isImage"
size="large" icon="i-lucide-rotate-cw"
color-scheme="secondary" slate
variant="clear" ghost
icon="arrow-rotate-clockwise"
@click="onRotate('clockwise')" @click="onRotate('clockwise')"
/> />
<woot-button <NextButton
size="large" icon="i-lucide-download"
color-scheme="secondary" slate
variant="clear" ghost
icon="arrow-download" :is-loading="isDownloading"
:disabled="isDownloading"
@click="onClickDownload" @click="onClickDownload"
/> />
<woot-button <NextButton icon="i-lucide-x" slate ghost @click="onClose" />
size="large"
color-scheme="secondary"
variant="clear"
icon="dismiss"
@click="onClose"
/>
</div> </div>
</div> </div>
<div class="flex items-center justify-center w-full h-full"> <div class="flex items-center justify-center w-full h-full">
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]"> <div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<woot-button <NextButton
v-if="hasMoreThanOneAttachment" v-if="hasMoreThanOneAttachment"
class="z-10" icon="i-lucide-chevron-left"
size="large" class="z-10 disabled:pointer-events-auto"
variant="smooth" blue
color-scheme="primary" faded
icon="chevron-left" lg
:disabled="activeImageIndex === 0" :disabled="activeImageIndex === 0"
@click.stop=" @click.stop="
onClickChangeAttachment( onClickChangeAttachment(
@@ -356,14 +365,14 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]"> <div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<woot-button <NextButton
v-if="hasMoreThanOneAttachment" v-if="hasMoreThanOneAttachment"
class="z-10" icon="i-lucide-chevron-right"
size="large" class="z-10 disabled:pointer-events-auto"
variant="smooth" blue
color-scheme="primary" faded
lg
:disabled="activeImageIndex === allAttachments.length - 1" :disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop=" @click.stop="
onClickChangeAttachment( onClickChangeAttachment(
allAttachments[activeImageIndex + 1], allAttachments[activeImageIndex + 1],

View File

@@ -1,20 +1,45 @@
<script> <script setup>
export default { import { computed, useTemplateRef } from 'vue';
props: { import { useWindowSize, useElementBounding } from '@vueuse/core';
option: {
type: Object, defineProps({
default: () => {}, option: {
}, type: Object,
subMenuAvailable: { default: () => {},
type: Boolean,
default: true,
},
}, },
}; subMenuAvailable: {
type: Boolean,
default: true,
},
});
const menuRef = useTemplateRef('menuRef');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { bottom, right } = useElementBounding(menuRef);
// Vertical position
const verticalPosition = computed(() => {
const SUBMENU_HEIGHT = 240; // 15rem in pixels
const spaceBelow = windowHeight.value - bottom.value;
return spaceBelow < SUBMENU_HEIGHT ? 'bottom-0' : 'top-0';
});
// Horizontal position
const horizontalPosition = computed(() => {
const SUBMENU_WIDTH = 240;
const spaceRight = windowWidth.value - right.value;
return spaceRight < SUBMENU_WIDTH ? 'right-full' : 'left-full';
});
const submenuPosition = computed(() => [
verticalPosition.value,
horizontalPosition.value,
]);
</script> </script>
<template> <template>
<div <div
ref="menuRef"
class="text-slate-800 dark:text-slate-100 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-n-alpha-3/50 backdrop-blur-[100px] justify-between hover:bg-n-brand/10 cursor-pointer dark:hover:bg-n-solid-3" class="text-slate-800 dark:text-slate-100 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-n-alpha-3/50 backdrop-blur-[100px] justify-between hover:bg-n-brand/10 cursor-pointer dark:hover:bg-n-solid-3"
:class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''" :class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''"
> >
@@ -25,7 +50,8 @@ export default {
<fluent-icon icon="chevron-right" size="12" /> <fluent-icon icon="chevron-right" size="12" />
<div <div
v-if="subMenuAvailable" v-if="subMenuAvailable"
class="submenu bg-n-alpha-3 backdrop-blur-[100px] p-1 shadow-lg rounded-md absolute left-full top-0 hidden min-h-min max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer" class="submenu bg-n-alpha-3 backdrop-blur-[100px] p-1 shadow-lg rounded-md absolute hidden max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
:class="submenuPosition"
> >
<slot /> <slot />
</div> </div>

View File

@@ -40,10 +40,12 @@ export default {
}, },
emits: ['update:modelValue', 'input', 'blur'], emits: ['update:modelValue', 'input', 'blur'],
mounted() { mounted() {
// eslint-disable-next-line if (import.meta.env.DEV) {
console.warn( // eslint-disable-next-line no-console
'[DEPRECATED] <WootInput> has be deprecated and will be removed soon. Please use v3/components/Form/Input.vue instead' console.warn(
); '[DEPRECATED] <WootInput> has be deprecated and will be removed soon. Please use v3/components/Form/Input.vue instead'
);
}
}, },
methods: { methods: {
onChange(e) { onChange(e) {

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