Merge branch 'chatwoot:develop' into chatwoot/develop
This commit is contained in:
commit
eed473ced8
@ -4,5 +4,15 @@ 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
|
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
|
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 \
|
||||||
@ -15,61 +19,80 @@ 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 apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
|
||||||
&& apt-get -y install --no-install-recommends \
|
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
build-essential \
|
&& apt-get update \
|
||||||
libssl-dev \
|
&& apt-get -y install --no-install-recommends \
|
||||||
zlib1g-dev \
|
build-essential \
|
||||||
gnupg2 \
|
libssl-dev \
|
||||||
tar \
|
zlib1g-dev \
|
||||||
tzdata \
|
gnupg \
|
||||||
postgresql-client \
|
tar \
|
||||||
libpq-dev \
|
tzdata \
|
||||||
yarn \
|
postgresql-client \
|
||||||
git \
|
libpq-dev \
|
||||||
imagemagick \
|
git \
|
||||||
tmux \
|
imagemagick \
|
||||||
zsh \
|
libyaml-dev \
|
||||||
git-flow \
|
curl \
|
||||||
npm \
|
ca-certificates \
|
||||||
libyaml-dev
|
tmux \
|
||||||
|
nodejs \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# Install rbenv and ruby
|
# Install rbenv and ruby for root user first
|
||||||
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
|
RUN git clone --depth 1 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 https://github.com/rbenv/ruby-build.git && \
|
RUN git clone --depth 1 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
|
||||||
|
|
||||||
# Install overmind
|
# Set up rbenv for vscode user
|
||||||
|
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 \
|
||||||
&& sudo mv overmind /usr/local/bin \
|
&& 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 \
|
||||||
# Install gh
|
&& apt-get update \
|
||||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
&& apt-get install -y --no-install-recommends gh \
|
||||||
&& 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 \
|
&& apt-get clean \
|
||||||
&& sudo apt update \
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
&& sudo apt install gh
|
|
||||||
|
|
||||||
|
|
||||||
# Do the set up required for chatwoot app
|
# Do the set up required for chatwoot app
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
COPY . /workspace
|
RUN chown vscode:vscode /workspace
|
||||||
|
|
||||||
# set up ruby
|
# set up node js, pnpm and claude code in single layer
|
||||||
COPY Gemfile Gemfile.lock ./
|
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
|
||||||
RUN gem install bundler && bundle install
|
&& npm cache clean --force
|
||||||
|
|
||||||
# set up node js
|
# Switch to vscode user
|
||||||
RUN npm install n -g && \
|
USER vscode
|
||||||
n $NODE_VERSION
|
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
|
||||||
RUN npm install --global yarn
|
|
||||||
RUN yarn
|
# Copy dependency files first for better caching
|
||||||
|
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
|
||||||
|
|||||||
@ -4,17 +4,26 @@
|
|||||||
"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": [
|
||||||
"rebornix.Ruby",
|
"Shopify.ruby-lsp",
|
||||||
"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"
|
||||||
],
|
],
|
||||||
@ -23,15 +32,15 @@
|
|||||||
// 5432 postgres
|
// 5432 postgres
|
||||||
// 6379 redis
|
// 6379 redis
|
||||||
// 1025,8025 mailhog
|
// 1025,8025 mailhog
|
||||||
"forwardPorts": [8025, 3000, 3035],
|
"forwardPorts": [8025, 3000, 3036],
|
||||||
|
|
||||||
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
|
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"3000": {
|
"3000": {
|
||||||
"label": "Rails Server"
|
"label": "Rails Server"
|
||||||
},
|
},
|
||||||
"3035": {
|
"3036": {
|
||||||
"label": "Webpack Dev Server"
|
"label": "Vite Dev Server"
|
||||||
},
|
},
|
||||||
"8025": {
|
"8025": {
|
||||||
"label": "Mailhog UI"
|
"label": "Mailhog UI"
|
||||||
|
|||||||
18
.devcontainer/docker-compose.base.yml
Normal file
18
.devcontainer/docker-compose.base.yml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
@ -5,19 +5,6 @@
|
|||||||
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.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: base:latest
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
|
|||||||
@ -2,12 +2,15 @@ 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.githubpreview.dev/" .env
|
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
|
||||||
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
|
|
||||||
# uncomment the webpacker env variable
|
# Setup Claude Code API key if available
|
||||||
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
|
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
|
||||||
# fix the error with webpacker
|
mkdir -p ~/.claude
|
||||||
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
|
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
|
||||||
|
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 3035:public 8025:public -c $CODESPACE_NAME
|
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
|
||||||
|
|||||||
@ -19,6 +19,5 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the Codespace Base Image
|
- name: Build the Codespace Base Image
|
||||||
run: |
|
run: |
|
||||||
docker-compose -f .devcontainer/docker-compose.yml build base
|
docker compose -f .devcontainer/docker-compose.base.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
|
||||||
|
|||||||
1
Makefile
1
Makefile
@ -41,6 +41,7 @@ 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
|
||||||
|
|
||||||
debug:
|
debug:
|
||||||
|
|||||||
@ -29,6 +29,11 @@ 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
|
||||||
|
|||||||
@ -38,6 +38,11 @@ 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,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]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
@ -15,6 +15,21 @@ 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
|
||||||
@ -39,7 +54,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,10 @@ 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();
|
||||||
|
|||||||
@ -102,4 +102,8 @@ 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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -51,6 +51,9 @@ 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 => {
|
||||||
|
|||||||
@ -9,5 +9,6 @@ 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 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;
|
@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;
|
||||||
|
|
||||||
&[disabled] {
|
&[disabled] {
|
||||||
@apply field-disabled;
|
@apply field-disabled;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs--container--with-border {
|
.tabs--container--with-border {
|
||||||
@apply border-b border-n-weak;
|
@apply border-b border-b-n-weak;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs--container--compact.tab--chat-type {
|
.tabs--container--compact.tab--chat-type {
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
<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-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>
|
||||||
@ -1,21 +1,29 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import CopilotHeader from './CopilotHeader.vue';
|
import SidebarActionsHeader from './SidebarActionsHeader.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Story
|
<Story
|
||||||
title="Captain/Copilot/CopilotHeader"
|
title="Components/SidebarActionsHeader"
|
||||||
:layout="{ type: 'grid', width: '800px' }"
|
:layout="{ type: 'grid', width: '800px' }"
|
||||||
>
|
>
|
||||||
<!-- Default State -->
|
<!-- Default State -->
|
||||||
<Variant title="Default State">
|
<Variant title="Default State">
|
||||||
<CopilotHeader />
|
<SidebarActionsHeader title="Default State" />
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|
||||||
<!-- With New Conversation Button -->
|
<!-- With New Conversation Button -->
|
||||||
<Variant title="With New Conversation Button">
|
<Variant title="With New Conversation Button">
|
||||||
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
|
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
|
||||||
<CopilotHeader :has-messages="true" />
|
<SidebarActionsHeader
|
||||||
|
title="With New Conversation Button"
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
key: 'new_conversation',
|
||||||
|
icon: 'i-lucide-plus',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</Variant>
|
</Variant>
|
||||||
</Story>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
<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>
|
||||||
@ -3,13 +3,15 @@ import { nextTick, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
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 ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||||
import Icon from '../icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
supportAgent: {
|
supportAgent: {
|
||||||
@ -54,10 +56,6 @@ const useSuggestion = opt => {
|
|||||||
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
emit('reset');
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatContainer = ref(null);
|
const chatContainer = ref(null);
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
@ -82,6 +80,21 @@ const promptOptions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const closeCopilotPanel = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSidebarAction = action => {
|
||||||
|
if (action === 'reset') {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.messages, () => props.isCaptainTyping],
|
[() => props.messages, () => props.isCaptainTyping],
|
||||||
() => {
|
() => {
|
||||||
@ -93,6 +106,18 @@ 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
|
||||||
|
:title="$t('CAPTAIN.COPILOT.TITLE')"
|
||||||
|
:buttons="[
|
||||||
|
{
|
||||||
|
key: 'reset',
|
||||||
|
icon: 'i-lucide-refresh-ccw',
|
||||||
|
tooltip: $t('CAPTAIN.COPILOT.RESET'),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
@click="handleSidebarAction"
|
||||||
|
@close="closeCopilotPanel"
|
||||||
|
/>
|
||||||
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
||||||
<template v-for="message in messages" :key="message.id">
|
<template v-for="message in messages" :key="message.id">
|
||||||
<CopilotAgentMessage
|
<CopilotAgentMessage
|
||||||
@ -139,14 +164,6 @@ watch(
|
|||||||
@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 class="mb-1 w-full" @send="sendMessage" />
|
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Button from '../button/Button.vue';
|
|
||||||
defineProps({
|
|
||||||
hasMessages: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
defineEmits(['reset', 'close']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between px-5 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">
|
|
||||||
{{ $t('CAPTAIN.COPILOT.TITLE') }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Button
|
|
||||||
v-if="hasMessages"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
ghost
|
|
||||||
sm
|
|
||||||
@click="$emit('reset')"
|
|
||||||
/>
|
|
||||||
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -477,7 +477,7 @@ const menuItems = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
||||||
>
|
>
|
||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||||
@ -519,7 +519,7 @@ const menuItems = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||||
<ul class="flex flex-col gap-2 m-0 list-none">
|
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
|
|||||||
@ -814,7 +814,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
|
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
|
||||||
:class="[
|
:class="[
|
||||||
{ hidden: !showConversationList },
|
{ hidden: !showConversationList },
|
||||||
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
|
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@ -80,16 +80,14 @@ const toggleConversationLayout = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-2 px-4"
|
class="flex items-center justify-between gap-2 px-3 h-12"
|
||||||
:class="{
|
:class="{
|
||||||
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||||
'pt-3 pb-2': showV4View,
|
|
||||||
'mb-2 pb-0': !showV4View,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center min-w-0">
|
<div class="flex items-center justify-center min-w-0">
|
||||||
<h1
|
<h1
|
||||||
class="text-lg font-medium truncate text-n-slate-12"
|
class="text-base font-medium truncate text-n-slate-12"
|
||||||
:title="pageTitle"
|
:title="pageTitle"
|
||||||
>
|
>
|
||||||
{{ pageTitle }}
|
{{ pageTitle }}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export default {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
|
class="shadow-sm bg-n-slate-12 dark:bg-n-slate-7 rounded-lg items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium text-white dark:text-white">
|
<div class="text-sm font-medium text-white dark:text-white">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
@ -29,7 +29,7 @@ export default {
|
|||||||
<router-link
|
<router-link
|
||||||
v-if="action.type == 'link'"
|
v-if="action.type == 'link'"
|
||||||
:to="action.to"
|
:to="action.to"
|
||||||
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
|
class="font-medium cursor-pointer select-none text-n-blue-10 hover:text-n-brand"
|
||||||
>
|
>
|
||||||
{{ action.message }}
|
{{ action.message }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@ -1,62 +1,72 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import WootSnackbar from './Snackbar.vue';
|
import WootSnackbar from './Snackbar.vue';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
duration: {
|
||||||
WootSnackbar,
|
type: Number,
|
||||||
},
|
default: 2500,
|
||||||
props: {
|
|
||||||
duration: {
|
|
||||||
type: Number,
|
|
||||||
default: 2500,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
data() {
|
const { t } = useI18n();
|
||||||
return {
|
|
||||||
snackMessages: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const snackMessages = ref([]);
|
||||||
emitter.on('newToastMessage', this.onNewToastMessage);
|
const snackbarContainer = ref(null);
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
emitter.off('newToastMessage', this.onNewToastMessage);
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onNewToastMessage({ message: originalMessage, action }) {
|
|
||||||
// FIX ME: This is a temporary workaround to pass string from functions
|
|
||||||
// that doesn't have the context of the VueApp.
|
|
||||||
const usei18n = action?.usei18n;
|
|
||||||
const duration = action?.duration || this.duration;
|
|
||||||
const message = usei18n ? this.$t(originalMessage) : originalMessage;
|
|
||||||
|
|
||||||
this.snackMessages.push({
|
const showPopover = () => {
|
||||||
key: new Date().getTime(),
|
try {
|
||||||
message,
|
const el = snackbarContainer.value;
|
||||||
action,
|
if (el?.matches(':popover-open')) {
|
||||||
});
|
el.hidePopover();
|
||||||
window.setTimeout(() => {
|
}
|
||||||
this.snackMessages.splice(0, 1);
|
el?.showPopover();
|
||||||
}, duration);
|
} catch (e) {
|
||||||
},
|
// ignore
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onNewToastMessage = ({ message: originalMessage, action }) => {
|
||||||
|
const message = action?.usei18n ? t(originalMessage) : originalMessage;
|
||||||
|
const duration = action?.duration || props.duration;
|
||||||
|
|
||||||
|
snackMessages.value.push({
|
||||||
|
key: Date.now(),
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
nextTick(showPopover);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
snackMessages.value.shift();
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
emitter.on('newToastMessage', onNewToastMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emitter.off('newToastMessage', onNewToastMessage);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<transition-group
|
<div
|
||||||
name="toast-fade"
|
ref="snackbarContainer"
|
||||||
tag="div"
|
popover="manual"
|
||||||
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
|
class="fixed top-4 left-1/2 -translate-x-1/2 max-w-[25rem] w-[calc(100%-2rem)] text-center bg-transparent border-0 p-0 m-0 outline-none overflow-visible"
|
||||||
>
|
>
|
||||||
<WootSnackbar
|
<transition-group name="toast-fade" tag="div">
|
||||||
v-for="snackMessage in snackMessages"
|
<WootSnackbar
|
||||||
:key="snackMessage.key"
|
v-for="snackMessage in snackMessages"
|
||||||
:message="snackMessage.message"
|
:key="snackMessage.key"
|
||||||
:action="snackMessage.action"
|
:message="snackMessage.message"
|
||||||
/>
|
:action="snackMessage.action"
|
||||||
</transition-group>
|
/>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -48,12 +48,13 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
<template>
|
<template>
|
||||||
<woot-tabs
|
<woot-tabs
|
||||||
:index="activeTabIndex"
|
:index="activeTabIndex"
|
||||||
class="w-full px-4 py-0 tab--chat-type"
|
class="w-full px-3 -mt-1 py-0 tab--chat-type"
|
||||||
@change="onTabChange"
|
@change="onTabChange"
|
||||||
>
|
>
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
|
class="text-sm"
|
||||||
:index="index"
|
:index="index"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:count="item.count"
|
:count="item.count"
|
||||||
|
|||||||
@ -4,17 +4,14 @@ import ConversationHeader from './ConversationHeader.vue';
|
|||||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||||
import EmptyState from './EmptyState/EmptyState.vue';
|
import EmptyState from './EmptyState/EmptyState.vue';
|
||||||
import MessagesView from './MessagesView.vue';
|
import MessagesView from './MessagesView.vue';
|
||||||
import ConversationSidebar from './ConversationSidebar.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ConversationSidebar,
|
|
||||||
ConversationHeader,
|
ConversationHeader,
|
||||||
DashboardAppFrame,
|
DashboardAppFrame,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
MessagesView,
|
MessagesView,
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
inboxId: {
|
inboxId: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
@ -34,7 +31,6 @@ export default {
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: ['contactPanelToggle'],
|
|
||||||
data() {
|
data() {
|
||||||
return { activeIndex: 0 };
|
return { activeIndex: 0 };
|
||||||
},
|
},
|
||||||
@ -86,9 +82,6 @@ export default {
|
|||||||
}
|
}
|
||||||
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.$emit('contactPanelToggle');
|
|
||||||
},
|
|
||||||
onDashboardAppTabChange(index) {
|
onDashboardAppTabChange(index) {
|
||||||
this.activeIndex = index;
|
this.activeIndex = index;
|
||||||
},
|
},
|
||||||
@ -98,7 +91,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="conversation-details-wrap bg-n-background"
|
class="conversation-details-wrap bg-n-background relative"
|
||||||
:class="{
|
:class="{
|
||||||
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
||||||
}"
|
}"
|
||||||
@ -106,15 +99,12 @@ export default {
|
|||||||
<ConversationHeader
|
<ConversationHeader
|
||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:chat="currentChat"
|
:chat="currentChat"
|
||||||
:is-inbox-view="isInboxView"
|
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<woot-tabs
|
<woot-tabs
|
||||||
v-if="dashboardApps.length && currentChat.id"
|
v-if="dashboardApps.length && currentChat.id"
|
||||||
:index="activeIndex"
|
:index="activeIndex"
|
||||||
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
|
class="-mt-px dashboard-app--tabs border-t border-t-n-background"
|
||||||
@change="onDashboardAppTabChange"
|
@change="onDashboardAppTabChange"
|
||||||
>
|
>
|
||||||
<woot-tabs-item
|
<woot-tabs-item
|
||||||
@ -130,18 +120,12 @@ export default {
|
|||||||
v-if="currentChat.id"
|
v-if="currentChat.id"
|
||||||
:inbox-id="inboxId"
|
:inbox-id="inboxId"
|
||||||
:is-inbox-view="isInboxView"
|
:is-inbox-view="isInboxView"
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="!currentChat.id && !isInboxView"
|
v-if="!currentChat.id && !isInboxView"
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
/>
|
/>
|
||||||
<ConversationSidebar
|
<slot />
|
||||||
v-if="showContactPanel"
|
|
||||||
:current-chat="currentChat"
|
|
||||||
@toggle-contact-panel="onToggleContactPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<DashboardAppFrame
|
<DashboardAppFrame
|
||||||
v-for="(dashboardApp, index) in dashboardApps"
|
v-for="(dashboardApp, index) in dashboardApps"
|
||||||
|
|||||||
@ -243,7 +243,7 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||||
:class="{
|
:class="{
|
||||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||||
isActiveChat,
|
isActiveChat,
|
||||||
@ -278,7 +278,7 @@ export default {
|
|||||||
:badge="inboxBadge"
|
:badge="inboxBadge"
|
||||||
:username="currentContact.name"
|
:username="currentContact.name"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
size="40px"
|
size="32px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { computed, ref } from 'vue';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useElementSize } from '@vueuse/core';
|
||||||
import BackButton from '../BackButton.vue';
|
import BackButton from '../BackButton.vue';
|
||||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
import MoreActions from './MoreActions.vue';
|
import MoreActions from './MoreActions.vue';
|
||||||
import Thumbnail from '../Thumbnail.vue';
|
import Thumbnail from '../Thumbnail.vue';
|
||||||
@ -12,203 +13,161 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
|||||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import Linear from './linear/index.vue';
|
import Linear from './linear/index.vue';
|
||||||
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
const props = defineProps({
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
showBackButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
const { t } = useI18n();
|
||||||
components: {
|
const store = useStore();
|
||||||
BackButton,
|
const route = useRoute();
|
||||||
InboxName,
|
const conversationHeader = ref(null);
|
||||||
MoreActions,
|
const { width } = useElementSize(conversationHeader);
|
||||||
Thumbnail,
|
const { isAWebWidgetInbox } = useInbox();
|
||||||
SLACardLabel,
|
|
||||||
Linear,
|
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||||
NextButton,
|
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||||
},
|
const isFeatureEnabledonAccount = computed(
|
||||||
mixins: [inboxMixin],
|
() => store.getters['accounts/isFeatureEnabledonAccount']
|
||||||
props: {
|
);
|
||||||
chat: {
|
const appIntegrations = computed(
|
||||||
type: Object,
|
() => store.getters['integrations/getAppIntegrations']
|
||||||
default: () => {},
|
);
|
||||||
},
|
|
||||||
isContactPanelOpen: {
|
const chatMetadata = computed(() => props.chat.meta);
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
const backButtonUrl = computed(() => {
|
||||||
},
|
const {
|
||||||
showBackButton: {
|
params: { inbox_id: inboxId, label, teamId },
|
||||||
type: Boolean,
|
name,
|
||||||
default: false,
|
} = route;
|
||||||
},
|
return conversationListPageURL({
|
||||||
isInboxView: {
|
accountId,
|
||||||
type: Boolean,
|
inboxId,
|
||||||
default: false,
|
label,
|
||||||
},
|
teamId,
|
||||||
},
|
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
||||||
emits: ['contactPanelToggle'],
|
});
|
||||||
setup(props, { emit }) {
|
});
|
||||||
const keyboardEvents = {
|
|
||||||
'Alt+KeyO': {
|
const isHMACVerified = computed(() => {
|
||||||
action: () => emit('contactPanelToggle'),
|
if (!isAWebWidgetInbox.value) {
|
||||||
},
|
return true;
|
||||||
};
|
}
|
||||||
useKeyboardEvents(keyboardEvents);
|
return chatMetadata.value.hmac_verified;
|
||||||
},
|
});
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
const currentContact = computed(() =>
|
||||||
currentChat: 'getSelectedChat',
|
store.getters['contacts/getContact'](props.chat.meta.sender.id)
|
||||||
accountId: 'getCurrentAccountId',
|
);
|
||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
|
||||||
appIntegrations: 'integrations/getAppIntegrations',
|
const isSnoozed = computed(
|
||||||
}),
|
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
|
||||||
chatMetadata() {
|
);
|
||||||
return this.chat.meta;
|
|
||||||
},
|
const snoozedDisplayText = computed(() => {
|
||||||
backButtonUrl() {
|
const { snoozed_until: snoozedUntil } = currentChat.value;
|
||||||
const {
|
if (snoozedUntil) {
|
||||||
params: { accountId, inbox_id: inboxId, label, teamId },
|
return `${t('CONVERSATION.HEADER.SNOOZED_UNTIL')} ${snoozedReopenTime(snoozedUntil)}`;
|
||||||
name,
|
}
|
||||||
} = this.$route;
|
return t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||||
return conversationListPageURL({
|
});
|
||||||
accountId,
|
|
||||||
inboxId,
|
const inbox = computed(() => {
|
||||||
label,
|
const { inbox_id: inboxId } = props.chat;
|
||||||
teamId,
|
return store.getters['inboxes/getInbox'](inboxId);
|
||||||
conversationType: name === 'conversation_mentions' ? 'mention' : '',
|
});
|
||||||
});
|
|
||||||
},
|
const hasMultipleInboxes = computed(
|
||||||
isHMACVerified() {
|
() => store.getters['inboxes/getInboxes'].length > 1
|
||||||
if (!this.isAWebWidgetInbox) {
|
);
|
||||||
return true;
|
|
||||||
}
|
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||||
return this.chatMetadata.hmac_verified;
|
|
||||||
},
|
const isLinearIntegrationEnabled = computed(() =>
|
||||||
currentContact() {
|
appIntegrations.value.find(
|
||||||
return this.$store.getters['contacts/getContact'](
|
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||||
this.chat.meta.sender.id
|
)
|
||||||
);
|
);
|
||||||
},
|
|
||||||
isSnoozed() {
|
const isLinearFeatureEnabled = computed(() =>
|
||||||
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
|
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
|
||||||
},
|
);
|
||||||
snoozedDisplayText() {
|
|
||||||
const { snoozed_until: snoozedUntil } = this.currentChat;
|
|
||||||
if (snoozedUntil) {
|
|
||||||
return `${this.$t(
|
|
||||||
'CONVERSATION.HEADER.SNOOZED_UNTIL'
|
|
||||||
)} ${snoozedReopenTime(snoozedUntil)}`;
|
|
||||||
}
|
|
||||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
|
||||||
},
|
|
||||||
contactPanelToggleText() {
|
|
||||||
return `${
|
|
||||||
this.isContactPanelOpen
|
|
||||||
? this.$t('CONVERSATION.HEADER.CLOSE')
|
|
||||||
: this.$t('CONVERSATION.HEADER.OPEN')
|
|
||||||
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
|
|
||||||
},
|
|
||||||
inbox() {
|
|
||||||
const { inbox_id: inboxId } = this.chat;
|
|
||||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
|
||||||
},
|
|
||||||
hasMultipleInboxes() {
|
|
||||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
|
||||||
},
|
|
||||||
hasSlaPolicyId() {
|
|
||||||
return this.chat?.sla_policy_id;
|
|
||||||
},
|
|
||||||
isLinearIntegrationEnabled() {
|
|
||||||
return this.appIntegrations.find(
|
|
||||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isLinearFeatureEnabled() {
|
|
||||||
return this.isFeatureEnabledonAccount(
|
|
||||||
this.accountId,
|
|
||||||
FEATURE_FLAGS.LINEAR
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
|
ref="conversationHeader"
|
||||||
|
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
|
||||||
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-start max-w-full min-w-0 w-fit">
|
<BackButton
|
||||||
<BackButton
|
v-if="showBackButton"
|
||||||
v-if="showBackButton"
|
:back-url="backButtonUrl"
|
||||||
:back-url="backButtonUrl"
|
class="ltr:mr-2 rtl:ml-2"
|
||||||
class="ltr:mr-2 rtl:ml-2"
|
/>
|
||||||
/>
|
<Thumbnail
|
||||||
<Thumbnail
|
:src="currentContact.thumbnail"
|
||||||
:src="currentContact.thumbnail"
|
:username="currentContact.name"
|
||||||
:badge="inboxBadge"
|
:status="currentContact.availability_status"
|
||||||
:username="currentContact.name"
|
size="32px"
|
||||||
:status="currentContact.availability_status"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
>
|
||||||
>
|
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
|
||||||
<div
|
<span
|
||||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
class="text-sm font-medium truncate leading-tight text-n-slate-12"
|
||||||
>
|
>
|
||||||
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
|
{{ currentContact.name }}
|
||||||
<span
|
</span>
|
||||||
class="text-base font-medium truncate leading-tight text-n-slate-12"
|
<fluent-icon
|
||||||
>
|
v-if="!isHMACVerified"
|
||||||
{{ currentContact.name }}
|
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
||||||
</span>
|
size="14"
|
||||||
</NextButton>
|
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
|
||||||
<fluent-icon
|
icon="warning"
|
||||||
v-if="!isHMACVerified"
|
/>
|
||||||
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
</div>
|
||||||
size="14"
|
|
||||||
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
|
|
||||||
icon="warning"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
|
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
|
||||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||||
{{ snoozedDisplayText }}
|
{{ snoozedDisplayText }}
|
||||||
</span>
|
</span>
|
||||||
<NextButton
|
|
||||||
link
|
|
||||||
xs
|
|
||||||
blue
|
|
||||||
:label="contactPanelToggleText"
|
|
||||||
@click="$emit('contactPanelToggle')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
<div
|
||||||
:class="{ 'justify-end': isContactPanelOpen }"
|
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
|
||||||
>
|
>
|
||||||
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
<SLACardLabel
|
||||||
<Linear
|
v-if="hasSlaPolicyId"
|
||||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
:chat="chat"
|
||||||
:conversation-id="currentChat.id"
|
show-extended-info
|
||||||
/>
|
:parent-width="width"
|
||||||
<MoreActions :conversation-id="currentChat.id" />
|
class="hidden lg:flex"
|
||||||
</div>
|
/>
|
||||||
|
<Linear
|
||||||
|
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||||
|
:conversation-id="currentChat.id"
|
||||||
|
:parent-width="width"
|
||||||
|
class="hidden lg:flex"
|
||||||
|
/>
|
||||||
|
<MoreActions :conversation-id="currentChat.id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.conversation--header--actions {
|
|
||||||
::v-deep .inbox--name {
|
|
||||||
@apply m-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
||||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
currentChat: {
|
currentChat: {
|
||||||
@ -14,33 +13,8 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['toggleContactPanel']);
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
||||||
|
|
||||||
const CONTACT_TABS_OPTIONS = [
|
|
||||||
{ key: 'CONTACT', value: 'contact' },
|
|
||||||
{ key: 'COPILOT', value: 'copilot' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabs = computed(() => {
|
|
||||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
|
||||||
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
|
|
||||||
value: tab.value,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
const activeTab = ref(0);
|
|
||||||
const toggleContactPanel = () => {
|
|
||||||
emit('toggleContactPanel');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTabChange = selectedTab => {
|
|
||||||
activeTab.value = tabs.value.findIndex(
|
|
||||||
tabItem => tabItem.value === selectedTab.value
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
@ -49,29 +23,37 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
const showCopilotTab = computed(() =>
|
const showCopilotTab = computed(() =>
|
||||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
|
const activeTab = computed(() => {
|
||||||
|
const {
|
||||||
|
is_contact_sidebar_open: isContactSidebarOpen,
|
||||||
|
is_copilot_panel_open: isCopilotPanelOpen,
|
||||||
|
} = uiSettings.value;
|
||||||
|
|
||||||
|
if (isContactSidebarOpen) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (isCopilotPanelOpen) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
||||||
>
|
>
|
||||||
<div v-if="showCopilotTab" class="p-2">
|
|
||||||
<TabBar
|
|
||||||
:tabs="tabs"
|
|
||||||
:initial-active-tab="activeTab"
|
|
||||||
class="w-full [&>button]:w-full"
|
|
||||||
@tab-changed="handleTabChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-1 overflow-auto">
|
<div class="flex flex-1 overflow-auto">
|
||||||
<ContactPanel
|
<ContactPanel
|
||||||
v-if="!activeTab"
|
v-show="activeTab === 0"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:inbox-id="currentChat.inbox_id"
|
:inbox-id="currentChat.inbox_id"
|
||||||
:on-toggle="toggleContactPanel"
|
|
||||||
/>
|
/>
|
||||||
<CopilotContainer
|
<CopilotContainer
|
||||||
v-else-if="activeTab === 1 && showCopilotTab"
|
v-show="activeTab === 1 && showCopilotTab"
|
||||||
:key="currentChat.id"
|
:key="currentChat.id"
|
||||||
:conversation-inbox-type="channelType"
|
:conversation-inbox-type="channelType"
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
|
|||||||
@ -38,8 +38,6 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
|||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
Message,
|
Message,
|
||||||
@ -47,20 +45,8 @@ export default {
|
|||||||
ReplyBox,
|
ReplyBox,
|
||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
NextButton,
|
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
props: {
|
|
||||||
isContactPanelOpen: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isInboxView: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['contactPanelToggle'],
|
|
||||||
setup() {
|
setup() {
|
||||||
const isPopOutReplyBox = ref(false);
|
const isPopOutReplyBox = ref(false);
|
||||||
const conversationPanelRef = ref(null);
|
const conversationPanelRef = ref(null);
|
||||||
@ -203,12 +189,6 @@ export default {
|
|||||||
isATweet() {
|
isATweet() {
|
||||||
return this.conversationType === 'tweet';
|
return this.conversationType === 'tweet';
|
||||||
},
|
},
|
||||||
isRightOrLeftIcon() {
|
|
||||||
if (this.isContactPanelOpen) {
|
|
||||||
return 'arrow-chevron-right';
|
|
||||||
}
|
|
||||||
return 'arrow-chevron-left';
|
|
||||||
},
|
|
||||||
getLastSeenAt() {
|
getLastSeenAt() {
|
||||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||||
return contactLastSeenAt;
|
return contactLastSeenAt;
|
||||||
@ -444,9 +424,6 @@ export default {
|
|||||||
relevantMessages
|
relevantMessages
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.$emit('contactPanelToggle');
|
|
||||||
},
|
|
||||||
setScrollParams() {
|
setScrollParams() {
|
||||||
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
||||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||||
@ -530,19 +507,6 @@ export default {
|
|||||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end">
|
|
||||||
<NextButton
|
|
||||||
faded
|
|
||||||
xs
|
|
||||||
slate
|
|
||||||
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
|
|
||||||
:icon="
|
|
||||||
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
|
|
||||||
"
|
|
||||||
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
|
|
||||||
@click="onToggleContactPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<NextMessageList
|
<NextMessageList
|
||||||
v-if="showNextBubbles"
|
v-if="showNextBubbles"
|
||||||
ref="conversationPanelRef"
|
ref="conversationPanelRef"
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { mapGetters } from 'vuex';
|
import { computed, onUnmounted } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import EmailTranscriptModal from './EmailTranscriptModal.vue';
|
import EmailTranscriptModal from './EmailTranscriptModal.vue';
|
||||||
import ResolveAction from '../../buttons/ResolveAction.vue';
|
import ResolveAction from '../../buttons/ResolveAction.vue';
|
||||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CMD_MUTE_CONVERSATION,
|
CMD_MUTE_CONVERSATION,
|
||||||
@ -12,97 +16,111 @@ import {
|
|||||||
CMD_UNMUTE_CONVERSATION,
|
CMD_UNMUTE_CONVERSATION,
|
||||||
} from 'dashboard/helper/commandbar/events';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
export default {
|
// No props needed as we're getting currentChat from the store directly
|
||||||
components: {
|
const store = useStore();
|
||||||
EmailTranscriptModal,
|
const { t } = useI18n();
|
||||||
ResolveAction,
|
|
||||||
ButtonV4,
|
const [showEmailActionsModal, toggleEmailModal] = useToggle(false);
|
||||||
},
|
const [showActionsDropdown, toggleDropdown] = useToggle(false);
|
||||||
data() {
|
|
||||||
return {
|
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||||
showEmailActionsModal: false,
|
|
||||||
};
|
const actionMenuItems = computed(() => {
|
||||||
},
|
const items = [];
|
||||||
computed: {
|
|
||||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
if (!currentChat.value.muted) {
|
||||||
},
|
items.push({
|
||||||
mounted() {
|
icon: 'i-lucide-volume-off',
|
||||||
emitter.on(CMD_MUTE_CONVERSATION, this.mute);
|
label: t('CONTACT_PANEL.MUTE_CONTACT'),
|
||||||
emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
|
action: 'mute',
|
||||||
emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
value: 'mute',
|
||||||
},
|
});
|
||||||
unmounted() {
|
} else {
|
||||||
emitter.off(CMD_MUTE_CONVERSATION, this.mute);
|
items.push({
|
||||||
emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
|
icon: 'i-lucide-volume-1',
|
||||||
emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
label: t('CONTACT_PANEL.UNMUTE_CONTACT'),
|
||||||
},
|
action: 'unmute',
|
||||||
methods: {
|
value: 'unmute',
|
||||||
mute() {
|
});
|
||||||
this.$store.dispatch('muteConversation', this.currentChat.id);
|
}
|
||||||
useAlert(this.$t('CONTACT_PANEL.MUTED_SUCCESS'));
|
|
||||||
},
|
items.push({
|
||||||
unmute() {
|
icon: 'i-lucide-share',
|
||||||
this.$store.dispatch('unmuteConversation', this.currentChat.id);
|
label: t('CONTACT_PANEL.SEND_TRANSCRIPT'),
|
||||||
useAlert(this.$t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
action: 'send_transcript',
|
||||||
},
|
value: 'send_transcript',
|
||||||
toggleEmailActionsModal() {
|
});
|
||||||
this.showEmailActionsModal = !this.showEmailActionsModal;
|
|
||||||
},
|
return items;
|
||||||
},
|
});
|
||||||
|
|
||||||
|
const handleActionClick = ({ action }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
|
||||||
|
if (action === 'mute') {
|
||||||
|
store.dispatch('muteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
|
||||||
|
} else if (action === 'unmute') {
|
||||||
|
store.dispatch('unmuteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
||||||
|
} else if (action === 'send_transcript') {
|
||||||
|
toggleEmailModal();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These functions are needed for the event listeners
|
||||||
|
const mute = () => {
|
||||||
|
store.dispatch('muteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.MUTED_SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmute = () => {
|
||||||
|
store.dispatch('unmuteConversation', currentChat.value.id);
|
||||||
|
useAlert(t('CONTACT_PANEL.UNMUTED_SUCCESS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter.on(CMD_MUTE_CONVERSATION, mute);
|
||||||
|
emitter.on(CMD_UNMUTE_CONVERSATION, unmute);
|
||||||
|
emitter.on(CMD_SEND_TRANSCRIPT, toggleEmailModal);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
emitter.off(CMD_MUTE_CONVERSATION, mute);
|
||||||
|
emitter.off(CMD_UNMUTE_CONVERSATION, unmute);
|
||||||
|
emitter.off(CMD_SEND_TRANSCRIPT, toggleEmailModal);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex items-center gap-2 actions--container">
|
<div class="relative flex items-center gap-2 actions--container">
|
||||||
<ButtonV4
|
|
||||||
v-if="!currentChat.muted"
|
|
||||||
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-volume-off"
|
|
||||||
@click="mute"
|
|
||||||
/>
|
|
||||||
<ButtonV4
|
|
||||||
v-else
|
|
||||||
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-volume-1"
|
|
||||||
@click="unmute"
|
|
||||||
/>
|
|
||||||
<ButtonV4
|
|
||||||
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-share"
|
|
||||||
@click="toggleEmailActionsModal"
|
|
||||||
/>
|
|
||||||
<ResolveAction
|
<ResolveAction
|
||||||
:conversation-id="currentChat.id"
|
:conversation-id="currentChat.id"
|
||||||
:status="currentChat.status"
|
:status="currentChat.status"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<ButtonV4
|
||||||
|
v-tooltip="$t('CONVERSATION.HEADER.MORE_ACTIONS')"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
icon="i-lucide-more-vertical"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showActionsDropdown"
|
||||||
|
:menu-items="actionMenuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||||
|
@action="handleActionClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<EmailTranscriptModal
|
<EmailTranscriptModal
|
||||||
v-if="showEmailActionsModal"
|
v-if="showEmailActionsModal"
|
||||||
:show="showEmailActionsModal"
|
:show="showEmailActionsModal"
|
||||||
:current-chat="currentChat"
|
:current-chat="currentChat"
|
||||||
@cancel="toggleEmailActionsModal"
|
@cancel="toggleEmailModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.more--button {
|
|
||||||
@apply items-center flex ml-2 rtl:ml-0 rtl:mr-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-pane {
|
|
||||||
@apply -right-2 top-12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
@apply mr-1 rtl:mr-0 rtl:ml-1 min-w-[1rem];
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -1,132 +1,115 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
showExtendedInfo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
parentWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 60000;
|
const REFRESH_INTERVAL = 60000;
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
export default {
|
const timer = ref(null);
|
||||||
components: {
|
const slaStatus = ref({
|
||||||
SLAPopoverCard,
|
threshold: null,
|
||||||
},
|
isSlaMissed: false,
|
||||||
props: {
|
type: null,
|
||||||
chat: {
|
icon: null,
|
||||||
type: Object,
|
});
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
showExtendedInfo: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
timer: null,
|
|
||||||
showSlaPopover: false,
|
|
||||||
slaStatus: {
|
|
||||||
threshold: null,
|
|
||||||
isSlaMissed: false,
|
|
||||||
type: null,
|
|
||||||
icon: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
slaPolicyId() {
|
|
||||||
return this.chat?.sla_policy_id;
|
|
||||||
},
|
|
||||||
appliedSLA() {
|
|
||||||
return this.chat?.applied_sla;
|
|
||||||
},
|
|
||||||
slaEvents() {
|
|
||||||
return this.chat?.sla_events;
|
|
||||||
},
|
|
||||||
hasSlaThreshold() {
|
|
||||||
return this.slaStatus?.threshold;
|
|
||||||
},
|
|
||||||
isSlaMissed() {
|
|
||||||
return this.slaStatus?.isSlaMissed;
|
|
||||||
},
|
|
||||||
slaTextStyles() {
|
|
||||||
return this.isSlaMissed ? 'text-n-ruby-11' : 'text-n-amber-11';
|
|
||||||
},
|
|
||||||
slaStatusText() {
|
|
||||||
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
|
|
||||||
const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
|
|
||||||
|
|
||||||
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||||
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
const slaEvents = computed(() => props.chat?.sla_events);
|
||||||
});
|
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||||
},
|
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||||
showSlaPopoverCard() {
|
const slaTextStyles = computed(() =>
|
||||||
return (
|
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
|
||||||
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
|
);
|
||||||
);
|
|
||||||
},
|
const slaStatusText = computed(() => {
|
||||||
},
|
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||||
watch: {
|
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
|
||||||
chat() {
|
|
||||||
this.updateSlaStatus();
|
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||||
},
|
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||||
},
|
});
|
||||||
mounted() {
|
});
|
||||||
this.updateSlaStatus();
|
|
||||||
this.createTimer();
|
const showSlaPopoverCard = computed(
|
||||||
},
|
() => props.showExtendedInfo && slaEvents.value?.length > 0
|
||||||
unmounted() {
|
);
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
const groupClass = computed(() => {
|
||||||
}
|
return props.showExtendedInfo
|
||||||
},
|
? 'h-[26px] rounded-lg bg-n-alpha-1'
|
||||||
methods: {
|
: 'rounded h-5 border border-n-strong';
|
||||||
createTimer() {
|
});
|
||||||
this.timer = setTimeout(() => {
|
|
||||||
this.updateSlaStatus();
|
const updateSlaStatus = () => {
|
||||||
this.createTimer();
|
slaStatus.value = evaluateSLAStatus({
|
||||||
}, REFRESH_INTERVAL);
|
appliedSla: appliedSLA.value,
|
||||||
},
|
chat: props.chat,
|
||||||
updateSlaStatus() {
|
});
|
||||||
this.slaStatus = evaluateSLAStatus({
|
|
||||||
appliedSla: this.appliedSLA,
|
|
||||||
chat: this.chat,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openSlaPopover() {
|
|
||||||
if (!this.showExtendedInfo) return;
|
|
||||||
this.showSlaPopover = true;
|
|
||||||
},
|
|
||||||
closeSlaPopover() {
|
|
||||||
this.showSlaPopover = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createTimer = () => {
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
createTimer();
|
||||||
|
}, REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.chat,
|
||||||
|
() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const slaPopoverClass = computed(() => {
|
||||||
|
return props.showExtendedInfo
|
||||||
|
? 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateSlaStatus();
|
||||||
|
createTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="hasSlaThreshold"
|
v-if="hasSlaThreshold"
|
||||||
class="relative flex items-center cursor-pointer min-w-fit"
|
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||||
:class="
|
:class="groupClass"
|
||||||
showExtendedInfo
|
|
||||||
? 'h-[26px] rounded-lg bg-n-alpha-1'
|
|
||||||
: 'rounded h-5 border border-n-strong'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="closeSlaPopover"
|
class="flex items-center w-full truncate px-1.5"
|
||||||
class="flex items-center w-full truncate"
|
:class="showExtendedInfo ? '' : 'gap-1'"
|
||||||
:class="showExtendedInfo ? 'px-1.5' : 'px-2 gap-1'"
|
|
||||||
@mouseover="openSlaPopover()"
|
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex items-center gap-1" :class="slaPopoverClass">
|
||||||
class="flex items-center gap-1"
|
|
||||||
:class="
|
|
||||||
showExtendedInfo &&
|
|
||||||
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-n-strong'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
size="14"
|
size="12"
|
||||||
:icon="slaStatus.icon"
|
:icon="slaStatus.icon"
|
||||||
type="outline"
|
type="outline"
|
||||||
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
||||||
@ -134,7 +117,7 @@ export default {
|
|||||||
:class="slaTextStyles"
|
:class="slaTextStyles"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="showExtendedInfo"
|
v-if="showExtendedInfo && parentWidth > 650"
|
||||||
class="text-xs font-medium"
|
class="text-xs font-medium"
|
||||||
:class="slaTextStyles"
|
:class="slaTextStyles"
|
||||||
>
|
>
|
||||||
@ -151,7 +134,7 @@ export default {
|
|||||||
<SLAPopoverCard
|
<SLAPopoverCard
|
||||||
v-if="showSlaPopoverCard"
|
v-if="showSlaPopoverCard"
|
||||||
:sla-missed-events="slaEvents"
|
:sla-missed-events="slaEvents"
|
||||||
class="right-0 top-7"
|
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -16,6 +16,10 @@ const props = defineProps({
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
parentWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 10000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@ -73,6 +77,14 @@ const unlinkIssue = async linkId => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldShowIssueIdentifier = computed(() => {
|
||||||
|
if (!linkedIssue.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.parentWidth > 600;
|
||||||
|
});
|
||||||
|
|
||||||
const openIssue = () => {
|
const openIssue = () => {
|
||||||
if (!linkedIssue.value) shouldShowPopup.value = true;
|
if (!linkedIssue.value) shouldShowPopup.value = true;
|
||||||
shouldShow.value = true;
|
shouldShow.value = true;
|
||||||
@ -119,7 +131,10 @@ onMounted(() => {
|
|||||||
class="text-[#5E6AD2] flex-shrink-0"
|
class="text-[#5E6AD2] flex-shrink-0"
|
||||||
view-box="0 0 19 19"
|
view-box="0 0 19 19"
|
||||||
/>
|
/>
|
||||||
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11">
|
<span
|
||||||
|
v-if="shouldShowIssueIdentifier"
|
||||||
|
class="text-xs font-medium text-n-slate-11"
|
||||||
|
>
|
||||||
{{ linkedIssue.issue.identifier }}
|
{{ linkedIssue.issue.identifier }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -127,7 +142,7 @@ onMounted(() => {
|
|||||||
v-if="linkedIssue"
|
v-if="linkedIssue"
|
||||||
:issue="linkedIssue.issue"
|
:issue="linkedIssue.issue"
|
||||||
:link-id="linkedIssue.id"
|
:link-id="linkedIssue.id"
|
||||||
class="absolute right-0 top-[36px] invisible group-hover:visible"
|
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
|
||||||
@unlink-issue="unlinkIssue"
|
@unlink-issue="unlinkIssue"
|
||||||
/>
|
/>
|
||||||
<woot-modal
|
<woot-modal
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
|
||||||
import { createStore } from 'vuex';
|
|
||||||
import MoreActions from '../MoreActions.vue';
|
|
||||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
|
||||||
|
|
||||||
vi.mock('shared/helpers/mitt', () => ({
|
|
||||||
emitter: {
|
|
||||||
emit: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
off: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockDirective = {
|
|
||||||
mounted: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
|
||||||
|
|
||||||
describe('MoveActions', () => {
|
|
||||||
let currentChat = { id: 8, muted: false };
|
|
||||||
let store = null;
|
|
||||||
let muteConversation = null;
|
|
||||||
let unmuteConversation = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
muteConversation = vi.fn(() => Promise.resolve());
|
|
||||||
unmuteConversation = vi.fn(() => Promise.resolve());
|
|
||||||
|
|
||||||
store = createStore({
|
|
||||||
state: {
|
|
||||||
authenticated: true,
|
|
||||||
currentChat,
|
|
||||||
},
|
|
||||||
getters: {
|
|
||||||
getSelectedChat: () => currentChat,
|
|
||||||
},
|
|
||||||
modules: {
|
|
||||||
conversations: {
|
|
||||||
namespaced: false,
|
|
||||||
actions: { muteConversation, unmuteConversation },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const createWrapper = () =>
|
|
||||||
mount(MoreActions, {
|
|
||||||
global: {
|
|
||||||
plugins: [store],
|
|
||||||
components: {
|
|
||||||
'fluent-icon': FluentIcon,
|
|
||||||
},
|
|
||||||
directives: {
|
|
||||||
'on-clickaway': mockDirective,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('muting discussion', () => {
|
|
||||||
it('triggers "muteConversation"', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(muteConversation).toHaveBeenCalledTimes(1);
|
|
||||||
expect(muteConversation).toHaveBeenCalledWith(
|
|
||||||
expect.any(Object), // First argument is the Vuex context object
|
|
||||||
currentChat.id // Second argument is the ID of the conversation
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows alert', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(emitter.emit).toBeCalledWith('newToastMessage', {
|
|
||||||
message:
|
|
||||||
'This contact is blocked successfully. You will not be notified of any future conversations.',
|
|
||||||
action: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unmuting discussion', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
currentChat.muted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('triggers "unmuteConversation"', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(unmuteConversation).toHaveBeenCalledTimes(1);
|
|
||||||
expect(unmuteConversation).toHaveBeenCalledWith(
|
|
||||||
expect.any(Object), // First argument is the Vuex context object
|
|
||||||
currentChat.id // Second argument is the ID of the conversation
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows alert', async () => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
await wrapper.find('button:first-child').trigger('click');
|
|
||||||
|
|
||||||
expect(emitter.emit).toBeCalledWith('newToastMessage', {
|
|
||||||
message: 'This contact is unblocked successfully.',
|
|
||||||
action: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -43,7 +43,7 @@ describe('useFontSize', () => {
|
|||||||
|
|
||||||
it('returns fontSizeOptions with correct structure', () => {
|
it('returns fontSizeOptions with correct structure', () => {
|
||||||
const { fontSizeOptions } = useFontSize();
|
const { fontSizeOptions } = useFontSize();
|
||||||
expect(fontSizeOptions).toHaveLength(6);
|
expect(fontSizeOptions).toHaveLength(5);
|
||||||
expect(fontSizeOptions[0]).toHaveProperty('value');
|
expect(fontSizeOptions[0]).toHaveProperty('value');
|
||||||
expect(fontSizeOptions[0]).toHaveProperty('label');
|
expect(fontSizeOptions[0]).toHaveProperty('label');
|
||||||
|
|
||||||
@ -59,12 +59,6 @@ describe('useFontSize', () => {
|
|||||||
label:
|
label:
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
|
|
||||||
value: '22px',
|
|
||||||
label:
|
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns currentFontSize from UI settings', () => {
|
it('returns currentFontSize from UI settings', () => {
|
||||||
@ -84,9 +78,6 @@ describe('useFontSize', () => {
|
|||||||
applyFontSize('14px');
|
applyFontSize('14px');
|
||||||
expect(document.documentElement.style.fontSize).toBe('14px');
|
expect(document.documentElement.style.fontSize).toBe('14px');
|
||||||
|
|
||||||
applyFontSize('22px');
|
|
||||||
expect(document.documentElement.style.fontSize).toBe('22px');
|
|
||||||
|
|
||||||
applyFontSize('16px');
|
applyFontSize('16px');
|
||||||
expect(document.documentElement.style.fontSize).toBe('16px');
|
expect(document.documentElement.style.fontSize).toBe('16px');
|
||||||
});
|
});
|
||||||
@ -145,8 +136,6 @@ describe('useFontSize', () => {
|
|||||||
'Smaller',
|
'Smaller',
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
|
||||||
'Default',
|
'Default',
|
||||||
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
|
|
||||||
'Extra Large',
|
|
||||||
};
|
};
|
||||||
return translations[key] || key;
|
return translations[key] || key;
|
||||||
});
|
});
|
||||||
@ -160,9 +149,6 @@ describe('useFontSize', () => {
|
|||||||
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
|
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
|
||||||
'Default'
|
'Default'
|
||||||
);
|
);
|
||||||
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
|
|
||||||
'Extra Large'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify translation function was called with correct keys
|
// Verify translation function was called with correct keys
|
||||||
expect(mockTranslate).toHaveBeenCalledWith(
|
expect(mockTranslate).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
|
|||||||
DEFAULT: '16px',
|
DEFAULT: '16px',
|
||||||
LARGE: '18px',
|
LARGE: '18px',
|
||||||
LARGER: '20px',
|
LARGER: '20px',
|
||||||
EXTRA_LARGE: '22px',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export function usePolicy() {
|
|||||||
return {
|
return {
|
||||||
checkPermissions,
|
checkPermissions,
|
||||||
shouldShowPaywall,
|
shouldShowPaywall,
|
||||||
|
isFeatureFlagEnabled,
|
||||||
shouldShow,
|
shouldShow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,7 +62,9 @@
|
|||||||
"ACCESS_TOKEN": {
|
"ACCESS_TOKEN": {
|
||||||
"TITLE": "Access Token",
|
"TITLE": "Access Token",
|
||||||
"DESCRIPTION": "Copy the access token and save it securely",
|
"DESCRIPTION": "Copy the access token and save it securely",
|
||||||
"COPY_SUCCESSFUL": "Access token copied to clipboard"
|
"COPY_SUCCESSFUL": "Access token copied to clipboard",
|
||||||
|
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||||
|
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||||
},
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"AVATAR": {
|
"AVATAR": {
|
||||||
|
|||||||
@ -70,6 +70,7 @@
|
|||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
"REOPEN_ACTION": "Reopen",
|
"REOPEN_ACTION": "Reopen",
|
||||||
"OPEN_ACTION": "Open",
|
"OPEN_ACTION": "Open",
|
||||||
|
"MORE_ACTIONS": "More actions",
|
||||||
"OPEN": "More",
|
"OPEN": "More",
|
||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"DETAILS": "details",
|
"DETAILS": "details",
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
"PHONE_INPUT": {
|
"PHONE_INPUT": {
|
||||||
"PLACEHOLDER": "Search",
|
"PLACEHOLDER": "Search",
|
||||||
"EMPTY_STATE": "No results found"
|
"EMPTY_STATE": "No results found"
|
||||||
}
|
},
|
||||||
|
"CLOSE": "Close"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,7 +76,12 @@
|
|||||||
"ACCESS_TOKEN": {
|
"ACCESS_TOKEN": {
|
||||||
"TITLE": "Access Token",
|
"TITLE": "Access Token",
|
||||||
"NOTE": "This token can be used if you are building an API based integration",
|
"NOTE": "This token can be used if you are building an API based integration",
|
||||||
"COPY": "Copy"
|
"COPY": "Copy",
|
||||||
|
"RESET": "Reset",
|
||||||
|
"CONFIRM_RESET": "Are you sure?",
|
||||||
|
"CONFIRM_HINT": "Click again to confirm",
|
||||||
|
"RESET_SUCCESS": "Access token regenerated successfully",
|
||||||
|
"RESET_ERROR": "Unable to regenerate access token. Please try again"
|
||||||
},
|
},
|
||||||
"AUDIO_NOTIFICATIONS_SECTION": {
|
"AUDIO_NOTIFICATIONS_SECTION": {
|
||||||
"TITLE": "Audio Alerts",
|
"TITLE": "Audio Alerts",
|
||||||
|
|||||||
@ -10,10 +10,8 @@ import {
|
|||||||
CONTACT_PERMISSIONS,
|
CONTACT_PERMISSIONS,
|
||||||
PORTAL_PERMISSIONS,
|
PORTAL_PERMISSIONS,
|
||||||
} from 'dashboard/constants/permissions.js';
|
} from 'dashboard/constants/permissions.js';
|
||||||
import {
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||||
getUserPermissions,
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
filterItemsByPermission,
|
|
||||||
} from 'dashboard/helper/permissionsHelper.js';
|
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
@ -39,8 +37,6 @@ const pages = ref({
|
|||||||
articles: 1,
|
articles: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
|
||||||
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
||||||
const conversationRecords = useMapGetter(
|
const conversationRecords = useMapGetter(
|
||||||
'conversationSearch/getConversationRecords'
|
'conversationSearch/getConversationRecords'
|
||||||
@ -83,9 +79,7 @@ const filterConversations = filterByTab('conversations');
|
|||||||
const filterMessages = filterByTab('messages');
|
const filterMessages = filterByTab('messages');
|
||||||
const filterArticles = filterByTab('articles');
|
const filterArticles = filterByTab('articles');
|
||||||
|
|
||||||
const userPermissions = computed(() =>
|
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
|
||||||
getUserPermissions(currentUser.value, currentAccountId.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const TABS_CONFIG = {
|
const TABS_CONFIG = {
|
||||||
all: {
|
all: {
|
||||||
@ -111,47 +105,67 @@ const TABS_CONFIG = {
|
|||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||||
count: () => mappedArticles.value.length,
|
count: () => mappedArticles.value.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
|
return Object.entries(TABS_CONFIG)
|
||||||
key,
|
.map(([key, config]) => ({
|
||||||
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
key,
|
||||||
count: config.count(),
|
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
||||||
showBadge: key !== 'all',
|
count: config.count(),
|
||||||
permissions: config.permissions,
|
showBadge: key !== 'all',
|
||||||
}));
|
permissions: config.permissions,
|
||||||
|
featureFlag: config.featureFlag,
|
||||||
return filterItemsByPermission(
|
}))
|
||||||
configs,
|
.filter(config => {
|
||||||
userPermissions.value,
|
// why the double check, glad you asked.
|
||||||
item => item.permissions
|
// Some features are marked as premium features, that means
|
||||||
);
|
// the feature will be visible, but a Paywall will be shown instead
|
||||||
|
// this works for pages and routes, but fails for UI elements like search here
|
||||||
|
// so we explicitly check if the feature is enabled
|
||||||
|
return (
|
||||||
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
||||||
|
isFeatureFlagEnabled(config.featureFlag)
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSearchResultsCount = computed(() => {
|
const totalSearchResultsCount = computed(() => {
|
||||||
const permissionCounts = {
|
const permissionCounts = [
|
||||||
contacts: {
|
{
|
||||||
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
||||||
count: () => contacts.value.length,
|
count: () => contacts.value.length,
|
||||||
},
|
},
|
||||||
conversations: {
|
{
|
||||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
count: () => conversations.value.length + messages.value.length,
|
count: () => conversations.value.length + messages.value.length,
|
||||||
},
|
},
|
||||||
articles: {
|
{
|
||||||
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
||||||
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||||
count: () => articles.value.length,
|
count: () => articles.value.length,
|
||||||
},
|
},
|
||||||
};
|
];
|
||||||
return filterItemsByPermission(
|
|
||||||
permissionCounts,
|
return permissionCounts
|
||||||
userPermissions.value,
|
.filter(config => {
|
||||||
item => item.permissions,
|
// why the double check, glad you asked.
|
||||||
(_, item) => item.count
|
// Some features are marked as premium features, that means
|
||||||
).reduce((total, count) => total + count(), 0);
|
// the feature will be visible, but a Paywall will be shown instead
|
||||||
|
// this works for pages and routes, but fails for UI elements like search here
|
||||||
|
// so we explicitly check if the feature is enabled
|
||||||
|
return (
|
||||||
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
||||||
|
isFeatureFlagEnabled(config.featureFlag)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(config => {
|
||||||
|
return config.count();
|
||||||
|
})
|
||||||
|
.reduce((sum, count) => sum + count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTabIndex = computed(() => {
|
const activeTabIndex = computed(() => {
|
||||||
@ -355,7 +369,9 @@ onUnmounted(() => {
|
|||||||
</Policy>
|
</Policy>
|
||||||
|
|
||||||
<Policy
|
<Policy
|
||||||
|
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
|
||||||
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
||||||
|
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
|
||||||
class="flex flex-col justify-center"
|
class="flex flex-col justify-center"
|
||||||
>
|
>
|
||||||
<SearchResultArticlesList
|
<SearchResultArticlesList
|
||||||
|
|||||||
@ -11,14 +11,14 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
|
|||||||
import ContactConversations from './ContactConversations.vue';
|
import ContactConversations from './ContactConversations.vue';
|
||||||
import ConversationAction from './ConversationAction.vue';
|
import ConversationAction from './ConversationAction.vue';
|
||||||
import ConversationParticipant from './ConversationParticipant.vue';
|
import ConversationParticipant from './ConversationParticipant.vue';
|
||||||
|
|
||||||
import ContactInfo from './contact/ContactInfo.vue';
|
import ContactInfo from './contact/ContactInfo.vue';
|
||||||
import ContactNotes from './contact/ContactNotes.vue';
|
import ContactNotes from './contact/ContactNotes.vue';
|
||||||
import ConversationInfo from './ConversationInfo.vue';
|
import ConversationInfo from './ConversationInfo.vue';
|
||||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||||
import Draggable from 'vuedraggable';
|
import Draggable from 'vuedraggable';
|
||||||
import MacrosList from './Macros/List.vue';
|
import MacrosList from './Macros/List.vue';
|
||||||
import ShopifyOrdersList from '../../../components/widgets/conversation/ShopifyOrdersList.vue';
|
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
|
||||||
|
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversationId: {
|
conversationId: {
|
||||||
@ -29,10 +29,6 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: undefined,
|
default: undefined,
|
||||||
},
|
},
|
||||||
onToggle: {
|
|
||||||
type: Function,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -89,8 +85,6 @@ watch(conversationId, (newConversationId, prevConversationId) => {
|
|||||||
|
|
||||||
watch(contactId, getContactDetails);
|
watch(contactId, getContactDetails);
|
||||||
|
|
||||||
const onPanelToggle = props.onToggle;
|
|
||||||
|
|
||||||
const onDragEnd = () => {
|
const onDragEnd = () => {
|
||||||
dragging.value = false;
|
dragging.value = false;
|
||||||
updateUISettings({
|
updateUISettings({
|
||||||
@ -98,6 +92,13 @@ const onDragEnd = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeContactPanel = () => {
|
||||||
|
updateUISettings({
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
|
is_copilot_panel_open: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
|
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
|
||||||
getContactDetails();
|
getContactDetails();
|
||||||
@ -107,11 +108,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<ContactInfo
|
<SidebarActionsHeader
|
||||||
:contact="contact"
|
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
:channel-type="channelType"
|
@close="closeContactPanel"
|
||||||
@toggle-panel="onPanelToggle"
|
|
||||||
/>
|
/>
|
||||||
|
<ContactInfo :contact="contact" :channel-type="channelType" />
|
||||||
<div class="list-group pb-8">
|
<div class="list-group pb-8">
|
||||||
<Draggable
|
<Draggable
|
||||||
:list="conversationSidebarItems"
|
:list="conversationSidebarItems"
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|||||||
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
|
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
|
||||||
|
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@ -17,6 +19,8 @@ export default {
|
|||||||
ConversationBox,
|
ConversationBox,
|
||||||
PopOverSearch,
|
PopOverSearch,
|
||||||
CmdBarConversationSnooze,
|
CmdBarConversationSnooze,
|
||||||
|
SidepanelSwitch,
|
||||||
|
ConversationSidebar,
|
||||||
},
|
},
|
||||||
beforeRouteLeave(to, from, next) {
|
beforeRouteLeave(to, from, next) {
|
||||||
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
|
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
|
||||||
@ -87,13 +91,17 @@ export default {
|
|||||||
this.uiSettings;
|
this.uiSettings;
|
||||||
return conversationDisplayType !== CONDENSED;
|
return conversationDisplayType !== CONDENSED;
|
||||||
},
|
},
|
||||||
isContactPanelOpen() {
|
|
||||||
if (this.currentChat.id) {
|
shouldShowSidebar() {
|
||||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
if (!this.currentChat.id) {
|
||||||
this.uiSettings;
|
return false;
|
||||||
return isContactSidebarOpen;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
const {
|
||||||
|
is_contact_sidebar_open: isContactSidebarOpen,
|
||||||
|
is_copilot_panel_open: isCopilotPanelOpen,
|
||||||
|
} = this.uiSettings;
|
||||||
|
return isContactSidebarOpen || isCopilotPanelOpen;
|
||||||
},
|
},
|
||||||
showPopOverSearch() {
|
showPopOverSearch() {
|
||||||
return !this.isFeatureEnabledonAccount(
|
return !this.isFeatureEnabledonAccount(
|
||||||
@ -189,11 +197,6 @@ export default {
|
|||||||
this.$store.dispatch('clearSelectedState');
|
this.$store.dispatch('clearSelectedState');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleContactPanel() {
|
|
||||||
this.updateUISettings({
|
|
||||||
is_contact_sidebar_open: !this.isContactPanelOpen,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSearch() {
|
onSearch() {
|
||||||
this.showSearchModal = true;
|
this.showSearchModal = true;
|
||||||
},
|
},
|
||||||
@ -225,10 +228,11 @@ export default {
|
|||||||
<ConversationBox
|
<ConversationBox
|
||||||
v-if="showMessageView"
|
v-if="showMessageView"
|
||||||
:inbox-id="inboxId"
|
:inbox-id="inboxId"
|
||||||
:is-contact-panel-open="isContactPanelOpen"
|
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
@contact-panel-toggle="onToggleContactPanel"
|
>
|
||||||
/>
|
<SidepanelSwitch v-if="currentChat.id" />
|
||||||
|
</ConversationBox>
|
||||||
|
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
|
||||||
<CmdBarConversationSnooze />
|
<CmdBarConversationSnooze />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -228,7 +228,20 @@ const initializeForm = () => {
|
|||||||
|
|
||||||
const onCopyToken = async value => {
|
const onCopyToken = async value => {
|
||||||
await copyTextToClipboard(value);
|
await copyTextToClipboard(value);
|
||||||
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResetToken = async () => {
|
||||||
|
const response = await store.dispatch(
|
||||||
|
'agentBots/resetAccessToken',
|
||||||
|
props.selectedBot.id
|
||||||
|
);
|
||||||
|
if (response) {
|
||||||
|
accessToken.value = response.access_token;
|
||||||
|
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
|
||||||
|
} else {
|
||||||
|
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
@ -312,7 +325,18 @@ defineExpose({ dialogRef });
|
|||||||
>
|
>
|
||||||
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
||||||
</label>
|
</label>
|
||||||
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
|
<AccessToken
|
||||||
|
v-if="type === MODAL_TYPES.EDIT"
|
||||||
|
:value="accessToken"
|
||||||
|
@on-copy="onCopyToken"
|
||||||
|
@on-reset="onResetToken"
|
||||||
|
/>
|
||||||
|
<AccessToken
|
||||||
|
v-else
|
||||||
|
:value="accessToken"
|
||||||
|
:show-reset-button="false"
|
||||||
|
@on-copy="onCopyToken"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import FormButton from 'v3/components/Form/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
value: {
|
value: { type: String, default: '' },
|
||||||
type: String,
|
showResetButton: { type: Boolean, default: true },
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['onCopy']);
|
|
||||||
|
const emit = defineEmits(['onCopy', 'onReset']);
|
||||||
|
|
||||||
const inputType = ref('password');
|
const inputType = ref('password');
|
||||||
|
|
||||||
const toggleMasked = () => {
|
const toggleMasked = () => {
|
||||||
inputType.value = inputType.value === 'password' ? 'text' : 'password';
|
inputType.value = inputType.value === 'password' ? 'text' : 'password';
|
||||||
};
|
};
|
||||||
@ -20,6 +23,10 @@ const maskIcon = computed(() => {
|
|||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('onCopy', props.value);
|
emit('onCopy', props.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onReset = () => {
|
||||||
|
emit('onReset');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -38,7 +45,7 @@ const onClick = () => {
|
|||||||
>
|
>
|
||||||
<template #masked>
|
<template #masked>
|
||||||
<button
|
<button
|
||||||
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
|
class="absolute top-0 bottom-0 ltr:right-0.5 rtl:left-0.5"
|
||||||
type="button"
|
type="button"
|
||||||
@click="toggleMasked"
|
@click="toggleMasked"
|
||||||
>
|
>
|
||||||
@ -46,15 +53,28 @@ const onClick = () => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</woot-input>
|
</woot-input>
|
||||||
<FormButton
|
<div class="flex flex-row gap-2">
|
||||||
type="button"
|
<NextButton
|
||||||
size="large"
|
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY')"
|
||||||
icon="text-copy"
|
slate
|
||||||
variant="outline"
|
outline
|
||||||
color-scheme="secondary"
|
type="button"
|
||||||
@click="onClick"
|
icon="i-lucide-copy"
|
||||||
>
|
class="rounded-xl"
|
||||||
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
|
@click="onClick"
|
||||||
</FormButton>
|
/>
|
||||||
|
<ConfirmButton
|
||||||
|
v-if="showResetButton"
|
||||||
|
:label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET')"
|
||||||
|
:confirm-label="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_RESET')"
|
||||||
|
:confirm-hint="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.CONFIRM_HINT')"
|
||||||
|
color="slate"
|
||||||
|
confirm-color="ruby"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-key-round"
|
||||||
|
class="rounded-xl"
|
||||||
|
@click="onReset"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -181,6 +181,14 @@ export default {
|
|||||||
await copyTextToClipboard(value);
|
await copyTextToClipboard(value);
|
||||||
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||||
},
|
},
|
||||||
|
async resetAccessToken() {
|
||||||
|
const success = await this.$store.dispatch('resetAccessToken');
|
||||||
|
if (success) {
|
||||||
|
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
|
||||||
|
} else {
|
||||||
|
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -237,7 +245,12 @@ export default {
|
|||||||
<button
|
<button
|
||||||
v-for="hotKey in hotKeys"
|
v-for="hotKey in hotKeys"
|
||||||
:key="hotKey.key"
|
:key="hotKey.key"
|
||||||
class="px-0 reset-base w-full sm:flex-1"
|
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline"
|
||||||
|
:class="
|
||||||
|
isEditorHotKeyEnabled(hotKey.key)
|
||||||
|
? 'outline-n-brand/30'
|
||||||
|
: 'outline-n-weak'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<HotKeyCard
|
<HotKeyCard
|
||||||
:key="hotKey.title"
|
:key="hotKey.title"
|
||||||
@ -281,7 +294,11 @@ export default {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" />
|
<AccessToken
|
||||||
|
:value="currentUser.access_token"
|
||||||
|
@on-copy="onCopyToken"
|
||||||
|
@on-reset="resetAccessToken"
|
||||||
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -172,6 +172,17 @@ export const actions = {
|
|||||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
|
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetAccessToken: async ({ commit }, botId) => {
|
||||||
|
try {
|
||||||
|
const response = await AgentBotsAPI.resetAccessToken(botId);
|
||||||
|
commit(types.EDIT_AGENT_BOT, response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
|||||||
@ -213,6 +213,16 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetAccessToken: async ({ commit }) => {
|
||||||
|
try {
|
||||||
|
const response = await authAPI.resetAccessToken();
|
||||||
|
commit(types.SET_CURRENT_USER, response.data);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
resendConfirmation: async () => {
|
resendConfirmation: async () => {
|
||||||
try {
|
try {
|
||||||
await authAPI.resendConfirmation();
|
await authAPI.resendConfirmation();
|
||||||
|
|||||||
@ -18,13 +18,34 @@ const getters = {
|
|||||||
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
|
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
|
||||||
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
|
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
|
||||||
},
|
},
|
||||||
getFilteredConversations: ({
|
getFilteredConversations: (
|
||||||
allConversations,
|
{ allConversations, chatSortFilter, appliedFilters },
|
||||||
chatSortFilter,
|
_,
|
||||||
appliedFilters,
|
__,
|
||||||
}) => {
|
rootGetters
|
||||||
|
) => {
|
||||||
|
const currentUser = rootGetters.getCurrentUser;
|
||||||
|
const currentUserId = rootGetters.getCurrentUser.id;
|
||||||
|
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||||
|
|
||||||
|
const permissions = getUserPermissions(currentUser, currentAccountId);
|
||||||
|
const userRole = getUserRole(currentUser, currentAccountId);
|
||||||
|
|
||||||
return allConversations
|
return allConversations
|
||||||
.filter(conversation => matchesFilters(conversation, appliedFilters))
|
.filter(conversation => {
|
||||||
|
const matchesFilterResult = matchesFilters(
|
||||||
|
conversation,
|
||||||
|
appliedFilters
|
||||||
|
);
|
||||||
|
const allowedForRole = applyRoleFilter(
|
||||||
|
conversation,
|
||||||
|
userRole,
|
||||||
|
permissions,
|
||||||
|
currentUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
return matchesFilterResult && allowedForRole;
|
||||||
|
})
|
||||||
.sort((a, b) => sortComparator(a, b, chatSortFilter));
|
.sort((a, b) => sortComparator(a, b, chatSortFilter));
|
||||||
},
|
},
|
||||||
getSelectedChat: ({ selectedChatId, allConversations }) => {
|
getSelectedChat: ({ selectedChatId, allConversations }) => {
|
||||||
|
|||||||
@ -170,4 +170,21 @@ describe('#actions', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('#resetAccessToken', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { ...agentBotRecords[0], access_token: 'new_token_123' },
|
||||||
|
};
|
||||||
|
axios.post.mockResolvedValue(mockResponse);
|
||||||
|
const result = await actions.resetAccessToken(
|
||||||
|
{ commit },
|
||||||
|
agentBotRecords[0].id
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.EDIT_AGENT_BOT, mockResponse.data],
|
||||||
|
]);
|
||||||
|
expect(result).toBe(mockResponse.data);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -228,4 +228,20 @@ describe('#actions', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#resetAccessToken', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: { id: 1, name: 'John', access_token: 'new_token_123' },
|
||||||
|
headers: { expiry: 581842904 },
|
||||||
|
};
|
||||||
|
axios.post.mockResolvedValue(mockResponse);
|
||||||
|
const result = await actions.resetAccessToken({ commit });
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.SET_CURRENT_USER, mockResponse.data],
|
||||||
|
]);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -325,4 +325,308 @@ describe('#getters', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#getFilteredConversations', () => {
|
||||||
|
const mockConversations = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
status: 'open',
|
||||||
|
meta: { assignee: { id: 1 } },
|
||||||
|
last_activity_at: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
status: 'open',
|
||||||
|
meta: {},
|
||||||
|
last_activity_at: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
status: 'resolved',
|
||||||
|
meta: { assignee: { id: 2 } },
|
||||||
|
last_activity_at: 3000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockRootGetters = {
|
||||||
|
getCurrentUser: {
|
||||||
|
id: 1,
|
||||||
|
accounts: [{ id: 1, role: 'agent', permissions: [] }],
|
||||||
|
},
|
||||||
|
getCurrentAccountId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('filters conversations based on role permissions for administrator', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockConversations[2],
|
||||||
|
mockConversations[1],
|
||||||
|
mockConversations[0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters conversations based on role permissions for agent', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [{ id: 1, role: 'agent', permissions: [] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockConversations[2],
|
||||||
|
mockConversations[1],
|
||||||
|
mockConversations[0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters conversations for custom role with conversation_manage permission', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
custom_role_id: 5,
|
||||||
|
permissions: ['conversation_manage'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockConversations[2],
|
||||||
|
mockConversations[1],
|
||||||
|
mockConversations[0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
custom_role_id: 5,
|
||||||
|
permissions: ['conversation_unassigned_manage'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should include conversation assigned to user (id: 1) and unassigned conversation
|
||||||
|
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters conversations for custom role with conversation_participating_manage permission', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
custom_role_id: 5,
|
||||||
|
permissions: ['conversation_participating_manage'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only include conversation assigned to user (id: 1)
|
||||||
|
expect(result).toEqual([mockConversations[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters conversations for custom role with no permissions', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
custom_role_id: 5,
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should return empty array as user has no permissions
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies filters and role permissions together', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [
|
||||||
|
{
|
||||||
|
attribute_key: 'status',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: ['open'],
|
||||||
|
query_operator: 'and',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootGetters = {
|
||||||
|
...mockRootGetters,
|
||||||
|
getCurrentUser: {
|
||||||
|
...mockRootGetters.getCurrentUser,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
custom_role_id: 5,
|
||||||
|
permissions: ['conversation_participating_manage'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
rootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should only include open conversation assigned to user (id: 1)
|
||||||
|
expect(result).toEqual([mockConversations[0]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no conversations match filters', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_desc',
|
||||||
|
appliedFilters: [
|
||||||
|
{
|
||||||
|
attribute_key: 'status',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: ['pending'],
|
||||||
|
query_operator: 'and',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
mockRootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts filtered conversations according to chatSortFilter', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: mockConversations,
|
||||||
|
chatSortFilter: 'last_activity_at_asc',
|
||||||
|
appliedFilters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredConversations(
|
||||||
|
state,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
mockRootGetters
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
mockConversations[0],
|
||||||
|
mockConversations[1],
|
||||||
|
mockConversations[2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createApp } from 'vue';
|
|||||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
|
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
|
||||||
import { directive as onClickaway } from 'vue3-click-away';
|
import { directive as onClickaway } from 'vue3-click-away';
|
||||||
|
import { isSameHost } from '@chatwoot/utils';
|
||||||
|
|
||||||
import slugifyWithCounter from '@sindresorhus/slugify';
|
import slugifyWithCounter from '@sindresorhus/slugify';
|
||||||
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
||||||
@ -25,52 +26,6 @@ export const getHeadingsfromTheArticle = () => {
|
|||||||
return rows;
|
return rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts various input formats to URL objects.
|
|
||||||
* Handles URL objects, domain strings, relative paths, and full URLs.
|
|
||||||
* @param {string|URL} input - Input to convert to URL object
|
|
||||||
* @returns {URL|null} URL object or null if input is invalid
|
|
||||||
*/
|
|
||||||
const toURL = input => {
|
|
||||||
if (!input) return null;
|
|
||||||
if (input instanceof URL) return input;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof input === 'string' &&
|
|
||||||
!input.includes('://') &&
|
|
||||||
!input.startsWith('/')
|
|
||||||
) {
|
|
||||||
return new URL(`https://${input}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof input === 'string' && input.startsWith('/')) {
|
|
||||||
return new URL(input, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new URL(input);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if two URLs belong to the same host by comparing their normalized URL objects.
|
|
||||||
* Handles various input formats including URL objects, domain strings, relative paths, and full URLs.
|
|
||||||
* Returns false if either URL cannot be parsed or normalized.
|
|
||||||
* @param {string|URL} url1 - First URL to compare
|
|
||||||
* @param {string|URL} url2 - Second URL to compare
|
|
||||||
* @returns {boolean} True if both URLs have the same host, false otherwise
|
|
||||||
*/
|
|
||||||
const isSameHost = (url1, url2) => {
|
|
||||||
try {
|
|
||||||
const urlObj1 = toURL(url1);
|
|
||||||
const urlObj2 = toURL(url2);
|
|
||||||
|
|
||||||
if (!urlObj1 || !urlObj2) return false;
|
|
||||||
|
|
||||||
return urlObj1.hostname === urlObj2.hostname;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const openExternalLinksInNewTab = () => {
|
export const openExternalLinksInNewTab = () => {
|
||||||
const { customDomain, hostURL } = window.portalConfig;
|
const { customDomain, hostURL } = window.portalConfig;
|
||||||
const isOnArticlePage =
|
const isOnArticlePage =
|
||||||
|
|||||||
@ -64,11 +64,11 @@ export default {
|
|||||||
:selected="modelValue"
|
:selected="modelValue"
|
||||||
:name="name"
|
:name="name"
|
||||||
:class="{
|
:class="{
|
||||||
'text-ash-400': !modelValue,
|
'text-n-slate-9': !modelValue,
|
||||||
'text-ash-900': modelValue,
|
'text-n-slate-12': modelValue,
|
||||||
'pl-9': icon,
|
'pl-9': icon,
|
||||||
}"
|
}"
|
||||||
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm outline-none appearance-none rounded-xl select-caret ring-ash-200 ring-1 ring-inset placeholder:text-ash-900 focus:ring-2 focus:ring-inset focus:ring-primary-500 text-sm leading-6"
|
class="block w-full px-3 py-2 pr-6 mb-0 border-0 shadow-sm appearance-none rounded-xl select-caret leading-6"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
>
|
>
|
||||||
<option value="" disabled selected class="hidden">
|
<option value="" disabled selected class="hidden">
|
||||||
|
|||||||
@ -5,7 +5,6 @@ module ActivityMessageHandler
|
|||||||
include LabelActivityMessageHandler
|
include LabelActivityMessageHandler
|
||||||
include SlaActivityMessageHandler
|
include SlaActivityMessageHandler
|
||||||
include TeamActivityMessageHandler
|
include TeamActivityMessageHandler
|
||||||
include CsatActivityMessageHandler
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
module CsatActivityMessageHandler
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
def create_csat_not_sent_activity_message
|
|
||||||
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
|
||||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -22,4 +22,8 @@ class AgentBotPolicy < ApplicationPolicy
|
|||||||
def avatar?
|
def avatar?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reset_access_token?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -11,3 +11,5 @@ class CsatSurveyResponsePolicy < ApplicationPolicy
|
|||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
CsatSurveyResponsePolicy.prepend_mod_with('CsatSurveyResponsePolicy')
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
|
|||||||
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
|
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
|
||||||
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
|
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
|
||||||
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
|
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
|
||||||
handle_csat_survey
|
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_send_out_of_office_message?
|
def should_send_out_of_office_message?
|
||||||
@ -65,26 +65,13 @@ class MessageTemplates::HookExecutionService
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_csat_survey
|
def should_send_csat_survey?
|
||||||
return unless csat_enabled_conversation?
|
return unless csat_enabled_conversation?
|
||||||
|
|
||||||
# only send CSAT once in a conversation
|
# only send CSAT once in a conversation
|
||||||
return if csat_already_sent?
|
return if conversation.messages.where(content_type: :input_csat).present?
|
||||||
|
|
||||||
# Only send CSAT if agent can still reply by checking the messaging window restriction
|
true
|
||||||
# https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
|
|
||||||
if within_messaging_window?
|
|
||||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
|
||||||
else
|
|
||||||
conversation.create_csat_not_sent_activity_message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def csat_already_sent?
|
|
||||||
conversation.messages.where(content_type: :input_csat).present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def within_messaging_window?
|
|
||||||
conversation.can_reply?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
|
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)
|
||||||
@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
|
||||||
@ -73,6 +73,30 @@ By default, it renders:
|
|||||||
<% end %>
|
<% end %>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<% if @article.present? %>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
let viewTracked = false;
|
||||||
|
const trackView = function() {
|
||||||
|
if (!viewTracked) {
|
||||||
|
viewTracked = true;
|
||||||
|
const img = new Image();
|
||||||
|
img.src = '<%= request.base_url %>/hc/<%= @portal.slug %>/articles/<%= @article.slug %>.png';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTrackingListeners = function() {
|
||||||
|
const events = ['mouseenter', 'touchstart', 'focus'];
|
||||||
|
events.forEach(event => {
|
||||||
|
document.body.addEventListener(event, function() {
|
||||||
|
setTimeout(trackView, 5000);
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
addTrackingListeners();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
</body>
|
</body>
|
||||||
<style>
|
<style>
|
||||||
html.dark {
|
html.dark {
|
||||||
|
|||||||
@ -62,6 +62,15 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Disable host check during development
|
# Disable host check during development
|
||||||
config.hosts = nil
|
config.hosts = nil
|
||||||
|
|
||||||
|
# GitHub Codespaces configuration
|
||||||
|
if ENV['CODESPACES']
|
||||||
|
# Allow web console access from any IP
|
||||||
|
config.web_console.allowed_ips = %w(0.0.0.0/0 ::/0)
|
||||||
|
# Allow CSRF from codespace URLs
|
||||||
|
config.force_ssl = false
|
||||||
|
config.action_controller.forgery_protection_origin_check = false
|
||||||
|
end
|
||||||
|
|
||||||
# customize using the environment variables
|
# customize using the environment variables
|
||||||
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
|
config.log_level = ENV.fetch('LOG_LEVEL', 'debug').to_sym
|
||||||
|
|||||||
@ -146,7 +146,7 @@
|
|||||||
premium: true
|
premium: true
|
||||||
- name: chatwoot_v4
|
- name: chatwoot_v4
|
||||||
display_name: Chatwoot V4
|
display_name: Chatwoot V4
|
||||||
enabled: false
|
enabled: true
|
||||||
- name: report_v4
|
- name: report_v4
|
||||||
display_name: Report V4
|
display_name: Report V4
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -185,8 +185,6 @@ en:
|
|||||||
removed: '%{user_name} removed %{labels}'
|
removed: '%{user_name} removed %{labels}'
|
||||||
sla:
|
sla:
|
||||||
added: '%{user_name} added SLA policy %{sla_name}'
|
added: '%{user_name} added SLA policy %{sla_name}'
|
||||||
csat:
|
|
||||||
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
|
|
||||||
removed: '%{user_name} removed SLA policy %{sla_name}'
|
removed: '%{user_name} removed SLA policy %{sla_name}'
|
||||||
muted: '%{user_name} has muted the conversation'
|
muted: '%{user_name} has muted the conversation'
|
||||||
unmuted: '%{user_name} has unmuted the conversation'
|
unmuted: '%{user_name} has unmuted the conversation'
|
||||||
|
|||||||
132
config/markdown_embeds.yml
Normal file
132
config/markdown_embeds.yml
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# Markdown Embed Configuration
|
||||||
|
#
|
||||||
|
# This file defines patterns and templates for converting URLs into embedded content
|
||||||
|
# in markdown rendering. Each embed type has:
|
||||||
|
# - regex: Pattern with named capture groups (?<name>...)
|
||||||
|
# - template: HTML template with %{capture_group_name} placeholders
|
||||||
|
#
|
||||||
|
# To add a new embed type:
|
||||||
|
# 1. Add a new top-level key
|
||||||
|
# 2. Define the regex pattern with named capture groups: (?<name>pattern)
|
||||||
|
# 3. Create an HTML template using %{name} placeholders matching the capture groups
|
||||||
|
|
||||||
|
youtube:
|
||||||
|
regex: 'https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)(?<video_id>[^&/]+)'
|
||||||
|
template: |
|
||||||
|
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube-nocookie.com/embed/%{video_id}"
|
||||||
|
frameborder="0"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
loom:
|
||||||
|
regex: 'https?://(?:www\.)?loom\.com/share/(?<video_id>[^&/]+)'
|
||||||
|
template: |
|
||||||
|
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||||
|
<iframe
|
||||||
|
src="https://www.loom.com/embed/%{video_id}"
|
||||||
|
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
vimeo:
|
||||||
|
regex: 'https?://(?:www\.)?vimeo\.com/(?<video_id>\d+)'
|
||||||
|
template: |
|
||||||
|
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||||
|
<iframe
|
||||||
|
src="https://player.vimeo.com/video/%{video_id}?dnt=true"
|
||||||
|
frameborder="0"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
mp4:
|
||||||
|
regex: '(?<link_url>https?://(?:www\.)?.+\.mp4)'
|
||||||
|
template: |
|
||||||
|
<video width="640" height="360" controls>
|
||||||
|
<source src="%{link_url}" type="video/mp4">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
|
||||||
|
arcade:
|
||||||
|
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/]+)'
|
||||||
|
template: |
|
||||||
|
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||||
|
<iframe
|
||||||
|
src="https://app.arcade.software/embed/%{video_id}"
|
||||||
|
frameborder="0"
|
||||||
|
webkitallowfullscreen
|
||||||
|
mozallowfullscreen
|
||||||
|
allowfullscreen
|
||||||
|
allow="fullscreen"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
wistia:
|
||||||
|
regex: 'https?://(?:www\.)?[^/]+\.wistia\.com/medias/(?<video_id>[^&/]+)'
|
||||||
|
template: |
|
||||||
|
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
|
||||||
|
<script src="https://fast.wistia.com/player.js" async></script>
|
||||||
|
<script src="https://fast.wistia.com/embed/%{video_id}.js" async type="module"></script>
|
||||||
|
<style>
|
||||||
|
wistia-player[media-id='%{video_id}']:not(:defined) {
|
||||||
|
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/%{video_id}/swatch');
|
||||||
|
display: block;
|
||||||
|
filter: blur(5px);
|
||||||
|
padding-top:56.25%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<wistia-player
|
||||||
|
media-id="%{video_id}"
|
||||||
|
aspect="1.7777777777777777"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||||
|
</wistia-player>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
bunny:
|
||||||
|
regex: 'https?://iframe\.mediadelivery\.net/play/(?<library_id>\d+)/(?<video_id>[^&/?]+)'
|
||||||
|
template: |
|
||||||
|
<div style="position: relative; padding-top: 56.25%;">
|
||||||
|
<iframe
|
||||||
|
src="https://iframe.mediadelivery.net/embed/%{library_id}/%{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
||||||
|
title="Bunny video player"
|
||||||
|
loading="lazy"
|
||||||
|
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
||||||
|
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
||||||
|
allowfullscreen>
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
codepen:
|
||||||
|
regex: 'https?://(?:www\.)?codepen\.io/(?<user>[^/]+)/pen/(?<pen_id>[^/?]+)'
|
||||||
|
template: |
|
||||||
|
<div style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<iframe
|
||||||
|
height="400"
|
||||||
|
style="width: 100%;"
|
||||||
|
scrolling="no"
|
||||||
|
title="CodePen Embed"
|
||||||
|
src="https://codepen.io/%{user}/embed/%{pen_id}?default-tab=result"
|
||||||
|
frameborder="no"
|
||||||
|
loading="lazy"
|
||||||
|
allowtransparency="true"
|
||||||
|
allowfullscreen="true">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
github_gist:
|
||||||
|
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
|
||||||
|
template: |
|
||||||
|
<script src="https://gist.github.com/%{username}/%{gist_id}.js"></script>
|
||||||
|
<noscript>
|
||||||
|
<div style="border: 1px solid #d1d9e0; border-radius: 6px; padding: 16px; margin: 16px 0; background: #f6f8fa;">
|
||||||
|
<a href="https://gist.github.com/%{username}/%{gist_id}" target="_blank" style="color: #0969da; text-decoration: none;">
|
||||||
|
View this gist on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
@ -67,6 +67,7 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||||
delete :avatar, on: :member
|
delete :avatar, on: :member
|
||||||
|
post :reset_access_token, on: :member
|
||||||
end
|
end
|
||||||
resources :contact_inboxes, only: [] do
|
resources :contact_inboxes, only: [] do
|
||||||
collection do
|
collection do
|
||||||
@ -296,6 +297,7 @@ Rails.application.routes.draw do
|
|||||||
post :auto_offline
|
post :auto_offline
|
||||||
put :set_active_account
|
put :set_active_account
|
||||||
post :resend_confirmation
|
post :resend_confirmation
|
||||||
|
post :reset_access_token
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -447,6 +449,7 @@ Rails.application.routes.draw do
|
|||||||
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
||||||
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
|
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
|
||||||
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
|
get 'hc/:slug/:locale/categories/:category_slug/articles', to: 'public/api/v1/portals/articles#index'
|
||||||
|
get 'hc/:slug/articles/:article_slug.png', to: 'public/api/v1/portals/articles#tracking_pixel'
|
||||||
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
|
get 'hc/:slug/articles/:article_slug', to: 'public/api/v1/portals/articles#show'
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
module Enterprise::CsatSurveyResponsePolicy
|
||||||
|
def index?
|
||||||
|
@account_user.custom_role&.permissions&.include?('report_manage') || super
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics?
|
||||||
|
@account_user.custom_role&.permissions&.include?('report_manage') || super
|
||||||
|
end
|
||||||
|
|
||||||
|
def download?
|
||||||
|
@account_user.custom_role&.permissions&.include?('report_manage') || super
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,13 +1,13 @@
|
|||||||
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||||
# TODO: let move this regex from here to a config file where we can update this list much more easily
|
CONFIG_PATH = Rails.root.join('config/markdown_embeds.yml')
|
||||||
# the config file will also have the matching embed template as well.
|
|
||||||
YOUTUBE_REGEX = %r{https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([^&/]+)}
|
def self.config
|
||||||
LOOM_REGEX = %r{https?://(?:www\.)?loom\.com/share/([^&/]+)}
|
@config ||= YAML.load_file(CONFIG_PATH)
|
||||||
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
end
|
||||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
|
||||||
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
def self.embed_regexes
|
||||||
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
|
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
|
||||||
BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)}
|
end
|
||||||
|
|
||||||
def text(node)
|
def text(node)
|
||||||
content = node.string_content
|
content = node.string_content
|
||||||
@ -23,7 +23,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
def link(node)
|
def link(node)
|
||||||
return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
|
return if surrounded_by_empty_lines?(node) && render_embedded_content(node)
|
||||||
|
|
||||||
# If it's not YouTube or Vimeo link, render normally
|
# If it's not a supported embed link, render normally
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -47,25 +47,35 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
|
|
||||||
def render_embedded_content(node)
|
def render_embedded_content(node)
|
||||||
link_url = node.url
|
link_url = node.url
|
||||||
embedding_methods = {
|
embed_html = find_matching_embed(link_url)
|
||||||
YOUTUBE_REGEX => :make_youtube_embed,
|
|
||||||
VIMEO_REGEX => :make_vimeo_embed,
|
|
||||||
MP4_REGEX => :make_video_embed,
|
|
||||||
LOOM_REGEX => :make_loom_embed,
|
|
||||||
ARCADE_REGEX => :make_arcade_embed,
|
|
||||||
WISTIA_REGEX => :make_wistia_embed,
|
|
||||||
BUNNY_REGEX => :make_bunny_embed
|
|
||||||
}
|
|
||||||
|
|
||||||
embedding_methods.each do |regex, method|
|
return false unless embed_html
|
||||||
|
|
||||||
|
out(embed_html)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_matching_embed(link_url)
|
||||||
|
self.class.embed_regexes.each do |embed_key, regex|
|
||||||
match = link_url.match(regex)
|
match = link_url.match(regex)
|
||||||
if match
|
next unless match
|
||||||
out(send(method, match))
|
|
||||||
return true
|
return render_embed_from_match(embed_key, match)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
false
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_embed_from_match(embed_key, match_data)
|
||||||
|
embed_config = self.class.config[embed_key]
|
||||||
|
return nil unless embed_config
|
||||||
|
|
||||||
|
template = embed_config['template']
|
||||||
|
# Use Ruby's built-in named captures with gsub to handle CSS % values
|
||||||
|
match_data.named_captures.each do |var_name, value|
|
||||||
|
template = template.gsub("%{#{var_name}}", value)
|
||||||
|
end
|
||||||
|
template
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_sup(content)
|
def parse_sup(content)
|
||||||
@ -77,39 +87,4 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def make_youtube_embed(youtube_match)
|
|
||||||
video_id = youtube_match[1]
|
|
||||||
EmbedRenderer.youtube(video_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_loom_embed(loom_match)
|
|
||||||
video_id = loom_match[1]
|
|
||||||
EmbedRenderer.loom(video_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_vimeo_embed(vimeo_match)
|
|
||||||
video_id = vimeo_match[1]
|
|
||||||
EmbedRenderer.vimeo(video_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_video_embed(link_url)
|
|
||||||
EmbedRenderer.video(link_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_wistia_embed(wistia_match)
|
|
||||||
video_id = wistia_match[2]
|
|
||||||
EmbedRenderer.wistia(video_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_arcade_embed(arcade_match)
|
|
||||||
video_id = arcade_match[1]
|
|
||||||
EmbedRenderer.arcade(video_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def make_bunny_embed(bunny_match)
|
|
||||||
library_id = bunny_match[1]
|
|
||||||
video_id = bunny_match[2]
|
|
||||||
EmbedRenderer.bunny(library_id, video_id)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
module EmbedRenderer
|
|
||||||
def self.youtube(video_id)
|
|
||||||
%(
|
|
||||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
|
||||||
<iframe
|
|
||||||
src="https://www.youtube-nocookie.com/embed/#{video_id}"
|
|
||||||
frameborder="0"
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen></iframe>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.loom(video_id)
|
|
||||||
%(
|
|
||||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
|
||||||
<iframe
|
|
||||||
src="https://www.loom.com/embed/#{video_id}"
|
|
||||||
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.vimeo(video_id)
|
|
||||||
%(
|
|
||||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
|
||||||
<iframe
|
|
||||||
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
|
|
||||||
frameborder="0"
|
|
||||||
allow="autoplay; fullscreen; picture-in-picture"
|
|
||||||
allowfullscreen
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.video(link_url)
|
|
||||||
%(
|
|
||||||
<video width="640" height="360" controls>
|
|
||||||
<source src="#{link_url}" type="video/mp4">
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generates an HTML embed for a Wistia video.
|
|
||||||
# @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
|
|
||||||
def self.wistia(video_id)
|
|
||||||
%(
|
|
||||||
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
|
|
||||||
<script src="https://fast.wistia.com/player.js" async></script>
|
|
||||||
<script src="https://fast.wistia.com/embed/#{video_id}.js" async type="module"></script>
|
|
||||||
<style>
|
|
||||||
wistia-player[media-id='#{video_id}']:not(:defined) {
|
|
||||||
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/#{video_id}/swatch');
|
|
||||||
display: block;
|
|
||||||
filter: blur(5px);
|
|
||||||
padding-top:56.25%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<wistia-player
|
|
||||||
media-id="#{video_id}"
|
|
||||||
aspect="1.7777777777777777"
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
|
||||||
</wistia-player>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.arcade(video_id)
|
|
||||||
%(
|
|
||||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
|
||||||
<iframe
|
|
||||||
src="https://app.arcade.software/embed/#{video_id}"
|
|
||||||
frameborder="0"
|
|
||||||
webkitallowfullscreen
|
|
||||||
mozallowfullscreen
|
|
||||||
allowfullscreen
|
|
||||||
allow="fullscreen"
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.bunny(library_id, video_id)
|
|
||||||
%(
|
|
||||||
<div style="position: relative; padding-top: 56.25%;">
|
|
||||||
<iframe
|
|
||||||
src="https://iframe.mediadelivery.net/embed/#{library_id}/#{video_id}?autoplay=false&loop=false&muted=false&preload=true&responsive=true"
|
|
||||||
title="Bunny video player"
|
|
||||||
loading="lazy"
|
|
||||||
style="border: 0; position: absolute; top: 0; height: 100%; width: 100%;"
|
|
||||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
|
||||||
allowfullscreen>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@ -19,6 +19,7 @@ class Seeders::AccountSeeder
|
|||||||
def perform!
|
def perform!
|
||||||
set_up_account
|
set_up_account
|
||||||
seed_teams
|
seed_teams
|
||||||
|
seed_custom_roles
|
||||||
set_up_users
|
set_up_users
|
||||||
seed_labels
|
seed_labels
|
||||||
seed_canned_responses
|
seed_canned_responses
|
||||||
@ -32,6 +33,7 @@ class Seeders::AccountSeeder
|
|||||||
@account.labels.destroy_all
|
@account.labels.destroy_all
|
||||||
@account.inboxes.destroy_all
|
@account.inboxes.destroy_all
|
||||||
@account.contacts.destroy_all
|
@account.contacts.destroy_all
|
||||||
|
@account.custom_roles.destroy_all if @account.respond_to?(:custom_roles)
|
||||||
end
|
end
|
||||||
|
|
||||||
def seed_teams
|
def seed_teams
|
||||||
@ -40,6 +42,18 @@ class Seeders::AccountSeeder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def seed_custom_roles
|
||||||
|
return unless @account_data['custom_roles'].present? && @account.respond_to?(:custom_roles)
|
||||||
|
|
||||||
|
@account_data['custom_roles'].each do |role_data|
|
||||||
|
@account.custom_roles.create!(
|
||||||
|
name: role_data['name'],
|
||||||
|
description: role_data['description'],
|
||||||
|
permissions: role_data['permissions']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def seed_labels
|
def seed_labels
|
||||||
@account_data['labels'].each do |label|
|
@account_data['labels'].each do |label|
|
||||||
@account.labels.create!(label)
|
@account.labels.create!(label)
|
||||||
@ -48,17 +62,34 @@ class Seeders::AccountSeeder
|
|||||||
|
|
||||||
def set_up_users
|
def set_up_users
|
||||||
@account_data['users'].each do |user|
|
@account_data['users'].each do |user|
|
||||||
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: (user['email']).to_s)
|
user_record = create_user_record(user)
|
||||||
user_record.skip_confirmation!
|
create_account_user(user_record, user)
|
||||||
user_record.save!
|
add_user_to_teams(user: user_record, teams: user['team']) if user['team'].present?
|
||||||
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
|
|
||||||
AccountUser.create_with(role: (user['role'] || 'agent')).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
|
|
||||||
next if user['team'].blank?
|
|
||||||
|
|
||||||
add_user_to_teams(user: user_record, teams: user['team'])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_user_record(user)
|
||||||
|
user_record = User.create_with(name: user['name'], password: 'Password1!.').find_or_create_by!(email: user['email'].to_s)
|
||||||
|
user_record.skip_confirmation!
|
||||||
|
user_record.save!
|
||||||
|
Avatar::AvatarFromUrlJob.perform_later(user_record, "https://xsgames.co/randomusers/avatar.php?g=#{user['gender']}")
|
||||||
|
user_record
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_account_user(user_record, user)
|
||||||
|
account_user_attrs = build_account_user_attrs(user)
|
||||||
|
AccountUser.create_with(account_user_attrs).find_or_create_by!(account_id: @account.id, user_id: user_record.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_account_user_attrs(user)
|
||||||
|
attrs = { role: (user['role'] || 'agent') }
|
||||||
|
custom_role = find_custom_role(user['custom_role']) if user['custom_role'].present?
|
||||||
|
attrs[:custom_role] = custom_role if custom_role
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
def add_user_to_teams(user:, teams:)
|
def add_user_to_teams(user:, teams:)
|
||||||
teams.each do |team|
|
teams.each do |team|
|
||||||
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
|
team_record = @account.teams.where('name LIKE ?', "%#{team.downcase}%").first if team.present?
|
||||||
@ -66,6 +97,12 @@ class Seeders::AccountSeeder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def find_custom_role(role_name)
|
||||||
|
return nil unless @account.respond_to?(:custom_roles)
|
||||||
|
|
||||||
|
@account.custom_roles.find_by(name: role_name)
|
||||||
|
end
|
||||||
|
|
||||||
def seed_canned_responses(count: 50)
|
def seed_canned_responses(count: 50)
|
||||||
count.times do
|
count.times do
|
||||||
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
|
@account.canned_responses.create(content: Faker::Quote.fortune_cookie, short_code: Faker::Alphanumeric.alpha(number: 10))
|
||||||
|
|||||||
@ -61,41 +61,49 @@ users:
|
|||||||
email: 'karn@paperlayer.test'
|
email: 'karn@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Sales'
|
- 'Sales'
|
||||||
|
custom_role: 'Sales Representative'
|
||||||
- name: 'Danny Cordray'
|
- name: 'Danny Cordray'
|
||||||
gender: female
|
gender: female
|
||||||
email: 'danny@paperlayer.test'
|
email: 'danny@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Sales'
|
- 'Sales'
|
||||||
|
custom_role: 'Customer Support Lead'
|
||||||
- name: 'Ben Nugent'
|
- name: 'Ben Nugent'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'ben@paperlayer.test'
|
email: 'ben@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Sales'
|
- 'Sales'
|
||||||
|
custom_role: 'Junior Agent'
|
||||||
- name: 'Todd Packer'
|
- name: 'Todd Packer'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'todd@paperlayer.test'
|
email: 'todd@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Sales'
|
- 'Sales'
|
||||||
|
custom_role: 'Sales Representative'
|
||||||
- name: 'Cathy Simms'
|
- name: 'Cathy Simms'
|
||||||
gender: female
|
gender: female
|
||||||
email: 'cathy@paperlayer.test'
|
email: 'cathy@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Administration'
|
- 'Administration'
|
||||||
|
custom_role: 'Knowledge Manager'
|
||||||
- name: 'Hunter Jo'
|
- name: 'Hunter Jo'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'hunter@paperlayer.test'
|
email: 'hunter@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Administration'
|
- 'Administration'
|
||||||
|
custom_role: 'Analytics Specialist'
|
||||||
- name: 'Rolando Silva'
|
- name: 'Rolando Silva'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'rolando@paperlayer.test'
|
email: 'rolando@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Administration'
|
- 'Administration'
|
||||||
|
custom_role: 'Junior Agent'
|
||||||
- name: 'Stephanie Wilson'
|
- name: 'Stephanie Wilson'
|
||||||
gender: female
|
gender: female
|
||||||
email: 'stephanie@paperlayer.test'
|
email: 'stephanie@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Administration'
|
- 'Administration'
|
||||||
|
custom_role: 'Escalation Handler'
|
||||||
- name: 'Jordan Garfield'
|
- name: 'Jordan Garfield'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'jorodan@paperlayer.test'
|
email: 'jorodan@paperlayer.test'
|
||||||
@ -111,6 +119,7 @@ users:
|
|||||||
email: 'lonny@paperlayer.test'
|
email: 'lonny@paperlayer.test'
|
||||||
team:
|
team:
|
||||||
- 'Warehouse'
|
- 'Warehouse'
|
||||||
|
custom_role: 'Customer Support Lead'
|
||||||
- name: 'Madge Madsen'
|
- name: 'Madge Madsen'
|
||||||
gender: female
|
gender: female
|
||||||
email: 'madge@paperlayer.test'
|
email: 'madge@paperlayer.test'
|
||||||
@ -162,6 +171,7 @@ users:
|
|||||||
- name: 'Devon White'
|
- name: 'Devon White'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'devon@paperlayer.test'
|
email: 'devon@paperlayer.test'
|
||||||
|
custom_role: 'Escalation Handler'
|
||||||
- name: 'Kendall'
|
- name: 'Kendall'
|
||||||
gender: male
|
gender: male
|
||||||
email: 'kendall@paperlayer.test'
|
email: 'kendall@paperlayer.test'
|
||||||
@ -173,6 +183,39 @@ teams:
|
|||||||
- '💼 Management'
|
- '💼 Management'
|
||||||
- '👩💼 Administration'
|
- '👩💼 Administration'
|
||||||
- '🚛 Warehouse'
|
- '🚛 Warehouse'
|
||||||
|
custom_roles:
|
||||||
|
- name: 'Customer Support Lead'
|
||||||
|
description: 'Lead support agent with full conversation and contact management'
|
||||||
|
permissions:
|
||||||
|
- 'conversation_manage'
|
||||||
|
- 'contact_manage'
|
||||||
|
- 'report_manage'
|
||||||
|
- name: 'Sales Representative'
|
||||||
|
description: 'Sales team member with conversation and contact access'
|
||||||
|
permissions:
|
||||||
|
- 'conversation_unassigned_manage'
|
||||||
|
- 'conversation_participating_manage'
|
||||||
|
- 'contact_manage'
|
||||||
|
- name: 'Knowledge Manager'
|
||||||
|
description: 'Manages knowledge base and participates in conversations'
|
||||||
|
permissions:
|
||||||
|
- 'knowledge_base_manage'
|
||||||
|
- 'conversation_participating_manage'
|
||||||
|
- name: 'Junior Agent'
|
||||||
|
description: 'Entry-level agent with basic conversation access'
|
||||||
|
permissions:
|
||||||
|
- 'conversation_participating_manage'
|
||||||
|
- name: 'Analytics Specialist'
|
||||||
|
description: 'Focused on reports and data analysis'
|
||||||
|
permissions:
|
||||||
|
- 'report_manage'
|
||||||
|
- 'conversation_participating_manage'
|
||||||
|
- name: 'Escalation Handler'
|
||||||
|
description: 'Handles unassigned conversations and escalations'
|
||||||
|
permissions:
|
||||||
|
- 'conversation_unassigned_manage'
|
||||||
|
- 'conversation_participating_manage'
|
||||||
|
- 'contact_manage'
|
||||||
labels:
|
labels:
|
||||||
- title: 'billing'
|
- title: 'billing'
|
||||||
color: '#28AD21'
|
color: '#28AD21'
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@chatwoot/ninja-keys": "1.2.3",
|
||||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||||
"@chatwoot/utils": "^0.0.43",
|
"@chatwoot/utils": "^0.0.45",
|
||||||
"@formkit/core": "^1.6.7",
|
"@formkit/core": "^1.6.7",
|
||||||
"@formkit/vue": "^1.6.7",
|
"@formkit/vue": "^1.6.7",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@ -23,8 +23,8 @@ importers:
|
|||||||
specifier: 1.1.1-next
|
specifier: 1.1.1-next
|
||||||
version: 1.1.1-next
|
version: 1.1.1-next
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.43
|
specifier: ^0.0.45
|
||||||
version: 0.0.43
|
version: 0.0.45
|
||||||
'@formkit/core':
|
'@formkit/core':
|
||||||
specifier: ^1.6.7
|
specifier: ^1.6.7
|
||||||
version: 1.6.7
|
version: 1.6.7
|
||||||
@ -406,8 +406,8 @@ packages:
|
|||||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.43':
|
'@chatwoot/utils@0.0.45':
|
||||||
resolution: {integrity: sha512-kMIXAGebCak9qOi68QnGer+rQLLo/z2N9cR+7tvGdZCW0ThDiVCF7JbHYHVDlYsdDFIx0FLlyIdCfEbooVT2Dw==}
|
resolution: {integrity: sha512-zqmuri6MrEFAY1tLv7Z3HBy4Ig60LhSrLkEiHegVsOVSxPv4Bedq+xmAW7LphvcLNgbkkvu17MU91gvMVlpEHw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@codemirror/commands@6.7.0':
|
'@codemirror/commands@6.7.0':
|
||||||
@ -5255,7 +5255,7 @@ snapshots:
|
|||||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||||
prosemirror-view: 1.34.1
|
prosemirror-view: 1.34.1
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.43':
|
'@chatwoot/utils@0.0.45':
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 2.30.0
|
date-fns: 2.30.0
|
||||||
|
|
||||||
|
|||||||
BIN
public/assets/images/tracking-pixel.png
Normal file
BIN
public/assets/images/tracking-pixel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
103
spec/config/markdown_embeds_spec.rb
Normal file
103
spec/config/markdown_embeds_spec.rb
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# rubocop:disable RSpec/DescribeClass
|
||||||
|
describe 'Markdown Embeds Configuration' do
|
||||||
|
# rubocop:enable RSpec/DescribeClass
|
||||||
|
let(:config) { YAML.load_file(Rails.root.join('config/markdown_embeds.yml')) }
|
||||||
|
|
||||||
|
describe 'YAML structure' do
|
||||||
|
it 'loads valid YAML' do
|
||||||
|
expect(config).to be_a(Hash)
|
||||||
|
expect(config).not_to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has required keys for each embed type' do
|
||||||
|
config.each do |embed_type, embed_config|
|
||||||
|
expect(embed_config).to have_key('regex'), "#{embed_type} missing regex"
|
||||||
|
expect(embed_config).to have_key('template'), "#{embed_type} missing template"
|
||||||
|
expect(embed_config['regex']).to be_a(String), "#{embed_type} regex should be string"
|
||||||
|
expect(embed_config['template']).to be_a(String), "#{embed_type} template should be string"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'contains expected embed types' do
|
||||||
|
expected_types = %w[youtube loom vimeo mp4 arcade wistia bunny codepen github_gist]
|
||||||
|
expect(config.keys).to match_array(expected_types)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'regex patterns and named capture groups' do
|
||||||
|
let(:test_cases) do
|
||||||
|
{
|
||||||
|
'youtube' => [
|
||||||
|
{ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
|
||||||
|
{ url: 'https://youtu.be/dQw4w9WgXcQ', expected: { 'video_id' => 'dQw4w9WgXcQ' } },
|
||||||
|
{ url: 'https://youtube.com/watch?v=abc123XYZ', expected: { 'video_id' => 'abc123XYZ' } }
|
||||||
|
],
|
||||||
|
'loom' => [
|
||||||
|
{ url: 'https://www.loom.com/share/abc123def456', expected: { 'video_id' => 'abc123def456' } },
|
||||||
|
{ url: 'https://loom.com/share/xyz789', expected: { 'video_id' => 'xyz789' } }
|
||||||
|
],
|
||||||
|
'vimeo' => [
|
||||||
|
{ url: 'https://vimeo.com/123456789', expected: { 'video_id' => '123456789' } },
|
||||||
|
{ url: 'https://www.vimeo.com/987654321', expected: { 'video_id' => '987654321' } }
|
||||||
|
],
|
||||||
|
'mp4' => [
|
||||||
|
{ url: 'https://example.com/video.mp4', expected: { 'link_url' => 'https://example.com/video.mp4' } },
|
||||||
|
{ url: 'https://www.test.com/path/to/movie.mp4', expected: { 'link_url' => 'https://www.test.com/path/to/movie.mp4' } }
|
||||||
|
],
|
||||||
|
'arcade' => [
|
||||||
|
{ url: 'https://app.arcade.software/share/arcade123', expected: { 'video_id' => 'arcade123' } },
|
||||||
|
{ url: 'https://www.app.arcade.software/share/demo456', expected: { 'video_id' => 'demo456' } }
|
||||||
|
],
|
||||||
|
'wistia' => [
|
||||||
|
{ url: 'https://chatwoot.wistia.com/medias/kjwjeq6f9i', expected: { 'video_id' => 'kjwjeq6f9i' } },
|
||||||
|
{ url: 'https://www.company.wistia.com/medias/abc123def', expected: { 'video_id' => 'abc123def' } }
|
||||||
|
],
|
||||||
|
'bunny' => [
|
||||||
|
{ url: 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c',
|
||||||
|
expected: { 'library_id' => '431789', 'video_id' => '1f105841-cad9-46fe-a70e-b7623c60797c' } },
|
||||||
|
{ url: 'https://iframe.mediadelivery.net/play/12345/abcdef-ghijkl', expected: { 'library_id' => '12345', 'video_id' => 'abcdef-ghijkl' } }
|
||||||
|
],
|
||||||
|
'codepen' => [
|
||||||
|
{ url: 'https://codepen.io/username/pen/abcdef', expected: { 'user' => 'username', 'pen_id' => 'abcdef' } },
|
||||||
|
{ url: 'https://www.codepen.io/testuser/pen/xyz123', expected: { 'user' => 'testuser', 'pen_id' => 'xyz123' } }
|
||||||
|
],
|
||||||
|
'github_gist' => [
|
||||||
|
{ url: 'https://gist.github.com/username/1234567890abcdef1234567890abcdef',
|
||||||
|
expected: { 'username' => 'username', 'gist_id' => '1234567890abcdef1234567890abcdef' } },
|
||||||
|
{ url: 'https://gist.github.com/testuser/fedcba0987654321fedcba0987654321', expected: { 'username' => 'testuser', 'gist_id' => 'fedcba0987654321fedcba0987654321' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'correctly captures named groups for all embed types' do
|
||||||
|
test_cases.each do |embed_type, cases|
|
||||||
|
regex = Regexp.new(config[embed_type]['regex'])
|
||||||
|
|
||||||
|
cases.each do |test_case|
|
||||||
|
match = regex.match(test_case[:url])
|
||||||
|
expect(match).not_to be_nil, "#{embed_type} regex failed to match URL: #{test_case[:url]}"
|
||||||
|
expect(match.named_captures).to eq(test_case[:expected]),
|
||||||
|
"#{embed_type} captured groups don't match expected for URL: #{test_case[:url]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates that template variables match capture group names' do
|
||||||
|
config.each do |embed_type, embed_config|
|
||||||
|
regex = Regexp.new(embed_config['regex'])
|
||||||
|
template = embed_config['template']
|
||||||
|
|
||||||
|
# Extract template variables like %{video_id}
|
||||||
|
template_vars = template.scan(/%\{(\w+)\}/).flatten.uniq
|
||||||
|
|
||||||
|
# Get named capture groups from regex
|
||||||
|
capture_names = regex.names
|
||||||
|
|
||||||
|
expect(capture_names).to match_array(template_vars),
|
||||||
|
"#{embed_type}: Template variables #{template_vars} don't match capture groups #{capture_names}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -262,4 +262,55 @@ RSpec.describe 'Agent Bot API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST /api/v1/accounts/{account.id}/agent_bots/:id/reset_access_token' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
it 'regenerates the access token when administrator' do
|
||||||
|
old_token = agent_bot.access_token.token
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
agent_bot.reload
|
||||||
|
expect(agent_bot.access_token.token).not_to eq(old_token)
|
||||||
|
json_response = response.parsed_body
|
||||||
|
expect(json_response['access_token']).to eq(agent_bot.access_token.token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'would not reset the access token when agent' do
|
||||||
|
old_token = agent_bot.access_token.token
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}/reset_access_token",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
agent_bot.reload
|
||||||
|
expect(agent_bot.access_token.token).to eq(old_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'would not reset access token for a global agent bot' do
|
||||||
|
global_bot = create(:agent_bot)
|
||||||
|
old_token = global_bot.access_token.token
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/agent_bots/#{global_bot.id}/reset_access_token",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
global_bot.reload
|
||||||
|
expect(global_bot.access_token.token).to eq(old_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -296,4 +296,32 @@ RSpec.describe 'Profile API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST /api/v1/profile/reset_access_token' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
post '/api/v1/profile/reset_access_token'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
|
it 'regenerates the access token' do
|
||||||
|
old_token = agent.access_token.token
|
||||||
|
|
||||||
|
post '/api/v1/profile/reset_access_token',
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
agent.reload
|
||||||
|
expect(agent.access_token.token).not_to eq(old_token)
|
||||||
|
json_response = response.parsed_body
|
||||||
|
expect(json_response['access_token']).to eq(agent.access_token.token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -82,7 +82,7 @@ RSpec.describe 'Public Articles API', type: :request do
|
|||||||
get "/hc/#{portal.slug}/articles/#{article.slug}"
|
get "/hc/#{portal.slug}/articles/#{article.slug}"
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
|
expect(response.body).to include(ChatwootMarkdownRenderer.new(article.content).render_article)
|
||||||
expect(article.reload.views).to eq 1
|
expect(article.reload.views).to eq 0 # View count should not increment on show
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not increment the view count if the article is not published' do
|
it 'does not increment the view count if the article is not published' do
|
||||||
@ -98,4 +98,42 @@ RSpec.describe 'Public Articles API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'GET /public/api/v1/portals/:slug/articles/:slug.png (tracking pixel)' do
|
||||||
|
it 'serves a PNG image and increments view count for published article' do
|
||||||
|
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.headers['Content-Type']).to eq('image/png')
|
||||||
|
expect(response.headers['Cache-Control']).to include('max-age=86400')
|
||||||
|
expect(response.headers['Cache-Control']).to include('private')
|
||||||
|
expect(article.reload.views).to eq 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'serves a PNG image but does not increment view count for draft article' do
|
||||||
|
draft_article = create(:article, category: category, status: :draft, portal: portal, account_id: account.id, author_id: agent.id, views: 0)
|
||||||
|
|
||||||
|
get "/hc/#{portal.slug}/articles/#{draft_article.slug}.png"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.headers['Content-Type']).to eq('image/png')
|
||||||
|
expect(response.headers['Cache-Control']).to include('max-age=86400')
|
||||||
|
expect(response.headers['Cache-Control']).to include('private')
|
||||||
|
expect(draft_article.reload.views).to eq 0
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 404 if article does not exist' do
|
||||||
|
get "/hc/#{portal.slug}/articles/non-existent-article.png"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets proper cache headers for performance' do
|
||||||
|
get "/hc/#{portal.slug}/articles/#{article.slug}.png"
|
||||||
|
|
||||||
|
expect(response.headers['Cache-Control']).to include('max-age=86400')
|
||||||
|
expect(response.headers['Cache-Control']).to include('private')
|
||||||
|
expect(response.headers['Content-Type']).to eq('image/png')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
26
spec/enterprise/policies/csat_survey_response_policy_spec.rb
Normal file
26
spec/enterprise/policies/csat_survey_response_policy_spec.rb
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Enterprise::CsatSurveyResponsePolicy', type: :policy do
|
||||||
|
subject(:csat_policy) { CsatSurveyResponsePolicy }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:csat_survey_response) { create(:csat_survey_response, account: account) }
|
||||||
|
|
||||||
|
# Create a custom role with report_manage permission
|
||||||
|
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
|
||||||
|
let(:agent_with_role) { create(:user) } # Create without account
|
||||||
|
let(:agent_with_role_account_user) do
|
||||||
|
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
|
||||||
|
end
|
||||||
|
let(:agent_with_role_context) do
|
||||||
|
{ user: agent_with_role, account: account, account_user: agent_with_role_account_user }
|
||||||
|
end
|
||||||
|
|
||||||
|
permissions :index?, :metrics?, :download? do
|
||||||
|
context 'when agent with report_manage permission' do
|
||||||
|
it { expect(csat_policy).to permit(agent_with_role_context, csat_survey_response) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -435,20 +435,6 @@ RSpec.describe Conversation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#create_csat_not_sent_activity_message' do
|
|
||||||
subject(:create_csat_not_sent_activity_message) { conversation.create_csat_not_sent_activity_message }
|
|
||||||
|
|
||||||
let(:conversation) { create(:conversation) }
|
|
||||||
|
|
||||||
it 'creates CSAT not sent activity message' do
|
|
||||||
create_csat_not_sent_activity_message
|
|
||||||
expect(Conversations::ActivityMessageJob)
|
|
||||||
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
|
|
||||||
message_type: :activity,
|
|
||||||
content: 'CSAT survey not sent due to outgoing message restrictions' }))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'unread_messages' do
|
describe 'unread_messages' do
|
||||||
subject(:unread_messages) { conversation.unread_messages }
|
subject(:unread_messages) { conversation.unread_messages }
|
||||||
|
|
||||||
|
|||||||
25
spec/policies/csat_survey_response_policy_spec.rb
Normal file
25
spec/policies/csat_survey_response_policy_spec.rb
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CsatSurveyResponsePolicy, type: :policy do
|
||||||
|
subject(:csat_policy) { described_class }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:administrator) { create(:user, :administrator, account: account) }
|
||||||
|
let(:agent) { create(:user, account: account) }
|
||||||
|
let(:csat_survey_response) { create(:csat_survey_response, account: account) }
|
||||||
|
|
||||||
|
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||||
|
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.last } }
|
||||||
|
|
||||||
|
permissions :index?, :metrics?, :download? do
|
||||||
|
context 'when administrator' do
|
||||||
|
it { expect(csat_policy).to permit(administrator_context, csat_survey_response) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when agent' do
|
||||||
|
it { expect(csat_policy).not_to permit(agent_context, csat_survey_response) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -121,9 +121,8 @@ describe MessageTemplates::HookExecutionService do
|
|||||||
create(:message, conversation: conversation, message_type: 'incoming')
|
create(:message, conversation: conversation, message_type: 'incoming')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled and can reply' do
|
it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled' do
|
||||||
conversation.inbox.update(csat_survey_enabled: true)
|
conversation.inbox.update(csat_survey_enabled: true)
|
||||||
allow(conversation).to receive(:can_reply?).and_return(true)
|
|
||||||
|
|
||||||
conversation.resolved!
|
conversation.resolved!
|
||||||
Conversations::ActivityMessageJob.perform_now(conversation,
|
Conversations::ActivityMessageJob.perform_now(conversation,
|
||||||
@ -173,32 +172,6 @@ describe MessageTemplates::HookExecutionService do
|
|||||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
|
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
|
||||||
expect(csat_survey).not_to have_received(:perform)
|
expect(csat_survey).not_to have_received(:perform)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'will not call ::MessageTemplates::Template::CsatSurvey if cannot reply' do
|
|
||||||
conversation.inbox.update(csat_survey_enabled: true)
|
|
||||||
allow(conversation).to receive(:can_reply?).and_return(false)
|
|
||||||
|
|
||||||
conversation.resolved!
|
|
||||||
Conversations::ActivityMessageJob.perform_now(conversation,
|
|
||||||
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
|
||||||
content: 'Conversation marked resolved!!' })
|
|
||||||
|
|
||||||
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
|
|
||||||
expect(csat_survey).not_to have_received(:perform)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates activity message when CSAT not sent due to messaging window restriction' do
|
|
||||||
conversation.inbox.update(csat_survey_enabled: true)
|
|
||||||
allow(conversation).to receive(:can_reply?).and_return(false)
|
|
||||||
allow(conversation).to receive(:create_csat_not_sent_activity_message)
|
|
||||||
|
|
||||||
conversation.resolved!
|
|
||||||
Conversations::ActivityMessageJob.perform_now(conversation,
|
|
||||||
{ account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
|
||||||
content: 'Conversation marked resolved!!' })
|
|
||||||
|
|
||||||
expect(conversation).to have_received(:create_csat_not_sent_activity_message)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when it is after working hours' do
|
context 'when it is after working hours' do
|
||||||
|
|||||||
@ -60,6 +60,10 @@ webhook:
|
|||||||
$ref: ./resource/webhook.yml
|
$ref: ./resource/webhook.yml
|
||||||
account:
|
account:
|
||||||
$ref: ./resource/account.yml
|
$ref: ./resource/account.yml
|
||||||
|
account_detail:
|
||||||
|
$ref: ./resource/account_detail.yml
|
||||||
|
account_show_response:
|
||||||
|
$ref: ./resource/account_show_response.yml
|
||||||
account_user:
|
account_user:
|
||||||
$ref: ./resource/account_user.yml
|
$ref: ./resource/account_user.yml
|
||||||
platform_account:
|
platform_account:
|
||||||
@ -87,6 +91,9 @@ public_inbox:
|
|||||||
account_create_update_payload:
|
account_create_update_payload:
|
||||||
$ref: ./request/account/create_update_payload.yml
|
$ref: ./request/account/create_update_payload.yml
|
||||||
|
|
||||||
|
account_update_payload:
|
||||||
|
$ref: ./request/account/update_payload.yml
|
||||||
|
|
||||||
account_user_create_update_payload:
|
account_user_create_update_payload:
|
||||||
$ref: ./request/account_user/create_update_payload.yml
|
$ref: ./request/account_user/create_update_payload.yml
|
||||||
|
|
||||||
|
|||||||
49
swagger/definitions/request/account/update_payload.yml
Normal file
49
swagger/definitions/request/account/update_payload.yml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name of the account
|
||||||
|
example: 'My Account'
|
||||||
|
locale:
|
||||||
|
type: string
|
||||||
|
description: The locale of the account
|
||||||
|
example: 'en'
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: The domain of the account
|
||||||
|
example: 'example.com'
|
||||||
|
support_email:
|
||||||
|
type: string
|
||||||
|
description: The support email of the account
|
||||||
|
example: 'support@example.com'
|
||||||
|
# Settings parameters (stored in settings JSONB column)
|
||||||
|
auto_resolve_after:
|
||||||
|
type: integer
|
||||||
|
minimum: 10
|
||||||
|
maximum: 1439856
|
||||||
|
nullable: true
|
||||||
|
description: Auto resolve conversations after specified minutes
|
||||||
|
example: 1440
|
||||||
|
auto_resolve_message:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Message to send when auto resolving
|
||||||
|
example: "This conversation has been automatically resolved due to inactivity"
|
||||||
|
auto_resolve_ignore_waiting:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
description: Whether to ignore waiting conversations for auto resolve
|
||||||
|
example: false
|
||||||
|
# Custom attributes parameters (stored in custom_attributes JSONB column)
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
description: Industry type
|
||||||
|
example: "Technology"
|
||||||
|
company_size:
|
||||||
|
type: string
|
||||||
|
description: Company size
|
||||||
|
example: "50-100"
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
description: Account timezone
|
||||||
|
example: "UTC"
|
||||||
84
swagger/definitions/resource/account_detail.yml
Normal file
84
swagger/definitions/resource/account_detail.yml
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
description: Account ID
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Name of the account
|
||||||
|
locale:
|
||||||
|
type: string
|
||||||
|
description: The locale of the account
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: The domain of the account
|
||||||
|
support_email:
|
||||||
|
type: string
|
||||||
|
description: The support email of the account
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
description: The status of the account
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: The creation date of the account
|
||||||
|
cache_keys:
|
||||||
|
type: object
|
||||||
|
description: Cache keys for the account
|
||||||
|
features:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Enabled features for the account
|
||||||
|
settings:
|
||||||
|
type: object
|
||||||
|
description: Account settings
|
||||||
|
properties:
|
||||||
|
auto_resolve_after:
|
||||||
|
type: number
|
||||||
|
description: Auto resolve conversations after specified minutes
|
||||||
|
auto_resolve_message:
|
||||||
|
type: string
|
||||||
|
description: Message to send when auto resolving
|
||||||
|
auto_resolve_ignore_waiting:
|
||||||
|
type: boolean
|
||||||
|
description: Whether to ignore waiting conversations for auto resolve
|
||||||
|
custom_attributes:
|
||||||
|
type: object
|
||||||
|
description: Custom attributes of the account
|
||||||
|
properties:
|
||||||
|
plan_name:
|
||||||
|
type: string
|
||||||
|
description: Subscription plan name
|
||||||
|
subscribed_quantity:
|
||||||
|
type: number
|
||||||
|
description: Subscribed quantity
|
||||||
|
subscription_status:
|
||||||
|
type: string
|
||||||
|
description: Subscription status
|
||||||
|
subscription_ends_on:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Subscription end date
|
||||||
|
industry:
|
||||||
|
type: string
|
||||||
|
description: Industry type
|
||||||
|
company_size:
|
||||||
|
type: string
|
||||||
|
description: Company size
|
||||||
|
timezone:
|
||||||
|
type: string
|
||||||
|
description: Account timezone
|
||||||
|
logo:
|
||||||
|
type: string
|
||||||
|
description: Account logo URL
|
||||||
|
onboarding_step:
|
||||||
|
type: string
|
||||||
|
description: Current onboarding step
|
||||||
|
marked_for_deletion_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: When account was marked for deletion
|
||||||
|
marked_for_deletion_reason:
|
||||||
|
type: string
|
||||||
|
description: Reason for account deletion
|
||||||
13
swagger/definitions/resource/account_show_response.yml
Normal file
13
swagger/definitions/resource/account_show_response.yml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/account_detail'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
latest_chatwoot_version:
|
||||||
|
type: string
|
||||||
|
description: Latest version of Chatwoot available
|
||||||
|
example: "3.0.0"
|
||||||
|
subscribed_features:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: List of subscribed enterprise features (if enterprise edition is enabled)
|
||||||
28
swagger/paths/application/accounts/show.yml
Normal file
28
swagger/paths/application/accounts/show.yml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
tags:
|
||||||
|
- Account
|
||||||
|
operationId: get-account-details
|
||||||
|
summary: Get account details
|
||||||
|
description: Get the details of the current account
|
||||||
|
security:
|
||||||
|
- userApiKey: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/account_id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/account_show_response'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/bad_request_error'
|
||||||
|
'404':
|
||||||
|
description: Account not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/bad_request_error'
|
||||||
43
swagger/paths/application/accounts/update.yml
Normal file
43
swagger/paths/application/accounts/update.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
tags:
|
||||||
|
- Account
|
||||||
|
operationId: update-account
|
||||||
|
summary: Update account
|
||||||
|
description: Update account details, settings, and custom attributes
|
||||||
|
security:
|
||||||
|
- userApiKey: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/account_id'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/account_update_payload'
|
||||||
|
application/x-www-form-urlencoded:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/account_update_payload'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/account_detail'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized (requires administrator role)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/bad_request_error'
|
||||||
|
'404':
|
||||||
|
description: Account not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/bad_request_error'
|
||||||
|
'422':
|
||||||
|
description: Validation error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/bad_request_error'
|
||||||
@ -166,6 +166,15 @@
|
|||||||
|
|
||||||
# ------------ Application API routes ------------#
|
# ------------ Application API routes ------------#
|
||||||
|
|
||||||
|
# Accounts
|
||||||
|
/api/v1/accounts/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/account_id'
|
||||||
|
get:
|
||||||
|
$ref: ./application/accounts/show.yml
|
||||||
|
patch:
|
||||||
|
$ref: ./application/accounts/update.yml
|
||||||
|
|
||||||
# AgentBots
|
# AgentBots
|
||||||
/api/v1/accounts/{account_id}/agent_bots:
|
/api/v1/accounts/{account_id}/agent_bots:
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@ -1476,6 +1476,138 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/accounts/{id}": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/account_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Account"
|
||||||
|
],
|
||||||
|
"operationId": "get-account-details",
|
||||||
|
"summary": "Get account details",
|
||||||
|
"description": "Get the details of the current account",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"userApiKey": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/account_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/account_show_response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/bad_request_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Account not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/bad_request_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"Account"
|
||||||
|
],
|
||||||
|
"operationId": "update-account",
|
||||||
|
"summary": "Update account",
|
||||||
|
"description": "Update account details, settings, and custom attributes",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"userApiKey": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/account_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/account_update_payload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/x-www-form-urlencoded": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/account_update_payload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/account_detail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized (requires administrator role)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/bad_request_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Account not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/bad_request_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/bad_request_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/accounts/{account_id}/agent_bots": {
|
"/api/v1/accounts/{account_id}/agent_bots": {
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@ -8774,6 +8906,145 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Account ID"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The status of the account"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "The creation date of the account"
|
||||||
|
},
|
||||||
|
"cache_keys": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cache keys for the account"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Enabled features for the account"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Account settings",
|
||||||
|
"properties": {
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Auto resolve conversations after specified minutes"
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Message to send when auto resolving"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom_attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Custom attributes of the account",
|
||||||
|
"properties": {
|
||||||
|
"plan_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription plan name"
|
||||||
|
},
|
||||||
|
"subscribed_quantity": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Subscribed quantity"
|
||||||
|
},
|
||||||
|
"subscription_status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription status"
|
||||||
|
},
|
||||||
|
"subscription_ends_on": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Subscription end date"
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account logo URL"
|
||||||
|
},
|
||||||
|
"onboarding_step": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current onboarding step"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When account was marked for deletion"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for account deletion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account_show_response": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/account_detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"latest_chatwoot_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Latest version of Chatwoot available",
|
||||||
|
"example": "3.0.0"
|
||||||
|
},
|
||||||
|
"subscribed_features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "List of subscribed enterprise features (if enterprise edition is enabled)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"account_user": {
|
"account_user": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Array of account users",
|
"description": "Array of account users",
|
||||||
@ -9113,6 +9384,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_update_payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account",
|
||||||
|
"example": "My Account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account",
|
||||||
|
"example": "en"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account",
|
||||||
|
"example": "example.com"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account",
|
||||||
|
"example": "support@example.com"
|
||||||
|
},
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 10,
|
||||||
|
"maximum": 1439856,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Auto resolve conversations after specified minutes",
|
||||||
|
"example": 1440
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Message to send when auto resolving",
|
||||||
|
"example": "This conversation has been automatically resolved due to inactivity"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type",
|
||||||
|
"example": "Technology"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size",
|
||||||
|
"example": "50-100"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone",
|
||||||
|
"example": "UTC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"account_user_create_update_payload": {
|
"account_user_create_update_payload": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@ -7135,6 +7135,145 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Account ID"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The status of the account"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "The creation date of the account"
|
||||||
|
},
|
||||||
|
"cache_keys": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cache keys for the account"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Enabled features for the account"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Account settings",
|
||||||
|
"properties": {
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Auto resolve conversations after specified minutes"
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Message to send when auto resolving"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom_attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Custom attributes of the account",
|
||||||
|
"properties": {
|
||||||
|
"plan_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription plan name"
|
||||||
|
},
|
||||||
|
"subscribed_quantity": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Subscribed quantity"
|
||||||
|
},
|
||||||
|
"subscription_status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription status"
|
||||||
|
},
|
||||||
|
"subscription_ends_on": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Subscription end date"
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account logo URL"
|
||||||
|
},
|
||||||
|
"onboarding_step": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current onboarding step"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When account was marked for deletion"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for account deletion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account_show_response": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/account_detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"latest_chatwoot_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Latest version of Chatwoot available",
|
||||||
|
"example": "3.0.0"
|
||||||
|
},
|
||||||
|
"subscribed_features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "List of subscribed enterprise features (if enterprise edition is enabled)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"account_user": {
|
"account_user": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Array of account users",
|
"description": "Array of account users",
|
||||||
@ -7474,6 +7613,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_update_payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account",
|
||||||
|
"example": "My Account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account",
|
||||||
|
"example": "en"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account",
|
||||||
|
"example": "example.com"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account",
|
||||||
|
"example": "support@example.com"
|
||||||
|
},
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 10,
|
||||||
|
"maximum": 1439856,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Auto resolve conversations after specified minutes",
|
||||||
|
"example": 1440
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Message to send when auto resolving",
|
||||||
|
"example": "This conversation has been automatically resolved due to inactivity"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type",
|
||||||
|
"example": "Technology"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size",
|
||||||
|
"example": "50-100"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone",
|
||||||
|
"example": "UTC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"account_user_create_update_payload": {
|
"account_user_create_update_payload": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@ -1978,6 +1978,145 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Account ID"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The status of the account"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "The creation date of the account"
|
||||||
|
},
|
||||||
|
"cache_keys": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cache keys for the account"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Enabled features for the account"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Account settings",
|
||||||
|
"properties": {
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Auto resolve conversations after specified minutes"
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Message to send when auto resolving"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom_attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Custom attributes of the account",
|
||||||
|
"properties": {
|
||||||
|
"plan_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription plan name"
|
||||||
|
},
|
||||||
|
"subscribed_quantity": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Subscribed quantity"
|
||||||
|
},
|
||||||
|
"subscription_status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription status"
|
||||||
|
},
|
||||||
|
"subscription_ends_on": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Subscription end date"
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account logo URL"
|
||||||
|
},
|
||||||
|
"onboarding_step": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current onboarding step"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When account was marked for deletion"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for account deletion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account_show_response": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/account_detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"latest_chatwoot_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Latest version of Chatwoot available",
|
||||||
|
"example": "3.0.0"
|
||||||
|
},
|
||||||
|
"subscribed_features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "List of subscribed enterprise features (if enterprise edition is enabled)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"account_user": {
|
"account_user": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Array of account users",
|
"description": "Array of account users",
|
||||||
@ -2317,6 +2456,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_update_payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account",
|
||||||
|
"example": "My Account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account",
|
||||||
|
"example": "en"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account",
|
||||||
|
"example": "example.com"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account",
|
||||||
|
"example": "support@example.com"
|
||||||
|
},
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 10,
|
||||||
|
"maximum": 1439856,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Auto resolve conversations after specified minutes",
|
||||||
|
"example": 1440
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Message to send when auto resolving",
|
||||||
|
"example": "This conversation has been automatically resolved due to inactivity"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type",
|
||||||
|
"example": "Technology"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size",
|
||||||
|
"example": "50-100"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone",
|
||||||
|
"example": "UTC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"account_user_create_update_payload": {
|
"account_user_create_update_payload": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@ -1393,6 +1393,145 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_detail": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Account ID"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The status of the account"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "The creation date of the account"
|
||||||
|
},
|
||||||
|
"cache_keys": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cache keys for the account"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Enabled features for the account"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Account settings",
|
||||||
|
"properties": {
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Auto resolve conversations after specified minutes"
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Message to send when auto resolving"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom_attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Custom attributes of the account",
|
||||||
|
"properties": {
|
||||||
|
"plan_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription plan name"
|
||||||
|
},
|
||||||
|
"subscribed_quantity": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Subscribed quantity"
|
||||||
|
},
|
||||||
|
"subscription_status": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Subscription status"
|
||||||
|
},
|
||||||
|
"subscription_ends_on": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"description": "Subscription end date"
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone"
|
||||||
|
},
|
||||||
|
"logo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account logo URL"
|
||||||
|
},
|
||||||
|
"onboarding_step": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current onboarding step"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When account was marked for deletion"
|
||||||
|
},
|
||||||
|
"marked_for_deletion_reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for account deletion"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account_show_response": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/account_detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"latest_chatwoot_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Latest version of Chatwoot available",
|
||||||
|
"example": "3.0.0"
|
||||||
|
},
|
||||||
|
"subscribed_features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "List of subscribed enterprise features (if enterprise edition is enabled)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"account_user": {
|
"account_user": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"description": "Array of account users",
|
"description": "Array of account users",
|
||||||
@ -1732,6 +1871,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"account_update_payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the account",
|
||||||
|
"example": "My Account"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The locale of the account",
|
||||||
|
"example": "en"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The domain of the account",
|
||||||
|
"example": "example.com"
|
||||||
|
},
|
||||||
|
"support_email": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The support email of the account",
|
||||||
|
"example": "support@example.com"
|
||||||
|
},
|
||||||
|
"auto_resolve_after": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 10,
|
||||||
|
"maximum": 1439856,
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Auto resolve conversations after specified minutes",
|
||||||
|
"example": 1440
|
||||||
|
},
|
||||||
|
"auto_resolve_message": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Message to send when auto resolving",
|
||||||
|
"example": "This conversation has been automatically resolved due to inactivity"
|
||||||
|
},
|
||||||
|
"auto_resolve_ignore_waiting": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Whether to ignore waiting conversations for auto resolve",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"industry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Industry type",
|
||||||
|
"example": "Technology"
|
||||||
|
},
|
||||||
|
"company_size": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Company size",
|
||||||
|
"example": "50-100"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Account timezone",
|
||||||
|
"example": "UTC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"account_user_create_update_payload": {
|
"account_user_create_update_payload": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user