Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 4bb414727f test update for mintlify app 2025-05-08 13:25:22 +07:00
Tanmay Deep Sharma f4a77dabd9 add Open API v3 developer docs 2025-05-08 12:47:22 +07:00
1463 changed files with 10903 additions and 65571 deletions
+3 -3
View File
@@ -73,15 +73,15 @@ jobs:
libvips libvips
- run: - run:
name: Install RVM and Ruby 3.4.4 name: Install RVM and Ruby 3.3.3
command: | command: |
sudo apt-get install -y gpg sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable \curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
rvm install "3.4.4" rvm install "3.3.3"
rvm use 3.4.4 --default rvm use 3.3.3 --default
gem install bundler -v 2.5.16 gem install bundler -v 2.5.16
- run: - run:
+1 -11
View File
@@ -4,15 +4,5 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml ./
COPY Gemfile Gemfile.lock ./
# Install dependencies (will be cached if files don't change)
RUN pnpm install --frozen-lockfile && \
gem install bundler && \
bundle install --jobs=$(nproc)
# Copy source code after dependencies are installed
COPY . /workspace COPY . /workspace
RUN yarn && gem install bundler && bundle install
+42 -65
View File
@@ -1,16 +1,12 @@
ARG VARIANT="ubuntu-22.04"
ARG VARIANT
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION ARG NODE_VERSION
ARG RUBY_VERSION ARG RUBY_VERSION
ARG USER_UID ARG USER_UID
ARG USER_GID ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
@@ -19,80 +15,61 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \ && chmod -R $USER_UID:$USER_GID /home/vscode; \
fi fi
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ && apt-get -y install --no-install-recommends \
&& apt-get update \ build-essential \
&& apt-get -y install --no-install-recommends \ libssl-dev \
build-essential \ zlib1g-dev \
libssl-dev \ gnupg2 \
zlib1g-dev \ tar \
gnupg \ tzdata \
tar \ postgresql-client \
tzdata \ libpq-dev \
postgresql-client \ yarn \
libpq-dev \ git \
git \ imagemagick \
imagemagick \ tmux \
libyaml-dev \ zsh \
curl \ git-flow \
ca-certificates \ npm \
tmux \ libyaml-dev
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby for root user first # Install rbenv and ruby
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc && echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \ RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \ RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \ rbenv global $RUBY_VERSION && \
rbenv versions rbenv versions
# Set up rbenv for vscode user # Install overmind
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \ && gunzip overmind.gz \
&& mv overmind /usr/local/bin \ && sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \ && chmod +x /usr/local/bin/overmind
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \ # Install gh
&& apt-get install -y --no-install-recommends gh \ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& apt-get clean \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
RUN chown vscode:vscode /workspace COPY . /workspace
# set up node js, pnpm and claude code in single layer # set up ruby
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ COPY Gemfile Gemfile.lock ./
&& npm cache clean --force RUN gem install bundler && bundle install
# Switch to vscode user # set up node js
USER vscode RUN npm install n -g && \
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" n $NODE_VERSION
RUN npm install --global yarn
# Copy dependency files first for better caching RUN yarn
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
# Install dependencies as vscode user
RUN eval "$(rbenv init -)" \
&& gem install bundler -N \
&& bundle install --jobs=$(nproc) \
&& pnpm install --frozen-lockfile
# Copy source code after dependencies are installed
COPY --chown=vscode:vscode . /workspace
+8 -17
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"settings": { "settings": {
"terminal.integrated.shell.linux": "/bin/zsh", "terminal.integrated.shell.linux": "/bin/zsh"
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"Shopify.ruby-lsp", "rebornix.Ruby",
"misogi.ruby-rubocop", "misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner", "davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot", "github.copilot",
"mrmlnc.vscode-duplicate" "mrmlnc.vscode-duplicate"
], ],
@@ -32,15 +23,15 @@
// 5432 postgres // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3036], "forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install", "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "Rails Server" "label": "Rails Server"
}, },
"3036": { "3035": {
"label": "Vite Dev Server" "label": "Webpack Dev Server"
}, },
"8025": { "8025": {
"label": "Mailhog UI" "label": "Mailhog UI"
-18
View File
@@ -1,18 +0,0 @@
# Docker Compose file for building the base image in GitHub Actions
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
+14 -1
View File
@@ -5,6 +5,19 @@
version: '3' version: '3'
services: services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app: app:
build: build:
context: .. context: ..
@@ -12,7 +25,7 @@ services:
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
USER_GID: '1000' USER_GID: '1000'
+7 -10
View File
@@ -2,15 +2,12 @@ cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# Setup Claude Code API key if available # uncomment the webpacker env variable
if [ -n "$CLAUDE_CODE_API_KEY" ]; then sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
mkdir -p ~/.claude # fix the error with webpacker
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public # codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
+2 -3
View File
@@ -2,7 +2,7 @@
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables # https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set # Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. # SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable # Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex SECRET_KEY_BASE=replace_with_lengthy_secure_hex
@@ -216,8 +216,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# ENABLE_RACK_ATTACK=true # ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300 # RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true # ENABLE_RACK_ATTACK_WIDGET_API=true
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## Running chatwoot as an API only server ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
@@ -259,3 +257,4 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed # contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
@@ -19,5 +19,6 @@ jobs:
- name: Build the Codespace Base Image - name: Build the Codespace Base Image
run: | run: |
docker compose -f .devcontainer/docker-compose.base.yml build base docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker push ghcr.io/chatwoot/chatwoot_codespace:latest docker push ghcr.io/chatwoot/chatwoot_codespace:latest
+5 -5
View File
@@ -71,6 +71,9 @@ test/cypress/videos/*
/config/master.key /config/master.key
/config/*.enc /config/*.enc
#ignore files under .vscode directory
.vscode
.cursor
# yalc for local testing # yalc for local testing
.yalc .yalc
@@ -89,8 +92,5 @@ yarn-debug.log*
# https://vitejs.dev/guide/env-and-mode.html#env-files # https://vitejs.dev/guide/env-and-mode.html#env-files
*.local *.local
# Claude.ai config file
# TextEditors & AI Agents config files CLAUDE.md
.vscode
.claude/settings.local.json
.cursor
+8 -179
View File
@@ -1,10 +1,7 @@
plugins: require:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
- rubocop-factory_bot
require:
- ./rubocop/use_from_email.rb - ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb - ./rubocop/custom_cop_location.rb
@@ -16,61 +13,44 @@ Metrics/ClassLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
Metrics/MethodLength: Metrics/MethodLength:
Max: 19 Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/ExponentialNotation: Style/ExponentialNotation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:
Enabled: false Enabled: false
Style/SymbolArray: Style/SymbolArray:
Enabled: false Enabled: false
Style/OpenStructUse: Style/OpenStructUse:
Enabled: false Enabled: false
Style/OptionalBooleanParameter: Style/OptionalBooleanParameter:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb' - 'app/dispatchers/dispatcher.rb'
Style/GlobalVars: Style/GlobalVars:
Exclude: Exclude:
- 'config/initializers/01_redis.rb' - 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb' - 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb' - 'lib/redis/alfred.rb'
- 'lib/global_config.rb' - 'lib/global_config.rb'
Style/ClassVars: Style/ClassVars:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
Lint/MissingSuper: Lint/MissingSuper:
Exclude: Exclude:
- 'app/drops/base_drop.rb' - 'app/drops/base_drop.rb'
Lint/SymbolConversion: Lint/SymbolConversion:
Enabled: false Enabled: false
Lint/EmptyBlock: Lint/EmptyBlock:
Exclude: Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder' - 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant: Lint/OrAssignmentToConstant:
Exclude: Exclude:
- 'lib/redis/config.rb' - 'lib/redis/config.rb'
Metrics/BlockLength: Metrics/BlockLength:
Max: 30 Max: 30
Exclude: Exclude:
@@ -78,16 +58,10 @@ Metrics/BlockLength:
- '**/routes.rb' - '**/routes.rb'
- 'config/environments/*' - 'config/environments/*'
- db/schema.rb - db/schema.rb
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
- lib/seeders/message_seeder.rb - lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb - spec/support/slack_stubs.rb
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -97,101 +71,74 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb' - 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb' - 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb' - 'app/controllers/survey/responses_controller.rb'
Rails/FindEach: Rails/FindEach:
Enabled: true Enabled: true
Include: Include:
- 'app/**/*.rb' - 'app/**/*.rb'
Rails/CompactBlank: Rails/CompactBlank:
Enabled: false Enabled: false
Rails/EnvironmentVariableAccess: Rails/EnvironmentVariableAccess:
Enabled: false Enabled: false
Rails/TimeZoneAssignment: Rails/TimeZoneAssignment:
Enabled: false Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo: Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false Enabled: false
Rails/InverseOf:
Exclude:
- enterprise/app/models/captain/assistant.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- app/models/canned_response.rb
- app/models/telegram_bot.rb
- enterprise/app/models/captain_inbox.rb
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
EnforcedStyle: compact EnforcedStyle: compact
Exclude: Exclude:
- 'config/application.rb' - 'config/application.rb'
- 'config/initializers/monkey_patches/*' - 'config/initializers/monkey_patches/*'
Style/MapToHash: Style/MapToHash:
Enabled: false Enabled: false
Style/HashSyntax: Style/HashSyntax:
Enabled: true Enabled: true
EnforcedStyle: no_mixed_keys EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never EnforcedShorthandSyntax: never
RSpec/NestedGroups: RSpec/NestedGroups:
Enabled: true Enabled: true
Max: 4 Max: 4
RSpec/MessageSpies: RSpec/MessageSpies:
Enabled: false Enabled: false
RSpec/StubbedMock: RSpec/StubbedMock:
Enabled: false Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber: Naming/VariableNumber:
Enabled: false Enabled: false
Naming/MemoizedInstanceVariableName: Naming/MemoizedInstanceVariableName:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
Style/GuardClause: Style/GuardClause:
Exclude: Exclude:
- 'app/builders/account_builder.rb' - 'app/builders/account_builder.rb'
- 'app/models/attachment.rb' - 'app/models/attachment.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
Metrics/AbcSize: Metrics/AbcSize:
Max: 26 Max: 26
Exclude: Exclude:
- 'app/controllers/concerns/auth_helper.rb' - 'app/controllers/concerns/auth_helper.rb'
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
- 'app/models/integrations/hook.rb' - 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb' - 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb' - 'app/models/telegram_bot.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Rails/ThreeStateBooleanColumn: Rails/ThreeStateBooleanColumn:
Exclude: Exclude:
- 'db/migrate/20230503101201_create_sla_policies.rb' - 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet: RSpec/IndexedLet:
Enabled: false Enabled: false
RSpec/NamedSubject: RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 7 Max: 7
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 14 Max: 14
@@ -219,121 +166,3 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20230426130150_init_schema.rb'
FactoryBot/SyntaxMethods:
Enabled: false
# Disable new rules causing errors
Layout/LeadingCommentSpace:
Enabled: false
Style/ReturnNilInPredicateMethodDefinition:
Enabled: false
Style/RedundantParentheses:
Enabled: false
Performance/StringIdentifierArgument:
Enabled: false
Layout/EmptyLinesAroundExceptionHandlingKeywords:
Enabled: false
Lint/LiteralAsCondition:
Enabled: false
Style/RedundantReturn:
Enabled: false
Layout/SpaceAroundOperators:
Enabled: false
Rails/EnvLocal:
Enabled: false
Rails/WhereRange:
Enabled: false
Lint/UselessConstantScoping:
Enabled: false
Style/MultipleComparison:
Enabled: false
Bundler/OrderedGems:
Enabled: false
RSpec/ExampleWording:
Enabled: false
RSpec/ReceiveMessages:
Enabled: false
FactoryBot/AssociationStyle:
Enabled: false
Rails/EnumSyntax:
Enabled: false
Lint/RedundantTypeConversion:
Enabled: false
# Additional rules to disable
Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Style/SafeNavigationChainLength:
Enabled: false
Lint/SafeNavigationConsistency:
Enabled: false
Lint/CopDirectiveSyntax:
Enabled: false
# Final set of rules to disable
FactoryBot/ExcessiveCreateList:
Enabled: false
RSpec/MissingExpectationTargetMethod:
Enabled: false
Performance/InefficientHashSearch:
Enabled: false
Style/RedundantSelfAssignmentBranch:
Enabled: false
Style/YAMLFileRead:
Enabled: false
Layout/ExtraSpacing:
Enabled: false
Style/RedundantFilterChain:
Enabled: false
Performance/MapMethodChain:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
Style/SuperArguments:
Enabled: false
# Final remaining rules to disable
Rails/Delegate:
Enabled: false
Style/CaseLikeIf:
Enabled: false
FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
+1 -1
View File
@@ -1 +1 @@
3.4.4 3.3.3
-1
View File
@@ -1 +0,0 @@
../../AGENTS.md
-58
View File
@@ -1,58 +0,0 @@
# Chatwoot Development Guidelines
## Build / Test / Lint
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
## Code Style
- **Ruby**: Follow RuboCop rules (150 character max line length)
- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended)
- **Vue Components**: Use PascalCase
- **Events**: Use camelCase
- **I18n**: No bare strings in templates; use i18n
- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`)
- **Models**: Validate presence/uniqueness, add proper indexes
- **Type Safety**: Use PropTypes in Vue, strong params in Rails
- **Naming**: Use clear, descriptive names with consistent casing
- **Vue API**: Always use Composition API with `<script setup>` at the top
## Styling
- **Tailwind Only**:
- Do not write custom CSS
- Do not use scoped CSS
- Do not use inline styles
- Always use Tailwind utility classes
- **Colors**: Refer to `tailwind.config.js` for color definitions
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
## Project-Specific
- **Translations**:
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+5 -5
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.4.4' ruby '3.3.3'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors' gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1' gem 'rails', '~> 7.0.8.4'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
@@ -33,8 +33,6 @@ gem 'liquid'
gem 'commonmarker' gem 'commonmarker'
# Validate Data against JSON Schema # Validate Data against JSON Schema
gem 'json_schemer' gem 'json_schemer'
# used in swagger build
gem 'json_refs'
# Rack middleware for blocking & throttling abusive requests # Rack middleware for blocking & throttling abusive requests
gem 'rack-attack', '>= 6.7.0' gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files # a utility tool for streaming, flexible and safe downloading of remote files
@@ -198,6 +196,9 @@ group :development do
gem 'scss_lint', require: false gem 'scss_lint', require: false
gem 'web-console', '>= 4.2.1' gem 'web-console', '>= 4.2.1'
# used in swagger build
gem 'json_refs'
# When we want to squash migrations # When we want to squash migrations
gem 'squasher' gem 'squasher'
@@ -236,7 +237,6 @@ group :development, :test do
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false gem 'rubocop-rspec', require: false
gem 'rubocop-factory_bot', require: false
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false gem 'simplecov', '0.17.1', require: false
+141 -161
View File
@@ -25,89 +25,76 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.1.5.1) actioncable (7.0.8.7)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) actionmailbox (7.0.8.7)
actionmailbox (7.1.5.1) actionpack (= 7.0.8.7)
actionpack (= 7.1.5.1) activejob (= 7.0.8.7)
activejob (= 7.1.5.1) activerecord (= 7.0.8.7)
activerecord (= 7.1.5.1) activestorage (= 7.0.8.7)
activestorage (= 7.1.5.1) activesupport (= 7.0.8.7)
activesupport (= 7.1.5.1)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.5.1) actionmailer (7.0.8.7)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.7)
actionview (= 7.1.5.1) actionview (= 7.0.8.7)
activejob (= 7.1.5.1) activejob (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
actionpack (7.1.5.1) actionpack (7.0.8.7)
actionview (= 7.1.5.1) actionview (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
nokogiri (>= 1.8.5) rack (~> 2.0, >= 2.2.4)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.1.5.1) actiontext (7.0.8.7)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.7)
activerecord (= 7.1.5.1) activerecord (= 7.0.8.7)
activestorage (= 7.1.5.1) activestorage (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.5.1) actionview (7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.4)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.1.5.1) activejob (7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.5.1) activemodel (7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
activerecord (7.1.5.1) activerecord (7.0.8.7)
activemodel (= 7.1.5.1) activemodel (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
timeout (>= 0.4.0) activerecord-import (1.4.1)
activerecord-import (2.1.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.1.5.1) activestorage (7.0.8.7)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.7)
activejob (= 7.1.5.1) activejob (= 7.0.8.7)
activerecord (= 7.1.5.1) activerecord (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.5.1) mini_mime (>= 1.1.0)
base64 activesupport (7.0.8.7)
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0)
acts-as-taggable-on (12.0.0) acts-as-taggable-on (9.0.1)
activerecord (>= 7.1, < 8.1) activerecord (>= 6.0, < 7.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1) administrate (0.20.1)
@@ -129,7 +116,7 @@ GEM
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.3) ast (2.4.2)
attr_extras (7.1.0) attr_extras (7.1.0)
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
@@ -155,15 +142,14 @@ GEM
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.0) bigdecimal (3.1.8)
bigdecimal (3.1.9)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (5.4.1) brakeman (5.4.1)
browser (5.3.1) browser (5.3.1)
builder (3.3.0) builder (3.3.0)
bullet (8.0.7) bullet (7.0.7)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundle-audit (0.1.0) bundle-audit (0.1.0)
@@ -175,8 +161,8 @@ GEM
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.4)
connection_pool (2.5.3) connection_pool (2.4.1)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@@ -190,10 +176,16 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) datadog-ci (0.8.3)
ddtrace (0.48.0)
ffi (~> 1.0)
msgpack msgpack
date (3.4.1)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
libdatadog (~> 7.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
msgpack
debase-ruby_core_source (3.3.1)
debug (1.8.0) debug (1.8.0)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
@@ -204,10 +196,10 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise_token_auth (1.2.5) devise_token_auth (1.2.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 8.1) rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1) diff-lcs (1.5.1)
digest-crc (0.6.5) digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
@@ -220,7 +212,6 @@ GEM
railties (>= 6.1) railties (>= 6.1)
down (5.4.0) down (5.4.0)
addressable (~> 2.8) addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0) dry-cli (1.1.0)
ecma-re-validator (0.4.0) ecma-re-validator (0.4.0)
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
@@ -263,10 +254,7 @@ GEM
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1) googleauth (~> 1)
ffi (1.17.2) ffi (1.16.3)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@@ -327,13 +315,16 @@ GEM
google-cloud-translate-v3 (0.10.0) google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a) gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.7) google-protobuf (3.25.5)
google-protobuf (3.25.5-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux)
googleapis-common-protos (1.6.0) googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7) googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41) grpc (~> 1.41)
googleapis-common-protos-types (1.20.0) googleapis-common-protos-types (1.14.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (~> 3.18)
googleauth (1.11.2) googleauth (1.11.2)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1) google-cloud-env (~> 2.1)
@@ -343,17 +334,17 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
groupdate (6.2.1) groupdate (6.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.72.0) grpc (1.62.0)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-arm64-darwin) grpc (1.62.0-arm64-darwin)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-x86_64-darwin) grpc (1.62.0-x86_64-darwin)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-x86_64-linux) grpc (1.62.0-x86_64-linux)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (1.0.0) hairtrigger (1.0.0)
@@ -379,7 +370,7 @@ GEM
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.7) i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@@ -397,7 +388,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.12.0) json (2.6.3)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -432,13 +423,21 @@ GEM
faraday-multipart faraday-multipart
json (>= 1.8) json (>= 1.8)
rexml rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
launchy (>= 2.2, < 3) launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0) line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0) liquid (5.4.0)
listen (3.8.0) listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
@@ -446,7 +445,7 @@ GEM
llhttp-ffi (0.4.0) llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
logger (1.7.0) logger (1.6.0)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
@@ -472,10 +471,10 @@ GEM
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.8)
minitest (5.25.5) minitest (5.25.4)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.8.0) msgpack (1.7.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
@@ -546,16 +545,14 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.1) ostruct (0.6.1)
parallel (1.27.0) parallel (1.23.0)
parser (3.3.8.0) parser (3.2.2.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc
pg (1.5.3) pg (1.5.3)
pg_search (2.3.6) pg_search (2.3.6)
activerecord (>= 5.2) activerecord (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
pgvector (0.1.1) pgvector (0.1.1)
prism (1.4.0)
procore-sift (1.0.0) procore-sift (1.0.0)
activerecord (>= 6.1) activerecord (>= 6.1)
pry (0.14.2) pry (0.14.2)
@@ -570,7 +567,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.15) rack (2.2.13)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -584,28 +581,23 @@ GEM
rack (~> 2.2, >= 2.2.4) rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (1.0.2)
rack (< 3)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rackup (1.0.1) rails (7.0.8.7)
rack (< 3) actioncable (= 7.0.8.7)
webrick actionmailbox (= 7.0.8.7)
rails (7.1.5.1) actionmailer (= 7.0.8.7)
actioncable (= 7.1.5.1) actionpack (= 7.0.8.7)
actionmailbox (= 7.1.5.1) actiontext (= 7.0.8.7)
actionmailer (= 7.1.5.1) actionview (= 7.0.8.7)
actionpack (= 7.1.5.1) activejob (= 7.0.8.7)
actiontext (= 7.1.5.1) activemodel (= 7.0.8.7)
actionview (= 7.1.5.1) activerecord (= 7.0.8.7)
activejob (= 7.1.5.1) activestorage (= 7.0.8.7)
activemodel (= 7.1.5.1) activesupport (= 7.0.8.7)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.5.1) railties (= 7.0.8.7)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -613,14 +605,13 @@ GEM
rails-html-sanitizer (1.6.1) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.5.1) railties (7.0.8.7)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.7)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.7)
irb method_source
rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0)
zeitwerk (~> 2.6) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
@@ -632,7 +623,7 @@ GEM
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
regexp_parser (2.10.0) regexp_parser (2.8.0)
reline (0.3.6) reline (0.3.6)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
@@ -652,7 +643,7 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.4.1) rexml (3.3.9)
rspec-core (3.13.0) rspec-core (3.13.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.2) rspec-expectations (3.13.2)
@@ -672,36 +663,30 @@ GEM
rspec-support (3.13.1) rspec-support (3.13.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.75.6) rubocop (1.50.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 1.8, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.44.1) rubocop-ast (1.28.1)
parser (>= 3.3.7.2) parser (>= 3.2.1.0)
prism (~> 1.4) rubocop-capybara (2.18.0)
rubocop-factory_bot (2.27.1) rubocop (~> 1.41)
lint_roller (~> 1.1) rubocop-performance (1.17.1)
rubocop (~> 1.72, >= 1.72.1) rubocop (>= 1.7.0, < 2.0)
rubocop-performance (1.25.0) rubocop-ast (>= 0.4.0)
lint_roller (~> 1.1) rubocop-rails (2.19.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0) rubocop-rspec (2.21.0)
rubocop-rspec (3.6.0) rubocop (~> 1.33)
lint_roller (~> 1.1) rubocop-capybara (~> 2.17)
rubocop (~> 1.72, >= 1.72.1)
ruby-openai (7.3.1) ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0) event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1) faraday (>= 1)
@@ -831,10 +816,8 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (3.1.4) unicode-display_width (2.4.2)
unicode-emoji (~> 4.0, >= 4.0.4) uniform_notifier (1.16.0)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.3) uri (1.0.3)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
@@ -862,9 +845,7 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) websocket-driver (0.7.6)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
@@ -970,7 +951,7 @@ DEPENDENCIES
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.1) rails (~> 7.0.8.4)
redis redis
redis-namespace redis-namespace
responders (>= 3.1.1) responders (>= 3.1.1)
@@ -979,7 +960,6 @@ DEPENDENCIES
rspec-rails (>= 6.1.5) rspec-rails (>= 6.1.5)
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rubocop-factory_bot
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
@@ -1017,7 +997,7 @@ DEPENDENCIES
working_hours working_hours
RUBY VERSION RUBY VERSION
ruby 3.4.4p34 ruby 3.3.3p89
BUNDLED WITH BUNDLED WITH
2.5.16 2.5.16
+1 -8
View File
@@ -41,15 +41,8 @@ run:
force_run: force_run:
rm -f ./.overmind.sock rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug: debug:
overmind connect backend overmind connect backend
@@ -59,4 +52,4 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker .PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
-4
View File
@@ -1,4 +0,0 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return; if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') { if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10; textElement.textContent = '•'.repeat(10);
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true'; textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText); navigator.clipboard.writeText(secretField.dataset.secretText);
} }
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field { .cell-data__secret-field {
align-items: center; align-items: center;
color: $hint-grey;
display: flex; display: flex;
span { span {
flex: 0 0 auto; flex: 1;
} }
[data-secret-toggler], button {
[data-secret-copier] { margin-left: 5px;
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg { svg {
fill: currentColor; fill: currentColor;
height: 1.25rem;
width: 1.25rem;
} }
} }
} }
+25 -1
View File
@@ -32,7 +32,14 @@ class AccountBuilder
end end
def validate_email def validate_email
Account::SignUpEmailValidationService.new(@email).perform raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable?
true
else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
end
end end
def validate_user def validate_user
@@ -74,4 +81,21 @@ class AccountBuilder
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
end end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end end
+2 -4
View File
@@ -59,13 +59,11 @@ class ContactInboxBuilder
end end
def create_contact_inbox def create_contact_inbox
attrs = { ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id, contact_id: @contact.id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
source_id: @source_id source_id: @source_id
} )
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}") Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox update_old_contact_inbox
@@ -152,13 +152,11 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
end end
def message_already_exists? def message_already_exists?
find_message_by_source_id(@messaging[:message][:mid]).present? cw_message = conversation.messages.where(
end source_id: @messaging[:message][:mid]
).first
def find_message_by_source_id(source_id) cw_message.present?
return unless source_id
@message = Message.find_by(source_id: source_id)
end end
def all_unsupported_files? def all_unsupported_files?
@@ -1,103 +0,0 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -29,11 +29,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private private
def agent_bot def agent_bot
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status, :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title, :locale, meta: [:title,
:description, :description,
{ tags: [] }] { tags: [] }]
@@ -1,6 +1,3 @@
# TODO : Move this to inboxes controller and deprecate this controller
# No need to retain this controller as we could handle everything centrally in inboxes controller
class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts::BaseController
before_action :authorize_request before_action :authorize_request
@@ -12,6 +12,10 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
Current.account Current.account
).perform ).perform
# Only allow conversations from inboxes the user has access to
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
@conversations = conversations.order(last_activity_at: :desc).limit(20) @conversations = conversations.order(last_activity_at: :desc).limit(20)
end end
end end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = contacts.page(@current_page)
end end
def show; end def show; end
@@ -124,12 +124,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save! @conversation.save!
end end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private private
def permitted_update_params def permitted_update_params
@@ -1,6 +1,6 @@
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_custom_filters, only: [:index] before_action :fetch_custom_filters, except: [:create]
before_action :fetch_custom_filter, only: [:show, :update, :destroy] before_action :fetch_custom_filter, only: [:show, :update, :destroy]
DEFAULT_FILTER_TYPE = 'conversation'.freeze DEFAULT_FILTER_TYPE = 'conversation'.freeze
@@ -9,8 +9,8 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
def show; end def show; end
def create def create
@custom_filter = Current.account.custom_filters.create!( @custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(user: Current.user) permitted_payload.merge(account_id: Current.account.id)
) )
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid? render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
end end
@@ -27,16 +27,14 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
private private
def fetch_custom_filters def fetch_custom_filters
@custom_filters = Current.account.custom_filters.where( @custom_filters = current_user.custom_filters.where(
user: Current.user, account_id: Current.account.id,
filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE
) )
end end
def fetch_custom_filter def fetch_custom_filter
@custom_filter = Current.account.custom_filters.where( @custom_filter = @custom_filters.find(permitted_params[:id])
user: Current.user
).find(permitted_params[:id])
end end
def permitted_payload def permitted_payload
@@ -42,9 +42,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update def update
inbox_params = permitted_params.except(:channel, :csat_config) @inbox.update!(permitted_params.except(:channel))
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours update_inbox_working_hours
update_channel if channel_update_required? update_channel if channel_update_required?
end end
@@ -123,22 +121,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save! @inbox.channel.save!
end end
def format_csat_config(config)
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
}
end
def inbox_attributes def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name]
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
end end
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy] before_action :fetch_hook, only: [:destroy]
def destroy def destroy
@@ -31,12 +31,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -48,30 +42,17 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
def unlink_issue def unlink_issue
link_id = permitted_params[:link_id] link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id) issue = linear_processor_service.unlink_issue(link_id)
if issue[:error] if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity render json: { error: issue[:error] }, status: :unprocessable_entity
else else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok render json: issue[:data], status: :ok
end end
end end
@@ -113,8 +94,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end end
def permitted_params def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id, params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
label_ids: [])
end end
def fetch_hook def fetch_hook
@@ -15,10 +15,6 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
@result = search('Message') @result = search('Message')
end end
def articles
@result = search('Article')
end
private private
def search(search_type) def search(search_type)
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def settings_params def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) params.permit(:auto_resolve_after, :auto_resolve_message)
end end
def check_signup_enabled def check_signup_enabled
@@ -38,11 +38,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,10 +14,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder) render_report_with(V2::Reports::InboxSummaryBuilder)
end end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private private
def check_authorization def check_authorization
@@ -1,6 +1,6 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'] 'api/v1/accounts/conversations/assignments' => ['create']
}.freeze }.freeze
+1 -1
View File
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
private private
def ensure_html_format def ensure_html_format
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? head :not_acceptable unless request.format.html?
end end
def set_global_config def set_global_config
@@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def sign_up_user 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: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
create_account_for_user create_account_for_user
token = @resource.send(:set_reset_password_token) token = @resource.send(:set_reset_password_token)
@@ -53,11 +53,9 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
).first ).first
end end
def validate_signup_email_is_business_domain? def validate_business_account?
# return true if the user is a business account, false if it is a blocked domain account # return true if the user is a business account, false if it is a gmail account
Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform auth_hash['info']['email'].downcase.exclude?('@gmail.com')
rescue CustomExceptions::Account::InvalidEmail
false
end end
def create_account_for_user def create_account_for_user
@@ -71,7 +71,6 @@ class OauthCallbackController < ApplicationController
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!( account.inboxes.create!(
account: account, account: account,
channel: channel_email, channel: channel_email,
@@ -1,9 +1,9 @@
class Platform::Api::V1::UsersController < PlatformController class Platform::Api::V1::UsersController < PlatformController
# ref: https://stackoverflow.com/a/45190318/939299 # ref: https://stackoverflow.com/a/45190318/939299
# set resource is called for other actions already in platform controller # set resource is called for other actions already in platform controller
# we want to add login and token to that chain as well # we want to add login to that chain as well
before_action(only: [:login, :token]) { set_resource } before_action(only: [:login]) { set_resource }
before_action(only: [:login, :token]) { validate_platform_app_permissible } before_action(only: [:login]) { validate_platform_app_permissible }
def show; end def show; end
@@ -18,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link } render json: { url: @resource.generate_sso_link }
end end
def token; end
def update def update
@resource.assign_attributes(user_update_params) @resource.assign_attributes(user_update_params)
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
@@ -15,21 +15,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show; end def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private private
def limit_results def limit_results
@@ -54,6 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end
@@ -2,13 +2,6 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create def create
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)
@@ -32,17 +32,22 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end end
def allowed_configs def allowed_configs
mapping = { @allowed_configs = case @config
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], when 'facebook'
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], when 'shopify'
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], when 'microsoft'
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], %w[AZURE_APP_ID AZURE_APP_SECRET]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] when 'email'
} ['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
end end
end end
@@ -24,10 +24,9 @@ class Twilio::CallbackController < ApplicationController
:Body, :Body,
:ToCountry, :ToCountry,
:FromState, :FromState,
*Array.new(10) { |i| :"MediaUrl#{i}" }, :MediaUrl0,
*Array.new(10) { |i| :"MediaContentType#{i}" }, :MediaContentType0,
:MessagingServiceSid, :MessagingServiceSid
:NumMedia
) )
end end
end end
+1 -4
View File
@@ -88,10 +88,7 @@ class ConversationFinder
def find_conversation_by_inbox def find_conversation_by_inbox
@conversations = current_account.conversations @conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
return unless params[:inbox_id]
@conversations = @conversations.where(inbox_id: @inbox_ids)
end end
def find_all_conversations def find_all_conversations
@@ -36,13 +36,9 @@ module Api::V2::Accounts::ReportsHelper
end end
def generate_labels_report def generate_labels_report
reports = V2::Reports::LabelSummaryBuilder.new( Current.account.labels.map do |label|
account: Current.account, label_report = report_builder({ type: :label, id: label.id }).short_summary
params: build_params({}) [label.title] + generate_readable_report_metrics(label_report)
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
end end
end end
@@ -21,10 +21,6 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) { deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`); return axios.delete(`${this.url}/${botId}/avatar`);
} }
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();
+11 -15
View File
@@ -38,7 +38,13 @@ export default {
} }
return false; return false;
}, },
profileUpdate({ displayName, avatar, ...profileAttributes }) { profileUpdate({
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
const formData = new FormData(); const formData = new FormData();
Object.keys(profileAttributes).forEach(key => { Object.keys(profileAttributes).forEach(key => {
const hasValue = profileAttributes[key] === undefined; const hasValue = profileAttributes[key] === undefined;
@@ -47,22 +53,16 @@ export default {
} }
}); });
formData.append('profile[display_name]', displayName || ''); formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) { if (avatar) {
formData.append('profile[avatar]', avatar); formData.append('profile[avatar]', avatar);
} }
return axios.put(endPoints('profileUpdate').url, formData); return axios.put(endPoints('profileUpdate').url, formData);
}, },
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
return axios.put(endPoints('profileUpdate').url, {
profile: {
current_password: currentPassword,
password,
password_confirmation: passwordConfirmation,
},
});
},
updateUISettings({ uiSettings }) { updateUISettings({ uiSettings }) {
return axios.put(endPoints('profileUpdate').url, { return axios.put(endPoints('profileUpdate').url, {
profile: { ui_settings: uiSettings }, profile: { ui_settings: uiSettings },
@@ -102,8 +102,4 @@ export default {
const urlData = endPoints('resendConfirmation'); const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url); return axios.post(urlData.url);
}, },
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
}; };
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CopilotMessages extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
get(threadId) {
return axios.get(`${this.url}/${threadId}/copilot_messages`);
}
create({ threadId, ...rest }) {
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
}
}
export default new CopilotMessages();
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class CopilotThreads extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
}
export default new CopilotThreads();
-5
View File
@@ -61,11 +61,6 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) { filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -51,10 +51,6 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
}; };
export default page => { export default page => {
@@ -134,12 +134,12 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`); return axios.get(`${this.url}/${conversationId}/attachments`);
} }
getInboxAssistant(conversationId) { requestCopilot(conversationId, body) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`); return axios.post(`${this.url}/${conversationId}/copilot`, body);
} }
delete(conversationId) { getInboxAssistant(conversationId) {
return axios.delete(`${this.url}/${conversationId}`); return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
} }
} }
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
); );
} }
unlinkIssue(linkId, issueIdentifier, conversationId) { unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, { return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId, link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
}); });
} }
-9
View File
@@ -40,15 +40,6 @@ class SearchAPI extends ApiClient {
}, },
}); });
} }
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
},
});
}
} }
export default new SearchAPI(); export default new SearchAPI();
@@ -9,6 +9,5 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create'); expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update'); expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete'); expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
}); });
}); });
@@ -91,19 +91,6 @@ describe('#linearAPI', () => {
issueData issueData
); );
}); });
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
}); });
describe('link_issue', () => { describe('link_issue', () => {
@@ -133,18 +120,6 @@ describe('#linearAPI', () => {
} }
); );
}); });
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
}); });
describe('getLinkedIssue', () => { describe('getLinkedIssue', () => {
@@ -189,26 +164,12 @@ describe('#linearAPI', () => {
window.axios = originalAxios; window.axios = originalAxios;
}); });
it('creates a valid request with link_id only', () => { it('creates a valid request', () => {
LinearAPIClient.unlinkIssue('link123'); LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue', '/api/v1/integrations/linear/unlink_issue',
{ {
link_id: 'link123', link_id: 1,
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
} }
); );
}); });
@@ -35,16 +35,6 @@ class SummaryReportsAPI extends ApiClient {
}, },
}); });
} }
getLabelReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/label`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
} }
export default new SummaryReportsAPI(); export default new SummaryReportsAPI();
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>"); background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px; background-size: 9px 6px;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; @apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] { &[disabled] {
@apply field-disabled; @apply field-disabled;
@@ -3,7 +3,7 @@
} }
.tabs--container--with-border { .tabs--container--with-border {
@apply border-b border-b-n-weak; @apply border-b border-n-weak;
} }
.tabs--container--compact.tab--chat-type { .tabs--container--compact.tab--chat-type {
@@ -1,65 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</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()"
/>
<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>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</template>
@@ -7,7 +7,6 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import countries from 'shared/constants/countries'; import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
@@ -17,7 +16,6 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false },
}); });
@@ -93,13 +91,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template> <template>
<CardLayout :key="id" layout="row"> <CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar <Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1"> <div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-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"> <span class="text-base font-medium truncate text-n-slate-12">
@@ -157,15 +149,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
/> />
<template #after> <template #after>
<div <transition
class="transition-all duration-500 ease-in-out grid overflow-hidden" enter-active-class="overflow-hidden transition-all duration-300 ease-out"
:class=" leave-active-class="overflow-hidden transition-all duration-300 ease-in"
isExpanded enter-from-class="overflow-hidden opacity-0 max-h-0"
? 'grid-rows-[1fr] opacity-100' enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
: 'grid-rows-[0fr] opacity-0' leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
" leave-to-class="overflow-hidden opacity-0 max-h-0"
> >
<div class="overflow-hidden"> <div v-show="isExpanded" class="w-full">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong"> <div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm <ContactsForm
ref="contactsFormRef" ref="contactsFormRef"
@@ -184,14 +176,8 @@ const onClickViewDetails = () => emit('showContact', props.id);
/> />
</div> </div>
</div> </div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div> </div>
</div> </transition>
</template> </template>
</CardLayout> </CardLayout>
</template> </template>
@@ -47,7 +47,11 @@ defineExpose({ dialogRef });
ref="dialogRef" ref="dialogRef"
type="alert" type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')" :title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')" :description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')" :confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm" @confirm="handleDialogConfirm"
/> />
@@ -7,16 +7,42 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({ defineProps({
showSearch: { type: Boolean, default: true }, showSearch: {
searchValue: { type: String, default: '' }, type: Boolean,
headerTitle: { type: String, required: true }, default: true,
buttonLabel: { type: String, default: '' }, },
activeSort: { type: String, default: 'last_activity_at' }, searchValue: {
activeOrdering: { type: String, default: '' }, type: String,
isSegmentsView: { type: Boolean, default: false }, default: '',
hasActiveFilters: { type: Boolean, default: false }, },
isLabelView: { type: Boolean, default: false }, headerTitle: {
isActiveView: { type: Boolean, default: false }, type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -59,7 +85,7 @@ const emit = defineEmits([
</Input> </Input>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative"> <div v-if="!isLabelView" class="relative">
<Button <Button
id="toggleContactsFilterButton" id="toggleContactsFilterButton"
:icon=" :icon="
@@ -79,12 +105,7 @@ const emit = defineEmits([
<slot name="filter" /> <slot name="filter" />
</div> </div>
<Button <Button
v-if=" v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save" icon="i-lucide-save"
color="slate" color="slate"
size="sm" size="sm"
@@ -92,7 +113,7 @@ const emit = defineEmits([
@click="emit('createSegment')" @click="emit('createSegment')"
/> />
<Button <Button
v-if="isSegmentsView && !isLabelView && !isActiveView" v-if="isSegmentsView && !isLabelView"
icon="i-lucide-trash" icon="i-lucide-trash"
color="slate" color="slate"
size="sm" size="sm"
@@ -36,7 +36,6 @@ const props = defineProps({
activeSegment: { type: Object, default: null }, activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false }, hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false }, isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -278,7 +277,6 @@ defineExpose({
:header-title="headerTitle" :header-title="headerTitle"
:is-segments-view="hasActiveSegments" :is-segments-view="hasActiveSegments"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters" :has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')" :button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)" @search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue'; import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({ defineProps({
searchValue: { type: String, default: '' }, searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' }, headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true }, showPaginationFooter: { type: Boolean, default: true },
@@ -37,23 +37,10 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index'; return route.name !== 'contacts_dashboard_segments_index';
}); });
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed( const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index' () => route.name === 'contacts_dashboard_labels_index'
); );
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
}; };
@@ -70,7 +57,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300"> <div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper <ContactListHeaderWrapper
ref="contactListHeaderWrapper" ref="contactListHeaderWrapper"
:show-search="isNotSegmentView && !isActiveView" :show-search="isNotSegmentView"
:search-value="searchValue" :search-value="searchValue"
:active-sort="activeSort" :active-sort="activeSort"
:active-ordering="activeOrdering" :active-ordering="activeOrdering"
@@ -79,7 +66,6 @@ const openFilter = () => {
:segments-id="segmentsId" :segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters" :has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
@search="emit('search', $event)" @search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@@ -88,7 +74,11 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview" v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment" :active-segment="activeSegment"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
@open-filter="openFilter" @open-filter="openFilter"
@@ -71,7 +71,6 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating" :is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)" @toggle="toggleExpanded(contact.id)"
@@ -1,87 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
const { updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
};
const keyboardEvents = {
'Alt+KeyO': {
action: toggleConversationSidebarToggle,
},
};
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"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
v-if="showCopilotTab"
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -4,14 +4,38 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: {
label: { type: String, default: '' }, type: String,
placeholder: { type: String, default: '' }, default: '',
focusOnMount: { type: Boolean, default: false }, },
maxLength: { type: Number, default: 200 }, label: {
showCharacterCount: { type: Boolean, default: true }, type: String,
disabled: { type: Boolean, default: false }, default: '',
message: { type: String, default: '' }, },
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
@@ -19,7 +43,6 @@ const props = defineProps({
}, },
enableVariables: { type: Boolean, default: false }, enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -97,7 +120,6 @@ watch(
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables" :enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -200,7 +200,6 @@ defineExpose({ state, isSubmitDisabled });
:label="state.icon" :label="state.icon"
color="slate" color="slate"
size="sm" size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''" :icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none" class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen" @click="isEmojiPickerOpen = !isEmojiPickerOpen"
@@ -1,29 +0,0 @@
<script setup>
import SidebarActionsHeader from './SidebarActionsHeader.vue';
</script>
<template>
<Story
title="Components/SidebarActionsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<SidebarActionsHeader title="Default State" />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<SidebarActionsHeader
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant>
</Story>
</template>
@@ -1,47 +0,0 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -1,28 +0,0 @@
<!--
* Preserves RTL/LTR context when teleporting content
* Ensures direction-specific classes (ltr:tailwind-class, rtl:tailwind-class) work correctly
* when content is teleported outside the app's container with [dir] attribute
-->
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
to: {
type: String,
default: 'body',
},
});
const isRTL = useMapGetter('accounts/isRTL');
const contentDirection = computed(() => (isRTL.value ? 'rtl' : 'ltr'));
</script>
<template>
<Teleport :to="to">
<div :dir="contentDirection">
<slot />
</div>
</Teleport>
</template>
@@ -117,7 +117,7 @@ const STYLE_CONFIG = {
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8', 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: ghost:
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent', 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent', link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
}, },
amber: { amber: {
solid: solid:
@@ -1,41 +0,0 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-white dark:bg-slate-900">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6"> <header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
@@ -42,7 +42,6 @@ const initialState = {
conversationFaqs: false, conversationFaqs: false,
memories: false, memories: false,
}, },
temperature: 1,
}; };
const state = reactive({ ...initialState }); const state = reactive({ ...initialState });
@@ -88,7 +87,6 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false, conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false, memories: config.feature_memory || false,
}; };
state.temperature = config.temperature || 1;
}; };
const handleBasicInfoUpdate = async () => { const handleBasicInfoUpdate = async () => {
@@ -102,10 +100,7 @@ const handleBasicInfoUpdate = async () => {
const payload = { const payload = {
name: state.name, name: state.name,
description: state.description, description: state.description,
config: { product_name: state.productName,
...props.assistant.config,
product_name: state.productName,
},
}; };
emit('submit', payload); emit('submit', payload);
@@ -138,7 +133,6 @@ const handleInstructionsUpdate = async () => {
const payload = { const payload = {
config: { config: {
...props.assistant.config, ...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions, instructions: state.instructions,
}, },
}; };
@@ -215,7 +209,7 @@ watch(
<!-- Instructions Section --> <!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')"> <Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4 pt-4">
<Editor <Editor
v-model="state.instructions" v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')" :placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
@@ -224,25 +218,6 @@ watch(
:message-type="formErrors.instructions ? 'error' : 'info'" :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"> <div class="flex justify-end">
<Button <Button
size="small" size="small"
@@ -1,24 +1,29 @@
<script setup> <script setup>
import { nextTick, ref, watch, computed } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
import CopilotInput from './CopilotInput.vue'; import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue'; import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue'; import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue'; import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue'; import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import CopilotEmptyState from './CopilotEmptyState.vue'; import Icon from '../icon/Icon.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: { messages: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -37,11 +42,22 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n(); const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
emit('sendMessage', message); emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE); useTrack(COPILOT_EVENTS.SEND_MESSAGE);
}; };
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null); const chatContainer = ref(null);
const scrollToBottom = async () => { const scrollToBottom = async () => {
@@ -51,72 +67,23 @@ const scrollToBottom = async () => {
} }
}; };
const groupedMessages = computed(() => { const promptOptions = [
const result = []; {
let thinkingGroup = []; label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
props.messages.forEach(message => { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
if (message.message_type === 'assistant_thinking') { },
thinkingGroup.push(message); {
} else { label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
if (thinkingGroup.length > 0) { prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
result.push({ },
id: thinkingGroup[0].id, {
message_type: 'thinking_group', label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
messages: thinkingGroup, prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
}); },
thinkingGroup = []; ];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings();
const closeCopilotPanel = () => {
updateUISettings({
is_copilot_panel_open: false,
is_contact_sidebar_open: false,
});
};
const handleSidebarAction = action => {
if (action === 'reset') {
emit('reset');
}
};
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch( watch(
[() => props.messages], [() => props.messages, () => props.isCaptainTyping],
() => { () => {
scrollToBottom(); scrollToBottom();
}, },
@@ -126,59 +93,62 @@ watch(
<template> <template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader <div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
:title="$t('CAPTAIN.COPILOT.TITLE')" <template v-for="message in messages" :key="message.id">
:buttons="copilotButtons" <CopilotAgentMessage
@click="handleSidebarAction" v-if="message.role === 'user'"
@close="closeCopilotPanel" :support-agent="supportAgent"
/> :message="message"
<div />
ref="chatContainer" <CopilotAssistantMessage
class="flex-1 flex px-4 py-4 overflow-y-auto items-start" v-else-if="COPILOT_USER_ROLES.includes(message.role)"
> :message="message"
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full"> :conversation-inbox-type="conversationInboxType"
<template v-for="(item, index) in groupedMessages" :key="item.id"> />
<CopilotAgentMessage </template>
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" /> <CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div> </div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div> </div>
<div class="mx-3 mt-px mb-2"> <div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1"> <div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant <ToggleCopilotAssistant
v-if="assistants.length > 1" v-if="assistants.length"
:assistants="assistants" :assistants="assistants"
:active-assistant="activeAssistant" :active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)" @set-assistant="$event => emit('setAssistant', $event)"
/> />
<div v-else /> <div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div> </div>
<CopilotInput <CopilotInput class="mb-1 w-full" @send="sendMessage" />
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -1,17 +1,31 @@
<script setup> <script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({ defineProps({
message: { message: {
type: Object, type: Object,
required: true, required: true,
}, },
supportAgent: {
type: Object,
required: true,
},
}); });
</script> </script>
<template> <template>
<div class="space-y-1 text-n-slate-12"> <div class="flex flex-row gap-2">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div> <Avatar
<div class="break-words"> :name="supportAgent.available_name"
{{ message.content }} :src="supportAgent.avatar_url"
:size="24"
rounded-full
/>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -9,12 +9,9 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: { message: {
type: Object, type: Object,
required: true, required: true,
@@ -24,15 +21,6 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => { const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content); const formatter = new MessageFormatter(props.message.content);
@@ -45,6 +33,8 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -56,25 +46,33 @@ const useCopilotResponse = () => {
</script> </script>
<template> <template>
<div class="flex flex-col gap-1 text-n-slate-12"> <div class="flex flex-row gap-2">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div> <Avatar
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11"> name="Captain Copilot"
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }} icon-name="i-woot-captain"
</span> :size="24"
<div rounded-full
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/> />
<div class="flex flex-row mt-1"> <div class="flex flex-col gap-1 text-n-slate-12">
<Button <div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
v-if="showUseButton" <span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
:label="$t('CAPTAIN.COPILOT.USE')" {{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
faded </span>
sm <div
slate v-else
@click="useCopilotResponse" v-dompurify-html="messageContent"
class="prose-sm break-words"
/> />
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
slate
@click="useCopilotResponse"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -1,106 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -13,16 +13,19 @@ const sendMessage = () => {
</script> </script>
<template> <template>
<form class="relative" @submit.prevent="sendMessage"> <form
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
@submit.prevent="sendMessage"
>
<input <input
v-model="message" v-model="message"
type="text" type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')" :placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11" class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
@keyup.enter="sendMessage" @keyup.enter="sendMessage"
/> />
<button <button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11" class="h-auto w-12 flex items-center justify-center text-n-slate-11"
type="submit" type="submit"
> >
<i class="i-ph-arrow-up" /> <i class="i-ph-arrow-up" />
@@ -1,63 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -1,25 +0,0 @@
<script setup>
import Icon from '../../components-next/icon/Icon.vue';
defineProps({
content: {
type: String,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-lg bg-n-background/50 border border-n-weak hover:bg-n-background/80 transition-colors duration-200"
>
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/>
<div class="text-sm text-n-slate-12">
{{ content }}
</div>
</div>
</div>
</template>
@@ -1,34 +0,0 @@
<script setup>
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
const messages = [
{
id: 1,
content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
},
{
id: 2,
content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
},
{
id: 3,
content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
},
];
</script>
<template>
<Story title="Captain/Copilot/CopilotThinkingGroup" group="components">
<Variant title="Default">
<CopilotThinkingGroup :messages="messages" />
</Variant>
<Variant title="With Default Collapsed">
<!-- eslint-disable-next-line -->
<CopilotThinkingGroup :messages="messages" :default-collapsed="true" />
</Variant>
</Story>
</template>
@@ -1,61 +0,0 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from '../icon/Icon.vue';
import CopilotThinkingBlock from './CopilotThinkingBlock.vue';
const props = defineProps({
messages: { type: Array, required: true },
defaultCollapsed: { type: Boolean, default: false },
});
const { t } = useI18n();
const isExpanded = ref(!props.defaultCollapsed);
const thinkingCount = computed(() => props.messages.length);
watch(
() => props.defaultCollapsed,
newValue => {
if (newValue) {
isExpanded.value = false;
}
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<button
class="group flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-11 transition-colors duration-200 -ml-3"
@click="isExpanded = !isExpanded"
>
<Icon
:icon="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
/>
<span class="flex items-center gap-2">
{{ t('CAPTAIN.COPILOT.SHOW_STEPS') }}
<span
class="inline-flex items-center justify-center h-4 min-w-4 px-1 text-xs font-medium rounded-full bg-n-solid-3 text-n-slate-11"
>
{{ thinkingCount }}
</span>
</span>
</button>
<div
v-show="isExpanded"
class="space-y-3 transition-all duration-200"
:class="{
'opacity-100': isExpanded,
'opacity-0 max-h-0 overflow-hidden': !isExpanded,
}"
>
<CopilotThinkingBlock
v-for="copilotMessage in messages"
:key="copilotMessage.id"
:content="copilotMessage.message.content"
:reasoning="copilotMessage.message.reasoning"
/>
</div>
</div>
</template>
@@ -2,9 +2,9 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components'; import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({ const props = defineProps({
type: { type: {
@@ -59,6 +59,8 @@ const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n(); const { t } = useI18n();
const isRTL = useMapGetter('accounts/isRTL');
const dialogRef = ref(null); const dialogRef = ref(null);
const dialogContentRef = ref(null); const dialogContentRef = ref(null);
@@ -92,7 +94,7 @@ defineExpose({ open, close });
</script> </script>
<template> <template>
<TeleportWithDirection to="body"> <Teleport to="body">
<dialog <dialog
ref="dialogRef" ref="dialogRef"
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl" class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
@@ -100,6 +102,7 @@ defineExpose({ open, close });
maxWidthClass, maxWidthClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible', overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]" ]"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close" @close="close"
> >
<OnClickOutside @trigger="close"> <OnClickOutside @trigger="close">
@@ -149,7 +152,7 @@ defineExpose({ open, close });
</form> </form>
</OnClickOutside> </OnClickOutside>
</dialog> </dialog>
</TeleportWithDirection> </Teleport>
</template> </template>
<style scoped> <style scoped>
@@ -81,7 +81,6 @@ onMounted(() => {
<button <button
v-for="(item, index) in filteredMenuItems" v-for="(item, index) in filteredMenuItems"
:key="index" :key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{ :class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected, 'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed } from 'vue';
import { useElementBounding, useWindowSize } from '@vueuse/core';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue'; import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
@@ -26,10 +25,6 @@ const props = defineProps({
type: String, type: String,
default: 'faded', default: 'faded',
}, },
label: {
type: String,
default: null,
},
}); });
const selected = defineModel({ const selected = defineModel({
@@ -37,13 +32,6 @@ const selected = defineModel({
required: true, required: true,
}); });
const triggerRef = ref(null);
const dropdownRef = ref(null);
const { top } = useElementBounding(triggerRef);
const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => { const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {}; return props.options.find(o => o.value === selected.value) || {};
}); });
@@ -53,16 +41,6 @@ const iconToRender = computed(() => {
return selectedOption.value.icon || 'i-lucide-chevron-down'; return selectedOption.value.icon || 'i-lucide-chevron-down';
}); });
const dropdownPosition = computed(() => {
const DROPDOWN_MAX_HEIGHT = 340;
// Get actual height if available or use default
const menuHeight = dropdownHeight.value
? dropdownHeight.value + 20
: DROPDOWN_MAX_HEIGHT;
const spaceBelow = height.value - top.value;
return spaceBelow < menuHeight ? 'bottom-0' : 'top-0';
});
const updateSelected = newValue => { const updateSelected = newValue => {
selected.value = newValue; selected.value = newValue;
}; };
@@ -73,24 +51,17 @@ const updateSelected = newValue => {
<template #trigger="{ toggle }"> <template #trigger="{ toggle }">
<slot name="trigger" :toggle="toggle"> <slot name="trigger" :toggle="toggle">
<Button <Button
ref="triggerRef"
type="button"
sm sm
slate slate
:variant :variant
:icon="iconToRender" :icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true" :trailing-icon="selectedOption.icon ? false : true"
:label="label || (hideLabel ? null : selectedOption.label)" :label="hideLabel ? null : selectedOption.label"
@click="toggle" @click="toggle"
/> />
</slot> </slot>
</template> </template>
<DropdownBody <DropdownBody class="top-0 min-w-48 z-50" strong>
ref="dropdownRef"
class="min-w-48 z-50"
:class="dropdownPosition"
strong
>
<DropdownSection class="max-h-80 overflow-scroll"> <DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem <DropdownItem
v-for="option in options" v-for="option in options"
@@ -9,14 +9,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { const { options } = defineProps({
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -25,22 +18,6 @@ const {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -92,26 +69,15 @@ const toggleSelected = option => {
sm sm
slate slate
faded faded
type="button"
:icon="selectedItem.icon" :icon="selectedItem.icon"
:label="selectedItem.name" :label="selectedItem.name"
@click="toggle" @click="toggle"
/> />
<Button <Button v-else sm slate faded @click="toggle">
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon> <template #icon>
<Icon :icon="placeholderIcon" class="text-n-slate-11" /> <Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template> </template>
<span class="text-n-slate-11">{{ <span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-56 z-50" strong> <DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -121,7 +87,7 @@ const toggleSelected = option => {
v-model="searchTerm" v-model="searchTerm"
autofocus autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection class="max-h-80 overflow-scroll"> <DropdownSection class="max-h-80 overflow-scroll">
@@ -1,8 +1,7 @@
<script setup> <script setup>
import { computed, watch } from 'vue'; import { computed, ref } from 'vue';
import Input from './Input.vue'; import Input from './Input.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
@@ -12,50 +11,36 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null }); const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const convertToMinutes = newValue => { const UNIT_TYPES = {
if (unit.value === DURATION_UNITS.MINUTES) { MINUTES: 'minutes',
return Math.floor(newValue); HOURS: 'hours',
} DAYS: 'days',
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
}; };
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS) if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
return Math.floor(duration.value / 60); if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60); return Math.floor(duration.value / 24 / 60);
return 0; return 0;
}, },
set(newValue) { set(newValue) {
let minuteValue = convertToMinutes(newValue); let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
}, },
}); });
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script> </script>
<template> <template>
@@ -72,12 +57,10 @@ watch(unit, () => {
:disabled="disabled" :disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40" class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
> >
<option :value="DURATION_UNITS.MINUTES"> <option :value="UNIT_TYPES.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }} {{ t('DURATION_INPUT.MINUTES') }}
</option> </option>
<option :value="DURATION_UNITS.HOURS"> <option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
{{ t('DURATION_INPUT.HOURS') }} <option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select> </select>
</template> </template>
@@ -1,5 +0,0 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -186,20 +186,12 @@ const isBotOrAgentMessage = computed(() => {
return true; return true;
} }
const senderId = props.senderId ?? props.sender?.id; const senderId = props.senderId ?? props.sender?.id;
const senderType = props.sender?.type ?? props.senderType; const senderType = props.senderType ?? props.sender?.type;
if (!senderType || !senderId) { if (!senderType || !senderId) {
return true; return true;
} }
if (
[SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(
senderType
)
) {
return true;
}
return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase(); return senderType.toLowerCase() === SENDER_TYPES.USER.toLowerCase();
}); });
@@ -315,7 +307,11 @@ const componentToRender = computed(() => {
}); });
const shouldShowContextMenu = computed(() => { const shouldShowContextMenu = computed(() => {
return !props.contentAttributes?.isUnsupported; return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
}); });
const isBubble = computed(() => { const isBubble = computed(() => {
@@ -340,23 +336,12 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0); const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING; const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return { return {
copy: hasText, copy: hasText,
delete: delete: hasText || hasAttachments,
(hasText || hasAttachments) && cannedResponse: isOutgoing && hasText,
!isFailedOrProcessing && replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
}; };
}); });
@@ -421,7 +406,7 @@ const avatarInfo = computed(() => {
const { name, type, avatarUrl, thumbnail } = sender || {}; const { name, type, avatarUrl, thumbnail } = sender || {};
// If sender type is agent bot, use avatarUrl // If sender type is agent bot, use avatarUrl
if ([SENDER_TYPES.AGENT_BOT, SENDER_TYPES.CAPTAIN_ASSISTANT].includes(type)) { if (type === SENDER_TYPES.AGENT_BOT) {
return { return {
name: name ?? '', name: name ?? '',
src: avatarUrl ?? '', src: avatarUrl ?? '',
@@ -506,8 +491,8 @@ provideMessageContext({
<div <div
class="[grid-area:bubble] flex" class="[grid-area:bubble] flex"
:class="{ :class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT, 'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL, 'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
@@ -523,7 +508,7 @@ provideMessageContext({
</div> </div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap"> <div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu <ContextMenu
v-if="isBubble" v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition" :context-menu-position="contextMenuPosition"
:is-open="showContextMenu" :is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions" :enabled-options="contextMenuEnabledOptions"
@@ -40,7 +40,7 @@ const senderName = computed(() => {
<Icon :icon="icon" class="text-white size-4" /> <Icon :icon="icon" class="text-white size-4" />
</slot> </slot>
</div> </div>
<div class="space-y-1 overflow-hidden"> <div class="space-y-1">
<div v-if="senderName" class="text-n-slate-12 text-sm truncate"> <div v-if="senderName" class="text-n-slate-12 text-sm truncate">
{{ {{
t(senderTranslationKey, { t(senderTranslationKey, {
@@ -2,10 +2,10 @@
import { computed } from 'vue'; import { computed } from 'vue';
import BaseBubble from './Base.vue'; import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages'; import { CSAT_RATINGS } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
const { contentAttributes, content } = useMessageContext(); const { contentAttributes } = useMessageContext();
const { t } = useI18n(); const { t } = useI18n();
const response = computed(() => { const response = computed(() => {
@@ -16,14 +16,6 @@ const isRatingSubmitted = computed(() => {
return !!response.value.rating; return !!response.value.rating;
}); });
const displayType = computed(() => {
return contentAttributes.value?.displayType || CSAT_DISPLAY_TYPES.EMOJI;
});
const isStarRating = computed(() => {
return displayType.value === CSAT_DISPLAY_TYPES.STAR;
});
const rating = computed(() => { const rating = computed(() => {
if (isRatingSubmitted.value) { if (isRatingSubmitted.value) {
return CSAT_RATINGS.find( return CSAT_RATINGS.find(
@@ -33,33 +25,16 @@ const rating = computed(() => {
return null; return null;
}); });
const starRatingValue = computed(() => {
return response.value.rating || 0;
});
</script> </script>
<template> <template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat"> <BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4> <h4>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4"> <dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic"> <dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }} {{ t('CONVERSATION.RATING_TITLE') }}
</dt> </dt>
<dd v-if="!isStarRating"> <dd>{{ t(rating.translationKey) }}</dd>
{{ t(rating.translationKey) }}
</dd>
<dd v-else class="flex mt-1">
<span v-for="n in 5" :key="n" class="text-2xl mr-1">
<i
:class="[
n <= starRatingValue
? 'i-ri-star-fill text-n-amber-9'
: 'i-ri-star-line text-n-slate-10',
]"
/>
</span>
</dd>
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2"> <dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
{{ t('CONVERSATION.FEEDBACK_TITLE') }} {{ t('CONVERSATION.FEEDBACK_TITLE') }}
@@ -14,8 +14,7 @@ const fromEmail = computed(() => {
}); });
const toEmail = computed(() => { const toEmail = computed(() => {
const { toEmails, email } = contentAttributes.value; return contentAttributes.value?.email?.to ?? [];
return email?.to ?? toEmails ?? [];
}); });
const ccEmail = computed(() => { const ccEmail = computed(() => {

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