Compare commits
169
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef19fcd8c0 | ||
|
|
d261dfd394 | ||
|
|
1b9673b272 | ||
|
|
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 | ||
|
|
5491ca2470 | ||
|
|
72391f9c36 | ||
|
|
f89d9a4401 | ||
|
|
e8ae73230d | ||
|
|
40c75941f5 | ||
|
|
d9b840f161 | ||
|
|
1fbdd68222 | ||
|
|
ef54f07d5b | ||
|
|
e771d99552 | ||
|
|
358a3924b8 | ||
|
|
6b87d6784e | ||
|
|
faaf67129e | ||
|
|
159c810117 | ||
|
|
ce400a36d7 | ||
|
|
d20de25da1 | ||
|
|
c31d693add | ||
|
|
31497d9c63 | ||
|
|
a35c3e4c06 | ||
|
|
344e8d5016 | ||
|
|
3e27e28848 | ||
|
|
38af08534c | ||
|
|
ee1ea9576b | ||
|
|
26ea87a6cb | ||
|
|
7e8fe78ecd | ||
|
|
f3176afc1c | ||
|
|
c4ba925ae7 | ||
|
|
1f0b56b96e | ||
|
|
e7177364d4 | ||
|
|
f726dc2419 | ||
|
|
5891fd6f49 | ||
|
|
b9864fe1f6 | ||
|
|
5f97a2fd7b | ||
|
|
74fc9c0321 | ||
|
|
9898ccee9e | ||
|
|
eabdfc8168 | ||
|
|
9bd5f15450 | ||
|
|
aea1585fa3 | ||
|
|
254d5dcf9a | ||
|
|
b4c4f328b2 | ||
|
|
a509ef826a | ||
|
|
2180edc14a | ||
|
|
de2a35b617 | ||
|
|
40577e6a50 | ||
|
|
368d7c4608 | ||
|
|
f1f1ce644c | ||
|
|
38f16ba677 | ||
|
|
e7b01d80b3 | ||
|
|
ec9a82a017 | ||
|
|
cdd3b73fc9 | ||
|
|
610495123e | ||
|
|
cb65d615ea | ||
|
|
d8da1f5bf3 | ||
|
|
6829328182 | ||
|
|
6cc69f444b | ||
|
|
f89ed56258 | ||
|
|
7c5bb343c6 | ||
|
|
170ea7691f | ||
|
|
606adffeeb | ||
|
|
e9c1c61fe4 | ||
|
|
78ebdbbbd8 | ||
|
|
978f4c431a | ||
|
|
4b2ebb8877 | ||
|
|
c4c1f3eb63 | ||
|
|
829142c808 | ||
|
|
3a71829b46 | ||
|
|
0974aea300 | ||
|
|
9fb0dfa4a7 | ||
|
|
8bbb8ba5a4 | ||
|
|
a8aefa0c73 | ||
|
|
b536f35fa7 | ||
|
|
0d721bc898 | ||
|
|
c29a08f0ca | ||
|
|
66cfef9298 | ||
|
|
109aaa2341 | ||
|
|
ecff66146a | ||
|
|
21366e1c3b | ||
|
|
406a470c81 | ||
|
|
6c6aaf573c | ||
|
|
487209574c | ||
|
|
7f1671c083 | ||
|
|
b00261d7c2 | ||
|
|
fcb91ab88a | ||
|
|
59f7c8aa55 | ||
|
|
cd2c58726f | ||
|
|
03d0688cc2 | ||
|
|
b75ea7a762 | ||
|
|
f44e47a624 | ||
|
|
47bdb6d2bb | ||
|
|
c3680d50bc | ||
|
|
2ba4780bda | ||
|
|
d3cd647e49 | ||
|
|
eadbddaa9f | ||
|
|
9f14e6abb6 | ||
|
|
79793a5435 | ||
|
|
e68522318b | ||
|
|
44fab70048 | ||
|
|
68c070bcd9 | ||
|
|
728956a734 | ||
|
|
114c25cae8 | ||
|
|
36cbd5745e | ||
|
|
d762829519 | ||
|
|
2e108653ae | ||
|
|
5c5abc24e3 | ||
|
|
9a5a71b34f | ||
|
|
df1f85a7f0 | ||
|
|
46b75e1b03 | ||
|
|
8162473eb6 | ||
|
|
8764ade161 | ||
|
|
3655f4cedc | ||
|
|
0e41263f9c | ||
|
|
b28c08059f | ||
|
|
b5deecc9f9 | ||
|
|
be44108500 | ||
|
|
891404aaf1 | ||
|
|
98f4a6f797 | ||
|
|
d06aba6020 |
+196
-49
@@ -3,6 +3,7 @@ orbs:
|
||||
node: circleci/node@6.1.0
|
||||
qlty-orb: qltysh/qlty-orb@0.0
|
||||
|
||||
# Shared defaults for setup steps
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
machine:
|
||||
@@ -12,10 +13,106 @@ defaults: &defaults
|
||||
RAILS_LOG_TO_STDOUT: false
|
||||
COVERAGE: true
|
||||
LOG_LEVEL: warn
|
||||
parallelism: 4
|
||||
|
||||
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
|
||||
steps:
|
||||
- checkout
|
||||
@@ -25,8 +122,38 @@ jobs:
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
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:
|
||||
name: Add PostgreSQL repository and update
|
||||
command: |
|
||||
@@ -91,20 +218,6 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
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
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
@@ -127,57 +240,91 @@ jobs:
|
||||
name: Run DB migrations
|
||||
command: bundle exec rails db:chatwoot_prepare
|
||||
|
||||
# Bundle audit
|
||||
- run:
|
||||
name: Bundle audit
|
||||
command: bundle exec bundle audit update && bundle exec bundle audit check -v
|
||||
|
||||
# Rubocop linting
|
||||
- run:
|
||||
name: Rubocop
|
||||
command: bundle exec rubocop
|
||||
|
||||
# ESLint linting
|
||||
- run:
|
||||
name: 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 backend tests (parallelized)
|
||||
- run:
|
||||
name: Run backend tests
|
||||
command: |
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
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 \
|
||||
--format RspecJunitFormatter \
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- ${TESTFILES}
|
||||
-- $TESTS
|
||||
no_output_timeout: 30m
|
||||
|
||||
# Qlty coverage publish
|
||||
- qlty-orb/coverage_publish:
|
||||
files: |
|
||||
coverage/coverage.json
|
||||
coverage/lcov.info
|
||||
# Store test results for better splitting in future runs
|
||||
- store_test_results:
|
||||
path: ~/tmp/test-results
|
||||
|
||||
- run:
|
||||
name: List coverage directory contents
|
||||
name: Move coverage files if they exist
|
||||
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:
|
||||
root: ~/build
|
||||
paths:
|
||||
- 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:
|
||||
path: 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
|
||||
# postmark for Postmark
|
||||
# sendgrid for Sendgrid
|
||||
# ses for Amazon SES
|
||||
RAILS_INBOUND_EMAIL_SERVICE=
|
||||
# Use one of the following based on the email ingress service
|
||||
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
|
||||
@@ -114,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD=
|
||||
MAILGUN_INGRESS_SIGNING_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:
|
||||
# Inbound webhook URL format:
|
||||
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
|
||||
@@ -256,6 +261,8 @@ AZURE_APP_SECRET=
|
||||
## Change these values to fine tune performance
|
||||
# control the concurrency setting of sidekiq
|
||||
# SIDEKIQ_CONCURRENCY=10
|
||||
# Enable verbose logging each time a job is dequeued in Sidekiq
|
||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||
|
||||
|
||||
# AI powered features
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
name: Run Chatwoot CE spec
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -8,11 +10,58 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
# Separate linting jobs for faster feedback
|
||||
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:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
@@ -20,8 +69,6 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
# tmpfs makes DB faster by using RAM
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
@@ -29,7 +76,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
@@ -43,7 +90,7 @@ jobs:
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
bundler-cache: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -64,19 +111,36 @@ jobs:
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run frontend tests
|
||||
run: pnpm run test:coverage
|
||||
|
||||
# Run rails tests
|
||||
- name: Run backend tests
|
||||
- name: Run backend tests (parallelized)
|
||||
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:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload rails log folder
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
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
|
||||
|
||||
@@ -70,6 +70,7 @@ jobs:
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
|
||||
@@ -36,5 +36,5 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
load: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ Metrics/MethodLength:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Max: 50
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
|
||||
@@ -21,6 +21,7 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
@@ -54,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 'image_processing'
|
||||
|
||||
##-- for actionmailbox --##
|
||||
gem 'aws-actionmailbox-ses', '~> 0'
|
||||
|
||||
##-- gems for database --#
|
||||
gem 'groupdate'
|
||||
gem 'pg'
|
||||
@@ -103,7 +107,7 @@ gem 'twitty', '~> 0.1.5'
|
||||
# facebook client
|
||||
gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client', '~> 2.5.2'
|
||||
gem 'slack-ruby-client', '~> 2.7.0'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||
gem 'grpc'
|
||||
|
||||
+21
-11
@@ -136,9 +136,13 @@ GEM
|
||||
audited (5.4.1)
|
||||
activerecord (>= 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-partitions (1.760.0)
|
||||
aws-sdk-core (3.171.1)
|
||||
aws-sdk-core (3.188.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -146,10 +150,13 @@ GEM
|
||||
aws-sdk-kms (1.64.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.122.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-s3 (1.126.0)
|
||||
aws-sdk-core (~> 3, >= 3.174.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
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-eventstream (~> 1, >= 1.0.2)
|
||||
barnes (0.0.9)
|
||||
@@ -270,6 +277,7 @@ GEM
|
||||
concurrent-ruby (~> 1.0)
|
||||
http (>= 3.0)
|
||||
ruby2_keywords
|
||||
email-provider-info (0.0.1)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
@@ -292,7 +300,7 @@ GEM
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-mashify (0.1.1)
|
||||
faraday-mashify (1.0.0)
|
||||
faraday (~> 2.0)
|
||||
hashie
|
||||
faraday-multipart (1.0.4)
|
||||
@@ -594,7 +602,7 @@ GEM
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
@@ -644,7 +652,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (3.2.3)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -653,7 +661,7 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
@@ -876,8 +884,8 @@ GEM
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
slack-ruby-client (2.5.2)
|
||||
faraday (>= 2.0)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
faraday-mashify
|
||||
faraday-multipart
|
||||
gli
|
||||
@@ -935,7 +943,7 @@ GEM
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.3)
|
||||
uri (1.0.4)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -994,6 +1002,7 @@ DEPENDENCIES
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
aws-actionmailbox-ses (~> 0)
|
||||
aws-sdk-s3
|
||||
azure-storage-blob!
|
||||
barnes
|
||||
@@ -1016,6 +1025,7 @@ DEPENDENCIES
|
||||
dotenv-rails (>= 3.0.0)
|
||||
down
|
||||
elastic-apm
|
||||
email-provider-info
|
||||
email_reply_trimmer
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
@@ -1103,7 +1113,7 @@ DEPENDENCIES
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
slack-ruby-client (~> 2.7.0)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.4.0
|
||||
4.8.0
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||
"value": "none"
|
||||
},
|
||||
"NODE_OPTIONS": {
|
||||
"description": "Increase V8 heap for Vite build to avoid OOM",
|
||||
"value": "--max-old-space-size=4096"
|
||||
}
|
||||
},
|
||||
"formation": {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
class Email::BaseBuilder
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
private
|
||||
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= message.conversation
|
||||
end
|
||||
|
||||
def custom_sender_name
|
||||
message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
|
||||
end
|
||||
|
||||
def sender_name(sender_email)
|
||||
# Friendly: <agent_name> from <business_name>
|
||||
# Professional: <business_name>
|
||||
if inbox.friendly?
|
||||
I18n.t(
|
||||
'conversations.reply.email.header.friendly_name',
|
||||
sender_name: custom_sender_name,
|
||||
business_name: business_name,
|
||||
from_email: sender_email
|
||||
)
|
||||
else
|
||||
I18n.t(
|
||||
'conversations.reply.email.header.professional_name',
|
||||
business_name: business_name,
|
||||
from_email: sender_email
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def business_name
|
||||
inbox.business_name || inbox.sanitized_name
|
||||
end
|
||||
|
||||
def account_support_email
|
||||
# Parse the email to ensure it's in the correct format, the user
|
||||
# can save it in the format "Name <email@domain.com>"
|
||||
parse_email(account.support_email)
|
||||
end
|
||||
|
||||
def parse_email(email_string)
|
||||
Mail::Address.new(email_string).address
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Email::FromBuilder < Email::BaseBuilder
|
||||
pattr_initialize [:inbox!, :message!]
|
||||
|
||||
def build
|
||||
return sender_name(account_support_email) unless inbox.email?
|
||||
|
||||
from_email = case email_channel_type
|
||||
when :standard_imap_smtp,
|
||||
:google_oauth,
|
||||
:microsoft_oauth,
|
||||
:forwarding_own_smtp
|
||||
channel.email
|
||||
when :imap_chatwoot_smtp,
|
||||
:forwarding_chatwoot_smtp
|
||||
channel.verified_for_sending ? channel.email : account_support_email
|
||||
else
|
||||
account_support_email
|
||||
end
|
||||
|
||||
sender_name(from_email)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def email_channel_type
|
||||
return :google_oauth if channel.google?
|
||||
return :microsoft_oauth if channel.microsoft?
|
||||
return :standard_imap_smtp if imap_and_smtp_enabled?
|
||||
return :imap_chatwoot_smtp if imap_enabled_without_smtp?
|
||||
return :forwarding_own_smtp if forwarding_with_own_smtp?
|
||||
return :forwarding_chatwoot_smtp if forwarding_without_smtp?
|
||||
|
||||
:unknown
|
||||
end
|
||||
|
||||
def imap_and_smtp_enabled?
|
||||
channel.imap_enabled && channel.smtp_enabled
|
||||
end
|
||||
|
||||
def imap_enabled_without_smtp?
|
||||
channel.imap_enabled && !channel.smtp_enabled
|
||||
end
|
||||
|
||||
def forwarding_with_own_smtp?
|
||||
!channel.imap_enabled && channel.smtp_enabled
|
||||
end
|
||||
|
||||
def forwarding_without_smtp?
|
||||
!channel.imap_enabled && !channel.smtp_enabled
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Email::ReplyToBuilder < Email::BaseBuilder
|
||||
pattr_initialize [:inbox!, :message!]
|
||||
|
||||
def build
|
||||
reply_to = if inbox.email?
|
||||
channel.email
|
||||
elsif inbound_email_enabled?
|
||||
"reply+#{conversation.uuid}@#{account.inbound_email_domain}"
|
||||
else
|
||||
account_support_email
|
||||
end
|
||||
|
||||
sender_name(reply_to)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbound_email_enabled?
|
||||
account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,8 @@
|
||||
class Messages::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
include ::EmailHelper
|
||||
include ::DataHelper
|
||||
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
@@ -7,6 +10,7 @@ class Messages::MessageBuilder
|
||||
@private = params[:private] || false
|
||||
@conversation = conversation
|
||||
@user = user
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
@@ -20,6 +24,9 @@ class Messages::MessageBuilder
|
||||
@message = @conversation.messages.build(message_params)
|
||||
process_attachments
|
||||
process_emails
|
||||
# When the message has no quoted content, it will just be rendered as a regular message
|
||||
# The frontend is equipped to handle this case
|
||||
process_email_content
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
@@ -34,30 +41,12 @@ class Messages::MessageBuilder
|
||||
params = convert_to_hash(@params)
|
||||
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)
|
||||
|
||||
{}
|
||||
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
|
||||
return if @attachments.blank?
|
||||
|
||||
@@ -92,18 +81,20 @@ class Messages::MessageBuilder
|
||||
@message.content_attributes[:to_emails] = to_emails
|
||||
end
|
||||
|
||||
def process_email_content
|
||||
return unless should_process_email_content?
|
||||
|
||||
@message.content_attributes ||= {}
|
||||
email_attributes = build_email_attributes
|
||||
@message.content_attributes[:email] = email_attributes
|
||||
end
|
||||
|
||||
def process_email_string(email_string)
|
||||
return [] if email_string.blank?
|
||||
|
||||
email_string.gsub(/\s+/, '').split(',')
|
||||
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
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
@@ -153,4 +144,81 @@ class Messages::MessageBuilder
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
end
|
||||
|
||||
def email_inbox?
|
||||
@conversation.inbox&.inbox_type == 'Email'
|
||||
end
|
||||
|
||||
def should_process_email_content?
|
||||
email_inbox? && !@private && @message.content.present?
|
||||
end
|
||||
|
||||
def build_email_attributes
|
||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||
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
|
||||
email_attributes[:html_content] = if custom_email_content_provided?
|
||||
build_custom_html_content
|
||||
else
|
||||
build_html_content(processed_content)
|
||||
end
|
||||
|
||||
email_attributes[:text_content] = build_text_content(processed_content)
|
||||
email_attributes
|
||||
end
|
||||
|
||||
def build_html_content(normalized_content)
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
rendered_html = render_email_html(normalized_content)
|
||||
html_content[:full] = rendered_html
|
||||
html_content[:reply] = rendered_html
|
||||
html_content
|
||||
end
|
||||
|
||||
def build_text_content(normalized_content)
|
||||
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
|
||||
text_content[:full] = normalized_content
|
||||
text_content[:reply] = normalized_content
|
||||
text_content
|
||||
end
|
||||
|
||||
def custom_email_content_provided?
|
||||
@params[:email_html_content].present?
|
||||
end
|
||||
|
||||
def build_custom_html_content
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
|
||||
html_content[:full] = @params[:email_html_content]
|
||||
html_content[:reply] = @params[:email_html_content]
|
||||
|
||||
html_content
|
||||
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
|
||||
|
||||
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
|
||||
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
before_action :agent_bot, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
||||
@agent_bots = AgentBot.accessible_to(Current.account)
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
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])
|
||||
end
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
def edit; end
|
||||
|
||||
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.draft!
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :type_matches?
|
||||
|
||||
def create
|
||||
if type_matches?
|
||||
::BulkActionsJob.perform_later(
|
||||
account: @current_account,
|
||||
user: current_user,
|
||||
params: permitted_params
|
||||
)
|
||||
case normalized_type
|
||||
when 'Conversation'
|
||||
enqueue_conversation_job
|
||||
head :ok
|
||||
when 'Contact'
|
||||
check_authorization_for_contact_action
|
||||
enqueue_contact_job
|
||||
head :ok
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
@@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
||||
|
||||
private
|
||||
|
||||
def type_matches?
|
||||
['Conversation'].include?(params[:type])
|
||||
def normalized_type
|
||||
params[:type].to_s.camelize
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||
def enqueue_conversation_job
|
||||
::BulkActionsJob.perform_later(
|
||||
account: @current_account,
|
||||
user: current_user,
|
||||
params: conversation_params
|
||||
)
|
||||
end
|
||||
|
||||
def enqueue_contact_job
|
||||
Contacts::BulkActionJob.perform_later(
|
||||
@current_account.id,
|
||||
current_user.id,
|
||||
contact_params
|
||||
)
|
||||
end
|
||||
|
||||
def delete_contact_action?
|
||||
params[:action_name] == 'delete'
|
||||
end
|
||||
|
||||
def check_authorization_for_contact_action
|
||||
authorize(Contact, :destroy?) if delete_contact_action?
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
|
||||
# and then remove this method in favor of a common params method.
|
||||
base = params.permit(
|
||||
:snoozed_until,
|
||||
fields: [:status, :assignee_id, :team_id]
|
||||
)
|
||||
append_common_bulk_attributes(base)
|
||||
end
|
||||
|
||||
def contact_params
|
||||
# TODO: remove this method in favor of a common params method.
|
||||
# once legacy conversation payloads are migrated.
|
||||
append_common_bulk_attributes({})
|
||||
end
|
||||
|
||||
def append_common_bulk_attributes(base_params)
|
||||
# NOTE: Conversation payloads historically diverged per action. Going forward we
|
||||
# want all objects to share a common contract: `{ action_name, action_attributes }`
|
||||
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
|
||||
base_params.merge(common)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@contacts = fetch_contacts(resolved_contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def search
|
||||
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def active
|
||||
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
.get_available_contact_ids(Current.account.id))
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contacts(contacts)
|
||||
contacts_with_avatar = filtrate(contacts)
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
# Build includes hash to avoid separate query when contact_inboxes are needed
|
||||
includes_hash = { avatar_attachment: [:blob] }
|
||||
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
||||
|
||||
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
|
||||
contacts_with_avatar
|
||||
filtrate(contacts)
|
||||
.includes(includes_hash)
|
||||
.page(@current_page)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
|
||||
# assigns agent/team to a conversation
|
||||
def create
|
||||
if params.key?(:assignee_id)
|
||||
if params.key?(:assignee_id) || agent_bot_assignment?
|
||||
set_agent
|
||||
elsif params.key?(:team_id)
|
||||
set_team
|
||||
@@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
||||
private
|
||||
|
||||
def set_agent
|
||||
@agent = Current.account.users.find_by(id: params[:assignee_id])
|
||||
@conversation.assignee = @agent
|
||||
@conversation.save!
|
||||
render_agent
|
||||
resource = Conversations::AssignmentService.new(
|
||||
conversation: @conversation,
|
||||
assignee_id: params[:assignee_id],
|
||||
assignee_type: params[:assignee_type]
|
||||
).perform
|
||||
|
||||
render_agent(resource)
|
||||
end
|
||||
|
||||
def render_agent
|
||||
if @agent.nil?
|
||||
render json: nil
|
||||
def render_agent(resource)
|
||||
case resource
|
||||
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
|
||||
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
|
||||
render json: nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
|
||||
@conversation.update!(team: @team)
|
||||
render json: @team
|
||||
end
|
||||
|
||||
def agent_bot_assignment?
|
||||
params[:assignee_type].to_s == 'AgentBot'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def inbox
|
||||
|
||||
@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show]
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
private
|
||||
|
||||
def authorize_request
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def render_response(response)
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
def webhook_params
|
||||
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||
end
|
||||
|
||||
def fetch_webhook
|
||||
|
||||
@@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def set_contact
|
||||
|
||||
@@ -14,6 +14,7 @@ module AccessTokenAuthHelper
|
||||
ensure_access_token
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
# NOTE: This ensures that current_user is set and available for the rest of the controller actions
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if allowed_current_user_type?(@resource)
|
||||
end
|
||||
|
||||
@@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
class DashboardController < ActionController::Base
|
||||
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_global_config
|
||||
before_action :set_dashboard_scripts
|
||||
@@ -19,25 +44,7 @@ class DashboardController < ActionController::Base
|
||||
end
|
||||
|
||||
def set_global_config
|
||||
@global_config = GlobalConfig.get(
|
||||
'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)
|
||||
@global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
|
||||
end
|
||||
|
||||
def set_dashboard_scripts
|
||||
@@ -71,10 +78,18 @@ class DashboardController < ActionController::Base
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
}
|
||||
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
|
||||
@application_pack = if request.path.include?('/auth') || request.path.include?('/login')
|
||||
'v3app'
|
||||
|
||||
@@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
# that will log them in
|
||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||
params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
|
||||
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
|
||||
end
|
||||
|
||||
def sign_up_user
|
||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
@@ -12,9 +12,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
return handle_mfa_verification if mfa_verification_request?
|
||||
return handle_sso_authentication if sso_authentication_request?
|
||||
|
||||
super do |resource|
|
||||
return handle_mfa_required(resource) if resource&.mfa_enabled?
|
||||
end
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
@@ -23,6 +25,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def find_user_for_authentication
|
||||
return nil unless params[:email].present? && params[:password].present?
|
||||
|
||||
normalized_email = params[:email].strip.downcase
|
||||
user = User.from_email(normalized_email)
|
||||
return nil unless user&.valid_password?(params[:password])
|
||||
return nil unless user.active_for_authentication?
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def mfa_verification_request?
|
||||
params[:mfa_token].present?
|
||||
end
|
||||
@@ -59,10 +72,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def handle_mfa_required(resource)
|
||||
def handle_mfa_required(user)
|
||||
render json: {
|
||||
mfa_required: true,
|
||||
mfa_token: Mfa::TokenService.new(user: resource).generate_token
|
||||
mfa_token: Mfa::TokenService.new(user: user).generate_token
|
||||
}, status: :partial_content
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
|
||||
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
|
||||
|
||||
def index
|
||||
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
|
||||
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
errors = []
|
||||
params['app_config'].each do |key, value|
|
||||
next unless @allowed_configs.include?(key)
|
||||
|
||||
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
|
||||
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
|
||||
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||
end
|
||||
|
||||
private
|
||||
@@ -42,10 +48,13 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'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
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
|
||||
redirect_to new_super_admin_user_path, notice: notice
|
||||
end
|
||||
end
|
||||
#
|
||||
# def update
|
||||
# super
|
||||
# send_foo_updated_email(requested_resource)
|
||||
# end
|
||||
|
||||
def update
|
||||
requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present?
|
||||
super
|
||||
end
|
||||
|
||||
# Override this method to specify custom lookup behavior.
|
||||
# This will be used to set the resource for the `show`, `edit`, and `update`
|
||||
|
||||
@@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def set_web_widget
|
||||
|
||||
@@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
avatar_url
|
||||
unconfirmed_email
|
||||
name
|
||||
type
|
||||
display_name
|
||||
email
|
||||
unconfirmed_email
|
||||
created_at
|
||||
updated_at
|
||||
confirmed_at
|
||||
|
||||
@@ -6,19 +6,54 @@ class EmailChannelFinder
|
||||
end
|
||||
|
||||
def perform
|
||||
channel = nil
|
||||
|
||||
recipient_mails.each do |email|
|
||||
normalized_email = normalize_email_with_plus_addressing(email)
|
||||
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
|
||||
|
||||
break if channel.present?
|
||||
end
|
||||
channel
|
||||
channel_from_primary_recipients || channel_from_bcc_recipients
|
||||
end
|
||||
|
||||
def recipient_mails
|
||||
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
|
||||
recipient_addresses.flatten.compact
|
||||
private
|
||||
|
||||
def channel_from_primary_recipients
|
||||
primary_recipient_emails.each do |email|
|
||||
channel = channel_from_email(email)
|
||||
return channel if channel.present?
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def channel_from_bcc_recipients
|
||||
bcc_recipient_emails.each do |email|
|
||||
channel = channel_from_email(email)
|
||||
|
||||
# Skip if BCC processing is disabled for this account
|
||||
next if channel && !allow_bcc_processing?(channel.account_id)
|
||||
|
||||
return channel if channel.present?
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def primary_recipient_emails
|
||||
(@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact
|
||||
end
|
||||
|
||||
def bcc_recipient_emails
|
||||
@email_object.bcc.to_a.flatten.compact
|
||||
end
|
||||
|
||||
def channel_from_email(email)
|
||||
normalized_email = normalize_email_with_plus_addressing(email)
|
||||
Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
|
||||
end
|
||||
|
||||
def bcc_processing_skipped_accounts
|
||||
config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '')
|
||||
return [] if config_value.blank?
|
||||
|
||||
config_value.split(',').map(&:to_i)
|
||||
end
|
||||
|
||||
def allow_bcc_processing?(account_id)
|
||||
bcc_processing_skipped_accounts.exclude?(account_id)
|
||||
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
|
||||
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
|
||||
# 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
|
||||
@@ -21,6 +34,10 @@ module EmailHelper
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
end
|
||||
|
||||
def modified_liquid_content(email)
|
||||
# This regex is used to match the code blocks in the content
|
||||
# We don't want to process liquid in code blocks
|
||||
@@ -29,7 +46,10 @@ module EmailHelper
|
||||
|
||||
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
|
||||
|
||||
@@ -9,6 +9,13 @@ captain:
|
||||
icon: 'icon-captain'
|
||||
config_key: 'captain'
|
||||
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:
|
||||
name: 'Custom Branding'
|
||||
description: 'Apply your own branding to this installation.'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from './components/app/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
@@ -25,7 +24,6 @@ export default {
|
||||
name: 'App',
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
@@ -51,7 +49,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
};
|
||||
@@ -64,21 +61,12 @@ export default {
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
return accounts.length > 0;
|
||||
},
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentUser() {
|
||||
if (!this.hasAccounts) {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
@@ -156,7 +144,6 @@ export default {
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainCustomTools extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/custom_tools', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: { page, searchKey },
|
||||
});
|
||||
}
|
||||
|
||||
show(id) {
|
||||
return axios.get(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
create(data = {}) {
|
||||
return axios.post(this.url, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data = {}) {
|
||||
return axios.put(`${this.url}/${id}`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import ApiClient from './ApiClient';
|
||||
import { CHANGELOG_API_URL } from 'shared/constants/links';
|
||||
|
||||
class ChangelogApi extends ApiClient {
|
||||
constructor() {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
return axios.get(CHANGELOG_API_URL);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChangelogApi();
|
||||
@@ -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();
|
||||
@@ -63,10 +63,9 @@ class ConversationApi extends ApiClient {
|
||||
}
|
||||
|
||||
assignAgent({ conversationId, agentId }) {
|
||||
return axios.post(
|
||||
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
|
||||
{}
|
||||
);
|
||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||
assignee_id: agentId,
|
||||
});
|
||||
}
|
||||
|
||||
assignTeam({ conversationId, teamId }) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class InboxHealthAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
@@ -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', () => {
|
||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments?assignee_id=34`,
|
||||
{}
|
||||
`/api/v1/conversations/12/assignments`,
|
||||
{
|
||||
assignee_id: 34,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
+38
-35
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -20,9 +21,17 @@ const props = defineProps({
|
||||
availabilityStatus: { type: String, default: null },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
||||
const emit = defineEmits([
|
||||
'toggle',
|
||||
'updateContact',
|
||||
'showContact',
|
||||
'select',
|
||||
'avatarHover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -88,111 +97,148 @@ const onClickExpand = () => {
|
||||
};
|
||||
|
||||
const onClickViewDetails = () => emit('showContact', props.id);
|
||||
|
||||
const toggleSelect = checked => {
|
||||
emit('select', checked);
|
||||
};
|
||||
|
||||
const handleAvatarHover = isHovered => {
|
||||
emit('avatarHover', isHovered);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :key="id" layout="row">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
<div class="relative">
|
||||
<CardLayout
|
||||
:key="id"
|
||||
layout="row"
|
||||
:class="{
|
||||
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="handleAvatarHover(true)"
|
||||
@mouseleave="handleAvatarHover(false)"
|
||||
>
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
|
||||
>
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
|
||||
defineProps({ contacts: { type: Array, required: true } });
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true },
|
||||
selectedContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleContact']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -20,6 +28,9 @@ const route = useRoute();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
const expandedCardId = ref(null);
|
||||
const hoveredAvatarId = ref(null);
|
||||
|
||||
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
|
||||
|
||||
const updateContact = async updatedData => {
|
||||
try {
|
||||
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
};
|
||||
|
||||
const isSelected = id => selectedIdsSet.value.has(id);
|
||||
|
||||
const shouldShowSelection = id => {
|
||||
return hoveredAvatarId.value === id || isSelected(id);
|
||||
};
|
||||
|
||||
const handleSelect = (id, value) => {
|
||||
emit('toggleContact', { id, value });
|
||||
};
|
||||
|
||||
const handleAvatarHover = (id, isHovered) => {
|
||||
hoveredAvatarId.value = isHovered ? id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<ContactsCard
|
||||
v-for="contact in contacts"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||
<ContactsCard
|
||||
:id="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
:selectable="shouldShowSelection(contact.id)"
|
||||
:is-selected="isSelected(contact.id)"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
@select="value => handleSelect(contact.id, value)"
|
||||
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
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 { computed } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
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"
|
||||
<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/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
|
||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full"
|
||||
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
|
||||
:class="{
|
||||
'bg-n-alpha-2': isContactSidebarOpen,
|
||||
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
|
||||
}"
|
||||
icon="i-ph-user-bold"
|
||||
@click="handleConversationSidebarToggle"
|
||||
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
|
||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||
ghost
|
||||
slate
|
||||
class="!rounded-full"
|
||||
:class="{
|
||||
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
||||
}"
|
||||
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"
|
||||
@click="handleCopilotSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,10 @@ const props = defineProps({
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
signature: { type: String, default: '' },
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -100,6 +104,10 @@ watch(
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
:signature="signature"
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -14,6 +14,10 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showBackdrop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,14 +29,24 @@ defineProps({
|
||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||
>
|
||||
<div
|
||||
v-if="showBackdrop"
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
|
||||
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
||||
:class="{
|
||||
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
|
||||
showBackdrop,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-6"
|
||||
:class="{
|
||||
'mt-48': !showBackdrop,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
||||
@@ -40,6 +54,7 @@ defineProps({
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
|
||||
+5
-2
@@ -14,6 +14,7 @@ import {
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
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 DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
|
||||
:disabled="!articleId"
|
||||
@click="previewArticle"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<ButtonGroup class="flex items-center">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||
size="sm"
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||
no-animation
|
||||
:is-loading="isArticlePublishing"
|
||||
:disabled="
|
||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
:disabled="!articleId"
|
||||
no-animation
|
||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||
/>
|
||||
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,6 @@ const setSignature = () => {
|
||||
|
||||
const toggleMessageSignature = () => {
|
||||
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
||||
setSignature();
|
||||
};
|
||||
|
||||
// Added this watch to dynamically set signature on target inbox change.
|
||||
|
||||
+28
-11
@@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => {
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
emit('searchContacts', {
|
||||
keys: ['email', 'phone_number', 'name'],
|
||||
query: value,
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -188,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
// Remove the signature from message content
|
||||
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
|
||||
if (props.sendWithSignature) {
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
if (signatureToRemove) {
|
||||
state.message = removeSignature(state.message, signatureToRemove);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
@@ -205,6 +220,7 @@ const removeTargetInbox = value => {
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -343,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
@@ -17,6 +17,7 @@ const props = defineProps({
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -90,6 +91,10 @@ const replaceText = async message => {
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
noAnimation: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
|
||||
md: 'text-sm font-medium',
|
||||
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: {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
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(() => {
|
||||
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const animationClasses = computed(() => {
|
||||
return props.noAnimation
|
||||
? ''
|
||||
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
|
||||
[STYLE_CONFIG.base]: true,
|
||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||
[animationClasses]: true,
|
||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||
'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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import Guardrails from './Guardrails.vue';
|
||||
import Scenarios from './Scenarios.vue';
|
||||
import ResponseGuidelines from './ResponseGuidelines.vue';
|
||||
import Settings from './Settings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/AnimatingImg/AnimatingImg"
|
||||
:layout="{ type: 'grid', width: '300px' }"
|
||||
>
|
||||
<Variant title="Guardrails">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<Guardrails class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Scenarios">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<Scenarios class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="ResponseGuidelines">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<ResponseGuidelines class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Settings">
|
||||
<div class="p-4 bg-n-background w-full h-full">
|
||||
<Settings class="size-60" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,990 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const paused = ref(false);
|
||||
|
||||
const toggle = () => {
|
||||
paused.value = !paused.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="svg-wrapper relative"
|
||||
:class="{ paused }"
|
||||
role="button"
|
||||
:aria-pressed="paused"
|
||||
tabindex="0"
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="absolute z-0 flex-shrink-0">
|
||||
<svg
|
||||
width="auto"
|
||||
height="auto"
|
||||
viewBox="0 0 200 156"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.5">
|
||||
<g clip-path="url(#clip0_773_34322)">
|
||||
<circle cx="8" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<rect
|
||||
width="200"
|
||||
height="156"
|
||||
fill="url(#paint0_linear_773_34322)"
|
||||
/>
|
||||
<rect
|
||||
width="200"
|
||||
height="156"
|
||||
fill="url(#paint1_linear_773_34322)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_773_34322"
|
||||
x1="100"
|
||||
y1="0"
|
||||
x2="100"
|
||||
y2="156"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--gradient-start)" />
|
||||
<stop
|
||||
offset="0.480769"
|
||||
stop-color="var(--gradient-start)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
<stop offset="1" stop-color="var(--gradient-start)" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_773_34322"
|
||||
x1="0"
|
||||
y1="78"
|
||||
x2="200"
|
||||
y2="78"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--gradient-start)" />
|
||||
<stop
|
||||
offset="0.480769"
|
||||
stop-color="var(--gradient-start)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
<stop offset="1" stop-color="var(--gradient-start)" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_773_34322">
|
||||
<rect width="200" height="156" rx="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 136 108"
|
||||
aria-hidden="false"
|
||||
focusable="false"
|
||||
class="z-10 relative flex-shrink-0"
|
||||
>
|
||||
<rect width="136" height="108" fill="url(#paint0_radial_720_25701)" />
|
||||
<path
|
||||
d="M49 35.039C49 33.9129 49.9129 33 51.039 33C52.1651 33 53.0779 33.9129 53.0779 35.039V39.7513C53.0779 40.8774 52.1651 41.7902 51.039 41.7902C49.9129 41.7902 49 40.8774 49 39.7513V35.039Z"
|
||||
class="fill-n-slate-10"
|
||||
/>
|
||||
<path
|
||||
d="M55.5244 35.039C55.5244 33.9129 56.4373 33 57.5634 33C58.6895 33 59.6024 33.9129 59.6024 35.039V39.7513C59.6024 40.8774 58.6895 41.7902 57.5634 41.7902C56.4373 41.7902 55.5244 40.8774 55.5244 39.7513V35.039Z"
|
||||
class="fill-n-slate-10"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M58.1862 24.173C50.5933 23.1017 44.4039 23.0594 36.6405 24.1683C34.678 24.4485 33.3796 24.638 32.3896 24.9273C31.4716 25.1956 30.9496 25.5155 30.5177 25.9981C29.6236 26.9974 29.5057 28.1276 29.3838 32.0695C29.264 35.9436 29.5013 39.4156 29.9507 43.3222C30.1901 45.4039 30.3549 46.8088 30.618 47.8769C30.8676 48.89 31.1694 49.4347 31.5889 49.8518C32.0117 50.2721 32.5536 50.5686 33.5482 50.8091C34.6002 51.0636 35.9806 51.2175 38.032 51.4413C44.6668 52.1652 49.6333 52.1615 56.2927 51.4451C58.369 51.2218 59.7719 51.0678 60.8396 50.815C61.8541 50.5749 62.3995 50.2805 62.8165 49.8722C63.2247 49.4724 63.5366 48.921 63.8071 47.8574C64.0894 46.7477 64.2815 45.2793 64.5594 43.1222C65.0553 39.2735 65.4123 35.8935 65.4262 32.1908C65.4411 28.232 65.3601 27.1056 64.4558 26.0608C64.019 25.5562 63.488 25.2244 62.5491 24.9481C61.5352 24.6497 60.2017 24.4573 58.1862 24.173ZM36.1664 20.849C44.2537 19.6939 50.7606 19.7391 58.6546 20.8529L58.7701 20.8692C60.6392 21.1328 62.2183 21.3555 63.4958 21.7315C64.8745 22.1372 66.0274 22.7532 66.991 23.8665C68.8087 25.9666 68.7969 28.4583 68.7811 31.7688C68.7804 31.9121 68.7797 32.057 68.7792 32.2034C68.7645 36.1184 68.3857 39.6634 67.8849 43.5506L67.8717 43.6535C67.6103 45.682 67.395 47.3537 67.0566 48.6839C66.7008 50.0827 66.169 51.282 65.1624 52.2678C64.1645 53.245 62.9854 53.7528 61.6119 54.0778C60.3149 54.3848 58.7011 54.5584 56.7558 54.7676L56.6513 54.7789C49.7551 55.5207 44.5465 55.5249 37.6684 54.7745L37.5622 54.7629C35.6442 54.5537 34.0471 54.3795 32.7599 54.0681C31.3915 53.7371 30.2202 53.219 29.2249 52.2296C28.2263 51.2369 27.7018 50.0571 27.3623 48.6788C27.0421 47.3788 26.856 45.7601 26.6318 43.8109L26.6197 43.7053C26.1588 39.6989 25.906 36.0546 26.0325 31.9659C26.037 31.8176 26.0414 31.6709 26.0458 31.5257C26.144 28.24 26.2178 25.7754 28.019 23.7623C28.9747 22.6942 30.1043 22.102 31.449 21.7089C32.6962 21.3444 34.2339 21.1249 36.0541 20.865C36.0914 20.8597 36.1288 20.8543 36.1664 20.849Z"
|
||||
class="fill-n-slate-10"
|
||||
/>
|
||||
<path
|
||||
d="M103.999 77.4062H108.999C110.104 77.4062 110.999 78.3017 110.999 79.4062C110.999 80.5108 110.104 81.4062 108.999 81.4062H103.999V77.4062Z"
|
||||
class="fill-n-slate-9"
|
||||
/>
|
||||
<path
|
||||
d="M63 77.4062H58C56.8954 77.4062 56 78.3017 56 79.4062C56 80.5108 56.8954 81.4062 58 81.4062H63V77.4062Z"
|
||||
class="fill-n-slate-9"
|
||||
/>
|
||||
<path
|
||||
d="M83.999 64.4063C85.4527 63.7978 86.4227 63.4566 87.9986 63.4508C89.5744 63.445 91.136 63.7496 92.5941 64.3472C94.0523 64.9449 95.3784 65.8239 96.4968 66.9341C97.6153 68.0442 98.5041 69.3638 99.1125 70.8175C99.721 72.2711 100.037 73.8304 100.043 75.4062"
|
||||
class="stroke-n-blue-11 fill-n-slate-2"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M67.049 75.0889C67.0432 73.5131 67.3478 71.9515 67.9454 70.4934C68.5431 69.0352 69.4221 67.7091 70.5322 66.5907C71.6424 65.4723 72.962 64.5834 74.4157 63.975C75.8693 63.3666 77.4286 63.0504 79.0044 63.0445C80.5803 63.0387 82.1419 63.3433 83.6 63.941C85.0581 64.5386 86.3843 65.4176 87.5027 66.5278C88.6211 67.638 89.5099 68.9576 90.1184 70.4112C90.7268 71.8649 91.043 73.4241 91.0488 75"
|
||||
class="stroke-n-blue-11 fill-n-slate-2"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M71.4659 74.9421C71.462 73.8915 71.6651 72.8505 72.0635 71.8784C72.462 70.9063 73.048 70.0222 73.7881 69.2766C74.5282 68.531 75.4079 67.9384 76.377 67.5328C77.3462 67.1272 78.3857 66.9164 79.4362 66.9125C80.4868 66.9086 81.5278 67.1117 82.4999 67.5101C83.472 67.9086 84.3561 68.4946 85.1017 69.2347C85.8473 69.9748 86.4399 70.8545 86.8455 71.8236C87.2511 72.7927 87.4619 73.8322 87.4658 74.8828"
|
||||
class="stroke-n-blue-11 fill-n-slate-2"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M88.4304 67.3188C89.4809 67.3149 90.522 67.5179 91.4941 67.9164C92.4662 68.3148 93.3503 68.9008 94.0959 69.6409C94.8415 70.381 95.434 71.2608 95.8397 72.2299C96.2453 73.199 96.4561 74.2385 96.46 75.2891"
|
||||
class="stroke-n-blue-11 fill-n-slate-2"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M75.9473 74.9203C75.9454 74.395 76.0469 73.8745 76.2461 73.3884C76.4453 72.9024 76.7383 72.4603 77.1084 72.0875C77.4785 71.7147 77.9183 71.4184 78.4029 71.2156C78.8874 71.0128 79.4072 70.9074 79.9325 70.9055C80.4578 70.9035 80.9783 71.0051 81.4643 71.2043C81.9504 71.4035 82.3924 71.6965 82.7652 72.0666C83.138 72.4366 83.4343 72.8765 83.6371 73.361C83.8399 73.8456 83.9453 74.3653 83.9473 74.8906"
|
||||
class="stroke-n-blue-11 fill-n-slate-2"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M90.4585 71.6066C90.9445 71.8058 91.3866 72.0988 91.7594 72.4689C92.1322 72.839 92.4284 73.2788 92.6313 73.7634C92.8341 74.2479 92.9395 74.7677 92.9414 75.293"
|
||||
class="stroke-n-blue-11"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M102 74.9062C103.09 74.9062 104.032 75.7999 103.973 76.957C103.853 79.29 103.335 81.588 102.439 83.751C101.939 84.9592 101.499 85.8713 100.931 86.7236C100.363 87.5763 99.6906 88.3338 98.7646 89.2598C96.6889 91.3355 93.3531 92.0527 91.0576 92.0527H76.5576C73.9588 92.0527 70.4405 91.3497 68.3506 89.2598C67.4349 88.3441 66.737 87.5905 66.1416 86.7441C65.5415 85.8911 65.0682 84.9765 64.5605 83.751C63.6646 81.588 63.1471 79.29 63.0273 76.957C62.968 75.7999 63.9098 74.9062 65 74.9062H102Z"
|
||||
class="fill-n-slate-2 stroke-n-slate-9"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<g class="default-group">
|
||||
<path
|
||||
d="M103.85 21.1431C104.824 20.8821 105.847 21.3479 106.291 22.2542L112.209 34.3514C112.793 35.544 112.143 36.9734 110.861 37.3171L100.014 40.2235C98.7313 40.5669 97.4538 39.654 97.3628 38.3295L96.4399 24.8937C96.3708 23.8871 97.0247 22.9718 97.9993 22.7107L103.85 21.1431Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M104.528 18.8482C104.84 18.7645 105.124 18.5966 105.347 18.363C106.576 17.0748 105.336 14.9834 103.616 15.4439L95.3492 17.659C93.6293 18.1203 93.6011 20.5515 95.3101 21.0523C95.6201 21.1431 95.9494 21.1468 96.2614 21.0633L104.528 18.8482Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<rect
|
||||
x="95.3496"
|
||||
y="21.2852"
|
||||
width="10.4348"
|
||||
height="2.08696"
|
||||
rx="0.695652"
|
||||
transform="rotate(-15 95.3496 21.2852)"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M103.249 14.4618C103.363 14.5616 103.464 14.6686 103.55 14.7819C103.782 15.0879 103.556 15.4819 103.185 15.5813L99.4893 16.5716L95.7936 17.5618C95.4224 17.6613 95.0296 17.4332 95.0777 17.052C95.0955 16.9109 95.1292 16.7679 95.1785 16.6242C95.3106 16.2393 95.5529 15.8568 95.8916 15.4986C96.2303 15.1403 96.6587 14.8133 97.1525 14.5362C97.6462 14.2592 98.1955 14.0375 98.7691 13.8838C99.3426 13.7301 99.9292 13.6474 100.495 13.6405C101.061 13.6336 101.596 13.7026 102.068 13.8435C102.541 13.9844 102.942 14.1945 103.249 14.4618Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="frame frame-1">
|
||||
<path
|
||||
d="M97.8899 24.7525C97.1819 24.0337 97.0822 22.9139 97.6517 22.0811L105.253 10.9636C106.002 9.8676 107.566 9.72734 108.498 10.6731L116.378 18.6731C117.31 19.619 117.146 21.1806 116.039 21.9133L104.809 29.3463C103.967 29.9032 102.848 29.7861 102.14 29.0673L97.8899 24.7525Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M95.5608 25.2908C95.3341 25.0607 95.0482 24.8971 94.735 24.8184C93.0078 24.3847 91.8006 26.4952 93.05 27.7641L99.0562 33.8613C100.306 35.1296 102.435 33.9543 102.027 32.2207C101.953 31.9063 101.794 31.6181 101.567 31.3879L95.5608 25.2908Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<rect
|
||||
x="102.207"
|
||||
y="32.0742"
|
||||
width="10.4348"
|
||||
height="2.08696"
|
||||
rx="0.695652"
|
||||
transform="rotate(-134.569 102.207 32.0742)"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M92.3768 28.5691C92.4071 28.4202 92.4505 28.2799 92.5066 28.1492C92.6581 27.7962 93.1124 27.7985 93.3821 28.0722L96.0671 30.7979L98.7521 33.5236C99.0218 33.7973 99.0172 34.2515 98.6619 34.3978C98.5304 34.4519 98.3895 34.4932 98.2402 34.5212C97.8402 34.5963 97.3879 34.5743 96.9092 34.4565C96.4304 34.3387 95.9346 34.1274 95.45 33.8347C94.9654 33.542 94.5015 33.1737 94.0848 32.7506C93.668 32.3276 93.3067 31.8582 93.0213 31.3692C92.7359 30.8803 92.5321 30.3813 92.4216 29.9009C92.311 29.4204 92.2958 28.9679 92.3768 28.5691Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<circle cx="85" cy="40" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="91" cy="38" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="91" cy="44" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="85" cy="46" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="79" cy="46" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="88" cy="51" r="1" class="fill-n-slate-11" />
|
||||
</g>
|
||||
|
||||
<g class="frame frame-2">
|
||||
<path
|
||||
d="M97.8879 24.7721C97.1799 24.0532 97.0803 22.9335 97.6497 22.1006L105.251 10.9832C106 9.88713 107.564 9.74688 108.496 10.6927L116.376 18.6926C117.308 19.6385 117.144 21.2001 116.037 21.9329L104.807 29.3658C103.965 29.9227 102.846 29.8056 102.138 29.0868L97.8879 24.7721Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M95.5579 25.3103C95.3311 25.0803 95.0453 24.9167 94.7321 24.838C93.0048 24.4042 91.7977 26.5148 93.0471 27.7836L99.0532 33.8808C100.303 35.1491 102.432 33.9738 102.024 32.2403C101.95 31.9259 101.791 31.6376 101.564 31.4075L95.5579 25.3103Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<rect
|
||||
x="102.205"
|
||||
y="32.0938"
|
||||
width="10.4348"
|
||||
height="2.08696"
|
||||
rx="0.695652"
|
||||
transform="rotate(-134.569 102.205 32.0938)"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M92.3758 28.5886C92.4061 28.4397 92.4495 28.2994 92.5056 28.1687C92.6572 27.8157 93.1115 27.818 93.3811 28.0917L96.0661 30.8174L98.7512 33.5431C99.0208 33.8168 99.0162 34.2711 98.661 34.4173C98.5294 34.4714 98.3885 34.5128 98.2392 34.5408C97.8392 34.6158 97.3869 34.5938 96.9082 34.476C96.4295 34.3582 95.9336 34.1469 95.449 33.8542C94.9644 33.5616 94.5005 33.1932 94.0838 32.7702C93.6671 32.3471 93.3057 31.8777 93.0203 31.3888C92.735 30.8998 92.5312 30.4009 92.4206 29.9204C92.31 29.44 92.2948 28.9874 92.3758 28.5886Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<circle cx="84" cy="42" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="90" cy="40" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="90" cy="46" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="84" cy="48" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="78" cy="48" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="87" cy="53" r="1" class="fill-n-slate-11" />
|
||||
</g>
|
||||
|
||||
<g class="frame frame-3">
|
||||
<path
|
||||
d="M97.8879 24.7486C97.1799 24.0298 97.0803 22.91 97.6497 22.0771L105.251 10.9597C106 9.86369 107.564 9.72344 108.496 10.6692L116.376 18.6692C117.308 19.6151 117.144 21.1767 116.037 21.9094L104.807 29.3424C103.965 29.8993 102.846 29.7822 102.138 29.0634L97.8879 24.7486Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M95.5579 25.2908C95.3311 25.0607 95.0453 24.8971 94.7321 24.8184C93.0048 24.3847 91.7977 26.4952 93.0471 27.7641L99.0532 33.8613C100.303 35.1296 102.432 33.9543 102.024 32.2207C101.95 31.9063 101.791 31.6181 101.564 31.3879L95.5579 25.2908Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<rect
|
||||
x="102.205"
|
||||
y="32.0703"
|
||||
width="10.4348"
|
||||
height="2.08696"
|
||||
rx="0.695652"
|
||||
transform="rotate(-134.569 102.205 32.0703)"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<path
|
||||
d="M92.3758 28.5691C92.4061 28.4202 92.4495 28.2799 92.5056 28.1492C92.6572 27.7962 93.1115 27.7985 93.3811 28.0722L96.0661 30.7979L98.7512 33.5236C99.0208 33.7973 99.0162 34.2515 98.661 34.3978C98.5294 34.4519 98.3885 34.4932 98.2392 34.5212C97.8392 34.5963 97.3869 34.5743 96.9082 34.4565C96.4295 34.3387 95.9336 34.1274 95.449 33.8347C94.9644 33.542 94.5005 33.1737 94.0838 32.7506C93.6671 32.3276 93.3057 31.8582 93.0203 31.3692C92.735 30.8803 92.5312 30.3813 92.4206 29.9009C92.31 29.4204 92.2948 28.9679 92.3758 28.5691Z"
|
||||
class="stroke-n-slate-11 fill-n-slate-2"
|
||||
stroke-width="1.43699"
|
||||
/>
|
||||
<circle cx="82" cy="44" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="88" cy="42" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="88" cy="48" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="82" cy="50" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="76" cy="50" r="1" class="fill-n-slate-11" />
|
||||
<circle cx="85" cy="55" r="1" class="fill-n-slate-11" />
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_720_25701"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(68 54) rotate(90) scale(54 68)"
|
||||
>
|
||||
<stop
|
||||
offset="0.527769"
|
||||
stop-color="var(--gradient-end)"
|
||||
stop-opacity="0.9"
|
||||
/>
|
||||
<stop offset="1" stop-color="var(--gradient-end)" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
--gradient-start: #fcfcfd;
|
||||
--gradient-end: #fcfcfd;
|
||||
}
|
||||
|
||||
body.dark svg,
|
||||
.htw-dark svg {
|
||||
--gradient-start: #121213;
|
||||
--gradient-end: #121213;
|
||||
}
|
||||
|
||||
.svg-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.default-group {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.frame {
|
||||
opacity: 0;
|
||||
animation-name: frameVisible;
|
||||
animation-duration: 600ms;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: steps(1, end);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.frame-1 {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.frame-2 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
.frame-3 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
@keyframes frameVisible {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
33.333% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.svg-wrapper.paused .frame {
|
||||
animation-play-state: paused;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.svg-wrapper.paused .default-group {
|
||||
opacity: 1;
|
||||
}
|
||||
.svg-wrapper:not(.paused) .default-group {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,752 @@
|
||||
<template>
|
||||
<div class="svg-wrapper relative" tabindex="0">
|
||||
<div class="absolute z-0 flex-shrink-0">
|
||||
<svg
|
||||
width="auto"
|
||||
height="auto"
|
||||
viewBox="0 0 200 156"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g opacity="0.5">
|
||||
<g clip-path="url(#clip0_773_34322)">
|
||||
<circle cx="8" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="3" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="9" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="15" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="21" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="27" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="33" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="39" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="45" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="51" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="57" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="63" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="69" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="75" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="81" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="87" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="93" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="99" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="105" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="111" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="117" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="123" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="129" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="135" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="141" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="147" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="8" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="16" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="24" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="32" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="40" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="48" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="56" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="64" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="72" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="80" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="88" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="96" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="104" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="112" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="120" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="128" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="136" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="144" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="152" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="160" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="168" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="176" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="184" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<circle cx="192" cy="153" r="1" class="fill-n-blue-9" />
|
||||
<rect
|
||||
width="200"
|
||||
height="156"
|
||||
fill="url(#paint0_linear_773_34322)"
|
||||
/>
|
||||
<rect
|
||||
width="200"
|
||||
height="156"
|
||||
fill="url(#paint1_linear_773_34322)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_773_34322"
|
||||
x1="100"
|
||||
y1="0"
|
||||
x2="100"
|
||||
y2="156"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--gradient-start)" />
|
||||
<stop
|
||||
offset="0.480769"
|
||||
stop-color="var(--gradient-start)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
<stop offset="1" stop-color="var(--gradient-start)" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_773_34322"
|
||||
x1="0"
|
||||
y1="78"
|
||||
x2="200"
|
||||
y2="78"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--gradient-start)" />
|
||||
<stop
|
||||
offset="0.480769"
|
||||
stop-color="var(--gradient-start)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
<stop offset="1" stop-color="var(--gradient-start)" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_773_34322">
|
||||
<rect width="200" height="156" rx="16" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 136 108"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="false"
|
||||
focusable="false"
|
||||
class="z-10 relative flex-shrink-0"
|
||||
>
|
||||
<rect width="136" height="108" fill="url(#paint0_radial_797_91519)" />
|
||||
<path
|
||||
d="M58 48.039C58 46.9129 58.9129 46 60.039 46C61.1651 46 62.0779 46.9129 62.0779 48.039V52.7513C62.0779 53.8774 61.1651 54.7902 60.039 54.7902C58.9129 54.7902 58 53.8774 58 52.7513V48.039Z"
|
||||
class="fill-n-slate-10"
|
||||
/>
|
||||
<path
|
||||
d="M64.5244 48.039C64.5244 46.9129 65.4373 46 66.5634 46C67.6895 46 68.6024 46.9129 68.6024 48.039V52.7513C68.6024 53.8774 67.6895 54.7902 66.5634 54.7902C65.4373 54.7902 64.5244 53.8774 64.5244 52.7513V48.039Z"
|
||||
class="fill-n-slate-10"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M79.1862 40.173C71.5933 39.1017 65.4039 39.0594 57.6405 40.1683C55.678 40.4485 54.3796 40.638 53.3896 40.9273C52.4716 41.1956 51.9496 41.5155 51.5177 41.9981C50.6236 42.9974 50.5057 44.1276 50.3838 48.0695C50.264 51.9436 50.5013 55.4156 50.9507 59.3222C51.1901 61.4039 51.3549 62.8088 51.618 63.8769C51.8676 64.89 52.1694 65.4347 52.5889 65.8518C53.0117 66.2721 53.5536 66.5686 54.5482 66.8091C55.6002 67.0636 56.9806 67.2175 59.032 67.4413C65.6668 68.1652 70.6333 68.1615 77.2927 67.4451C79.369 67.2218 80.7719 67.0678 81.8396 66.815C82.8541 66.5749 83.3995 66.2805 83.8165 65.8722C84.2247 65.4724 84.5366 64.921 84.8071 63.8574C85.0894 62.7477 85.2815 61.2793 85.5594 59.1222C86.0553 55.2735 86.4123 51.8935 86.4262 48.1908C86.4411 44.232 86.3601 43.1056 85.4558 42.0608C85.019 41.5562 84.488 41.2244 83.5491 40.9481C82.5352 40.6497 81.2017 40.4573 79.1862 40.173ZM57.1664 36.849C65.2537 35.6939 71.7606 35.7391 79.6546 36.8529L79.7701 36.8692C81.6392 37.1328 83.2183 37.3555 84.4958 37.7315C85.8745 38.1372 87.0274 38.7532 87.991 39.8665C89.8087 41.9666 89.7969 44.4583 89.7811 47.7688C89.7804 47.9121 89.7797 48.057 89.7792 48.2034C89.7645 52.1184 89.3857 55.6634 88.8849 59.5506L88.8717 59.6535C88.6103 61.682 88.395 63.3537 88.0566 64.6839C87.7008 66.0827 87.169 67.282 86.1624 68.2678C85.1645 69.245 83.9854 69.7528 82.6119 70.0778C81.3149 70.3848 79.7011 70.5584 77.7558 70.7676L77.6513 70.7789C70.7551 71.5207 65.5465 71.5249 58.6684 70.7745L58.5622 70.7629C56.6442 70.5537 55.0471 70.3795 53.7599 70.0681C52.3915 69.7371 51.2202 69.219 50.2249 68.2296C49.2263 67.2369 48.7018 66.0571 48.3623 64.6788C48.0421 63.3788 47.856 61.7601 47.6318 59.8109L47.6197 59.7053C47.1588 55.6989 46.906 52.0546 47.0325 47.9659C47.037 47.8176 47.0414 47.6709 47.0458 47.5257C47.144 44.24 47.2178 41.7754 49.019 39.7623C49.9747 38.6942 51.1043 38.102 52.449 37.7089C53.6962 37.3444 55.2339 37.1249 57.0541 36.865C57.0914 36.8597 57.1288 36.8543 57.1664 36.849Z"
|
||||
class="fill-n-slate-10"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_797_91519"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(68 54) rotate(90) scale(54 68)"
|
||||
>
|
||||
<stop
|
||||
offset="0.527769"
|
||||
stop-color="var(--gradient-end)"
|
||||
stop-opacity="0.9"
|
||||
/>
|
||||
<stop offset="1" stop-color="var(--gradient-end)" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
--gradient-start: #fcfcfd;
|
||||
--gradient-end: #fcfcfd;
|
||||
}
|
||||
|
||||
body.dark svg,
|
||||
.htw-dark svg {
|
||||
--gradient-start: #121213;
|
||||
--gradient-end: #121213;
|
||||
}
|
||||
|
||||
.svg-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,17 @@
|
||||
<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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
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({
|
||||
currentPage: {
|
||||
@@ -56,11 +62,36 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAssistantSwitcher: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const route = useRoute();
|
||||
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(() => {
|
||||
return shouldShowPaywall(props.featureFlag);
|
||||
});
|
||||
@@ -72,6 +103,15 @@ const handleButtonClick = () => {
|
||||
const handlePageChange = event => {
|
||||
emit('update:currentPage', event);
|
||||
};
|
||||
|
||||
const toggleAssistantSwitcher = () => {
|
||||
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
|
||||
};
|
||||
|
||||
const handleCreateAssistant = () => {
|
||||
showAssistantSwitcherDropdown.value = false;
|
||||
createAssistantDialogRef.value.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,39 +121,86 @@ const handlePageChange = event => {
|
||||
<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"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<BackButton v-if="backUrl" :to="backUrl" />
|
||||
<slot name="headerTitle">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</slot>
|
||||
<div class="flex gap-3 items-center">
|
||||
<BackButton v-if="backUrl" :back-url="backUrl" />
|
||||
<div
|
||||
v-if="!isEmpty && showKnowMore"
|
||||
v-if="showAssistantSwitcher && !showPaywall"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
<div class="flex items-center gap-2">
|
||||
<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
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Policy :permissions="buttonPolicy">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</Policy>
|
||||
<slot name="action" />
|
||||
<div class="flex gap-2">
|
||||
<slot name="search" />
|
||||
<div
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/captain-button"
|
||||
>
|
||||
<Policy :permissions="buttonPolicy">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/captain-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</Policy>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="subHeader" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
@@ -143,5 +230,6 @@ const handlePageChange = event => {
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</footer>
|
||||
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
|
||||
</section>
|
||||
</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>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<router-link
|
||||
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
|
||||
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
<h6
|
||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</router-link>
|
||||
</h6>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
@@ -29,6 +29,16 @@ const resetConversation = () => {
|
||||
newMessage.value = '';
|
||||
};
|
||||
|
||||
// Watch for assistant ID changes and reset conversation
|
||||
watch(
|
||||
() => assistantId,
|
||||
(newId, oldId) => {
|
||||
if (oldId && newId !== oldId) {
|
||||
resetConversation();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim() || isLoading.value) return;
|
||||
|
||||
@@ -65,16 +75,17 @@ const sendMessage = async () => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||
</h3>
|
||||
<NextButton
|
||||
ghost
|
||||
size="small"
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-rotate-ccw"
|
||||
@click="resetConversation"
|
||||
/>
|
||||
@@ -87,17 +98,17 @@ const sendMessage = async () => {
|
||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||
|
||||
<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
|
||||
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')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
size="small"
|
||||
sm
|
||||
:disabled="!newMessage.trim()"
|
||||
icon="i-lucide-send"
|
||||
@click="sendMessage"
|
||||
|
||||
@@ -64,20 +64,23 @@ const bulkCheckboxState = computed({
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
||||
<span
|
||||
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
|
||||
>
|
||||
{{ selectAllLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<slot name="secondary-actions" />
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
|
||||
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
|
||||
|
||||
const getMessageStyle = sender =>
|
||||
isUserMessage(sender)
|
||||
? 'bg-n-strong text-n-white'
|
||||
: 'bg-n-solid-iris text-n-slate-12';
|
||||
? '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 rounded-bl-sm rounded-br-xl rounded-t-xl';
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
</script>
|
||||
|
||||
<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
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
:class="getMessageAlignment(message.sender)"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
||||
<Avatar
|
||||
:name="getAvatarName(message.sender)"
|
||||
rounded-full
|
||||
:size="24"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<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)"
|
||||
>
|
||||
<div class="break-words" v-html="formatMessage(message.content)" />
|
||||
<div v-html="formatMessage(message.content)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
||||
<div class="inline-flex items-center">
|
||||
<span
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
sm
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||
icon="i-lucide-pencil-line"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||
icon="i-lucide-trash"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
:class="{ 'justify-between w-full': !showActions }"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 truncate" :title="documentable.name">
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
icon="i-ph-files-light"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'User'"
|
||||
icon="i-ph-user-circle-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="truncate"
|
||||
:title="documentable.name"
|
||||
>
|
||||
{{ documentable.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="documentable.type === 'User'"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-user-circle-plus text-base" />
|
||||
<span
|
||||
class="max-w-96 truncate"
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
<i class="i-ph-chat-circle-dots text-base" />
|
||||
<span class="group-hover:underline">
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<i class="i-ph-calendar-dot" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
translationKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
entity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const deleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
const i18nKey = computed(() => {
|
||||
return props.translationKey || props.type.toUpperCase();
|
||||
});
|
||||
|
||||
const deleteEntity = async payload => {
|
||||
if (!payload) return;
|
||||
|
||||
+13
-3
@@ -18,7 +18,7 @@ const props = defineProps({
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
@@ -35,8 +35,18 @@ const i18nKey = computed(
|
||||
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
||||
);
|
||||
|
||||
const createAssistant = assistantDetails =>
|
||||
store.dispatch('captainAssistants/create', assistantDetails);
|
||||
const createAssistant = async 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 => {
|
||||
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>
|
||||
+35
-2
@@ -3,6 +3,8 @@ import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
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 Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
@@ -17,10 +19,16 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const isCaptainV2Enabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
temperature: 1,
|
||||
};
|
||||
|
||||
@@ -29,6 +37,7 @@ const state = reactive({ ...initialState });
|
||||
const validationRules = {
|
||||
handoffMessage: { minLength: minLength(1) },
|
||||
resolutionMessage: { minLength: minLength(1) },
|
||||
instructions: { minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
@@ -40,20 +49,30 @@ const getErrorMessage = field => {
|
||||
const formErrors = computed(() => ({
|
||||
handoffMessage: getErrorMessage('handoffMessage'),
|
||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||
instructions: getErrorMessage('instructions'),
|
||||
}));
|
||||
|
||||
const updateStateFromAssistant = assistant => {
|
||||
const { config = {} } = assistant;
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.temperature = config.temperature || 1;
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
const result = await Promise.all([
|
||||
const validations = [
|
||||
v$.value.handoffMessage.$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;
|
||||
|
||||
const payload = {
|
||||
@@ -65,6 +84,10 @@ const handleSystemMessagesUpdate = async () => {
|
||||
},
|
||||
};
|
||||
|
||||
if (!isCaptainV2Enabled.value) {
|
||||
payload.config.instructions = state.instructions;
|
||||
}
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
@@ -95,6 +118,16 @@ watch(
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<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'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { defineModel, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
authType: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const authConfig = defineModel('authConfig', {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.authType,
|
||||
() => {
|
||||
authConfig.value = {};
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Input
|
||||
v-if="authType === 'bearer'"
|
||||
v-model="authConfig.token"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<template v-else-if="authType === 'basic'">
|
||||
<Input
|
||||
v-model="authConfig.username"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-model="authConfig.password"
|
||||
type="password"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="authType === 'api_key'">
|
||||
<Input
|
||||
v-model="authConfig.name"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
v-model="authConfig.key"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import CustomToolForm from './CustomToolForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedTool: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const updateTool = toolDetails =>
|
||||
store.dispatch('captainCustomTools/update', {
|
||||
id: props.selectedTool.id,
|
||||
...toolDetails,
|
||||
});
|
||||
|
||||
const i18nKey = computed(
|
||||
() => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}`
|
||||
);
|
||||
|
||||
const createTool = toolDetails =>
|
||||
store.dispatch('captainCustomTools/create', toolDetails);
|
||||
|
||||
const handleSubmit = async updatedTool => {
|
||||
try {
|
||||
if (props.type === 'edit') {
|
||||
await updateTool(updatedTool);
|
||||
} else {
|
||||
await createTool(updatedTool);
|
||||
}
|
||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogRef.value.close();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="2xl"
|
||||
:title="$t(`${i18nKey}.TITLE`)"
|
||||
:description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<CustomToolForm
|
||||
:mode="type"
|
||||
:tool="selectedTool"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
<template #footer />
|
||||
</Dialog>
|
||||
</template>
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
authType: {
|
||||
type: String,
|
||||
default: 'none',
|
||||
},
|
||||
updatedAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'),
|
||||
value: 'delete',
|
||||
action: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
]);
|
||||
|
||||
const timestamp = computed(() =>
|
||||
dynamicTime(props.updatedAt || props.createdAt)
|
||||
);
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
toggleDropdown(false);
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const authTypeLabel = computed(() => {
|
||||
return t(
|
||||
`CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}`
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="relative">
|
||||
<div class="flex relative justify-between w-full gap-1">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Policy
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
:permissions="['administrator']"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:right-0 rtl:right-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
v-if="authType !== 'none'"
|
||||
class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-lucide-lock text-base" />
|
||||
{{ authTypeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||
{{ timestamp }}
|
||||
</span>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import ParamRow from './ParamRow.vue';
|
||||
import AuthConfig from './AuthConfig.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
tool: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainCustomTools/getUIFlags'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
description: '',
|
||||
endpoint_url: '',
|
||||
http_method: 'GET',
|
||||
request_template: '',
|
||||
response_template: '',
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
param_schema: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
// Populate form when in edit mode
|
||||
watch(
|
||||
() => props.tool,
|
||||
newTool => {
|
||||
if (props.mode === 'edit' && newTool && newTool.id) {
|
||||
state.title = newTool.title || '';
|
||||
state.description = newTool.description || '';
|
||||
state.endpoint_url = newTool.endpoint_url || '';
|
||||
state.http_method = newTool.http_method || 'GET';
|
||||
state.request_template = newTool.request_template || '';
|
||||
state.response_template = newTool.response_template || '';
|
||||
state.auth_type = newTool.auth_type || 'none';
|
||||
state.auth_config = newTool.auth_config || {};
|
||||
state.param_schema = newTool.param_schema || [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const DEFAULT_PARAM = {
|
||||
name: '',
|
||||
type: 'string',
|
||||
description: '',
|
||||
required: false,
|
||||
};
|
||||
|
||||
const validationRules = {
|
||||
title: { required },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
};
|
||||
|
||||
const httpMethodOptions = computed(() => [
|
||||
{ value: 'GET', label: 'GET' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
]);
|
||||
|
||||
const authTypeOptions = computed(() => [
|
||||
{ value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') },
|
||||
{ value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') },
|
||||
{ value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') },
|
||||
{
|
||||
value: 'api_key',
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
|
||||
},
|
||||
]);
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
props.mode === 'edit'
|
||||
? formState.uiFlags.value.updatingItem
|
||||
: formState.uiFlags.value.creatingItem
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'),
|
||||
}));
|
||||
|
||||
const paramsRef = useTemplateRef('paramsRef');
|
||||
|
||||
const isParamsValid = () => {
|
||||
if (!paramsRef.value || paramsRef.value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return paramsRef.value.every(param => param.validate());
|
||||
};
|
||||
|
||||
const removeParam = index => {
|
||||
state.param_schema.splice(index, 1);
|
||||
};
|
||||
|
||||
const addParam = () => {
|
||||
state.param_schema.push({ ...DEFAULT_PARAM });
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid || !isParamsValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:rows="2"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 w-28">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.http_method"
|
||||
:options="httpMethodOptions"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.endpoint_url"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')"
|
||||
:message="formErrors.endpoint_url"
|
||||
:message-type="formErrors.endpoint_url ? 'error' : 'info'"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
v-model="state.auth_type"
|
||||
:options="authTypeOptions"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthConfig
|
||||
v-model:auth-config="state.auth_config"
|
||||
:auth-type="state.auth_type"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
|
||||
</label>
|
||||
<p class="text-xs text-n-slate-11 -mt-1">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }}
|
||||
</p>
|
||||
<ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none">
|
||||
<ParamRow
|
||||
v-for="(param, index) in state.param_schema"
|
||||
:key="index"
|
||||
ref="paramsRef"
|
||||
v-model:name="param.name"
|
||||
v-model:type="param.type"
|
||||
v-model:description="param.description"
|
||||
v-model:required="param.required"
|
||||
@remove="removeParam(index)"
|
||||
/>
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
sm
|
||||
ghost
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')"
|
||||
@click="addParam"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
v-if="state.http_method === 'POST'"
|
||||
v-model="state.request_template"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')"
|
||||
:rows="4"
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="state.response_template"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER')
|
||||
"
|
||||
:rows="4"
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="
|
||||
t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')
|
||||
"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed, defineModel, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
const { t } = useI18n();
|
||||
const showErrors = ref(false);
|
||||
|
||||
const name = defineModel('name', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const type = defineModel('type', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const description = defineModel('description', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const required = defineModel('required', {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
});
|
||||
|
||||
const paramTypeOptions = computed(() => [
|
||||
{ value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') },
|
||||
{ value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') },
|
||||
{
|
||||
value: 'boolean',
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'),
|
||||
},
|
||||
{ value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') },
|
||||
{ value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') },
|
||||
]);
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (!name.value || name.value.trim() === '') {
|
||||
return 'PARAM_NAME_REQUIRED';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
watch([name, type, description, required], () => {
|
||||
showErrors.value = false;
|
||||
});
|
||||
|
||||
const validate = () => {
|
||||
showErrors.value = true;
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="list-none">
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
|
||||
:class="{
|
||||
'animate-wiggle border-n-ruby-9': showErrors && validationError,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col flex-1 gap-3">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
v-model="name"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')"
|
||||
class="col-span-2"
|
||||
/>
|
||||
<ComboBox
|
||||
v-model="type"
|
||||
:options="paramTypeOptions"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="description"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox v-model="required" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="showErrors && validationError"
|
||||
class="block mt-1 text-sm text-n-ruby-11"
|
||||
>
|
||||
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
+12
-1
@@ -8,6 +8,13 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DocumentForm from './DocumentForm.vue';
|
||||
|
||||
defineProps({
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -48,7 +55,11 @@ defineExpose({ dialogRef });
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
|
||||
<DocumentForm
|
||||
:assistant-id="assistantId"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
<template #footer />
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+9
-28
@@ -2,7 +2,7 @@
|
||||
import { reactive, computed, ref, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 { 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 ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -18,13 +25,11 @@ const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
url: '',
|
||||
assistantId: null,
|
||||
documentType: 'url',
|
||||
pdfFile: null,
|
||||
};
|
||||
@@ -38,19 +43,11 @@ const validationRules = {
|
||||
url: requiredIf(() => state.documentType === 'url' && url),
|
||||
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
||||
},
|
||||
assistantId: { required },
|
||||
pdfFile: {
|
||||
required: requiredIf(() => state.documentType === 'pdf'),
|
||||
},
|
||||
};
|
||||
|
||||
const assistantList = computed(() =>
|
||||
formState.assistants.value.map(assistant => ({
|
||||
value: assistant.id,
|
||||
label: assistant.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const documentTypeOptions = [
|
||||
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
||||
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
||||
@@ -70,7 +67,6 @@ const getErrorMessage = (field, errorKey) => {
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
url: getErrorMessage('url', 'URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
||||
}));
|
||||
|
||||
@@ -106,7 +102,7 @@ const openFileDialog = () => {
|
||||
|
||||
const prepareDocumentDetails = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('document[assistant_id]', state.assistantId);
|
||||
formData.append('document[assistant_id]', props.assistantId);
|
||||
|
||||
if (state.documentType === 'url') {
|
||||
formData.append('document[external_link]', state.url);
|
||||
@@ -218,21 +214,6 @@ const handleSubmit = async () => {
|
||||
: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">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -20,6 +22,7 @@ const onClick = () => {
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-assistant"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="min-h-[600px]" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -19,6 +21,7 @@ const onClick = () => {
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-document"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
|
||||
+50
-9
@@ -1,32 +1,63 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'approved',
|
||||
validator: value => ['approved', 'pending'].includes(value),
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'clearFilters']);
|
||||
|
||||
const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const onClearFilters = () => {
|
||||
emit('clearFilters');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
v-if="isApproved"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:title="
|
||||
isPending
|
||||
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ResponseCard
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
@@ -42,11 +73,21 @@ const onClick = () => {
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
v-if="isApproved"
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="isPending && hasActiveFilters"
|
||||
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="onClearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
+10
-2
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ResponseForm from './ResponseForm.vue';
|
||||
@@ -21,6 +22,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const responseForm = ref(null);
|
||||
@@ -39,9 +41,15 @@ const createResponse = responseDetails =>
|
||||
const handleSubmit = async updatedResponse => {
|
||||
try {
|
||||
if (props.type === 'edit') {
|
||||
await updateResponse(updatedResponse);
|
||||
await updateResponse({
|
||||
...updatedResponse,
|
||||
assistant_id: route.params.assistantId,
|
||||
});
|
||||
} else {
|
||||
await createResponse(updatedResponse);
|
||||
await createResponse({
|
||||
...updatedResponse,
|
||||
assistant_id: route.params.assistantId,
|
||||
});
|
||||
}
|
||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||
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 Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -21,18 +20,17 @@ const props = defineProps({
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainResponses/getUIFlags'),
|
||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
question: '',
|
||||
answer: '',
|
||||
assistantId: null,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
@@ -40,16 +38,8 @@ const state = reactive({ ...initialState });
|
||||
const validationRules = {
|
||||
question: { 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 isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
@@ -63,7 +53,6 @@ const getErrorMessage = (field, errorKey) => {
|
||||
const formErrors = computed(() => ({
|
||||
question: getErrorMessage('question', 'QUESTION'),
|
||||
answer: getErrorMessage('answer', 'ANSWER'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
@@ -71,7 +60,6 @@ const handleCancel = () => emit('cancel');
|
||||
const prepareDocumentDetails = () => ({
|
||||
question: state.question,
|
||||
answer: state.answer,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -86,12 +74,11 @@ const handleSubmit = async () => {
|
||||
const updateStateFromResponse = response => {
|
||||
if (!response) return;
|
||||
|
||||
const { question, answer, assistant } = response;
|
||||
const { question, answer } = response;
|
||||
|
||||
Object.assign(state, {
|
||||
question,
|
||||
answer,
|
||||
assistantId: assistant.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,7 +102,6 @@ watch(
|
||||
:message="formErrors.question"
|
||||
:message-type="formErrors.question ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.answer"
|
||||
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||
@@ -124,22 +110,6 @@ watch(
|
||||
:max-length="10000"
|
||||
: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">
|
||||
<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>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import GroupedStackedChangelogCard from './GroupedStackedChangelogCard.vue';
|
||||
|
||||
const sampleCards = [
|
||||
{
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
meta_title: 'Chatwoot Captain',
|
||||
meta_description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
slug: 'chatwoot-captain',
|
||||
feature_image:
|
||||
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
},
|
||||
{
|
||||
id: 'smart-routing',
|
||||
title: 'Smart Routing Forms',
|
||||
meta_title: 'Smart Routing Forms',
|
||||
meta_description:
|
||||
'Screen bookers with intelligent forms and route them to the right team member.',
|
||||
slug: 'smart-routing',
|
||||
feature_image: 'https://www.chatwoot.com/images/dashboard-dark.webp',
|
||||
},
|
||||
{
|
||||
id: 'instant-meetings',
|
||||
title: 'Instant Meetings',
|
||||
meta_title: 'Instant Meetings',
|
||||
meta_description: 'Start instant meetings directly from shared links.',
|
||||
slug: 'instant-meetings',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1587614382346-4ec70e388b28?w=600',
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Advanced Analytics',
|
||||
meta_title: 'Advanced Analytics',
|
||||
meta_description:
|
||||
'Track meeting performance, conversion, and response rates in one place.',
|
||||
slug: 'analytics',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=500',
|
||||
},
|
||||
{
|
||||
id: 'team-collaboration',
|
||||
title: 'Team Collaboration',
|
||||
meta_title: 'Team Collaboration',
|
||||
meta_description:
|
||||
'Coordinate with your team seamlessly using shared availability.',
|
||||
slug: 'team-collaboration',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleCards = ref([...sampleCards]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
|
||||
const handleReadMore = slug => {
|
||||
console.log(`Read more: ${slug}`);
|
||||
};
|
||||
|
||||
const handleDismiss = slug => {
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
const idx = visibleCards.value.findIndex(c => c.slug === slug);
|
||||
if (idx !== -1) visibleCards.value.splice(idx, 1);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleImgClick = data => {
|
||||
currentIndex.value = data.index;
|
||||
console.log(`Card clicked: ${visibleCards.value[data.index].title}`);
|
||||
};
|
||||
|
||||
const resetDemo = () => {
|
||||
visibleCards.value = [...sampleCards];
|
||||
currentIndex.value = 0;
|
||||
dismissingCards.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ChangelogCard/GroupedStackedChangelogCard"
|
||||
:layout="{ type: 'grid', width: '320px' }"
|
||||
>
|
||||
<Variant title="Interactive Demo">
|
||||
<div class="p-4 bg-n-solid-2 rounded-md mx-auto w-64 h-[400px]">
|
||||
<GroupedStackedChangelogCard
|
||||
:posts="visibleCards"
|
||||
:current-index="currentIndex"
|
||||
:is-active="currentIndex === 0"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
class="min-h-[270px]"
|
||||
@read-more="handleReadMore"
|
||||
@dismiss="handleDismiss"
|
||||
@img-click="handleImgClick"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="mt-3 px-3 py-1 text-xs font-medium bg-n-brand text-white rounded hover:bg-n-brand/80 transition"
|
||||
@click="resetDemo"
|
||||
>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ 'Reset Cards' }}
|
||||
</button>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
posts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
currentIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
dismissingSlugs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||
|
||||
const stackedPosts = computed(() => props.posts?.slice(0, 5));
|
||||
|
||||
const isPostDismissing = post => props.dismissingSlugs.includes(post.slug);
|
||||
|
||||
const handleReadMore = post => emit('readMore', post.slug);
|
||||
const handleDismiss = post => emit('dismiss', post.slug);
|
||||
const handlePostClick = (post, index) => {
|
||||
if (index === props.currentIndex && !isPostDismissing(post)) {
|
||||
emit('imgClick', { slug: post.slug, index });
|
||||
}
|
||||
};
|
||||
|
||||
const getCardClasses = index => {
|
||||
const pos =
|
||||
(index - props.currentIndex + stackedPosts.value.length) %
|
||||
stackedPosts.value.length;
|
||||
const base =
|
||||
'relative transition-all duration-500 ease-out col-start-1 row-start-1';
|
||||
|
||||
const layers = [
|
||||
'z-50 scale-100 translate-y-0 opacity-100',
|
||||
'z-40 scale-[0.95] -translate-y-3 opacity-90',
|
||||
'z-30 scale-[0.9] -translate-y-6 opacity-70',
|
||||
'z-20 scale-[0.85] -translate-y-9 opacity-50',
|
||||
'z-10 scale-[0.8] -translate-y-12 opacity-30',
|
||||
];
|
||||
|
||||
return pos < layers.length
|
||||
? `${base} ${layers[pos]}`
|
||||
: `${base} opacity-0 scale-75 -translate-y-16`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<div class="relative grid grid-cols-1 pt-8 pb-1 px-2">
|
||||
<div
|
||||
v-for="(post, index) in stackedPosts"
|
||||
:key="post.slug || index"
|
||||
:class="getCardClasses(index)"
|
||||
>
|
||||
<StackedChangelogCard
|
||||
:card="post"
|
||||
:is-active="index === currentIndex"
|
||||
:is-dismissing="isPostDismissing(post)"
|
||||
@read-more="handleReadMore(post)"
|
||||
@dismiss="handleDismiss(post)"
|
||||
@img-click="handlePostClick(post, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||
|
||||
const imageCards = {
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
meta_title: 'Chatwoot Captain',
|
||||
meta_description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
slug: 'chatwoot-captain',
|
||||
feature_image:
|
||||
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
};
|
||||
|
||||
const handleReadMore = () => {
|
||||
console.log(`Read more: ${imageCards.title}`);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
console.log(`Dismissed: ${imageCards.title}`);
|
||||
};
|
||||
|
||||
const handleImgClick = () => {
|
||||
console.log(`Card clicked: ${imageCards.title}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ChangelogCard/StackedChangelogCard"
|
||||
:layout="{ type: 'grid', width: '260px' }"
|
||||
>
|
||||
<Variant title="Single Card - With Image">
|
||||
<div class="p-3 bg-n-solid-2 w-56">
|
||||
<StackedChangelogCard
|
||||
:card="imageCards"
|
||||
is-active
|
||||
:is-dismissing="false"
|
||||
@read-more="handleReadMore(imageCards)"
|
||||
@dismiss="handleDismiss(imageCards)"
|
||||
@img-click="handleImgClick(imageCards)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user