Merge branch 'chatwoot:develop' into chatwoot/develop

This commit is contained in:
Gabriel Jablonski 2025-05-30 09:06:38 -03:00 committed by GitHub
commit eed473ced8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 3456 additions and 1180 deletions

View File

@ -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

View File

@ -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

View File

@ -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"

View 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

View File

@ -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: ..

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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);
},
}; };

View File

@ -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 => {

View File

@ -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');
}); });
}); });

View File

@ -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;

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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 />

View File

@ -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 }}

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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,
});
});
});
});

View File

@ -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(

View File

@ -19,7 +19,6 @@ const FONT_SIZE_OPTIONS = {
DEFAULT: '16px', DEFAULT: '16px',
LARGE: '18px', LARGE: '18px',
LARGER: '20px', LARGER: '20px',
EXTRA_LARGE: '22px',
}; };
/** /**

View File

@ -129,6 +129,7 @@ export function usePolicy() {
return { return {
checkPermissions, checkPermissions,
shouldShowPaywall, shouldShowPaywall,
isFeatureFlagEnabled,
shouldShow, shouldShow,
}; };
} }

View File

@ -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": {

View File

@ -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",

View File

@ -4,6 +4,7 @@
"PHONE_INPUT": { "PHONE_INPUT": {
"PLACEHOLDER": "Search", "PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found" "EMPTY_STATE": "No results found"
} },
"CLOSE": "Close"
} }
} }

View File

@ -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",

View File

@ -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

View File

@ -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"

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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 = {

View File

@ -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();

View File

@ -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 }) => {

View File

@ -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);
});
});
}); });

View File

@ -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);
});
});
}); });

View File

@ -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],
]);
});
});
}); });

View File

@ -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 =

View File

@ -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">

View File

@ -5,7 +5,6 @@ module ActivityMessageHandler
include LabelActivityMessageHandler include LabelActivityMessageHandler
include SlaActivityMessageHandler include SlaActivityMessageHandler
include TeamActivityMessageHandler include TeamActivityMessageHandler
include CsatActivityMessageHandler
private private

View File

@ -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

View File

@ -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

View File

@ -11,3 +11,5 @@ class CsatSurveyResponsePolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
end end
CsatSurveyResponsePolicy.prepend_mod_with('CsatSurveyResponsePolicy')

View File

@ -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')

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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
View 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>

View File

@ -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'
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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'

View File

@ -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
View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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 }

View 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

View File

@ -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

View File

@ -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

View 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"

View 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

View 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)

View 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'

View 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'

View File

@ -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:

View File

@ -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": [

View File

@ -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": [

View File

@ -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": [

View File

@ -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