Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80eac55b63 | ||
|
|
b1c4ac7ede | ||
|
|
05f9c3053d | ||
|
|
8019e7c636 | ||
|
|
29f670189a | ||
|
|
2b5638287c | ||
|
|
c117257b42 | ||
|
|
07b561952b | ||
|
|
d747b95f6e | ||
|
|
7314c279ee | ||
|
|
ca5e112a8c | ||
|
|
116ed54c7e | ||
|
|
cfa0bb953b | ||
|
|
b6a14bda48 | ||
|
|
fd8919b901 | ||
|
|
0d490640f2 | ||
|
|
02216471c3 | ||
|
|
68d8e62a5c | ||
|
|
bb8bafe3dc | ||
|
|
d2ba9a2ad3 | ||
|
|
3fce56c98f | ||
|
|
26b4a24f11 | ||
|
|
a8ed074bf0 | ||
|
|
96fe3e146d | ||
|
|
696564863c | ||
|
|
df4c8cf58b | ||
|
|
2bd8e76886 | ||
|
|
1de8d3e56d | ||
|
|
f2054e703a | ||
|
|
89d02e2c92 | ||
|
|
0d8e249fe4 | ||
|
|
20fa5eeaa5 | ||
|
|
f2eaa845dc | ||
|
|
863c033699 | ||
|
|
141dfc3321 | ||
|
|
aa21c15d0e | ||
|
|
3051da1e44 | ||
|
|
399c91adaa | ||
|
|
eb759255d8 | ||
|
|
cc86b8c7f1 | ||
|
|
a971ff00f8 | ||
|
|
67dc21ea5f | ||
|
|
efc3b5e7d4 | ||
|
|
eed2eaceb0 | ||
|
|
0a17976913 | ||
|
|
57904a56a0 | ||
|
|
728a5a6710 | ||
|
|
87fe1e9ad7 | ||
|
|
5c3b85334b | ||
|
|
e6a7e836a0 | ||
|
|
b269cca0bf | ||
|
|
1df5fd513a | ||
|
|
4627aad56d | ||
|
|
f23d95e004 | ||
|
|
1ef945de7b | ||
|
|
92fc286ecb | ||
|
|
dedb0426fb | ||
|
|
eb0410af53 | ||
|
|
3ded13c8d5 | ||
|
|
f1079574e3 | ||
|
|
73ad27e94c | ||
|
|
6a83cad69d | ||
|
|
7e0507e3b5 | ||
|
|
94a9e4e067 | ||
|
|
7b20ecd27b | ||
|
|
e635122ff6 | ||
|
|
fa07b9158a | ||
|
|
46eb6f39f3 | ||
|
|
f0538b25ed | ||
|
|
389b864020 | ||
|
|
26778156dc | ||
|
|
48627da0f9 | ||
|
|
6a712b7592 | ||
|
|
e9c60aec04 | ||
|
|
a8e9acfae9 | ||
|
|
f5908b0f8a | ||
|
|
45fa697885 | ||
|
|
51fbf583b6 | ||
|
|
38d6ee6dd2 | ||
|
|
a547c28c8d | ||
|
|
5608f5a1a2 | ||
|
|
da4110a495 | ||
|
|
9a2136caf1 | ||
|
|
a44192bbe7 | ||
|
|
5a5b30fe1e | ||
|
|
08b9134486 | ||
|
|
7874a6a3dd | ||
|
|
e9edfcafbb | ||
|
|
5f2b2f4221 | ||
|
|
70c183ea6e | ||
|
|
6c07f62cfc | ||
|
|
e33f28dc33 | ||
|
|
58ca82c720 | ||
|
|
7acf3c8817 | ||
|
|
77f492590e | ||
|
|
5b56f64838 | ||
|
|
bf806f0c28 | ||
|
|
93374f4327 | ||
|
|
c9823d9409 | ||
|
|
2c4e65d68e | ||
|
|
6ae5e67b52 | ||
|
|
32da4eec27 | ||
|
|
945a08eadb | ||
|
|
559d1b6576 | ||
|
|
eed1825d5a | ||
|
|
651645fbd2 | ||
|
|
b4a252a459 | ||
|
|
bdcb1934c0 | ||
|
|
4f09c2203c | ||
|
|
f455e7994e | ||
|
|
28e16f7ee0 | ||
|
|
fb0be60ae2 | ||
|
|
3c9c0298ba | ||
|
|
d6af182f82 | ||
|
|
c07d03e4e5 | ||
|
|
e81152608d | ||
|
|
615e81731c | ||
|
|
fb1aa085cf | ||
|
|
e8a01ace41 | ||
|
|
01363042ce | ||
|
|
5bf39d20e5 | ||
|
|
90352b3a20 | ||
|
|
ba8df900e3 | ||
|
|
9b75d9bd1b | ||
|
|
ec6c3b3571 | ||
|
|
48ba273730 |
+196
-49
@@ -3,6 +3,7 @@ orbs:
|
|||||||
node: circleci/node@6.1.0
|
node: circleci/node@6.1.0
|
||||||
qlty-orb: qltysh/qlty-orb@0.0
|
qlty-orb: qltysh/qlty-orb@0.0
|
||||||
|
|
||||||
|
# Shared defaults for setup steps
|
||||||
defaults: &defaults
|
defaults: &defaults
|
||||||
working_directory: ~/build
|
working_directory: ~/build
|
||||||
machine:
|
machine:
|
||||||
@@ -12,10 +13,106 @@ defaults: &defaults
|
|||||||
RAILS_LOG_TO_STDOUT: false
|
RAILS_LOG_TO_STDOUT: false
|
||||||
COVERAGE: true
|
COVERAGE: true
|
||||||
LOG_LEVEL: warn
|
LOG_LEVEL: warn
|
||||||
parallelism: 4
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Separate job for linting (no parallelism needed)
|
||||||
|
lint:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
|
||||||
|
# Install minimal system dependencies for linting
|
||||||
|
- run:
|
||||||
|
name: Install System Dependencies
|
||||||
|
command: |
|
||||||
|
sudo apt-get update
|
||||||
|
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
|
||||||
|
libpq-dev \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libssl-dev \
|
||||||
|
zlib1g-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libyaml-dev \
|
||||||
|
openjdk-11-jdk \
|
||||||
|
jq \
|
||||||
|
software-properties-common \
|
||||||
|
ca-certificates \
|
||||||
|
imagemagick \
|
||||||
|
libxml2-dev \
|
||||||
|
libxslt1-dev \
|
||||||
|
file \
|
||||||
|
g++ \
|
||||||
|
gcc \
|
||||||
|
autoconf \
|
||||||
|
gnupg2 \
|
||||||
|
patch \
|
||||||
|
ruby-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libgmp-dev \
|
||||||
|
libncurses5-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libgdbm6 \
|
||||||
|
libgdbm-dev \
|
||||||
|
libvips
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install RVM and Ruby 3.4.4
|
||||||
|
command: |
|
||||||
|
sudo apt-get install -y gpg
|
||||||
|
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||||
|
\curl -sSL https://get.rvm.io | bash -s stable
|
||||||
|
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
||||||
|
source ~/.rvm/scripts/rvm
|
||||||
|
rvm install "3.4.4"
|
||||||
|
rvm use 3.4.4 --default
|
||||||
|
gem install bundler -v 2.5.16
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install Application Dependencies
|
||||||
|
command: |
|
||||||
|
source ~/.rvm/scripts/rvm
|
||||||
|
bundle install
|
||||||
|
|
||||||
|
- node/install:
|
||||||
|
node-version: '23.7'
|
||||||
|
- node/install-pnpm
|
||||||
|
- node/install-packages:
|
||||||
|
pkg-manager: pnpm
|
||||||
|
override-ci-command: pnpm i
|
||||||
|
|
||||||
|
# Swagger verification
|
||||||
|
- run:
|
||||||
|
name: Verify swagger API specification
|
||||||
|
command: |
|
||||||
|
bundle exec rake swagger:build
|
||||||
|
if [[ `git status swagger/swagger.json --porcelain` ]]
|
||||||
|
then
|
||||||
|
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p ~/tmp
|
||||||
|
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||||
|
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||||
|
|
||||||
|
# Bundle audit
|
||||||
|
- run:
|
||||||
|
name: Bundle audit
|
||||||
|
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||||
|
|
||||||
|
# Rubocop linting
|
||||||
|
- run:
|
||||||
|
name: Rubocop
|
||||||
|
command: bundle exec rubocop --parallel
|
||||||
|
|
||||||
|
# ESLint linting
|
||||||
|
- run:
|
||||||
|
name: eslint
|
||||||
|
command: pnpm run eslint
|
||||||
|
|
||||||
|
# Separate job for frontend tests
|
||||||
|
frontend-tests:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
@@ -25,8 +122,38 @@ jobs:
|
|||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
- run: node --version
|
|
||||||
- run: pnpm --version
|
- run:
|
||||||
|
name: Run frontend tests (with coverage)
|
||||||
|
command: pnpm run test:coverage
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Move coverage files if they exist
|
||||||
|
command: |
|
||||||
|
if [ -d "coverage" ]; then
|
||||||
|
mkdir -p ~/build/coverage
|
||||||
|
cp -r coverage ~/build/coverage/frontend || true
|
||||||
|
fi
|
||||||
|
when: always
|
||||||
|
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: ~/build
|
||||||
|
paths:
|
||||||
|
- coverage
|
||||||
|
|
||||||
|
# Backend tests with parallelization
|
||||||
|
backend-tests:
|
||||||
|
<<: *defaults
|
||||||
|
parallelism: 16
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- node/install:
|
||||||
|
node-version: '23.7'
|
||||||
|
- node/install-pnpm
|
||||||
|
- node/install-packages:
|
||||||
|
pkg-manager: pnpm
|
||||||
|
override-ci-command: pnpm i
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: Add PostgreSQL repository and update
|
name: Add PostgreSQL repository and update
|
||||||
command: |
|
command: |
|
||||||
@@ -91,20 +218,6 @@ jobs:
|
|||||||
source ~/.rvm/scripts/rvm
|
source ~/.rvm/scripts/rvm
|
||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
# Swagger verification
|
|
||||||
- run:
|
|
||||||
name: Verify swagger API specification
|
|
||||||
command: |
|
|
||||||
bundle exec rake swagger:build
|
|
||||||
if [[ `git status swagger/swagger.json --porcelain` ]]
|
|
||||||
then
|
|
||||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p ~/tmp
|
|
||||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
|
||||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
|
||||||
|
|
||||||
# Configure environment and database
|
# Configure environment and database
|
||||||
- run:
|
- run:
|
||||||
name: Database Setup and Configure Environment Variables
|
name: Database Setup and Configure Environment Variables
|
||||||
@@ -127,57 +240,91 @@ jobs:
|
|||||||
name: Run DB migrations
|
name: Run DB migrations
|
||||||
command: bundle exec rails db:chatwoot_prepare
|
command: bundle exec rails db:chatwoot_prepare
|
||||||
|
|
||||||
# Bundle audit
|
# Run backend tests (parallelized)
|
||||||
- run:
|
|
||||||
name: Bundle audit
|
|
||||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
|
||||||
|
|
||||||
# Rubocop linting
|
|
||||||
- run:
|
|
||||||
name: Rubocop
|
|
||||||
command: bundle exec rubocop
|
|
||||||
|
|
||||||
# ESLint linting
|
|
||||||
- run:
|
|
||||||
name: eslint
|
|
||||||
command: pnpm run eslint
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Run frontend tests (with coverage)
|
|
||||||
command: |
|
|
||||||
mkdir -p ~/build/coverage/frontend
|
|
||||||
pnpm run test:coverage
|
|
||||||
|
|
||||||
# Run backend tests
|
|
||||||
- run:
|
- run:
|
||||||
name: Run backend tests
|
name: Run backend tests
|
||||||
command: |
|
command: |
|
||||||
mkdir -p ~/tmp/test-results/rspec
|
mkdir -p ~/tmp/test-results/rspec
|
||||||
mkdir -p ~/tmp/test-artifacts
|
mkdir -p ~/tmp/test-artifacts
|
||||||
mkdir -p ~/build/coverage/backend
|
mkdir -p ~/build/coverage/backend
|
||||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
|
||||||
|
# Use round-robin distribution (same as GitHub Actions) for better test isolation
|
||||||
|
# This prevents tests with similar timing from being grouped on the same runner
|
||||||
|
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||||
|
TESTS=""
|
||||||
|
|
||||||
|
for i in "${!SPEC_FILES[@]}"; do
|
||||||
|
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
|
||||||
|
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||||
--format RspecJunitFormatter \
|
--format RspecJunitFormatter \
|
||||||
--out ~/tmp/test-results/rspec.xml \
|
--out ~/tmp/test-results/rspec.xml \
|
||||||
-- ${TESTFILES}
|
-- $TESTS
|
||||||
no_output_timeout: 30m
|
no_output_timeout: 30m
|
||||||
|
|
||||||
# Qlty coverage publish
|
# Store test results for better splitting in future runs
|
||||||
- qlty-orb/coverage_publish:
|
- store_test_results:
|
||||||
files: |
|
path: ~/tmp/test-results
|
||||||
coverage/coverage.json
|
|
||||||
coverage/lcov.info
|
|
||||||
|
|
||||||
- run:
|
- run:
|
||||||
name: List coverage directory contents
|
name: Move coverage files if they exist
|
||||||
command: |
|
command: |
|
||||||
ls -R ~/build/coverage
|
if [ -d "coverage" ]; then
|
||||||
|
mkdir -p ~/build/coverage
|
||||||
|
cp -r coverage ~/build/coverage/backend || true
|
||||||
|
fi
|
||||||
|
when: always
|
||||||
|
|
||||||
- persist_to_workspace:
|
- persist_to_workspace:
|
||||||
root: ~/build
|
root: ~/build
|
||||||
paths:
|
paths:
|
||||||
- coverage
|
- coverage
|
||||||
|
|
||||||
|
# Collect coverage from all jobs
|
||||||
|
coverage:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- attach_workspace:
|
||||||
|
at: ~/build
|
||||||
|
|
||||||
|
# Qlty coverage publish
|
||||||
|
- qlty-orb/coverage_publish:
|
||||||
|
files: |
|
||||||
|
coverage/frontend/lcov.info
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: List coverage directory contents
|
||||||
|
command: |
|
||||||
|
ls -R ~/build/coverage || echo "No coverage directory"
|
||||||
|
|
||||||
- store_artifacts:
|
- store_artifacts:
|
||||||
path: coverage
|
path: coverage
|
||||||
destination: coverage
|
destination: coverage
|
||||||
|
|
||||||
|
build:
|
||||||
|
<<: *defaults
|
||||||
|
steps:
|
||||||
|
- run:
|
||||||
|
name: Legacy build aggregator
|
||||||
|
command: |
|
||||||
|
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
build:
|
||||||
|
jobs:
|
||||||
|
- lint
|
||||||
|
- frontend-tests
|
||||||
|
- backend-tests
|
||||||
|
- coverage:
|
||||||
|
requires:
|
||||||
|
- frontend-tests
|
||||||
|
- backend-tests
|
||||||
|
- build:
|
||||||
|
requires:
|
||||||
|
- lint
|
||||||
|
- coverage
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ MAILER_INBOUND_EMAIL_DOMAIN=
|
|||||||
# mandrill for Mandrill
|
# mandrill for Mandrill
|
||||||
# postmark for Postmark
|
# postmark for Postmark
|
||||||
# sendgrid for Sendgrid
|
# sendgrid for Sendgrid
|
||||||
|
# ses for Amazon SES
|
||||||
RAILS_INBOUND_EMAIL_SERVICE=
|
RAILS_INBOUND_EMAIL_SERVICE=
|
||||||
# Use one of the following based on the email ingress service
|
# Use one of the following based on the email ingress service
|
||||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||||
@@ -114,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD=
|
|||||||
MAILGUN_INGRESS_SIGNING_KEY=
|
MAILGUN_INGRESS_SIGNING_KEY=
|
||||||
MANDRILL_INGRESS_API_KEY=
|
MANDRILL_INGRESS_API_KEY=
|
||||||
|
|
||||||
|
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
|
||||||
|
# Configure only if the rails_inbound_email_service = ses
|
||||||
|
ACTION_MAILBOX_SES_SNS_TOPIC=
|
||||||
|
|
||||||
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
|
||||||
# Inbound webhook URL format:
|
# Inbound webhook URL format:
|
||||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||||
@@ -215,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
|||||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||||
# DD_TRACE_AGENT_URL=
|
# DD_TRACE_AGENT_URL=
|
||||||
|
|
||||||
|
|
||||||
# MaxMindDB API key to download GeoLite2 City database
|
# MaxMindDB API key to download GeoLite2 City database
|
||||||
# IP_LOOKUP_API_KEY=
|
# IP_LOOKUP_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
name: Run Chatwoot CE spec
|
name: Run Chatwoot CE spec
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -8,11 +10,58 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
# Separate linting jobs for faster feedback
|
||||||
runs-on: ubuntu-22.04
|
lint-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ruby/setup-ruby@v1
|
||||||
|
with:
|
||||||
|
bundler-cache: true
|
||||||
|
- name: Run Rubocop
|
||||||
|
run: bundle exec rubocop --parallel
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 23
|
||||||
|
cache: 'pnpm'
|
||||||
|
- name: Install pnpm dependencies
|
||||||
|
run: pnpm i
|
||||||
|
- name: Run ESLint
|
||||||
|
run: pnpm run eslint
|
||||||
|
|
||||||
|
# Frontend tests run in parallel with backend
|
||||||
|
frontend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 23
|
||||||
|
cache: 'pnpm'
|
||||||
|
- name: Install pnpm dependencies
|
||||||
|
run: pnpm i
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: pnpm run test:coverage
|
||||||
|
|
||||||
|
# Backend tests with parallelization
|
||||||
|
backend-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
ci_node_total: [16]
|
||||||
|
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg15
|
image: pgvector/pgvector:pg16
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ''
|
POSTGRES_PASSWORD: ''
|
||||||
@@ -20,8 +69,6 @@ jobs:
|
|||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
# needed because the postgres container does not provide a healthcheck
|
|
||||||
# tmpfs makes DB faster by using RAM
|
|
||||||
options: >-
|
options: >-
|
||||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -29,7 +76,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis:alpine
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: --entrypoint redis-server
|
options: --entrypoint redis-server
|
||||||
@@ -43,7 +90,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ruby/setup-ruby@v1
|
- uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
bundler-cache: true
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -64,19 +111,36 @@ jobs:
|
|||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: bundle exec rake db:schema:load
|
run: bundle exec rake db:schema:load
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run backend tests (parallelized)
|
||||||
run: pnpm run test:coverage
|
|
||||||
|
|
||||||
# Run rails tests
|
|
||||||
- name: Run backend tests
|
|
||||||
run: |
|
run: |
|
||||||
bundle exec rspec --profile=10 --format documentation
|
# Get all spec files and split them using round-robin distribution
|
||||||
|
# This ensures slow tests are distributed evenly across all nodes
|
||||||
|
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||||
|
TESTS=""
|
||||||
|
|
||||||
|
for i in "${!SPEC_FILES[@]}"; do
|
||||||
|
# Assign spec to this node if: index % total == node_index
|
||||||
|
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
||||||
|
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$TESTS" ]; then
|
||||||
|
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
||||||
|
fi
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --openssl-legacy-provider
|
NODE_OPTIONS: --openssl-legacy-provider
|
||||||
|
|
||||||
- name: Upload rails log folder
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: rails-log-folder
|
name: rspec-results-${{ matrix.ci_node_index }}
|
||||||
|
path: tmp/rspec_results.json
|
||||||
|
|
||||||
|
- name: Upload rails log folder
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: rails-log-folder-${{ matrix.ci_node_index }}
|
||||||
path: log
|
path: log
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ jobs:
|
|||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
push: false
|
push: false
|
||||||
load: false
|
load: false
|
||||||
cache-from: type=gha
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|||||||
@@ -99,3 +99,5 @@ CLAUDE.local.md
|
|||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
.netlify
|
.netlify
|
||||||
.histoire
|
.histoire
|
||||||
|
.pnpm-store/*
|
||||||
|
local/
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
|||||||
"**/target/**",
|
"**/target/**",
|
||||||
"**/templates/**",
|
"**/templates/**",
|
||||||
"**/testdata/**",
|
"**/testdata/**",
|
||||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
test_patterns = [
|
test_patterns = [
|
||||||
|
|||||||
+12
-1
@@ -7,6 +7,8 @@ plugins:
|
|||||||
require:
|
require:
|
||||||
- ./rubocop/use_from_email.rb
|
- ./rubocop/use_from_email.rb
|
||||||
- ./rubocop/custom_cop_location.rb
|
- ./rubocop/custom_cop_location.rb
|
||||||
|
- ./rubocop/attachment_download.rb
|
||||||
|
- ./rubocop/one_class_per_file.rb
|
||||||
|
|
||||||
Layout/LineLength:
|
Layout/LineLength:
|
||||||
Max: 150
|
Max: 150
|
||||||
@@ -40,6 +42,12 @@ Style/SymbolArray:
|
|||||||
Style/OpenStructUse:
|
Style/OpenStructUse:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Chatwoot/AttachmentDownload:
|
||||||
|
Enabled: true
|
||||||
|
Exclude:
|
||||||
|
- 'spec/**/*'
|
||||||
|
- 'test/**/*'
|
||||||
|
|
||||||
Style/OptionalBooleanParameter:
|
Style/OptionalBooleanParameter:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/services/email_templates/db_resolver_service.rb'
|
- 'app/services/email_templates/db_resolver_service.rb'
|
||||||
@@ -87,7 +95,7 @@ Metrics/ModuleLength:
|
|||||||
Rails/HelperInstanceVariable:
|
Rails/HelperInstanceVariable:
|
||||||
Exclude:
|
Exclude:
|
||||||
- enterprise/app/helpers/captain/chat_helper.rb
|
- enterprise/app/helpers/captain/chat_helper.rb
|
||||||
|
- enterprise/app/helpers/captain/chat_response_helper.rb
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||||
@@ -205,6 +213,9 @@ UseFromEmail:
|
|||||||
CustomCopLocation:
|
CustomCopLocation:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
|
Style/OneClassPerFile:
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
AllCops:
|
AllCops:
|
||||||
NewCops: enable
|
NewCops: enable
|
||||||
Exclude:
|
Exclude:
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **Run Project**: `overmind start -f Procfile.dev`
|
||||||
|
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||||
|
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||||
|
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -37,11 +40,20 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- No unnecessary defensive programming
|
||||||
|
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||||
|
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
|
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||||
|
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||||
|
- Example: `feat(auth): add user authentication`
|
||||||
- Don't reference Claude in commit messages
|
- Don't reference Claude in commit messages
|
||||||
|
|
||||||
## Project-Specific
|
## Project-Specific
|
||||||
@@ -73,3 +85,4 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||||
|
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
|
|||||||
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
gem 'google-cloud-storage', '>= 1.48.0', require: false
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
|
|
||||||
|
##-- for actionmailbox --##
|
||||||
|
gem 'aws-actionmailbox-ses', '~> 0'
|
||||||
|
|
||||||
##-- gems for database --#
|
##-- gems for database --#
|
||||||
gem 'groupdate'
|
gem 'groupdate'
|
||||||
gem 'pg'
|
gem 'pg'
|
||||||
@@ -159,7 +162,7 @@ gem 'working_hours'
|
|||||||
gem 'pg_search'
|
gem 'pg_search'
|
||||||
|
|
||||||
# Subscriptions, Billing
|
# Subscriptions, Billing
|
||||||
gem 'stripe'
|
gem 'stripe', '~> 18.0'
|
||||||
|
|
||||||
## - helper gems --##
|
## - helper gems --##
|
||||||
## to populate db with sample data
|
## to populate db with sample data
|
||||||
@@ -188,11 +191,16 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.4.3'
|
gem 'ai-agents', '>= 0.7.0'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
|
gem 'ruby_llm', '>= 1.8.2'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
|
# OpenTelemetry for LLM observability
|
||||||
|
gem 'opentelemetry-sdk'
|
||||||
|
gem 'opentelemetry-exporter-otlp'
|
||||||
|
|
||||||
gem 'shopify_api'
|
gem 'shopify_api'
|
||||||
|
|
||||||
### Gems required only in specific deployment environments ###
|
### Gems required only in specific deployment environments ###
|
||||||
@@ -207,7 +215,7 @@ group :production do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem 'annotate'
|
gem 'annotaterb'
|
||||||
gem 'bullet'
|
gem 'bullet'
|
||||||
gem 'letter_opener'
|
gem 'letter_opener'
|
||||||
gem 'scss_lint', require: false
|
gem 'scss_lint', require: false
|
||||||
|
|||||||
+45
-14
@@ -126,19 +126,23 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.4.3)
|
ai-agents (0.7.0)
|
||||||
ruby_llm (~> 1.3)
|
ruby_llm (~> 1.8.2)
|
||||||
annotate (3.2.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 3.2, < 8.0)
|
activerecord (>= 6.0.0)
|
||||||
rake (>= 10.4, < 14.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_extras (7.1.0)
|
attr_extras (7.1.0)
|
||||||
audited (5.4.1)
|
audited (5.4.1)
|
||||||
activerecord (>= 5.0, < 7.7)
|
activerecord (>= 5.0, < 7.7)
|
||||||
activesupport (>= 5.0, < 7.7)
|
activesupport (>= 5.0, < 7.7)
|
||||||
|
aws-actionmailbox-ses (0.1.0)
|
||||||
|
actionmailbox (>= 7.1.0)
|
||||||
|
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||||
|
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.2.0)
|
||||||
aws-partitions (1.760.0)
|
aws-partitions (1.760.0)
|
||||||
aws-sdk-core (3.171.1)
|
aws-sdk-core (3.188.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.651.0)
|
aws-partitions (~> 1, >= 1.651.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
@@ -146,10 +150,13 @@ GEM
|
|||||||
aws-sdk-kms (1.64.0)
|
aws-sdk-kms (1.64.0)
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-core (~> 3, >= 3.165.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.122.0)
|
aws-sdk-s3 (1.126.0)
|
||||||
aws-sdk-core (~> 3, >= 3.165.0)
|
aws-sdk-core (~> 3, >= 3.174.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sdk-sns (1.70.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.188.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sigv4 (1.5.2)
|
aws-sigv4 (1.5.2)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
barnes (0.0.9)
|
barnes (0.0.9)
|
||||||
@@ -618,6 +625,25 @@ GEM
|
|||||||
faraday (>= 1.0, < 3)
|
faraday (>= 1.0, < 3)
|
||||||
multi_json (>= 1.0)
|
multi_json (>= 1.0)
|
||||||
openssl (3.2.0)
|
openssl (3.2.0)
|
||||||
|
opentelemetry-api (1.7.0)
|
||||||
|
opentelemetry-common (0.23.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
|
opentelemetry-exporter-otlp (0.31.1)
|
||||||
|
google-protobuf (>= 3.18)
|
||||||
|
googleapis-common-protos-types (~> 1.3)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-sdk (~> 1.10)
|
||||||
|
opentelemetry-semantic_conventions
|
||||||
|
opentelemetry-registry (0.4.0)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-sdk (1.10.0)
|
||||||
|
opentelemetry-api (~> 1.1)
|
||||||
|
opentelemetry-common (~> 0.20)
|
||||||
|
opentelemetry-registry (~> 0.2)
|
||||||
|
opentelemetry-semantic_conventions
|
||||||
|
opentelemetry-semantic_conventions (1.36.0)
|
||||||
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.1)
|
ostruct (0.6.1)
|
||||||
@@ -793,7 +819,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.5.1)
|
ruby_llm (1.8.2)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@@ -801,8 +827,9 @@ GEM
|
|||||||
faraday-net_http (>= 1)
|
faraday-net_http (>= 1)
|
||||||
faraday-retry (>= 1)
|
faraday-retry (>= 1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
|
ruby_llm-schema (~> 0.2.1)
|
||||||
zeitwerk (~> 2)
|
zeitwerk (~> 2)
|
||||||
ruby_llm-schema (0.1.0)
|
ruby_llm-schema (0.2.5)
|
||||||
ruby_parser (3.20.0)
|
ruby_parser (3.20.0)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
sass (3.7.4)
|
sass (3.7.4)
|
||||||
@@ -902,7 +929,7 @@ GEM
|
|||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (8.5.0)
|
stripe (18.0.1)
|
||||||
telephone_number (1.4.20)
|
telephone_number (1.4.20)
|
||||||
test-prof (1.2.1)
|
test-prof (1.2.1)
|
||||||
thor (1.4.0)
|
thor (1.4.0)
|
||||||
@@ -991,10 +1018,11 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.4.3)
|
ai-agents (>= 0.7.0)
|
||||||
annotate
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
|
aws-actionmailbox-ses (~> 0)
|
||||||
aws-sdk-s3
|
aws-sdk-s3
|
||||||
azure-storage-blob!
|
azure-storage-blob!
|
||||||
barnes
|
barnes
|
||||||
@@ -1065,6 +1093,8 @@ DEPENDENCIES
|
|||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
omniauth-saml
|
omniauth-saml
|
||||||
opensearch-ruby
|
opensearch-ruby
|
||||||
|
opentelemetry-exporter-otlp
|
||||||
|
opentelemetry-sdk
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -1090,6 +1120,7 @@ DEPENDENCIES
|
|||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubocop-rspec
|
rubocop-rspec
|
||||||
ruby-openai
|
ruby-openai
|
||||||
|
ruby_llm (>= 1.8.2)
|
||||||
ruby_llm-schema
|
ruby_llm-schema
|
||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
@@ -1110,7 +1141,7 @@ DEPENDENCIES
|
|||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
stackprof
|
stackprof
|
||||||
stripe
|
stripe (~> 18.0)
|
||||||
telephone_number
|
telephone_number
|
||||||
test-prof
|
test-prof
|
||||||
tidewave
|
tidewave
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.4.0
|
4.8.0
|
||||||
|
|||||||
@@ -103,3 +103,5 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
class Messages::MessageBuilder
|
class Messages::MessageBuilder
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
|
include ::EmailHelper
|
||||||
|
include ::DataHelper
|
||||||
|
|
||||||
attr_reader :message
|
attr_reader :message
|
||||||
|
|
||||||
def initialize(user, conversation, params)
|
def initialize(user, conversation, params)
|
||||||
@@ -38,30 +41,12 @@ class Messages::MessageBuilder
|
|||||||
params = convert_to_hash(@params)
|
params = convert_to_hash(@params)
|
||||||
content_attributes = params.fetch(:content_attributes, {})
|
content_attributes = params.fetch(:content_attributes, {})
|
||||||
|
|
||||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||||
return content_attributes if content_attributes.is_a?(Hash)
|
return content_attributes if content_attributes.is_a?(Hash)
|
||||||
|
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Converts the given object to a hash.
|
|
||||||
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
|
||||||
# Otherwise, returns the object as-is.
|
|
||||||
def convert_to_hash(obj)
|
|
||||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
|
||||||
|
|
||||||
obj
|
|
||||||
end
|
|
||||||
|
|
||||||
# Attempts to parse a string as JSON.
|
|
||||||
# If successful, returns the parsed hash with symbolized names.
|
|
||||||
# If unsuccessful, returns nil.
|
|
||||||
def parse_json(content)
|
|
||||||
JSON.parse(content, symbolize_names: true)
|
|
||||||
rescue JSON::ParserError
|
|
||||||
{}
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_attachments
|
def process_attachments
|
||||||
return if @attachments.blank?
|
return if @attachments.blank?
|
||||||
|
|
||||||
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
|
|||||||
email_string.gsub(/\s+/, '').split(',')
|
email_string.gsub(/\s+/, '').split(',')
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_email_addresses(all_emails)
|
|
||||||
all_emails&.each do |email|
|
|
||||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_type
|
def message_type
|
||||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||||
@@ -159,6 +138,7 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
|
content_attributes: content_attributes.presence,
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -178,14 +158,17 @@ class Messages::MessageBuilder
|
|||||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||||
normalized_content = normalize_email_body(@message.content)
|
normalized_content = normalize_email_body(@message.content)
|
||||||
|
|
||||||
|
# Process liquid templates in normalized content with code block protection
|
||||||
|
processed_content = process_liquid_in_email_body(normalized_content)
|
||||||
|
|
||||||
# Use custom HTML content if provided, otherwise generate from message content
|
# Use custom HTML content if provided, otherwise generate from message content
|
||||||
email_attributes[:html_content] = if custom_email_content_provided?
|
email_attributes[:html_content] = if custom_email_content_provided?
|
||||||
build_custom_html_content
|
build_custom_html_content
|
||||||
else
|
else
|
||||||
build_html_content(normalized_content)
|
build_html_content(processed_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
email_attributes[:text_content] = build_text_content(normalized_content)
|
email_attributes[:text_content] = build_text_content(processed_content)
|
||||||
email_attributes
|
email_attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -204,22 +187,6 @@ class Messages::MessageBuilder
|
|||||||
text_content
|
text_content
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_indifferent_access(hash)
|
|
||||||
return {} if hash.blank?
|
|
||||||
|
|
||||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalize_email_body(content)
|
|
||||||
content.to_s.gsub("\r\n", "\n")
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_email_html(content)
|
|
||||||
return '' if content.blank?
|
|
||||||
|
|
||||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def custom_email_content_provided?
|
def custom_email_content_provided?
|
||||||
@params[:email_html_content].present?
|
@params[:email_html_content].present?
|
||||||
end
|
end
|
||||||
@@ -232,4 +199,29 @@ class Messages::MessageBuilder
|
|||||||
|
|
||||||
html_content
|
html_content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Liquid processing methods for email content
|
||||||
|
def process_liquid_in_email_body(content)
|
||||||
|
return content if content.blank?
|
||||||
|
return content unless should_process_liquid?
|
||||||
|
|
||||||
|
# Protect code blocks from liquid processing
|
||||||
|
modified_content = modified_liquid_content(content)
|
||||||
|
template = Liquid::Template.parse(modified_content)
|
||||||
|
template.render(drops_with_sender)
|
||||||
|
rescue Liquid::Error
|
||||||
|
content
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_process_liquid?
|
||||||
|
@message_type == 'outgoing' || @message_type == 'template'
|
||||||
|
end
|
||||||
|
|
||||||
|
def drops_with_sender
|
||||||
|
message_drops(@conversation).merge({
|
||||||
|
'agent' => UserDrop.new(sender)
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
|
|||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
|
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
update_attachment_file_type(attachment_obj)
|
update_attachment_file_type(attachment_obj)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
|
|||||||
file_type = attachment['type'].to_sym
|
file_type = attachment['type'].to_sym
|
||||||
params = { file_type: file_type, account_id: @message.account_id }
|
params = { file_type: file_type, account_id: @message.account_id }
|
||||||
|
|
||||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||||
params.merge!(file_type_params(attachment))
|
params.merge!(file_type_params(attachment))
|
||||||
elsif file_type == :location
|
elsif file_type == :location
|
||||||
params.merge!(location_params(attachment))
|
params.merge!(location_params(attachment))
|
||||||
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def file_type_params(attachment)
|
def file_type_params(attachment)
|
||||||
|
# Handle different URL field names for different attachment types
|
||||||
|
url = case attachment['type'].to_sym
|
||||||
|
when :ig_story
|
||||||
|
attachment['payload']['story_media_url']
|
||||||
|
else
|
||||||
|
attachment['payload']['url']
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
external_url: attachment['payload']['url'],
|
external_url: url,
|
||||||
remote_file_url: attachment['payload']['url']
|
remote_file_url: url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
|
|||||||
message.save!
|
message.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_ig_story_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
||||||
|
message.content_attributes[:image_type] = 'ig_story'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_ig_post_link(attachment)
|
||||||
|
message = attachment.message
|
||||||
|
message.content_attributes[:image_type] = 'ig_post'
|
||||||
|
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
||||||
|
message.save!
|
||||||
|
end
|
||||||
|
|
||||||
# This is a placeholder method to be overridden by child classes
|
# This is a placeholder method to be overridden by child classes
|
||||||
def get_story_object_from_source_id(_source_id)
|
def get_story_object_from_source_id(_source_id)
|
||||||
{}
|
{}
|
||||||
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def unsupported_file_type?(attachment_type)
|
def unsupported_file_type?(attachment_type)
|
||||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
class YearInReviewBuilder
|
||||||
|
attr_reader :account, :user_id, :year
|
||||||
|
|
||||||
|
def initialize(account:, user_id:, year:)
|
||||||
|
@account = account
|
||||||
|
@user_id = user_id
|
||||||
|
@year = year
|
||||||
|
end
|
||||||
|
|
||||||
|
def build
|
||||||
|
{
|
||||||
|
year: year,
|
||||||
|
total_conversations: total_conversations_count,
|
||||||
|
busiest_day: busiest_day_data,
|
||||||
|
support_personality: support_personality_data
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def year_range
|
||||||
|
@year_range ||= begin
|
||||||
|
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
||||||
|
end_time = Time.zone.local(year, 12, 31).end_of_day
|
||||||
|
start_time..end_time
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_conversations_count
|
||||||
|
account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def busiest_day_data
|
||||||
|
daily_counts = account.conversations
|
||||||
|
.where(assignee_id: user_id, created_at: year_range)
|
||||||
|
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
||||||
|
.count
|
||||||
|
|
||||||
|
return nil if daily_counts.empty?
|
||||||
|
|
||||||
|
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
||||||
|
|
||||||
|
return nil if count.zero?
|
||||||
|
|
||||||
|
{
|
||||||
|
date: busiest_date.strftime('%b %d'),
|
||||||
|
count: count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def support_personality_data
|
||||||
|
response_time = average_response_time
|
||||||
|
|
||||||
|
return { avg_response_time_seconds: 0 } if response_time.nil?
|
||||||
|
|
||||||
|
{
|
||||||
|
avg_response_time_seconds: response_time.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def average_response_time
|
||||||
|
avg_time = account.reporting_events
|
||||||
|
.where(
|
||||||
|
name: 'first_response',
|
||||||
|
user_id: user_id,
|
||||||
|
created_at: year_range
|
||||||
|
)
|
||||||
|
.average(:value)
|
||||||
|
|
||||||
|
avg_time&.to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
before_action :agent_bot, except: [:index, :create]
|
before_action :agent_bot, except: [:index, :create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
|
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
|
||||||
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@article = @portal.articles.create!(article_params)
|
params_with_defaults = article_params
|
||||||
|
params_with_defaults[:status] ||= :draft
|
||||||
|
@article = @portal.articles.create!(params_with_defaults)
|
||||||
@article.associate_root_article(article_params[:associated_article_id])
|
@article.associate_root_article(article_params[:associated_article_id])
|
||||||
@article.draft!
|
|
||||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||||
# assigns agent/team to a conversation
|
# assigns agent/team to a conversation
|
||||||
def create
|
def create
|
||||||
if params.key?(:assignee_id)
|
if params.key?(:assignee_id) || agent_bot_assignment?
|
||||||
set_agent
|
set_agent
|
||||||
elsif params.key?(:team_id)
|
elsif params.key?(:team_id)
|
||||||
set_team
|
set_team
|
||||||
@@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_agent
|
def set_agent
|
||||||
@agent = Current.account.users.find_by(id: params[:assignee_id])
|
resource = Conversations::AssignmentService.new(
|
||||||
@conversation.assignee = @agent
|
conversation: @conversation,
|
||||||
@conversation.save!
|
assignee_id: params[:assignee_id],
|
||||||
render_agent
|
assignee_type: params[:assignee_type]
|
||||||
|
).perform
|
||||||
|
|
||||||
|
render_agent(resource)
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_agent
|
def render_agent(resource)
|
||||||
if @agent.nil?
|
case resource
|
||||||
render json: nil
|
when User
|
||||||
|
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
|
||||||
|
when AgentBot
|
||||||
|
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
|
||||||
else
|
else
|
||||||
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
|
render json: nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
|||||||
@conversation.update!(team: @team)
|
@conversation.update!(team: @team)
|
||||||
render json: @team
|
render json: @team
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def agent_bot_assignment?
|
||||||
|
params[:assignee_type].to_s == 'AgentBot'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||||
|
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||||
|
DEFAULT_LANGUAGE = 'en'.freeze
|
||||||
|
|
||||||
|
before_action :fetch_inbox
|
||||||
|
before_action :validate_whatsapp_channel
|
||||||
|
|
||||||
|
def show
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return render json: { template_exists: false } unless template
|
||||||
|
|
||||||
|
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
|
||||||
|
render_template_status_response(status_result, template_name)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||||
|
render json: { error: e.message }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
template_params = extract_template_params
|
||||||
|
return render_missing_message_error if template_params[:message].blank?
|
||||||
|
|
||||||
|
# Delete existing template even though we are using a new one.
|
||||||
|
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||||
|
delete_existing_template_if_needed
|
||||||
|
|
||||||
|
result = create_template_via_provider(template_params)
|
||||||
|
render_template_creation_result(result)
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||||
|
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_inbox
|
||||||
|
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||||
|
authorize @inbox, :show?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_whatsapp_channel
|
||||||
|
return if @inbox.whatsapp?
|
||||||
|
|
||||||
|
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||||
|
status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_template_params
|
||||||
|
params.require(:template).permit(:message, :button_text, :language)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_missing_message_error
|
||||||
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template_via_provider(template_params)
|
||||||
|
template_config = {
|
||||||
|
message: template_params[:message],
|
||||||
|
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||||
|
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||||
|
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||||
|
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template_creation_result(result)
|
||||||
|
if result[:success]
|
||||||
|
render_successful_template_creation(result)
|
||||||
|
else
|
||||||
|
render_failed_template_creation(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_successful_template_creation(result)
|
||||||
|
render json: {
|
||||||
|
template: {
|
||||||
|
name: result[:template_name],
|
||||||
|
template_id: result[:template_id],
|
||||||
|
status: 'PENDING',
|
||||||
|
language: result[:language] || DEFAULT_LANGUAGE
|
||||||
|
}
|
||||||
|
}, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_failed_template_creation(result)
|
||||||
|
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||||
|
error_message = whatsapp_error[:user_message] || result[:error]
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: error_message,
|
||||||
|
details: whatsapp_error[:technical_details]
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_existing_template_if_needed
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return true if template.blank?
|
||||||
|
|
||||||
|
template_name = template['name']
|
||||||
|
return true if template_name.blank?
|
||||||
|
|
||||||
|
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
return true unless template_status[:success]
|
||||||
|
|
||||||
|
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||||
|
if deletion_result[:success]
|
||||||
|
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_template_status_response(status_result, template_name)
|
||||||
|
if status_result[:success]
|
||||||
|
render json: {
|
||||||
|
template_exists: true,
|
||||||
|
template_name: template_name,
|
||||||
|
status: status_result[:template][:status],
|
||||||
|
template_id: status_result[:template][:id]
|
||||||
|
}
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
template_exists: false,
|
||||||
|
error: 'Template not found'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_whatsapp_error(response_body)
|
||||||
|
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
error_data = JSON.parse(response_body)
|
||||||
|
whatsapp_error = error_data['error'] || {}
|
||||||
|
|
||||||
|
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
||||||
|
technical_details = {
|
||||||
|
code: whatsapp_error['code'],
|
||||||
|
subcode: whatsapp_error['error_subcode'],
|
||||||
|
type: whatsapp_error['type'],
|
||||||
|
title: whatsapp_error['error_user_title']
|
||||||
|
}.compact
|
||||||
|
|
||||||
|
{ user_message: user_message, technical_details: technical_details }
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{ user_message: nil, technical_details: response_body }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format_csat_config(config)
|
def format_csat_config(config)
|
||||||
{
|
formatted = {
|
||||||
display_type: config['display_type'] || 'emoji',
|
'display_type' => config['display_type'] || 'emoji',
|
||||||
message: config['message'] || '',
|
'message' => config['message'] || '',
|
||||||
survey_rules: {
|
:survey_rules => {
|
||||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||||
values: config.dig('survey_rules', 'values') || []
|
'values' => config.dig('survey_rules', 'values') || []
|
||||||
}
|
},
|
||||||
|
'button_text' => config['button_text'] || 'Please rate us',
|
||||||
|
'language' => config['language'] || 'en'
|
||||||
}
|
}
|
||||||
|
format_template_config(config, formatted)
|
||||||
|
formatted
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_template_config(config, formatted)
|
||||||
|
formatted['template'] = config['template'] if config['template'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
|
{ survey_rules: [:operator, { values: [] }],
|
||||||
|
template: [:name, :template_id, :created_at, :language] }] }]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||||
|
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
||||||
params.permit(
|
|
||||||
*inbox_attributes,
|
|
||||||
channel: [:type, *channel_attributes]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def channel_type_from_params
|
def channel_type_from_params
|
||||||
@@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel_attributes(channel_type)
|
def get_channel_attributes(channel_type)
|
||||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
def whatsapp_channel?
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||||
|
include Tiktok::IntegrationHelper
|
||||||
|
|
||||||
|
def create
|
||||||
|
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||||
|
state: generate_tiktok_token(Current.account.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirect_url
|
||||||
|
render json: { success: true, url: redirect_url }
|
||||||
|
else
|
||||||
|
render json: { success: false }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def webhook_params
|
def webhook_params
|
||||||
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_webhook
|
def fetch_webhook
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||||
|
conversation_required_attributes: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME')
|
@global_config = GlobalConfig.get(
|
||||||
|
'LOGO_THUMBNAIL',
|
||||||
|
'BRAND_NAME',
|
||||||
|
'WIDGET_BRAND_URL',
|
||||||
|
'MAXIMUM_FILE_UPLOAD_SIZE',
|
||||||
|
'INSTALLATION_NAME'
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_contact
|
def set_contact
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
||||||
|
def show
|
||||||
|
year = params[:year] || 2025
|
||||||
|
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
||||||
|
|
||||||
|
cached_data = Current.user.ui_settings&.dig(cache_key)
|
||||||
|
|
||||||
|
if cached_data.present?
|
||||||
|
render json: cached_data
|
||||||
|
else
|
||||||
|
builder = YearInReviewBuilder.new(
|
||||||
|
account: Current.account,
|
||||||
|
user_id: Current.user.id,
|
||||||
|
year: year
|
||||||
|
)
|
||||||
|
|
||||||
|
data = builder.build
|
||||||
|
|
||||||
|
ui_settings = Current.user.ui_settings || {}
|
||||||
|
ui_settings[cache_key] = data
|
||||||
|
Current.user.update(ui_settings: ui_settings)
|
||||||
|
|
||||||
|
render json: data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_accessible_for_bot?(account)
|
def account_accessible_for_bot?(account)
|
||||||
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
return if @resource.account_id == account.id
|
||||||
|
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||||
|
|
||||||
|
render_unauthorized('Bot is not authorized to access this account')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
class DashboardController < ActionController::Base
|
class DashboardController < ActionController::Base
|
||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
|
|
||||||
|
GLOBAL_CONFIG_KEYS = %w[
|
||||||
|
LOGO
|
||||||
|
LOGO_DARK
|
||||||
|
LOGO_THUMBNAIL
|
||||||
|
INSTALLATION_NAME
|
||||||
|
WIDGET_BRAND_URL
|
||||||
|
TERMS_URL
|
||||||
|
BRAND_URL
|
||||||
|
BRAND_NAME
|
||||||
|
PRIVACY_URL
|
||||||
|
DISPLAY_MANIFEST
|
||||||
|
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
|
||||||
|
CHATWOOT_INBOX_TOKEN
|
||||||
|
API_CHANNEL_NAME
|
||||||
|
API_CHANNEL_THUMBNAIL
|
||||||
|
ANALYTICS_TOKEN
|
||||||
|
DIRECT_UPLOADS_ENABLED
|
||||||
|
MAXIMUM_FILE_UPLOAD_SIZE
|
||||||
|
HCAPTCHA_SITE_KEY
|
||||||
|
LOGOUT_REDIRECT_LINK
|
||||||
|
DISABLE_USER_PROFILE_UPDATE
|
||||||
|
DEPLOYMENT_ENV
|
||||||
|
INSTALLATION_PRICING_PLAN
|
||||||
|
].freeze
|
||||||
|
|
||||||
before_action :set_application_pack
|
before_action :set_application_pack
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
before_action :set_dashboard_scripts
|
before_action :set_dashboard_scripts
|
||||||
@@ -19,25 +44,7 @@ class DashboardController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get(
|
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
|
||||||
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
|
|
||||||
'INSTALLATION_NAME',
|
|
||||||
'WIDGET_BRAND_URL', 'TERMS_URL',
|
|
||||||
'BRAND_URL', 'BRAND_NAME',
|
|
||||||
'PRIVACY_URL',
|
|
||||||
'DISPLAY_MANIFEST',
|
|
||||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
|
||||||
'CHATWOOT_INBOX_TOKEN',
|
|
||||||
'API_CHANNEL_NAME',
|
|
||||||
'API_CHANNEL_THUMBNAIL',
|
|
||||||
'ANALYTICS_TOKEN',
|
|
||||||
'DIRECT_UPLOADS_ENABLED',
|
|
||||||
'HCAPTCHA_SITE_KEY',
|
|
||||||
'LOGOUT_REDIRECT_LINK',
|
|
||||||
'DISABLE_USER_PROFILE_UPDATE',
|
|
||||||
'DEPLOYMENT_ENV',
|
|
||||||
'INSTALLATION_PRICING_PLAN'
|
|
||||||
).merge(app_config)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_dashboard_scripts
|
def set_dashboard_scripts
|
||||||
@@ -66,15 +73,24 @@ class DashboardController < ActionController::Base
|
|||||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||||
|
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
|
||||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
||||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
GIT_SHA: GIT_HASH
|
GIT_SHA: GIT_HASH,
|
||||||
|
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allowed_login_methods
|
||||||
|
methods = ['email']
|
||||||
|
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||||
|
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
|
||||||
|
methods
|
||||||
|
end
|
||||||
|
|
||||||
def set_application_pack
|
def set_application_pack
|
||||||
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
||||||
'v3app'
|
'v3app'
|
||||||
|
|||||||
@@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
errors = []
|
||||||
params['app_config'].each do |key, value|
|
params['app_config'].each do |key, value|
|
||||||
next unless @allowed_configs.include?(key)
|
next unless @allowed_configs.include?(key)
|
||||||
|
|
||||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||||
i.value = value
|
i.value = value
|
||||||
i.save!
|
errors.concat(i.errors.full_messages) unless i.save
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.any?
|
||||||
|
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
|
||||||
|
else
|
||||||
|
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||||
end
|
end
|
||||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -40,12 +46,16 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
|
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
||||||
}
|
}
|
||||||
|
|
||||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
@allowed_configs = mapping.fetch(
|
||||||
|
@config,
|
||||||
|
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
class SuperAdmin::DashboardController < SuperAdmin::ApplicationController
|
class SuperAdmin::DashboardController < SuperAdmin::ApplicationController
|
||||||
include ActionView::Helpers::NumberHelper
|
include ActionView::Helpers::NumberHelper
|
||||||
|
|
||||||
|
ALLOWED_CHART_DAYS = [7, 30].freeze
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a
|
@chart_days = parse_chart_days
|
||||||
@accounts_count = number_with_delimiter(Account.count)
|
@data = conversations_by_day
|
||||||
@users_count = number_with_delimiter(User.count)
|
@accounts_count = format_count(Account.count)
|
||||||
@inboxes_count = number_with_delimiter(Inbox.count)
|
@users_count = format_count(User.count)
|
||||||
@conversations_count = number_with_delimiter(Conversation.count)
|
@inboxes_count = format_count(Inbox.count)
|
||||||
|
@conversations_count = format_count(recent_conversations.count)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse_chart_days
|
||||||
|
days = params[:chart_period].to_i
|
||||||
|
ALLOWED_CHART_DAYS.include?(days) ? days : 7
|
||||||
|
end
|
||||||
|
|
||||||
|
def chart_date_range
|
||||||
|
(@chart_days - 1).days.ago.beginning_of_day..Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_30_days
|
||||||
|
29.days.ago.beginning_of_day..Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def recent_conversations
|
||||||
|
Conversation.unscoped.where(created_at: last_30_days)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversations_by_day
|
||||||
|
Conversation.unscoped.group_by_day(:created_at, range: chart_date_range, default_value: 0).count.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_count(count)
|
||||||
|
number_with_delimiter(count)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
class Tiktok::CallbacksController < ApplicationController
|
||||||
|
include Tiktok::IntegrationHelper
|
||||||
|
|
||||||
|
def show
|
||||||
|
return handle_authorization_error if params[:error].present?
|
||||||
|
return handle_ungranted_scopes_error unless all_scopes_granted?
|
||||||
|
|
||||||
|
process_successful_authorization
|
||||||
|
rescue StandardError => e
|
||||||
|
handle_error(e)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def all_scopes_granted?
|
||||||
|
granted_scopes = short_term_access_token[:scope].to_s.split(',')
|
||||||
|
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_successful_authorization
|
||||||
|
inbox, already_exists = find_or_create_inbox
|
||||||
|
|
||||||
|
if already_exists
|
||||||
|
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||||
|
else
|
||||||
|
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_error(error)
|
||||||
|
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
|
||||||
|
ChatwootExceptionTracker.new(error).capture_exception
|
||||||
|
|
||||||
|
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles the case when a user denies permissions or cancels the authorization flow
|
||||||
|
def handle_authorization_error
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: params[:error] || 'access_denied',
|
||||||
|
code: params[:error_code],
|
||||||
|
error_message: params[:error_description] || 'User cancelled the Authorization'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handles the case when a user partially accepted the required scopes
|
||||||
|
def handle_ungranted_scopes_error
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: 'ungranted_scopes',
|
||||||
|
code: 400,
|
||||||
|
error_message: 'User did not grant all the required scopes'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Centralized method to redirect to error page with appropriate parameters
|
||||||
|
# This ensures consistent error handling across different error scenarios
|
||||||
|
# Frontend will handle the error page based on the error_type
|
||||||
|
def redirect_to_error_page(error_type:, code:, error_message:)
|
||||||
|
redirect_to app_new_tiktok_inbox_url(
|
||||||
|
account_id: account_id,
|
||||||
|
error_type: error_type,
|
||||||
|
code: code,
|
||||||
|
error_message: error_message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_inbox
|
||||||
|
business_details = tiktok_client.business_account_details
|
||||||
|
channel_tiktok = find_channel
|
||||||
|
channel_exists = channel_tiktok.present?
|
||||||
|
|
||||||
|
if channel_tiktok
|
||||||
|
update_channel(channel_tiktok, business_details)
|
||||||
|
else
|
||||||
|
channel_tiktok = create_channel_with_inbox(business_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
# reauthorized will also update cache keys for the associated inbox
|
||||||
|
channel_tiktok.reauthorized!
|
||||||
|
|
||||||
|
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
|
||||||
|
|
||||||
|
[channel_tiktok.inbox, channel_exists]
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_channel_with_inbox(business_details)
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
channel_tiktok = Channel::Tiktok.create!(
|
||||||
|
account: account,
|
||||||
|
business_id: short_term_access_token[:business_id],
|
||||||
|
access_token: short_term_access_token[:access_token],
|
||||||
|
refresh_token: short_term_access_token[:refresh_token],
|
||||||
|
expires_at: short_term_access_token[:expires_at],
|
||||||
|
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
account.inboxes.create!(
|
||||||
|
account: account,
|
||||||
|
channel: channel_tiktok,
|
||||||
|
name: business_details[:display_name].presence || business_details[:username]
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_tiktok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_channel
|
||||||
|
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_channel(channel_tiktok, business_details)
|
||||||
|
channel_tiktok.update!(
|
||||||
|
access_token: short_term_access_token[:access_token],
|
||||||
|
refresh_token: short_term_access_token[:refresh_token],
|
||||||
|
expires_at: short_term_access_token[:expires_at],
|
||||||
|
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_avatar(inbox, avatar_url)
|
||||||
|
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_id
|
||||||
|
@account_id ||= verify_tiktok_token(params[:state])
|
||||||
|
end
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= Account.find(account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def short_term_access_token
|
||||||
|
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
|
||||||
|
end
|
||||||
|
|
||||||
|
def tiktok_client
|
||||||
|
@tiktok_client ||= Tiktok::Client.new(
|
||||||
|
business_id: short_term_access_token[:business_id],
|
||||||
|
access_token: short_term_access_token[:access_token]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
class Webhooks::TiktokController < ActionController::API
|
||||||
|
before_action :verify_signature!
|
||||||
|
|
||||||
|
def events
|
||||||
|
event = JSON.parse(request_payload)
|
||||||
|
if echo_event?
|
||||||
|
# Add delay to prevent race condition where echo arrives before send message API completes
|
||||||
|
# This avoids duplicate messages when echo comes early during API processing
|
||||||
|
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
|
||||||
|
else
|
||||||
|
::Webhooks::TiktokEventsJob.perform_later(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def request_payload
|
||||||
|
@request_payload ||= request.body.read
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_signature!
|
||||||
|
signature_header = request.headers['Tiktok-Signature']
|
||||||
|
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||||
|
received_timestamp, received_signature = extract_signature_parts(signature_header)
|
||||||
|
|
||||||
|
return head :unauthorized unless client_secret && received_timestamp && received_signature
|
||||||
|
|
||||||
|
signature_payload = "#{received_timestamp}.#{request_payload}"
|
||||||
|
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
|
||||||
|
|
||||||
|
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
|
||||||
|
|
||||||
|
# Check timestamp delay (acceptable delay: 5 seconds)
|
||||||
|
current_timestamp = Time.current.to_i
|
||||||
|
delay = current_timestamp - received_timestamp
|
||||||
|
|
||||||
|
return head :unauthorized if delay > 5
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_signature_parts(signature_header)
|
||||||
|
return [nil, nil] if signature_header.blank?
|
||||||
|
|
||||||
|
keys = signature_header.split(',')
|
||||||
|
signature_parts = keys.map { |part| part.split('=') }.to_h
|
||||||
|
[signature_parts['t']&.to_i, signature_parts['s']]
|
||||||
|
end
|
||||||
|
|
||||||
|
def echo_event?
|
||||||
|
params[:event] == 'im_send_msg'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_global_config
|
def set_global_config
|
||||||
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME')
|
@global_config = GlobalConfig.get(
|
||||||
|
'LOGO_THUMBNAIL',
|
||||||
|
'BRAND_NAME',
|
||||||
|
'WIDGET_BRAND_URL',
|
||||||
|
'DIRECT_UPLOADS_ENABLED',
|
||||||
|
'MAXIMUM_FILE_UPLOAD_SIZE',
|
||||||
|
'INSTALLATION_NAME'
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_web_widget
|
def set_web_widget
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
updated_at: Field::DateTime,
|
updated_at: Field::DateTime,
|
||||||
users: CountField,
|
users: CountField,
|
||||||
conversations: CountField,
|
conversations: CountField,
|
||||||
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
|
locale: LocaleField,
|
||||||
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
|
status: StatusField,
|
||||||
account_users: Field::HasMany,
|
account_users: Field::HasMany,
|
||||||
custom_attributes: Field::String
|
custom_attributes: Field::String
|
||||||
}.merge(enterprise_attribute_types).freeze
|
}.merge(enterprise_attribute_types).freeze
|
||||||
@@ -45,10 +45,9 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
COLLECTION_ATTRIBUTES = %i[
|
COLLECTION_ATTRIBUTES = %i[
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
locale
|
|
||||||
users
|
|
||||||
conversations
|
|
||||||
status
|
status
|
||||||
|
users
|
||||||
|
locale
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
# SHOW_PAGE_ATTRIBUTES
|
# SHOW_PAGE_ATTRIBUTES
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
require 'administrate/field/base'
|
||||||
|
|
||||||
|
class LocaleField < Administrate::Field::Base
|
||||||
|
def to_s
|
||||||
|
language = LANGUAGES_CONFIG.find { |_key, val| val[:iso_639_1_code] == data }
|
||||||
|
language ? language[1][:name] : data
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_options
|
||||||
|
LANGUAGES_CONFIG.map { |_key, val| [val[:name], val[:iso_639_1_code]] }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
require 'administrate/field/base'
|
||||||
|
|
||||||
|
class StatusField < Administrate::Field::Base
|
||||||
|
def to_s
|
||||||
|
data.to_s.capitalize
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
data.to_s == 'active'
|
||||||
|
end
|
||||||
|
|
||||||
|
def selectable_options
|
||||||
|
[%w[Active active], %w[Suspended suspended]]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
|
||||||
|
# This module contains helper methods for converting between different data types,
|
||||||
|
# normalizing hashes, and safely handling JSON operations.
|
||||||
|
module DataHelper
|
||||||
|
# Ensures a hash supports indifferent access (string or symbol keys).
|
||||||
|
# Returns an empty hash if the input is blank.
|
||||||
|
def ensure_indifferent_access(hash)
|
||||||
|
return {} if hash.blank?
|
||||||
|
|
||||||
|
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert_to_hash(obj)
|
||||||
|
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||||
|
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_parse_json(content)
|
||||||
|
JSON.parse(content, symbolize_names: true)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,19 @@ module EmailHelper
|
|||||||
domain.split('.').first
|
domain.split('.').first
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_email_html(content)
|
||||||
|
return '' if content.blank?
|
||||||
|
|
||||||
|
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raise a standard error if any email address is invalid
|
||||||
|
def validate_email_addresses(emails_to_test)
|
||||||
|
emails_to_test&.each do |email|
|
||||||
|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
||||||
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
||||||
# john+test@xyc.com is the same as john@xyc.com
|
# john+test@xyc.com is the same as john@xyc.com
|
||||||
@@ -21,6 +34,10 @@ module EmailHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_email_body(content)
|
||||||
|
content.to_s.gsub("\r\n", "\n")
|
||||||
|
end
|
||||||
|
|
||||||
def modified_liquid_content(email)
|
def modified_liquid_content(email)
|
||||||
# This regex is used to match the code blocks in the content
|
# This regex is used to match the code blocks in the content
|
||||||
# We don't want to process liquid in code blocks
|
# We don't want to process liquid in code blocks
|
||||||
@@ -29,7 +46,10 @@ module EmailHelper
|
|||||||
|
|
||||||
def message_drops(conversation)
|
def message_drops(conversation)
|
||||||
{
|
{
|
||||||
'contact' => ContactDrop.new(conversation.contact)
|
'contact' => ContactDrop.new(conversation.contact),
|
||||||
|
'conversation' => ConversationDrop.new(conversation),
|
||||||
|
'inbox' => InboxDrop.new(conversation.inbox),
|
||||||
|
'account' => AccountDrop.new(conversation.account)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ captain:
|
|||||||
icon: 'icon-captain'
|
icon: 'icon-captain'
|
||||||
config_key: 'captain'
|
config_key: 'captain'
|
||||||
enterprise: true
|
enterprise: true
|
||||||
|
saml:
|
||||||
|
name: 'SAML SSO'
|
||||||
|
description: 'Configuration for controlling SAML Single Sign-On availability'
|
||||||
|
enabled: <%= ChatwootApp.enterprise? %>
|
||||||
|
icon: 'icon-lock-line'
|
||||||
|
config_key: 'saml'
|
||||||
|
enterprise: true
|
||||||
custom_branding:
|
custom_branding:
|
||||||
name: 'Custom Branding'
|
name: 'Custom Branding'
|
||||||
description: 'Apply your own branding to this installation.'
|
description: 'Apply your own branding to this installation.'
|
||||||
@@ -71,6 +78,12 @@ instagram:
|
|||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-instagram'
|
icon: 'icon-instagram'
|
||||||
config_key: 'instagram'
|
config_key: 'instagram'
|
||||||
|
tiktok:
|
||||||
|
name: 'TikTok'
|
||||||
|
description: 'Stay connected with your customers on TikTok'
|
||||||
|
enabled: true
|
||||||
|
icon: 'icon-tiktok'
|
||||||
|
config_key: 'tiktok'
|
||||||
whatsapp:
|
whatsapp:
|
||||||
name: 'WhatsApp'
|
name: 'WhatsApp'
|
||||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module SuperAdmin::NavigationHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Add general at the beginning
|
# Add general at the beginning
|
||||||
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
|
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General', 'icon' => 'icon-gear' }]]
|
||||||
|
|
||||||
general_feature + features.to_a
|
general_feature + features.to_a
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Tiktok::IntegrationHelper
|
||||||
|
# Generates a signed JWT token for Tiktok integration
|
||||||
|
#
|
||||||
|
# @param account_id [Integer] The account ID to encode in the token
|
||||||
|
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||||
|
def generate_tiktok_token(account_id)
|
||||||
|
return if client_secret.blank?
|
||||||
|
|
||||||
|
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verifies and decodes a Tiktok JWT token
|
||||||
|
#
|
||||||
|
# @param token [String] The JWT token to verify
|
||||||
|
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||||
|
def verify_tiktok_token(token)
|
||||||
|
return if token.blank? || client_secret.blank?
|
||||||
|
|
||||||
|
decode_token(token, client_secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def client_secret
|
||||||
|
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_payload(account_id)
|
||||||
|
{
|
||||||
|
sub: account_id,
|
||||||
|
iat: Time.current.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode_token(token, secret)
|
||||||
|
JWT.decode(token, secret, true, {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
verify_expiration: true
|
||||||
|
}).first['sub']
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import AddAccountModal from './components/app/AddAccountModal.vue';
|
|
||||||
import LoadingState from './components/widgets/LoadingState.vue';
|
import LoadingState from './components/widgets/LoadingState.vue';
|
||||||
import NetworkNotification from './components/NetworkNotification.vue';
|
import NetworkNotification from './components/NetworkNotification.vue';
|
||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
@@ -25,7 +24,6 @@ export default {
|
|||||||
name: 'App',
|
name: 'App',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
AddAccountModal,
|
|
||||||
LoadingState,
|
LoadingState,
|
||||||
NetworkNotification,
|
NetworkNotification,
|
||||||
UpdateBanner,
|
UpdateBanner,
|
||||||
@@ -51,7 +49,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showAddAccountModal: false,
|
|
||||||
latestChatwootVersion: null,
|
latestChatwootVersion: null,
|
||||||
reconnectService: null,
|
reconnectService: null,
|
||||||
};
|
};
|
||||||
@@ -64,21 +61,12 @@ export default {
|
|||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
}),
|
}),
|
||||||
hasAccounts() {
|
|
||||||
const { accounts = [] } = this.currentUser || {};
|
|
||||||
return accounts.length > 0;
|
|
||||||
},
|
|
||||||
hideOnOnboardingView() {
|
hideOnOnboardingView() {
|
||||||
return !isOnOnboardingView(this.$route);
|
return !isOnOnboardingView(this.$route);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
currentUser() {
|
|
||||||
if (!this.hasAccounts) {
|
|
||||||
this.showAddAccountModal = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
currentAccountId: {
|
currentAccountId: {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
handler() {
|
handler() {
|
||||||
@@ -156,7 +144,6 @@ export default {
|
|||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
|
||||||
<WootSnackbarBox />
|
<WootSnackbarBox />
|
||||||
<NetworkNotification />
|
<NetworkNotification />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class TiktokChannel extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('tiktok', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
generateAuthorization(payload) {
|
||||||
|
return axios.post(`${this.url}/authorization`, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TiktokChannel();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
export const buildCompanyParams = (page, sort) => {
|
||||||
|
let params = `page=${page}`;
|
||||||
|
if (sort) {
|
||||||
|
params = `${params}&sort=${sort}`;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSearchParams = (query, page, sort) => {
|
||||||
|
let params = `q=${encodeURIComponent(query)}&page=${page}`;
|
||||||
|
if (sort) {
|
||||||
|
params = `${params}&sort=${sort}`;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CompanyAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('companies', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(params = {}) {
|
||||||
|
const { page = 1, sort = 'name' } = params;
|
||||||
|
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
|
||||||
|
return axios.get(requestURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
search(query = '', page = 1, sort = 'name') {
|
||||||
|
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
|
||||||
|
return axios.get(requestURL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CompanyAPI();
|
||||||
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return axios.post(`${this.url}/${contactId}/call`, {
|
||||||
|
inbox_id: inboxId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
action_type: action,
|
action_type: action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTopupCheckout(credits) {
|
||||||
|
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
|
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
||||||
|
expect(accountAPI).toHaveProperty('getLimits');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
{ action_type: 'undelete' }
|
{ action_type: 'undelete' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with credits', () => {
|
||||||
|
accountAPI.createTopupCheckout(1000);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with different credit amounts', () => {
|
||||||
|
const creditAmounts = [1000, 2500, 6000, 12000];
|
||||||
|
creditAmounts.forEach(credits => {
|
||||||
|
accountAPI.createTopupCheckout(credits);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#getLimits', () => {
|
||||||
|
accountAPI.getLimits();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,10 +63,9 @@ class ConversationApi extends ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId }) {
|
||||||
return axios.post(
|
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||||
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
assignee_id: agentId,
|
||||||
{}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTeam({ conversationId, teamId }) {
|
assignTeam({ conversationId, teamId }) {
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
|
|||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Always include conversation_display_id when available for session tracking
|
||||||
|
if (conversationId) {
|
||||||
|
data.conversation_display_id = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For conversation-level events, only send conversation_display_id
|
||||||
if (this.conversation_events.includes(type)) {
|
if (this.conversation_events.includes(type)) {
|
||||||
data = {
|
data = {
|
||||||
conversation_display_id: conversationId,
|
conversation_display_id: conversationId,
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import companyAPI, {
|
||||||
|
buildCompanyParams,
|
||||||
|
buildSearchParams,
|
||||||
|
} from '../companies';
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
describe('#CompanyAPI', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(companyAPI).toBeInstanceOf(ApiClient);
|
||||||
|
expect(companyAPI).toHaveProperty('get');
|
||||||
|
expect(companyAPI).toHaveProperty('show');
|
||||||
|
expect(companyAPI).toHaveProperty('create');
|
||||||
|
expect(companyAPI).toHaveProperty('update');
|
||||||
|
expect(companyAPI).toHaveProperty('delete');
|
||||||
|
expect(companyAPI).toHaveProperty('search');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API calls', () => {
|
||||||
|
const originalAxios = window.axios;
|
||||||
|
const axiosMock = {
|
||||||
|
post: vi.fn(() => Promise.resolve()),
|
||||||
|
get: vi.fn(() => Promise.resolve()),
|
||||||
|
patch: vi.fn(() => Promise.resolve()),
|
||||||
|
delete: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.axios = axiosMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.axios = originalAxios;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#get with default params', () => {
|
||||||
|
companyAPI.get({});
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies?page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#get with page and sort params', () => {
|
||||||
|
companyAPI.get({ page: 2, sort: 'domain' });
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies?page=2&sort=domain'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#get with descending sort', () => {
|
||||||
|
companyAPI.get({ page: 1, sort: '-created_at' });
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies?page=1&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with query', () => {
|
||||||
|
companyAPI.search('acme', 1, 'name');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=acme&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with special characters in query', () => {
|
||||||
|
companyAPI.search('acme & co', 2, 'domain');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with descending sort', () => {
|
||||||
|
companyAPI.search('test', 1, '-created_at');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#search with empty query', () => {
|
||||||
|
companyAPI.search('', 1, 'name');
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/companies/search?q=&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#buildCompanyParams', () => {
|
||||||
|
it('returns correct string with page only', () => {
|
||||||
|
expect(buildCompanyParams(1)).toBe('page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with page and sort', () => {
|
||||||
|
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with different page', () => {
|
||||||
|
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with descending sort', () => {
|
||||||
|
expect(buildCompanyParams(1, '-created_at')).toBe(
|
||||||
|
'page=1&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string without sort parameter', () => {
|
||||||
|
expect(buildCompanyParams(2, '')).toBe('page=2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#buildSearchParams', () => {
|
||||||
|
it('returns correct string with all parameters', () => {
|
||||||
|
expect(buildSearchParams('acme', 1, 'name')).toBe(
|
||||||
|
'q=acme&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with special characters', () => {
|
||||||
|
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
|
||||||
|
'q=acme%20%26%20co&page=2&sort=domain'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with empty query', () => {
|
||||||
|
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string without sort parameter', () => {
|
||||||
|
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct string with descending sort', () => {
|
||||||
|
expect(buildSearchParams('company', 3, '-created_at')).toBe(
|
||||||
|
'q=company&page=3&sort=-created_at'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes special characters correctly', () => {
|
||||||
|
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
|
||||||
|
'q=test%40example.com&page=1&sort=name'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,8 +92,10 @@ describe('#ConversationAPI', () => {
|
|||||||
it('#assignAgent', () => {
|
it('#assignAgent', () => {
|
||||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
`/api/v1/conversations/12/assignments?assignee_id=34`,
|
`/api/v1/conversations/12/assignments`,
|
||||||
{}
|
{
|
||||||
|
assignee_id: 34,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
import tiktokClient from '../channel/tiktokClient';
|
||||||
|
|
||||||
|
describe('#TiktokClient', () => {
|
||||||
|
it('creates correct instance', () => {
|
||||||
|
expect(tiktokClient).toBeInstanceOf(ApiClient);
|
||||||
|
expect(tiktokClient).toHaveProperty('generateAuthorization');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#generateAuthorization', () => {
|
||||||
|
const originalAxios = window.axios;
|
||||||
|
const originalPathname = window.location.pathname;
|
||||||
|
const axiosMock = {
|
||||||
|
post: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.axios = axiosMock;
|
||||||
|
window.history.pushState({}, '', '/app/accounts/1/settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.axios = originalAxios;
|
||||||
|
window.history.pushState({}, '', originalPathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts to the authorization endpoint', () => {
|
||||||
|
tiktokClient.generateAuthorization({ state: 'test-state' });
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/accounts/1/tiktok/authorization',
|
||||||
|
{ state: 'test-state' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class YearInReviewAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(year) {
|
||||||
|
return axios.get(`${this.url}`, {
|
||||||
|
params: { year },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new YearInReviewAPI();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: { type: Number, required: true },
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
domain: { type: String, default: '' },
|
||||||
|
contactsCount: { type: Number, default: 0 },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
avatarUrl: { type: String, default: '' },
|
||||||
|
updatedAt: { type: [String, Number], default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['showCompany']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const onClickViewDetails = () => emit('showCompany', props.id);
|
||||||
|
|
||||||
|
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
|
||||||
|
|
||||||
|
const avatarSource = computed(() => props.avatarUrl || null);
|
||||||
|
|
||||||
|
const formattedUpdatedAt = computed(() => {
|
||||||
|
if (!props.updatedAt) return '';
|
||||||
|
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardLayout layout="row" @click="onClickViewDetails">
|
||||||
|
<div class="flex items-center justify-start flex-1 gap-4">
|
||||||
|
<Avatar
|
||||||
|
:username="displayName"
|
||||||
|
:src="avatarSource"
|
||||||
|
class="shrink-0"
|
||||||
|
:name="name"
|
||||||
|
:size="48"
|
||||||
|
hide-offline-status
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||||
|
<span class="text-base font-medium truncate text-n-slate-12">
|
||||||
|
{{ displayName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="domain && description"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||||
|
<span class="truncate">{{ domain }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
||||||
|
<span
|
||||||
|
v-if="domain && !description"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||||
|
<span class="truncate">{{ domain }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="description" class="text-sm text-n-slate-11 truncate">
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="(description || domain) && contactsCount"
|
||||||
|
class="w-px h-3 bg-n-slate-6"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="contactsCount"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
|
||||||
|
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="updatedAt"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{{ formattedUpdatedAt }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup>
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import CompanySortMenu from './components/CompanySortMenu.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showSearch: { type: Boolean, default: true },
|
||||||
|
searchValue: { type: String, default: '' },
|
||||||
|
headerTitle: { type: String, required: true },
|
||||||
|
activeSort: { type: String, default: 'last_activity_at' },
|
||||||
|
activeOrdering: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['search', 'update:sort']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-10">
|
||||||
|
<div
|
||||||
|
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
|
>
|
||||||
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
|
{{ headerTitle }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<CompanySortMenu
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||||
|
<Input
|
||||||
|
:model-value="searchValue"
|
||||||
|
type="search"
|
||||||
|
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
||||||
|
:custom-input-class="[
|
||||||
|
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||||
|
]"
|
||||||
|
class="w-full"
|
||||||
|
@input="emit('search', $event.target.value)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-search"
|
||||||
|
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, toRef } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeSort: {
|
||||||
|
type: String,
|
||||||
|
default: 'name',
|
||||||
|
},
|
||||||
|
activeOrdering: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:sort']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isMenuOpen = ref(false);
|
||||||
|
|
||||||
|
const sortMenus = [
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
|
||||||
|
value: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
|
||||||
|
value: 'domain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||||
|
value: 'created_at',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderingMenus = [
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
|
||||||
|
value: '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Converted the props to refs for better reactivity
|
||||||
|
const activeSort = toRef(props, 'activeSort');
|
||||||
|
|
||||||
|
const activeOrdering = toRef(props, 'activeOrdering');
|
||||||
|
|
||||||
|
const activeSortLabel = computed(() => {
|
||||||
|
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
|
||||||
|
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeOrderingLabel = computed(() => {
|
||||||
|
const selectedMenu = orderingMenus.find(
|
||||||
|
menu => menu.value === activeOrdering.value
|
||||||
|
);
|
||||||
|
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSortChange = value => {
|
||||||
|
emit('update:sort', { sort: value, order: props.activeOrdering });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrderChange = value => {
|
||||||
|
emit('update:sort', { sort: props.activeSort, order: value });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-arrow-down-up"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
|
||||||
|
@click="isMenuOpen = !isMenuOpen"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="isMenuOpen"
|
||||||
|
v-on-clickaway="() => (isMenuOpen = false)"
|
||||||
|
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ t('COMPANIES.SORT_BY.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SelectMenu
|
||||||
|
:model-value="activeSort"
|
||||||
|
:options="sortMenus"
|
||||||
|
:label="activeSortLabel"
|
||||||
|
@update:model-value="handleSortChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ t('COMPANIES.ORDER.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SelectMenu
|
||||||
|
:model-value="activeOrdering"
|
||||||
|
:options="orderingMenus"
|
||||||
|
:label="activeOrderingLabel"
|
||||||
|
@update:model-value="handleOrderChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup>
|
||||||
|
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
|
||||||
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
searchValue: { type: String, default: '' },
|
||||||
|
headerTitle: { type: String, default: '' },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
totalItems: { type: Number, default: 100 },
|
||||||
|
activeSort: { type: String, default: 'name' },
|
||||||
|
activeOrdering: { type: String, default: '' },
|
||||||
|
showPaginationFooter: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
|
||||||
|
|
||||||
|
const updateCurrentPage = page => {
|
||||||
|
emit('update:currentPage', page);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||||
|
<CompanyHeader
|
||||||
|
:search-value="searchValue"
|
||||||
|
:header-title="headerTitle"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@search="emit('search', $event)"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
<main class="flex-1 overflow-y-auto">
|
||||||
|
<div class="w-full mx-auto max-w-[60rem]">
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
|
<PaginationFooter
|
||||||
|
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:items-per-page="25"
|
||||||
|
@update:current-page="updateCurrentPage"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="selectedContact?.phoneNumber"
|
:phone="selectedContact?.phoneNumber"
|
||||||
|
:contact-id="contactId"
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
|||||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||||
INSTAGRAM: 'i-ri-instagram-line',
|
INSTAGRAM: 'i-ri-instagram-line',
|
||||||
|
TIKTOK: 'i-ri-tiktok-fill',
|
||||||
TWITTER: 'i-ri-twitter-x-fill',
|
TWITTER: 'i-ri-twitter-x-fill',
|
||||||
GITHUB: 'i-ri-github-fill',
|
GITHUB: 'i-ri-github-fill',
|
||||||
};
|
};
|
||||||
@@ -65,6 +66,7 @@ const defaultState = {
|
|||||||
facebook: '',
|
facebook: '',
|
||||||
github: '',
|
github: '',
|
||||||
instagram: '',
|
instagram: '',
|
||||||
|
tiktok: '',
|
||||||
linkedin: '',
|
linkedin: '',
|
||||||
twitter: '',
|
twitter: '',
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-1
@@ -40,7 +40,12 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
width="3xl"
|
||||||
|
overflow-y-auto
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
>
|
||||||
<ContactsForm
|
<ContactsForm
|
||||||
ref="contactsFormRef"
|
ref="contactsFormRef"
|
||||||
is-new-contact
|
is-new-contact
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
|
contactId: { type: [String, Number], required: true },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -18,10 +21,17 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|||||||
// Unified behavior: hide when no phone
|
// Unified behavior: hide when no phone
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const isInitiatingCall = computed(() => {
|
||||||
|
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||||
|
});
|
||||||
|
|
||||||
const onClick = () => {
|
const navigateToConversation = conversationId => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
if (conversationId && accountId) {
|
||||||
|
const path = frontendURL(
|
||||||
|
conversationUrl({
|
||||||
|
accountId,
|
||||||
|
id: conversationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.push({ path });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCall = async inboxId => {
|
||||||
|
if (isInitiatingCall.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await store.dispatch('contacts/initiateCall', {
|
||||||
|
contactId: props.contactId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
|
navigateToConversation(response?.conversation_id);
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error?.message;
|
||||||
|
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
if (voiceInboxes.value.length > 1) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
const [inbox] = voiceInboxes.value;
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickInbox = () => {
|
const onPickInbox = async inbox => {
|
||||||
// Placeholder until actual call wiring happens
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,6 +96,8 @@ const onPickInbox = () => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
|
:disabled="isInitiatingCall"
|
||||||
|
:is-loading="isInitiatingCall"
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:size="size"
|
:size="size"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<ButtonGroup
|
||||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
sm
|
sm
|
||||||
class="!rounded-full"
|
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-2': isContactSidebarOpen,
|
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
|
||||||
}"
|
}"
|
||||||
icon="i-ph-user-bold"
|
icon="i-ph-user-bold"
|
||||||
@click="handleConversationSidebarToggle"
|
@click="handleConversationSidebarToggle"
|
||||||
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
class="!rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
|
||||||
}"
|
|
||||||
sm
|
sm
|
||||||
|
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
|
||||||
|
isCopilotPanelOpen,
|
||||||
|
}"
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
@click="handleCopilotSidebarToggle"
|
@click="handleCopilotSidebarToggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attribute: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
|
const iconByType = {
|
||||||
|
text: 'i-lucide-align-justify',
|
||||||
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
|
list: 'i-lucide-list',
|
||||||
|
date: 'i-lucide-calendar',
|
||||||
|
link: 'i-lucide-link',
|
||||||
|
number: 'i-lucide-hash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeIcon = computed(() => {
|
||||||
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</h4>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon :icon="attributeIcon" class="size-4" />
|
||||||
|
<span class="text-sm">{{ attribute.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-3 bg-n-weak" />
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||||
|
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<AttributeBadge
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.type"
|
||||||
|
:type="badge.type"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="badges.length > 0"
|
||||||
|
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('edit', attribute)"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', attribute)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{ attribute.attribute_description || attribute.description || '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'resolution',
|
||||||
|
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attributeConfig = {
|
||||||
|
'pre-chat': {
|
||||||
|
colorClass: 'text-n-blue-11',
|
||||||
|
icon: 'i-lucide-message-circle',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
colorClass: 'text-n-teal-11',
|
||||||
|
icon: 'i-lucide-circle-check-big',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = computed(
|
||||||
|
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||||
|
>
|
||||||
|
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||||
|
<span class="text-xs" :class="config.colorClass">{{
|
||||||
|
t(config.labelKey)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -19,8 +19,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
enableVariables: { type: Boolean, default: false },
|
enableVariables: { type: Boolean, default: false },
|
||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
|
signature: { type: String, default: '' },
|
||||||
|
allowSignature: { type: Boolean, default: false },
|
||||||
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -98,8 +102,12 @@ watch(
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:enable-variables="enableVariables"
|
:enable-variables="enableVariables"
|
||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
:enabled-menu-options="enabledMenuOptions"
|
|
||||||
:enable-captain-tools="enableCaptainTools"
|
:enable-captain-tools="enableCaptainTools"
|
||||||
|
:signature="signature"
|
||||||
|
:allow-signature="allowSignature"
|
||||||
|
:send-with-signature="sendWithSignature"
|
||||||
|
:channel-type="channelType"
|
||||||
|
:medium="medium"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
@@ -131,19 +139,6 @@ watch(
|
|||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
::v-deep {
|
::v-deep {
|
||||||
.ProseMirror-menubar-wrapper {
|
.ProseMirror-menubar-wrapper {
|
||||||
@apply gap-2 !important;
|
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
|
||||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
|
|
||||||
|
|
||||||
.ProseMirror-menuitem {
|
|
||||||
@apply h-5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror-icon {
|
|
||||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ProseMirror.ProseMirror-woot-style {
|
.ProseMirror.ProseMirror-woot-style {
|
||||||
p {
|
p {
|
||||||
@apply first:mt-0 !important;
|
@apply first:mt-0 !important;
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
|||||||
@apply mr-0;
|
@apply mr-0;
|
||||||
|
|
||||||
.ProseMirror-icon {
|
.ProseMirror-icon {
|
||||||
@apply p-0 mt-1 !mr-0;
|
@apply p-0 mt-0 !mr-0;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 20px !important;
|
width: 20px !important;
|
||||||
|
|||||||
+5
-2
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'dashboard/helper/portalHelper';
|
} from 'dashboard/helper/portalHelper';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
@click="previewArticle"
|
@click="previewArticle"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center">
|
<ButtonGroup class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||||
|
no-animation
|
||||||
:is-loading="isArticlePublishing"
|
:is-loading="isArticlePublishing"
|
||||||
:disabled="
|
:disabled="
|
||||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
|
no-animation
|
||||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
searchContacts,
|
searchContacts,
|
||||||
createNewContact,
|
createNewContact,
|
||||||
@@ -226,6 +228,8 @@ const keyboardEvents = {
|
|||||||
action: () => {
|
action: () => {
|
||||||
if (showComposeNewConversation.value) {
|
if (showComposeNewConversation.value) {
|
||||||
showComposeNewConversation.value = false;
|
showComposeNewConversation.value = false;
|
||||||
|
emit('close');
|
||||||
|
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+29
-13
@@ -4,10 +4,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
import { useFileUpload } from 'dashboard/composables/useFileUpload';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useEventListener } from '@vueuse/core';
|
||||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||||
@@ -50,12 +51,6 @@ const EmojiInput = defineAsyncComponent(
|
|||||||
() => import('shared/components/emoji/EmojiInput.vue')
|
() => import('shared/components/emoji/EmojiInput.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
const signatureToApply = computed(() =>
|
|
||||||
props.isEmailOrWebWidgetInbox
|
|
||||||
? props.messageSignature
|
|
||||||
: extractTextFromMarkdown(props.messageSignature)
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
setSignatureFlagForInbox,
|
setSignatureFlagForInbox,
|
||||||
@@ -80,19 +75,26 @@ const isRegularMessageMode = computed(() => {
|
|||||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||||
|
|
||||||
|
const shouldShowSignatureButton = computed(() => {
|
||||||
|
return (
|
||||||
|
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const setSignature = () => {
|
const setSignature = () => {
|
||||||
if (signatureToApply.value) {
|
if (props.messageSignature) {
|
||||||
if (sendWithSignature.value) {
|
if (sendWithSignature.value) {
|
||||||
emit('addSignature', signatureToApply.value);
|
emit('addSignature', props.messageSignature);
|
||||||
} else {
|
} else {
|
||||||
emit('removeSignature', signatureToApply.value);
|
emit('removeSignature', props.messageSignature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleMessageSignature = () => {
|
const toggleMessageSignature = () => {
|
||||||
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
||||||
setSignature();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Added this watch to dynamically set signature on target inbox change.
|
// Added this watch to dynamically set signature on target inbox change.
|
||||||
@@ -102,7 +104,7 @@ watch(
|
|||||||
() => props.hasSelectedInbox,
|
() => props.hasSelectedInbox,
|
||||||
newValue => {
|
newValue => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
if (newValue && !isVoiceInbox.value) setSignature();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -162,6 +164,20 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
|
const onPaste = e => {
|
||||||
|
if (!props.isEmailOrWebWidgetInbox) return;
|
||||||
|
|
||||||
|
const files = e.clipboardData?.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
const { name, type, size } = file;
|
||||||
|
onFileUpload({ file, name, type, size });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener(document, 'paste', onPaste);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -221,7 +237,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
<Button
|
<Button
|
||||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
v-if="shouldShowSignatureButton"
|
||||||
icon="i-lucide-signature"
|
icon="i-lucide-signature"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 p-4">
|
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-if="filteredImageAttachments.length > 0"
|
v-if="filteredImageAttachments.length > 0"
|
||||||
class="flex flex-wrap gap-3"
|
class="flex flex-wrap gap-3"
|
||||||
|
|||||||
+36
-12
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
extractTextFromMarkdown,
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
buildContactableInboxesList,
|
buildContactableInboxesList,
|
||||||
@@ -87,6 +87,12 @@ const whatsappMessageTemplates = computed(() =>
|
|||||||
|
|
||||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||||
|
|
||||||
|
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||||
|
|
||||||
|
const effectiveChannelType = computed(() =>
|
||||||
|
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||||
|
);
|
||||||
|
|
||||||
const validationRules = computed(() => ({
|
const validationRules = computed(() => ({
|
||||||
selectedContact: { required },
|
selectedContact: { required },
|
||||||
targetInbox: { required },
|
targetInbox: { required },
|
||||||
@@ -194,27 +200,36 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
|
state.message = '';
|
||||||
emit('updateTargetInbox', { ...rest });
|
emit('updateTargetInbox', { ...rest });
|
||||||
showInboxesDropdown.value = false;
|
showInboxesDropdown.value = false;
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeSignatureFromMessage = () => {
|
||||||
|
// Always remove the signature from message content when inbox/contact is removed
|
||||||
|
// to ensure no leftover signature content remains
|
||||||
|
if (props.messageSignature) {
|
||||||
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
props.messageSignature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
// Remove the signature from message content
|
removeSignatureFromMessage();
|
||||||
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
|
state.message = '';
|
||||||
if (props.sendWithSignature) {
|
|
||||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
|
||||||
? props.messageSignature
|
|
||||||
: extractTextFromMarkdown(props.messageSignature);
|
|
||||||
state.message = removeSignature(state.message, signatureToRemove);
|
|
||||||
}
|
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
|
removeSignatureFromMessage();
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
|
state.message = '';
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,11 +238,19 @@ const onClickInsertEmoji = emoji => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSignature = signature => {
|
const handleAddSignature = signature => {
|
||||||
state.message = appendSignature(state.message, signature);
|
state.message = appendSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSignature = signature => {
|
const handleRemoveSignature = signature => {
|
||||||
state.message = removeSignature(state.message, signature);
|
state.message = removeSignature(
|
||||||
|
state.message,
|
||||||
|
signature,
|
||||||
|
effectiveChannelType.value
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachFile = files => {
|
const handleAttachFile = files => {
|
||||||
@@ -351,9 +374,10 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
v-model="state.message"
|
v-model="state.message"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
|
:channel-type="inboxChannelType"
|
||||||
|
:medium="targetInbox?.medium || ''"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
+25
-98
@@ -1,122 +1,49 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed, nextTick } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
|
||||||
appendSignature,
|
|
||||||
extractTextFromMarkdown,
|
|
||||||
removeSignature,
|
|
||||||
} from 'dashboard/helper/editorHelper';
|
|
||||||
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|
||||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
|
channelType: { type: String, default: '' },
|
||||||
|
medium: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const modelValue = defineModel({
|
const modelValue = defineModel({
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = ref({
|
|
||||||
hasSlashCommand: false,
|
|
||||||
showMentions: false,
|
|
||||||
mentionSearchKey: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const plainTextSignature = computed(() =>
|
|
||||||
extractTextFromMarkdown(props.messageSignature)
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
modelValue,
|
|
||||||
newValue => {
|
|
||||||
if (props.isEmailOrWebWidgetInbox) return;
|
|
||||||
|
|
||||||
const bodyWithoutSignature = newValue
|
|
||||||
? removeSignature(newValue, plainTextSignature.value)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Check if message starts with slash
|
|
||||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
|
||||||
|
|
||||||
// Update slash command and mentions state
|
|
||||||
state.value = {
|
|
||||||
...state.value,
|
|
||||||
hasSlashCommand: startsWithSlash,
|
|
||||||
showMentions: startsWithSlash,
|
|
||||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideMention = () => {
|
|
||||||
state.value.showMentions = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceText = async message => {
|
|
||||||
// Only append signature on replace if sendWithSignature is true
|
|
||||||
const finalMessage = props.sendWithSignature
|
|
||||||
? appendSignature(message, plainTextSignature.value)
|
|
||||||
: message;
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
modelValue.value = finalMessage;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||||
<template v-if="isEmailOrWebWidgetInbox">
|
<Editor
|
||||||
<Editor
|
:key="editorKey"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||||
:class="
|
:class="
|
||||||
hasErrors
|
hasErrors
|
||||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
enable-variables
|
enable-variables
|
||||||
:show-character-count="false"
|
:show-character-count="false"
|
||||||
/>
|
:signature="messageSignature"
|
||||||
</template>
|
allow-signature
|
||||||
<template v-else>
|
:send-with-signature="sendWithSignature"
|
||||||
<TextArea
|
:channel-type="channelType"
|
||||||
v-model="modelValue"
|
:medium="medium"
|
||||||
:placeholder="
|
/>
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
|
||||||
:custom-text-area-class="
|
|
||||||
hasErrors
|
|
||||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
auto-height
|
|
||||||
allow-signature
|
|
||||||
:signature="messageSignature"
|
|
||||||
:send-with-signature="sendWithSignature"
|
|
||||||
>
|
|
||||||
<CannedResponse
|
|
||||||
v-if="state.showMentions && state.hasSlashCommand"
|
|
||||||
v-on-clickaway="hideMention"
|
|
||||||
class="normal-editor__canned-box"
|
|
||||||
:search-key="state.mentionSearchKey"
|
|
||||||
@replace="replaceText"
|
|
||||||
/>
|
|
||||||
</TextArea>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
|||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
trailingIcon: { type: Boolean, default: false },
|
trailingIcon: { type: Boolean, default: false },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
|
noAnimation: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
|
|||||||
md: 'text-sm font-medium',
|
md: 'text-sm font-medium',
|
||||||
lg: 'text-base',
|
lg: 'text-base',
|
||||||
},
|
},
|
||||||
|
clickAnimation: {
|
||||||
|
xs: 'active:enabled:scale-[0.97]',
|
||||||
|
sm: 'active:enabled:scale-[0.97]',
|
||||||
|
md: 'active:enabled:scale-[0.98]',
|
||||||
|
lg: 'active:enabled:scale-[0.98]',
|
||||||
|
},
|
||||||
justify: {
|
justify: {
|
||||||
start: 'justify-start',
|
start: 'justify-start',
|
||||||
center: 'justify-center',
|
center: 'justify-center',
|
||||||
end: 'justify-end',
|
end: 'justify-end',
|
||||||
},
|
},
|
||||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = computed(() => {
|
const variantClasses = computed(() => {
|
||||||
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
|
|||||||
|
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const animationClasses = computed(() => {
|
||||||
|
return props.noAnimation
|
||||||
|
? ''
|
||||||
|
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
|
|||||||
[STYLE_CONFIG.base]: true,
|
[STYLE_CONFIG.base]: true,
|
||||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||||
|
[animationClasses]: true,
|
||||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
noAnimation: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
noAnimation
|
||||||
|
? ''
|
||||||
|
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
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 BackButton from 'dashboard/components/widgets/BackButton.vue';
|
import BackButton from 'dashboard/components/widgets/BackButton.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 Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
|
||||||
|
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentPage: {
|
currentPage: {
|
||||||
@@ -56,11 +62,36 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
showAssistantSwitcher: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const { shouldShowPaywall } = usePolicy();
|
const { shouldShowPaywall } = usePolicy();
|
||||||
|
|
||||||
|
const showAssistantSwitcherDropdown = ref(false);
|
||||||
|
const createAssistantDialogRef = ref(null);
|
||||||
|
|
||||||
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||||
|
|
||||||
|
const currentAssistantId = computed(() => route.params.assistantId);
|
||||||
|
const isFetchingAssistants = computed(() => uiFlags.value?.fetchingList);
|
||||||
|
|
||||||
|
const activeAssistantName = computed(() => {
|
||||||
|
return (
|
||||||
|
assistants.value?.find(
|
||||||
|
assistant => assistant.id === Number(currentAssistantId.value)
|
||||||
|
)?.name || t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const showPaywall = computed(() => {
|
const showPaywall = computed(() => {
|
||||||
return shouldShowPaywall(props.featureFlag);
|
return shouldShowPaywall(props.featureFlag);
|
||||||
});
|
});
|
||||||
@@ -72,6 +103,15 @@ const handleButtonClick = () => {
|
|||||||
const handlePageChange = event => {
|
const handlePageChange = event => {
|
||||||
emit('update:currentPage', event);
|
emit('update:currentPage', event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAssistantSwitcher = () => {
|
||||||
|
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAssistant = () => {
|
||||||
|
showAssistantSwitcherDropdown.value = false;
|
||||||
|
createAssistantDialogRef.value.dialogRef.open();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,37 +121,83 @@ const handlePageChange = event => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-3 items-center">
|
||||||
<BackButton v-if="backUrl" :to="backUrl" />
|
<BackButton v-if="backUrl" :back-url="backUrl" />
|
||||||
<slot name="headerTitle">
|
|
||||||
<span class="text-xl font-medium text-n-slate-12">
|
|
||||||
{{ headerTitle }}
|
|
||||||
</span>
|
|
||||||
</slot>
|
|
||||||
<div
|
<div
|
||||||
v-if="!isEmpty && showKnowMore"
|
v-if="showAssistantSwitcher && !showPaywall"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
<div class="flex items-center gap-2">
|
||||||
<slot name="knowMore" />
|
<span
|
||||||
|
v-if="!isFetchingAssistants"
|
||||||
|
class="text-xl font-medium truncate text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ activeAssistantName }}
|
||||||
|
</span>
|
||||||
|
<div class="relative group">
|
||||||
|
<OnClickOutside
|
||||||
|
@trigger="showAssistantSwitcherDropdown = false"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
:disabled="isFetchingAssistants"
|
||||||
|
:is-loading="isFetchingAssistants"
|
||||||
|
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||||
|
@click="toggleAssistantSwitcher"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AssistantSwitcher
|
||||||
|
v-if="showAssistantSwitcherDropdown"
|
||||||
|
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||||
|
@close="showAssistantSwitcherDropdown = false"
|
||||||
|
@create-assistant="handleCreateAssistant"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
v-if="showAssistantSwitcher && !showPaywall && headerTitle"
|
||||||
|
class="w-0.5 h-4 rounded-2xl bg-n-weak"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="headerTitle"
|
||||||
|
class="text-xl font-medium text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ headerTitle }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="!isEmpty && showKnowMore"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||||
|
<slot name="knowMore" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="flex gap-2">
|
||||||
v-if="!showPaywall && buttonLabel"
|
<slot name="search" />
|
||||||
v-on-clickaway="() => emit('close')"
|
<div
|
||||||
class="relative group/campaign-button"
|
v-if="!showPaywall && buttonLabel"
|
||||||
>
|
v-on-clickaway="() => emit('close')"
|
||||||
<Policy :permissions="buttonPolicy">
|
class="relative group/captain-button"
|
||||||
<Button
|
>
|
||||||
:label="buttonLabel"
|
<Policy :permissions="buttonPolicy">
|
||||||
icon="i-lucide-plus"
|
<Button
|
||||||
size="sm"
|
:label="buttonLabel"
|
||||||
class="group-hover/campaign-button:brightness-110"
|
icon="i-lucide-plus"
|
||||||
@click="handleButtonClick"
|
size="sm"
|
||||||
/>
|
class="group-hover/captain-button:brightness-110"
|
||||||
</Policy>
|
@click="handleButtonClick"
|
||||||
<slot name="action" />
|
/>
|
||||||
|
</Policy>
|
||||||
|
<slot name="action" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="subHeader" />
|
<slot name="subHeader" />
|
||||||
@@ -144,5 +230,6 @@ const handlePageChange = event => {
|
|||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
isFetching: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isEmpty: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
currentPage: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
totalCount: {
|
|
||||||
type: Number,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
itemsPerPage: {
|
|
||||||
type: Number,
|
|
||||||
default: 25,
|
|
||||||
},
|
|
||||||
showPaginationFooter: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
breadcrumbItems: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:currentPage']);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handlePageChange = event => {
|
|
||||||
emit('update:currentPage', event);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBreadcrumbClick = item => {
|
|
||||||
router.push({
|
|
||||||
name: item.routeName,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
|
||||||
>
|
|
||||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
|
||||||
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
|
||||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
|
||||||
</header>
|
|
||||||
<main class="flex gap-16 w-full flex-1 pb-16">
|
|
||||||
<section
|
|
||||||
v-if="$slots.body || $slots.emptyState || isFetching"
|
|
||||||
class="flex flex-col w-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isFetching"
|
|
||||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="isEmpty">
|
|
||||||
<slot name="emptyState" />
|
|
||||||
</div>
|
|
||||||
<slot v-else name="body" />
|
|
||||||
</section>
|
|
||||||
<section v-if="$slots.controls" class="flex w-full">
|
|
||||||
<slot name="controls" />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
|
|
||||||
<PaginationFooter
|
|
||||||
:current-page="currentPage"
|
|
||||||
:total-items="totalCount"
|
|
||||||
:items-per-page="itemsPerPage"
|
|
||||||
@update:current-page="handlePageChange"
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -76,12 +76,11 @@ const handleAction = ({ action, value }) => {
|
|||||||
<template>
|
<template>
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<router-link
|
<h6
|
||||||
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
|
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||||
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</router-link>
|
</h6>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import MessageList from './MessageList.vue';
|
import MessageList from './MessageList.vue';
|
||||||
@@ -29,6 +29,16 @@ const resetConversation = () => {
|
|||||||
newMessage.value = '';
|
newMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Watch for assistant ID changes and reset conversation
|
||||||
|
watch(
|
||||||
|
() => assistantId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (oldId && newId !== oldId) {
|
||||||
|
resetConversation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!newMessage.value.trim() || isLoading.value) return;
|
if (!newMessage.value.trim() || isLoading.value) return;
|
||||||
|
|
||||||
@@ -65,16 +75,17 @@ const sendMessage = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
|
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-8 px-6">
|
||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
<h3 class="text-lg font-medium">
|
<h3 class="text-lg font-medium">
|
||||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||||
</h3>
|
</h3>
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
sm
|
||||||
|
slate
|
||||||
icon="i-lucide-rotate-ccw"
|
icon="i-lucide-rotate-ccw"
|
||||||
@click="resetConversation"
|
@click="resetConversation"
|
||||||
/>
|
/>
|
||||||
@@ -87,17 +98,17 @@ const sendMessage = async () => {
|
|||||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
|
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
|
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<NextButton
|
<NextButton
|
||||||
ghost
|
ghost
|
||||||
size="small"
|
sm
|
||||||
:disabled="!newMessage.trim()"
|
:disabled="!newMessage.trim()"
|
||||||
icon="i-lucide-send"
|
icon="i-lucide-send"
|
||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
|
|||||||
|
|
||||||
const getMessageStyle = sender =>
|
const getMessageStyle = sender =>
|
||||||
isUserMessage(sender)
|
isUserMessage(sender)
|
||||||
? 'bg-n-strong text-n-white'
|
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
|
||||||
: 'bg-n-solid-iris text-n-slate-12';
|
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
|
<div
|
||||||
|
ref="messageContainer"
|
||||||
|
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
|
|||||||
:class="getMessageAlignment(message.sender)"
|
:class="getMessageAlignment(message.sender)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-1.5"
|
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
|
||||||
:class="getMessageDirection(message.sender)"
|
:class="getMessageDirection(message.sender)"
|
||||||
>
|
>
|
||||||
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
<Avatar
|
||||||
|
:name="getAvatarName(message.sender)"
|
||||||
|
rounded-full
|
||||||
|
:size="24"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="max-w-[80%] rounded-lg p-3 text-sm"
|
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
|
||||||
:class="getMessageStyle(message.sender)"
|
:class="getMessageStyle(message.sender)"
|
||||||
>
|
>
|
||||||
<div class="break-words" v-html="formatMessage(message.content)" />
|
<div v-html="formatMessage(message.content)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
assistantId: {
|
|
||||||
type: [String, Number],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update']);
|
|
||||||
const { t } = useI18n();
|
|
||||||
const isFilterOpen = ref(false);
|
|
||||||
|
|
||||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
|
||||||
const assistantOptions = computed(() => [
|
|
||||||
{
|
|
||||||
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
|
|
||||||
value: 'all',
|
|
||||||
action: 'filter',
|
|
||||||
},
|
|
||||||
...assistants.value.map(assistant => ({
|
|
||||||
value: assistant.id,
|
|
||||||
label: assistant.name,
|
|
||||||
action: 'filter',
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const selectedAssistantLabel = computed(() => {
|
|
||||||
const assistant = assistantOptions.value.find(
|
|
||||||
option => option.value === props.assistantId
|
|
||||||
);
|
|
||||||
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
|
|
||||||
selected: assistant ? assistant.label : '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAssistantFilterChange = ({ value }) => {
|
|
||||||
isFilterOpen.value = false;
|
|
||||||
emit('update', value);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<OnClickOutside @trigger="isFilterOpen = false">
|
|
||||||
<Button
|
|
||||||
:label="selectedAssistantLabel"
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
size="sm"
|
|
||||||
color="slate"
|
|
||||||
trailing-icon
|
|
||||||
class="max-w-48"
|
|
||||||
@click="isFilterOpen = !isFilterOpen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DropdownMenu
|
|
||||||
v-if="isFilterOpen"
|
|
||||||
:menu-items="assistantOptions"
|
|
||||||
class="mt-2"
|
|
||||||
@action="handleAssistantFilterChange"
|
|
||||||
/>
|
|
||||||
</OnClickOutside>
|
|
||||||
</template>
|
|
||||||
+13
-3
@@ -18,7 +18,7 @@ const props = defineProps({
|
|||||||
validator: value => ['create', 'edit'].includes(value),
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
@@ -35,8 +35,18 @@ const i18nKey = computed(
|
|||||||
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAssistant = assistantDetails =>
|
const createAssistant = async assistantDetails => {
|
||||||
store.dispatch('captainAssistants/create', assistantDetails);
|
try {
|
||||||
|
const newAssistant = await store.dispatch(
|
||||||
|
'captainAssistants/create',
|
||||||
|
assistantDetails
|
||||||
|
);
|
||||||
|
emit('created', newAssistant);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async updatedAssistant => {
|
const handleSubmit = async updatedAssistant => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
-333
@@ -1,333 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { reactive, computed, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|
||||||
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
mode: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
validator: value => ['edit', 'create'].includes(value),
|
|
||||||
},
|
|
||||||
assistant: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['submit']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const formState = {
|
|
||||||
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
productName: '',
|
|
||||||
welcomeMessage: '',
|
|
||||||
handoffMessage: '',
|
|
||||||
resolutionMessage: '',
|
|
||||||
instructions: '',
|
|
||||||
features: {
|
|
||||||
conversationFaqs: false,
|
|
||||||
memories: false,
|
|
||||||
citations: false,
|
|
||||||
},
|
|
||||||
temperature: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
|
||||||
|
|
||||||
const validationRules = {
|
|
||||||
name: { required, minLength: minLength(1) },
|
|
||||||
description: { required, minLength: minLength(1) },
|
|
||||||
productName: { required, minLength: minLength(1) },
|
|
||||||
welcomeMessage: { minLength: minLength(1) },
|
|
||||||
handoffMessage: { minLength: minLength(1) },
|
|
||||||
resolutionMessage: { minLength: minLength(1) },
|
|
||||||
instructions: { minLength: minLength(1) },
|
|
||||||
};
|
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
|
||||||
|
|
||||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
|
||||||
|
|
||||||
const getErrorMessage = field => {
|
|
||||||
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formErrors = computed(() => ({
|
|
||||||
name: getErrorMessage('name'),
|
|
||||||
description: getErrorMessage('description'),
|
|
||||||
productName: getErrorMessage('productName'),
|
|
||||||
welcomeMessage: getErrorMessage('welcomeMessage'),
|
|
||||||
handoffMessage: getErrorMessage('handoffMessage'),
|
|
||||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
|
||||||
instructions: getErrorMessage('instructions'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updateStateFromAssistant = assistant => {
|
|
||||||
const { config = {} } = assistant;
|
|
||||||
state.name = assistant.name;
|
|
||||||
state.description = assistant.description;
|
|
||||||
state.productName = config.product_name;
|
|
||||||
state.welcomeMessage = config.welcome_message;
|
|
||||||
state.handoffMessage = config.handoff_message;
|
|
||||||
state.resolutionMessage = config.resolution_message;
|
|
||||||
state.instructions = config.instructions;
|
|
||||||
state.features = {
|
|
||||||
conversationFaqs: config.feature_faq || false,
|
|
||||||
memories: config.feature_memory || false,
|
|
||||||
citations: config.feature_citation || false,
|
|
||||||
};
|
|
||||||
state.temperature = config.temperature || 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBasicInfoUpdate = async () => {
|
|
||||||
const result = await Promise.all([
|
|
||||||
v$.value.name.$validate(),
|
|
||||||
v$.value.description.$validate(),
|
|
||||||
v$.value.productName.$validate(),
|
|
||||||
]).then(results => results.every(Boolean));
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: state.name,
|
|
||||||
description: state.description,
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
product_name: state.productName,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSystemMessagesUpdate = async () => {
|
|
||||||
const result = await Promise.all([
|
|
||||||
v$.value.welcomeMessage.$validate(),
|
|
||||||
v$.value.handoffMessage.$validate(),
|
|
||||||
v$.value.resolutionMessage.$validate(),
|
|
||||||
]).then(results => results.every(Boolean));
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
welcome_message: state.welcomeMessage,
|
|
||||||
handoff_message: state.handoffMessage,
|
|
||||||
resolution_message: state.resolutionMessage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstructionsUpdate = async () => {
|
|
||||||
const result = await v$.value.instructions.$validate();
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
temperature: state.temperature || 1,
|
|
||||||
instructions: state.instructions,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeaturesUpdate = () => {
|
|
||||||
const payload = {
|
|
||||||
config: {
|
|
||||||
...props.assistant.config,
|
|
||||||
feature_faq: state.features.conversationFaqs,
|
|
||||||
feature_memory: state.features.memories,
|
|
||||||
feature_citation: state.features.citations,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('submit', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.assistant,
|
|
||||||
newAssistant => {
|
|
||||||
if (props.mode === 'edit' && newAssistant) {
|
|
||||||
updateStateFromAssistant(newAssistant);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
|
||||||
<!-- Basic Information Section -->
|
|
||||||
<Accordion
|
|
||||||
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
|
|
||||||
is-open
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
|
||||||
<Input
|
|
||||||
v-model="state.name"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
|
|
||||||
:message="formErrors.name"
|
|
||||||
:message-type="formErrors.name ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Editor
|
|
||||||
v-model="state.description"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
|
||||||
:message="formErrors.description"
|
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="state.productName"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
|
|
||||||
:message="formErrors.productName"
|
|
||||||
:message-type="formErrors.productName ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
@click="handleBasicInfoUpdate"
|
|
||||||
>
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- Instructions Section -->
|
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<Editor
|
|
||||||
v-model="state.instructions"
|
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
|
||||||
:message="formErrors.instructions"
|
|
||||||
:max-length="20000"
|
|
||||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 mt-4">
|
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
v-model="state.temperature"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-n-slate-11 italic">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
|
||||||
@click="handleInstructionsUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- Greeting Messages Section -->
|
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
|
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
|
||||||
<Editor
|
|
||||||
v-model="state.handoffMessage"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="formErrors.handoffMessage"
|
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Editor
|
|
||||||
v-model="state.resolutionMessage"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="formErrors.resolutionMessage"
|
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
|
||||||
@click="handleSystemMessagesUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
|
|
||||||
<div class="flex flex-col gap-4 pt-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="state.features.conversationFaqs"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
|
||||||
}}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.features.memories" type="checkbox" />
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input v-model="state.features.citations" type="checkbox" />
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
:loading="isLoading"
|
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
|
||||||
@click="handleFeaturesUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
+1
@@ -118,6 +118,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
:message-type="formErrors.description ? 'error' : 'info'"
|
:message-type="formErrors.description ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+38
-2
@@ -3,6 +3,8 @@ import { reactive, computed, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { minLength } from '@vuelidate/validators';
|
import { minLength } from '@vuelidate/validators';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
@@ -17,10 +19,16 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { isCloudFeatureEnabled } = useAccount();
|
||||||
|
|
||||||
|
const isCaptainV2Enabled = computed(() =>
|
||||||
|
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||||
|
);
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
handoffMessage: '',
|
handoffMessage: '',
|
||||||
resolutionMessage: '',
|
resolutionMessage: '',
|
||||||
|
instructions: '',
|
||||||
temperature: 1,
|
temperature: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +37,7 @@ const state = reactive({ ...initialState });
|
|||||||
const validationRules = {
|
const validationRules = {
|
||||||
handoffMessage: { minLength: minLength(1) },
|
handoffMessage: { minLength: minLength(1) },
|
||||||
resolutionMessage: { minLength: minLength(1) },
|
resolutionMessage: { minLength: minLength(1) },
|
||||||
|
instructions: { minLength: minLength(1) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
@@ -40,20 +49,30 @@ const getErrorMessage = field => {
|
|||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
handoffMessage: getErrorMessage('handoffMessage'),
|
handoffMessage: getErrorMessage('handoffMessage'),
|
||||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||||
|
instructions: getErrorMessage('instructions'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updateStateFromAssistant = assistant => {
|
const updateStateFromAssistant = assistant => {
|
||||||
const { config = {} } = assistant;
|
const { config = {} } = assistant;
|
||||||
state.handoffMessage = config.handoff_message;
|
state.handoffMessage = config.handoff_message;
|
||||||
state.resolutionMessage = config.resolution_message;
|
state.resolutionMessage = config.resolution_message;
|
||||||
|
state.instructions = config.instructions;
|
||||||
state.temperature = config.temperature || 1;
|
state.temperature = config.temperature || 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSystemMessagesUpdate = async () => {
|
const handleSystemMessagesUpdate = async () => {
|
||||||
const result = await Promise.all([
|
const validations = [
|
||||||
v$.value.handoffMessage.$validate(),
|
v$.value.handoffMessage.$validate(),
|
||||||
v$.value.resolutionMessage.$validate(),
|
v$.value.resolutionMessage.$validate(),
|
||||||
]).then(results => results.every(Boolean));
|
];
|
||||||
|
|
||||||
|
if (!isCaptainV2Enabled.value) {
|
||||||
|
validations.push(v$.value.instructions.$validate());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Promise.all(validations).then(results =>
|
||||||
|
results.every(Boolean)
|
||||||
|
);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -65,6 +84,10 @@ const handleSystemMessagesUpdate = async () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isCaptainV2Enabled.value) {
|
||||||
|
payload.config.instructions = state.instructions;
|
||||||
|
}
|
||||||
|
|
||||||
emit('submit', payload);
|
emit('submit', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +108,7 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.handoffMessage"
|
:message="formErrors.handoffMessage"
|
||||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
@@ -93,6 +117,18 @@ watch(
|
|||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||||
:message="formErrors.resolutionMessage"
|
:message="formErrors.resolutionMessage"
|
||||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
v-if="!isCaptainV2Enabled"
|
||||||
|
v-model="state.instructions"
|
||||||
|
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||||
|
:message="formErrors.instructions"
|
||||||
|
:max-length="20000"
|
||||||
|
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||||
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
+12
-1
@@ -8,6 +8,13 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
|||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import DocumentForm from './DocumentForm.vue';
|
import DocumentForm from './DocumentForm.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
assistantId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -48,7 +55,11 @@ defineExpose({ dialogRef });
|
|||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
|
<DocumentForm
|
||||||
|
:assistant-id="assistantId"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
<template #footer />
|
<template #footer />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+9
-28
@@ -2,7 +2,7 @@
|
|||||||
import { reactive, computed, ref, nextTick } from 'vue';
|
import { reactive, computed, ref, nextTick } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
|
import { minLength, requiredIf, url } from '@vuelidate/validators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
@@ -10,6 +10,13 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assistantId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
@@ -18,13 +25,11 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const formState = {
|
const formState = {
|
||||||
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
||||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
assistantId: null,
|
|
||||||
documentType: 'url',
|
documentType: 'url',
|
||||||
pdfFile: null,
|
pdfFile: null,
|
||||||
};
|
};
|
||||||
@@ -38,19 +43,11 @@ const validationRules = {
|
|||||||
url: requiredIf(() => state.documentType === 'url' && url),
|
url: requiredIf(() => state.documentType === 'url' && url),
|
||||||
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
||||||
},
|
},
|
||||||
assistantId: { required },
|
|
||||||
pdfFile: {
|
pdfFile: {
|
||||||
required: requiredIf(() => state.documentType === 'pdf'),
|
required: requiredIf(() => state.documentType === 'pdf'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const assistantList = computed(() =>
|
|
||||||
formState.assistants.value.map(assistant => ({
|
|
||||||
value: assistant.id,
|
|
||||||
label: assistant.name,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentTypeOptions = [
|
const documentTypeOptions = [
|
||||||
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
||||||
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
||||||
@@ -70,7 +67,6 @@ const getErrorMessage = (field, errorKey) => {
|
|||||||
|
|
||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
url: getErrorMessage('url', 'URL'),
|
url: getErrorMessage('url', 'URL'),
|
||||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
|
||||||
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -106,7 +102,7 @@ const openFileDialog = () => {
|
|||||||
|
|
||||||
const prepareDocumentDetails = () => {
|
const prepareDocumentDetails = () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('document[assistant_id]', state.assistantId);
|
formData.append('document[assistant_id]', props.assistantId);
|
||||||
|
|
||||||
if (state.documentType === 'url') {
|
if (state.documentType === 'url') {
|
||||||
formData.append('document[external_link]', state.url);
|
formData.append('document[external_link]', state.url);
|
||||||
@@ -218,21 +214,6 @@ const handleSubmit = async () => {
|
|||||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<ComboBox
|
|
||||||
id="assistant"
|
|
||||||
v-model="state.assistantId"
|
|
||||||
:options="assistantList"
|
|
||||||
:has-error="!!formErrors.assistantId"
|
|
||||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
|
||||||
class="[&>div>button]:bg-n-alpha-black2"
|
|
||||||
:message="formErrors.assistantId"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-between items-center w-full">
|
<div class="flex gap-3 justify-between items-center w-full">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||||
@@ -9,6 +10,8 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
|
|||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
};
|
};
|
||||||
@@ -35,7 +38,7 @@ const onClick = () => {
|
|||||||
v-for="(document, index) in documentsList.slice(0, 5)"
|
v-for="(document, index) in documentsList.slice(0, 5)"
|
||||||
:id="document.id"
|
:id="document.id"
|
||||||
:key="`document-${index}`"
|
:key="`document-${index}`"
|
||||||
:name="document.name"
|
:name="replaceInstallationName(document.name)"
|
||||||
:assistant="document.assistant"
|
:assistant="document.assistant"
|
||||||
:external-link="document.external_link"
|
:external-link="document.external_link"
|
||||||
:created-at="document.created_at"
|
:created-at="document.created_at"
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||||
@@ -26,6 +27,7 @@ const isApproved = computed(() => props.variant === 'approved');
|
|||||||
const isPending = computed(() => props.variant === 'pending');
|
const isPending = computed(() => props.variant === 'pending');
|
||||||
|
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
const { replaceInstallationName } = useBranding();
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
@@ -63,8 +65,8 @@ const onClearFilters = () => {
|
|||||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||||
:id="response.id"
|
:id="response.id"
|
||||||
:key="`response-${index}`"
|
:key="`response-${index}`"
|
||||||
:question="response.question"
|
:question="replaceInstallationName(response.question)"
|
||||||
:answer="response.answer"
|
:answer="replaceInstallationName(response.answer)"
|
||||||
:status="response.status"
|
:status="response.status"
|
||||||
:assistant="response.assistant"
|
:assistant="response.assistant"
|
||||||
:created-at="response.created_at"
|
:created-at="response.created_at"
|
||||||
|
|||||||
+10
-2
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
|||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import ResponseForm from './ResponseForm.vue';
|
import ResponseForm from './ResponseForm.vue';
|
||||||
@@ -21,6 +22,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
const responseForm = ref(null);
|
const responseForm = ref(null);
|
||||||
@@ -39,9 +41,15 @@ const createResponse = responseDetails =>
|
|||||||
const handleSubmit = async updatedResponse => {
|
const handleSubmit = async updatedResponse => {
|
||||||
try {
|
try {
|
||||||
if (props.type === 'edit') {
|
if (props.type === 'edit') {
|
||||||
await updateResponse(updatedResponse);
|
await updateResponse({
|
||||||
|
...updatedResponse,
|
||||||
|
assistant_id: route.params.assistantId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await createResponse(updatedResponse);
|
await createResponse({
|
||||||
|
...updatedResponse,
|
||||||
|
assistant_id: route.params.assistantId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||||
dialogRef.value.close();
|
dialogRef.value.close();
|
||||||
|
|||||||
+2
-32
@@ -8,7 +8,6 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
mode: {
|
mode: {
|
||||||
@@ -21,18 +20,17 @@ const props = defineProps({
|
|||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const formState = {
|
const formState = {
|
||||||
uiFlags: useMapGetter('captainResponses/getUIFlags'),
|
uiFlags: useMapGetter('captainResponses/getUIFlags'),
|
||||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
question: '',
|
question: '',
|
||||||
answer: '',
|
answer: '',
|
||||||
assistantId: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@@ -40,16 +38,8 @@ const state = reactive({ ...initialState });
|
|||||||
const validationRules = {
|
const validationRules = {
|
||||||
question: { required, minLength: minLength(1) },
|
question: { required, minLength: minLength(1) },
|
||||||
answer: { required, minLength: minLength(1) },
|
answer: { required, minLength: minLength(1) },
|
||||||
assistantId: { required },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const assistantList = computed(() =>
|
|
||||||
formState.assistants.value.map(assistant => ({
|
|
||||||
value: assistant.id,
|
|
||||||
label: assistant.name,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||||
@@ -63,7 +53,6 @@ const getErrorMessage = (field, errorKey) => {
|
|||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
question: getErrorMessage('question', 'QUESTION'),
|
question: getErrorMessage('question', 'QUESTION'),
|
||||||
answer: getErrorMessage('answer', 'ANSWER'),
|
answer: getErrorMessage('answer', 'ANSWER'),
|
||||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
const handleCancel = () => emit('cancel');
|
||||||
@@ -71,7 +60,6 @@ const handleCancel = () => emit('cancel');
|
|||||||
const prepareDocumentDetails = () => ({
|
const prepareDocumentDetails = () => ({
|
||||||
question: state.question,
|
question: state.question,
|
||||||
answer: state.answer,
|
answer: state.answer,
|
||||||
assistant_id: state.assistantId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -86,12 +74,11 @@ const handleSubmit = async () => {
|
|||||||
const updateStateFromResponse = response => {
|
const updateStateFromResponse = response => {
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
|
|
||||||
const { question, answer, assistant } = response;
|
const { question, answer } = response;
|
||||||
|
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
question,
|
question,
|
||||||
answer,
|
answer,
|
||||||
assistantId: assistant.id,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +102,6 @@ watch(
|
|||||||
:message="formErrors.question"
|
:message="formErrors.question"
|
||||||
:message-type="formErrors.question ? 'error' : 'info'"
|
:message-type="formErrors.question ? 'error' : 'info'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.answer"
|
v-model="state.answer"
|
||||||
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||||
@@ -124,22 +110,6 @@ watch(
|
|||||||
:max-length="10000"
|
:max-length="10000"
|
||||||
:message-type="formErrors.answer ? 'error' : 'info'"
|
:message-type="formErrors.answer ? 'error' : 'info'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<ComboBox
|
|
||||||
id="assistant"
|
|
||||||
v-model="state.assistantId"
|
|
||||||
:options="assistantList"
|
|
||||||
:has-error="!!formErrors.assistantId"
|
|
||||||
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
|
|
||||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
|
||||||
:message="formErrors.assistantId"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'createAssistant']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
|
||||||
|
const currentAssistantId = computed(() => route.params.assistantId);
|
||||||
|
|
||||||
|
const isAssistantActive = assistant => {
|
||||||
|
return assistant.id === Number(currentAssistantId.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDataForRoute = async (routeName, assistantId) => {
|
||||||
|
const dataFetchMap = {
|
||||||
|
captain_assistants_responses_index: async () => {
|
||||||
|
await store.dispatch('captainResponses/get', { assistantId });
|
||||||
|
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
|
||||||
|
},
|
||||||
|
captain_assistants_responses_pending: async () => {
|
||||||
|
await store.dispatch('captainResponses/get', {
|
||||||
|
assistantId,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
captain_assistants_documents_index: async () => {
|
||||||
|
await store.dispatch('captainDocuments/get', { assistantId });
|
||||||
|
},
|
||||||
|
captain_assistants_scenarios_index: async () => {
|
||||||
|
await store.dispatch('captainScenarios/get', { assistantId });
|
||||||
|
},
|
||||||
|
captain_assistants_playground_index: () => {
|
||||||
|
// Playground doesn't need pre-fetching, it loads on interaction
|
||||||
|
},
|
||||||
|
captain_assistants_inboxes_index: async () => {
|
||||||
|
await store.dispatch('captainInboxes/get', { assistantId });
|
||||||
|
},
|
||||||
|
captain_tools_index: async () => {
|
||||||
|
await store.dispatch('captainCustomTools/get', { page: 1 });
|
||||||
|
},
|
||||||
|
captain_assistants_settings_index: async () => {
|
||||||
|
await store.dispatch('captainAssistants/show', assistantId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFn = dataFetchMap[routeName];
|
||||||
|
if (fetchFn) {
|
||||||
|
await fetchFn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssistantChange = async assistant => {
|
||||||
|
if (isAssistantActive(assistant)) return;
|
||||||
|
|
||||||
|
const currentRouteName = route.name;
|
||||||
|
const targetRouteName =
|
||||||
|
currentRouteName || 'captain_assistants_responses_index';
|
||||||
|
|
||||||
|
await fetchDataForRoute(targetRouteName, assistant.id);
|
||||||
|
|
||||||
|
await router.push({
|
||||||
|
name: targetRouteName,
|
||||||
|
params: {
|
||||||
|
accountId: route.params.accountId,
|
||||||
|
assistantId: assistant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateAssistantDialog = () => {
|
||||||
|
emit('createAssistant');
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2
|
||||||
|
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||||
|
color="slate"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
size="sm"
|
||||||
|
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||||
|
@click="openCreateAssistantDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||||
|
<Button
|
||||||
|
v-for="assistant in assistants"
|
||||||
|
:key="assistant.id"
|
||||||
|
:label="assistant.name"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
trailing-icon
|
||||||
|
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
|
||||||
|
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
|
||||||
|
size="sm"
|
||||||
|
@click="handleAssistantChange(assistant)"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||||
|
{{ assistant.name || '' }}
|
||||||
|
</span>
|
||||||
|
<Avatar
|
||||||
|
v-if="assistant"
|
||||||
|
:name="assistant.name"
|
||||||
|
:size="20"
|
||||||
|
icon-name="i-lucide-bot"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col items-center gap-2 px-4 py-3">
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.EMPTY_LIST') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -96,6 +96,7 @@ watch(
|
|||||||
:label="selectedLabel"
|
:label="selectedLabel"
|
||||||
trailing-icon
|
trailing-icon
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
no-animation
|
||||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
||||||
:class="{
|
:class="{
|
||||||
focused: open,
|
focused: open,
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ const handleSuggestion = opt => {
|
|||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'captain_assistants_index',
|
name: 'captain_assistants_create_index',
|
||||||
params: { accountId: route.params.accountId },
|
params: {
|
||||||
|
accountId: route.params.accountId,
|
||||||
|
},
|
||||||
}"
|
}"
|
||||||
class="text-n-slate-11 underline hover:text-n-slate-12"
|
class="text-n-slate-11 underline hover:text-n-slate-12"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -53,14 +54,17 @@ const toggleSidebar = () => {
|
|||||||
v-if="showCopilotLauncher"
|
v-if="showCopilotLauncher"
|
||||||
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
||||||
>
|
>
|
||||||
<div class="rounded-full bg-n-alpha-2 p-1">
|
<ButtonGroup
|
||||||
|
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
no-animation
|
||||||
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||||
lg
|
lg
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
<template v-else />
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
menuItems: {
|
menuItems: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
default: () => [],
|
||||||
validator: value => {
|
validator: value => {
|
||||||
return value.every(item => item.action && item.value && item.label);
|
return value.every(item => item.action && item.value && item.label);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
menuSections: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
thumbnailSize: {
|
thumbnailSize: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 20,
|
default: 20,
|
||||||
@@ -42,19 +46,62 @@ const { t } = useI18n();
|
|||||||
const searchInput = ref(null);
|
const searchInput = ref(null);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const filteredMenuItems = computed(() => {
|
const hasSections = computed(() => props.menuSections.length > 0);
|
||||||
if (!searchQuery.value) return props.menuItems;
|
|
||||||
|
|
||||||
return props.menuItems.filter(item =>
|
const flattenedMenuItems = computed(() => {
|
||||||
|
if (!hasSections.value) {
|
||||||
|
return props.menuItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.menuSections.flatMap(section => section.items || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredMenuItems = computed(() => {
|
||||||
|
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||||
|
|
||||||
|
return flattenedMenuItems.value.filter(item =>
|
||||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredMenuSections = computed(() => {
|
||||||
|
if (!hasSections.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
return props.menuSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
|
||||||
|
return props.menuSections
|
||||||
|
.map(section => {
|
||||||
|
const filteredItems = (section.items || []).filter(item =>
|
||||||
|
item.label.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: filteredItems,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(section => section.items.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
const handleAction = item => {
|
const handleAction = item => {
|
||||||
const { action, value, ...rest } = item;
|
const { action, value, ...rest } = item;
|
||||||
emit('action', { action, value, ...rest });
|
emit('action', { action, value, ...rest });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowEmptyState = computed(() => {
|
||||||
|
if (hasSections.value) {
|
||||||
|
return filteredMenuSections.value.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMenuItems.value.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (searchInput.value && props.showSearch) {
|
if (searchInput.value && props.showSearch) {
|
||||||
searchInput.value.focus();
|
searchInput.value.focus();
|
||||||
@@ -64,54 +111,122 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
||||||
|
:class="{
|
||||||
|
'pt-2': !showSearch,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="showSearch" class="relative">
|
|
||||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
|
||||||
<input
|
|
||||||
ref="searchInput"
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="search"
|
|
||||||
:placeholder="
|
|
||||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
|
||||||
"
|
|
||||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-for="(item, index) in filteredMenuItems"
|
|
||||||
:key="index"
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
|
||||||
'text-n-ruby-11': item.action === 'delete',
|
|
||||||
'text-n-slate-12': item.action !== 'delete',
|
|
||||||
}"
|
|
||||||
:disabled="item.disabled"
|
|
||||||
@click="handleAction(item)"
|
|
||||||
>
|
|
||||||
<slot name="thumbnail" :item="item">
|
|
||||||
<Avatar
|
|
||||||
v-if="item.thumbnail"
|
|
||||||
:name="item.thumbnail.name"
|
|
||||||
:src="item.thumbnail.src"
|
|
||||||
:size="thumbnailSize"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
</slot>
|
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
|
||||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
|
||||||
<span
|
|
||||||
v-if="item.label"
|
|
||||||
class="min-w-0 text-sm truncate"
|
|
||||||
:class="labelClass"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
v-if="filteredMenuItems.length === 0"
|
v-if="showSearch"
|
||||||
|
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
:placeholder="
|
||||||
|
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="hasSections">
|
||||||
|
<div
|
||||||
|
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||||
|
:key="section.title || sectionIndex"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="section.title"
|
||||||
|
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ section.title }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-for="(item, itemIndex) in section.items"
|
||||||
|
:key="item.value || itemIndex"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||||
|
'text-n-ruby-11': item.action === 'delete',
|
||||||
|
'text-n-slate-12': item.action !== 'delete',
|
||||||
|
}"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="handleAction(item)"
|
||||||
|
>
|
||||||
|
<slot name="thumbnail" :item="item">
|
||||||
|
<Avatar
|
||||||
|
v-if="item.thumbnail"
|
||||||
|
:name="item.thumbnail.name"
|
||||||
|
:src="item.thumbnail.src"
|
||||||
|
:size="thumbnailSize"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
<Icon
|
||||||
|
v-if="item.icon"
|
||||||
|
:icon="item.icon"
|
||||||
|
class="flex-shrink-0 size-3.5"
|
||||||
|
/>
|
||||||
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.label"
|
||||||
|
class="min-w-0 text-sm truncate"
|
||||||
|
:class="labelClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||||
|
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in filteredMenuItems"
|
||||||
|
:key="index"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||||
|
'text-n-ruby-11': item.action === 'delete',
|
||||||
|
'text-n-slate-12': item.action !== 'delete',
|
||||||
|
}"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@click="handleAction(item)"
|
||||||
|
>
|
||||||
|
<slot name="thumbnail" :item="item">
|
||||||
|
<Avatar
|
||||||
|
v-if="item.thumbnail"
|
||||||
|
:name="item.thumbnail.name"
|
||||||
|
:src="item.thumbnail.src"
|
||||||
|
:size="thumbnailSize"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
<Icon
|
||||||
|
v-if="item.icon"
|
||||||
|
:icon="item.icon"
|
||||||
|
class="flex-shrink-0 size-3.5"
|
||||||
|
/>
|
||||||
|
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.label"
|
||||||
|
class="min-w-0 text-sm truncate"
|
||||||
|
:class="labelClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="shouldShowEmptyState"
|
||||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::WebWidget': 'i-woot-website',
|
'Channel::WebWidget': 'i-woot-website',
|
||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
'Channel::Voice': 'i-ri-phone-fill',
|
'Channel::Voice': 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-woot-instagram');
|
expect(icon).toBe('i-woot-instagram');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for TikTok channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Tiktok' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-woot-tiktok');
|
||||||
|
});
|
||||||
|
|
||||||
describe('TwilioSms channel', () => {
|
describe('TwilioSms channel', () => {
|
||||||
it('returns chat icon for regular Twilio SMS channel', () => {
|
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::TwilioSms' };
|
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import ImageBubble from './bubbles/Image.vue';
|
|||||||
import FileBubble from './bubbles/File.vue';
|
import FileBubble from './bubbles/File.vue';
|
||||||
import AudioBubble from './bubbles/Audio.vue';
|
import AudioBubble from './bubbles/Audio.vue';
|
||||||
import VideoBubble from './bubbles/Video.vue';
|
import VideoBubble from './bubbles/Video.vue';
|
||||||
|
import EmbedBubble from './bubbles/Embed.vue';
|
||||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||||
import EmailBubble from './bubbles/Email/Index.vue';
|
import EmailBubble from './bubbles/Email/Index.vue';
|
||||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||||
@@ -299,7 +300,12 @@ const componentToRender = computed(() => {
|
|||||||
return DyteBubble;
|
return DyteBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.contentAttributes.imageType === 'story_mention') {
|
const instagramSharedTypes = [
|
||||||
|
ATTACHMENT_TYPES.STORY_MENTION,
|
||||||
|
ATTACHMENT_TYPES.IG_STORY,
|
||||||
|
ATTACHMENT_TYPES.IG_POST,
|
||||||
|
];
|
||||||
|
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||||
return InstagramStoryBubble;
|
return InstagramStoryBubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +318,7 @@ const componentToRender = computed(() => {
|
|||||||
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
|
||||||
|
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
|
||||||
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
|
||||||
}
|
}
|
||||||
// Attachment content is the name of the contact
|
// Attachment content is the name of the contact
|
||||||
@@ -476,7 +483,7 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
v-if="shouldRenderMessage"
|
v-if="shouldRenderMessage"
|
||||||
:id="`message${props.id}`"
|
:id="`message${props.id}`"
|
||||||
class="flex w-full message-bubble-container mb-2"
|
class="flex mb-2 w-full message-bubble-container"
|
||||||
:data-message-id="props.id"
|
:data-message-id="props.id"
|
||||||
:class="[
|
:class="[
|
||||||
flexOrientationClass,
|
flexOrientationClass,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const {
|
|||||||
isAWhatsAppChannel,
|
isAWhatsAppChannel,
|
||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
|
isATiktokChannel,
|
||||||
} = useInbox();
|
} = useInbox();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -60,7 +61,8 @@ const isSent = computed(() => {
|
|||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isATelegramChannel.value ||
|
isATelegramChannel.value ||
|
||||||
isAnInstagramChannel.value
|
isAnInstagramChannel.value ||
|
||||||
|
isATiktokChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,8 @@ const isDelivered = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isASmsInbox.value ||
|
isASmsInbox.value ||
|
||||||
isAFacebookInbox.value
|
isAFacebookInbox.value ||
|
||||||
|
isATiktokChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||||
}
|
}
|
||||||
@@ -100,7 +103,8 @@ const isRead = computed(() => {
|
|||||||
isAWhatsAppChannel.value ||
|
isAWhatsAppChannel.value ||
|
||||||
isATwilioChannel.value ||
|
isATwilioChannel.value ||
|
||||||
isAFacebookInbox.value ||
|
isAFacebookInbox.value ||
|
||||||
isAnInstagramChannel.value
|
isAnInstagramChannel.value ||
|
||||||
|
isATiktokChannel.value
|
||||||
) {
|
) {
|
||||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import BaseBubble from './Base.vue';
|
||||||
|
import { useMessageContext } from '../provider.js';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { attachments } = useMessageContext();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attachment = computed(() => {
|
||||||
|
return attachments.value[0];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
|
||||||
|
<div
|
||||||
|
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
class="w-full h-full border-0 rounded-lg"
|
||||||
|
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
|
||||||
|
:src="attachment.dataUrl"
|
||||||
|
loading="lazy"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BaseBubble>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user