Compare commits

..
Author SHA1 Message Date
Pranav 38861ee06a Fix eslint 2024-12-12 17:41:43 -08:00
Pranav 0bba0ef6ff Fix stories 2024-12-12 17:36:08 -08:00
Shivam MishraandGitHub f865a2fb38 Merge branch 'develop' into feat/message-bubble 2024-12-12 18:49:12 +05:30
Shivam Mishra 67aa85a508 feat: add private note example 2024-12-12 18:42:27 +05:30
Shivam Mishra 898a959755 fix: required 2024-12-12 17:57:59 +05:30
Shivam Mishra dabde6fad6 fix: types 2024-12-12 17:57:34 +05:30
Shivam Mishra 5caa24ec58 feat: add content types 2024-12-12 13:35:39 +05:30
Shivam Mishra 25d9588095 feat: update styles 2024-12-12 13:35:34 +05:30
Shivam Mishra 974a9f00b9 fix: error handler 2024-12-12 13:33:37 +05:30
Shivam Mishra ed8adb36fe fix: styles for base 2024-12-12 13:33:10 +05:30
Shivam Mishra 59d07f71e7 feat: use formatted content 2024-12-12 13:31:51 +05:30
Shivam Mishra 1f71733400 fix: meta 2024-12-12 13:29:02 +05:30
Shivam Mishra 873e67363d feat: add bubble names 2024-12-12 13:25:44 +05:30
Shivam Mishra c561cf596b refactor: split story 2024-12-11 18:32:11 +05:30
Shivam Mishra ad6864fd18 feat: add dyte bubble 2024-12-11 18:25:13 +05:30
Shivam Mishra 9807ed2fd7 fix: import 2024-12-11 17:55:51 +05:30
Shivam Mishra 0935adb61f feat: move stories 2024-12-11 17:55:09 +05:30
Shivam Mishra 245da198cb feat: add broken image chip example 2024-12-11 17:53:37 +05:30
Shivam Mishra 13c2a12865 fix: ig reel 2024-12-11 17:51:02 +05:30
Shivam Mishra f9ddb38019 fix: link 2024-12-11 17:49:17 +05:30
Shivam Mishra 789038ea7d fix: bubbles 2024-12-11 17:48:49 +05:30
Shivam Mishra 6ec955c277 feat: use data url instead of thumb url 2024-12-11 17:47:32 +05:30
Shivam Mishra f15e997806 fix: handle error 2024-12-11 17:47:22 +05:30
Shivam Mishra 40888b8a1d style: remove unused imports 2024-12-11 17:47:16 +05:30
Shivam Mishra 409e367c21 fix: link 2024-12-11 17:47:08 +05:30
Shivam Mishra 8089d4fe70 feat: update design for audio bubble 2024-12-11 17:42:09 +05:30
Shivam Mishra 414c6c7e97 fix: audio chip design 2024-12-11 17:41:33 +05:30
Shivam Mishra 71aeb2abb3 feat: add i18n 2024-12-11 17:41:03 +05:30
Shivam Mishra 777747b237 feat: update provider 2024-12-11 17:39:22 +05:30
Shivam Mishra 7f2b6019f9 feat: handle errors and gallery 2024-12-11 17:38:47 +05:30
Shivam Mishra 98f1dd46f4 feat: common base attachment component 2024-12-11 17:36:55 +05:30
Shivam Mishra 12d41eb694 feat: update file chip 2024-12-11 17:24:33 +05:30
Shivam Mishra c3268dcc4c feat: update location bubble 2024-12-11 17:19:14 +05:30
Shivam Mishra 7c34b5ce0a feat: update contact design 2024-12-11 17:11:07 +05:30
Shivam Mishra af01147221 fix: attachments 2024-12-11 16:58:54 +05:30
Shivam Mishra 4dbdb0bf0c fix: email spacing 2024-12-11 16:58:06 +05:30
Shivam Mishra 87699ee296 feat: better stories 2024-12-11 16:55:40 +05:30
Shivam Mishra a20852722c fix: color 2024-12-11 16:03:40 +05:30
Shivam Mishra bd3a2b481b fix: white alpha color 2024-12-11 16:03:38 +05:30
Shivam Mishra f349a802d5 fix: download color 2024-12-11 16:03:35 +05:30
Shivam Mishra b0824f3b67 fix: orientatation for chips 2024-12-11 16:03:31 +05:30
Shivam Mishra 1825989663 feat: add colors to text chip 2024-12-11 16:01:51 +05:30
Shivam Mishra 233cc8b868 feat: use woot-icons 2024-12-11 16:01:49 +05:30
Shivam Mishra ea87274d61 feat: move icons outside 2024-12-11 16:00:45 +05:30
Shivam Mishra 64ba5857e0 feat: use lucide 2024-12-11 16:00:32 +05:30
Shivam Mishra ed158716ba fix: sender avatar 2024-12-06 18:53:50 +05:30
Shivam Mishra 97ae755783 fix: attachments check 2024-12-06 18:51:42 +05:30
Shivam Mishra bcc03a79f7 feat: allow otions 2024-12-06 18:19:49 +05:30
Shivam Mishra 55952f5fe0 fix: pnpm 2024-12-06 18:19:43 +05:30
Shivam Mishra be01ab63f7 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/message-bubble 2024-12-06 18:11:07 +05:30
Shivam Mishra e7471b8e74 feat: add contact card 2024-12-06 14:36:24 +05:30
Shivam Mishra 9c6b78700c fix: location 2024-12-06 10:45:26 +05:30
Shivam Mishra 685cf56819 feat: add support for unsupported 2024-12-05 17:37:50 +05:30
Shivam Mishra e7fddee4dc feat: add location bubble 2024-12-05 17:13:57 +05:30
Shivam Mishra 26b5c2c136 feat: setup instagram 2024-12-05 15:08:37 +05:30
Shivam Mishra 68f02e06b7 feat: add fixtures for IG 2024-12-05 14:26:31 +05:30
Shivam Mishra c2021a86d2 feat: add ig bubble 2024-12-05 14:20:16 +05:30
Shivam Mishra d610d3e0bb fix: locale 2024-12-05 14:19:43 +05:30
Shivam Mishra 59d5c91977 feat: simpler expandable check 2024-12-04 19:51:55 +05:30
Shivam Mishra b78a272806 feat: better resize observer 2024-12-04 19:44:48 +05:30
Shivam Mishra 2456b90a75 feat: add quote extracter 2024-12-04 19:38:39 +05:30
Shivam Mishra 828a46e40b feat: use sender 2024-12-04 18:39:41 +05:30
Shivam Mishra 4739d45376 feat: seprate email meta component 2024-12-04 18:38:21 +05:30
Shivam Mishra 12c239228a feat: format email text content 2024-12-04 17:49:58 +05:30
Shivam Mishra 89956d006c feat: common attachment chips 2024-12-04 17:46:57 +05:30
Shivam Mishra b1e55bab9e fix: fullwidth 2024-12-04 17:39:44 +05:30
Shivam Mishra 89f95742f8 fix: styling 2024-12-04 17:33:36 +05:30
Shivam Mishra 95674eb601 feat: more attachments 2024-12-04 17:32:50 +05:30
Shivam Mishra 5c863906e0 feat: add attachments 2024-12-04 17:31:21 +05:30
Shivam Mishra e68f923a09 fix: first child margin 2024-12-04 17:31:17 +05:30
Shivam Mishra 30f368cbcd feat: better story 2024-12-04 17:24:02 +05:30
Shivam Mishra 0a3840501e feat: add error case 2024-12-04 17:08:06 +05:30
Shivam Mishra 1fc4445bba feat: better stories 2024-12-02 17:58:05 +05:30
Shivam Mishra 478589de91 feat: more fixtures 2024-12-02 17:57:59 +05:30
Shivam Mishra 8c98715c97 fix: border color 2024-12-02 17:48:56 +05:30
Shivam Mishra 26ee5eb37b feat: update font weight 2024-12-02 17:44:51 +05:30
Shivam Mishra 79f57c74a2 feat: remove margin from first heading 2024-12-02 17:44:10 +05:30
Shivam Mishra f6b07d094e feat: add simple email 2024-12-02 17:42:50 +05:30
Shivam Mishra 8528bd331e feat: add hr 2024-12-02 17:42:33 +05:30
Shivam Mishra 83f0b57d55 fix: css 2024-12-02 16:24:57 +05:30
Shivam Mishra 197bc946d4 feat: add email overflow 2024-12-02 15:51:03 +05:30
Shivam Mishra 27c051984d feat: add expanded view 2024-12-02 15:42:12 +05:30
Shivam Mishra 15540dd633 feat: add showMeta 2024-12-02 15:14:23 +05:30
Shivam Mishra 801b4a3406 feat: check if is-overflowing 2024-12-02 15:08:13 +05:30
Shivam Mishra e434d9bb5d feat: add email meta 2024-12-02 15:03:33 +05:30
Shivam Mishra 2a9498819a feat: email typography 2024-12-02 14:45:46 +05:30
Shivam Mishra 59481afc2c feat: setup email content 2024-11-29 16:45:52 +05:30
Shivam Mishra 03389ad773 feat: setup email bubble 2024-11-29 16:37:42 +05:30
Shivam Mishra 376c6ae90a feat: setup base for email 2024-11-29 16:37:42 +05:30
Shivam Mishra 9c5ee5940d feat: add padding for right message 2024-11-29 16:37:42 +05:30
Shivam Mishra c2ede6a3e9 feat: add check for sender 2024-11-29 16:37:42 +05:30
Shivam Mishra e1f8c905f8 fix: sender type fallback in isMyMessage 2024-11-29 16:37:42 +05:30
Shivam Mishra c2d0da234a refactor: rename story
feat: remove email
2024-11-29 16:37:39 +05:30
Shivam Mishra 781182905d feat: better download 2024-11-29 14:59:23 +05:30
Shivam Mishra c841349de3 feat: use better placeholder 2024-11-29 14:56:40 +05:30
Shivam Mishra 12900928be feat: use iris 2024-11-28 19:10:01 +05:30
Shivam Mishra 39b06a76ce feat: add inreplyto 2024-11-28 18:55:44 +05:30
Shivam Mishra ebe35c7843 feat: better examples 2024-11-28 18:34:24 +05:30
Shivam Mishra d73d941568 fix: spacing 2024-11-28 18:32:18 +05:30
Shivam Mishra b304e86f4f feat: add attachment only component 2024-11-28 18:27:55 +05:30
Shivam Mishra 44b4c7ce14 fix: colors 2024-11-28 18:09:41 +05:30
Shivam Mishra b1fd52c275 feat: add file chip 2024-11-28 18:06:55 +05:30
Shivam Mishra 82c46dbb9c fix: download link 2024-11-28 17:58:34 +05:30
Shivam Mishra b8248978d8 feat: use new file icon 2024-11-28 17:47:47 +05:30
Shivam Mishra e8cbb6d8e8 feat: update colors 2024-11-28 17:46:04 +05:30
Shivam Mishra a706f26ef4 fix: dark mode colors 2024-11-28 17:43:00 +05:30
Shivam Mishra 0d40f2a027 feat: make content optional 2024-11-28 17:40:19 +05:30
Shivam Mishra 600b06f748 feat: add audio bubble 2024-11-28 17:39:55 +05:30
Shivam Mishra 3bf8468077 feat: rename media to image 2024-11-28 17:19:07 +05:30
Shivam Mishra bcd18a0bb0 feat: add audio chip 2024-11-28 17:11:24 +05:30
Shivam Mishra a565ae8cba feat: add more data 2024-11-28 15:47:50 +05:30
Shivam Mishra 2274141d99 feat: add more examples 2024-11-28 15:40:08 +05:30
Shivam Mishra d9ef6df4de feat: add image and video chip 2024-11-28 15:40:05 +05:30
Shivam Mishra 820fce8bfc feat: update layout 2024-11-28 15:05:12 +05:30
Shivam Mishra bba2befcb7 fix: extra spacing 2024-11-27 20:19:48 +05:30
Shivam Mishra 59afa50b71 feat: add text images 2024-11-27 20:19:06 +05:30
Shivam Mishra c2305a11c6 fix: condition to render files 2024-11-27 20:08:27 +05:30
Shivam Mishra 36e777aea5 feat: file icon 2024-11-27 20:06:49 +05:30
Shivam Mishra ac7f2c7153 feat: add teenyicons for files 2024-11-27 20:05:58 +05:30
Shivam Mishra d93e2ee60a feat: add download option
TODO: Get better designs
2024-11-27 20:05:42 +05:30
Shivam Mishra 32df64c965 feat: add file icon 2024-11-27 20:05:11 +05:30
Shivam Mishra fd51a89b59 Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/message-bubble 2024-11-27 19:05:23 +05:30
Shivam Mishra 226d7ceb03 feat: kickstart media bubble 2024-11-27 19:05:07 +05:30
Shivam Mishra 230279cafe feat: move padding to terminal bubble 2024-11-27 19:04:52 +05:30
Shivam Mishra a1479590e7 feat: add message id 2024-11-27 18:37:16 +05:30
Shivam Mishra ee71d6d50f feat: separate activity bubble 2024-11-27 18:35:09 +05:30
Shivam Mishra 0ae085ffbb feat: separate Base bubble
Also added a provider to inject local variant and orientation state to child components
2024-11-27 18:32:46 +05:30
Shivam Mishra 93bbcb2976 feat: capitalize sender name 2024-11-27 18:31:59 +05:30
Shivam Mishra 5a387689c7 fix: typo 2024-11-27 18:15:10 +05:30
Shivam Mishra 26501fc47c feat: add example for text with media 2024-11-27 18:14:07 +05:30
Shivam Mishra cffbc524d2 feat: move data to fixtures 2024-11-27 18:13:27 +05:30
Shivam Mishra da5a752c75 feat: add tooltip 2024-11-27 17:12:26 +05:30
Shivam Mishra 825e1b4fb3 feat: add i18n for error message 2024-11-27 17:09:03 +05:30
Shivam Mishra fe634293d8 chore: remove unused import 2024-11-27 17:06:45 +05:30
Shivam Mishra 4da133fe12 feat: add message meta component 2024-11-27 17:05:13 +05:30
Shivam Mishra 71e706a0bf feat: add in progress message 2024-11-27 16:46:46 +05:30
Shivam Mishra f8a400e704 feat: use content attributes 2024-11-27 16:33:53 +05:30
Shivam Mishra d70fa78e7d feat: show message status for outgoing messages 2024-11-27 16:27:13 +05:30
Shivam Mishra faf78bd447 feat: hide avatar for right oriented messages 2024-11-27 16:17:42 +05:30
Shivam Mishra b753d27301 feat: group when elements are in the same minute 2024-11-27 16:13:19 +05:30
Shivam Mishra caeb3bc88c feat: separate error component 2024-11-27 16:09:54 +05:30
Shivam Mishra 4dd7a5f693 feat: allow error 2024-11-27 16:07:27 +05:30
Shivam Mishra e3eca0dad8 feat: allow error variant 2024-11-27 15:56:29 +05:30
Shivam Mishra f2d115d2d7 fix: styling for p tag 2024-11-21 20:42:39 +05:30
Shivam MishraandGitHub 6ba5986b27 Merge branch 'develop' into feat/message-bubble 2024-11-21 20:37:29 +05:30
Shivam Mishra 2ed0d6a35d feat: don't use prose
We need to add the styling first
2024-11-21 20:35:02 +05:30
Shivam Mishra 4bb981566f fix: grouping space 2024-11-21 20:33:13 +05:30
Shivam Mishra fe4e906fc6 feat: format md 2024-11-21 20:31:42 +05:30
Shivam Mishra 036bfd830b feat: add grouping example 2024-11-21 20:05:45 +05:30
Shivam Mishra abfdd4d7fe chore: add some types 2024-11-21 19:49:35 +05:30
Shivam Mishra d2634cb9a3 feat: use realworld like data 2024-11-21 19:44:30 +05:30
Shivam Mishra c3327502a1 feat: add message container 2024-11-21 17:41:02 +05:30
Shivam Mishra 1fe1ace4a0 fix: color 2024-11-21 17:04:08 +05:30
Shivam Mishra 3a4e13922a feat: kickstart message bubble 2024-11-21 16:58:46 +05:30
Shivam Mishra b9cb39a9dd feat: allow dark across 2024-11-21 16:57:31 +05:30
2674 changed files with 47613 additions and 129259 deletions
+3 -11
View File
@@ -19,19 +19,13 @@ jobs:
steps:
- checkout
- node/install:
node-version: '23.7'
node-version: '20.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update -y
- run:
name: Install System Dependencies
@@ -40,9 +34,7 @@ jobs:
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
redis-server \
postgresql-common \
postgresql-16 \
postgresql-16-pgvector \
postgresql \
build-essential \
git \
curl \
@@ -82,7 +74,7 @@ jobs:
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler -v 2.5.16
gem install bundler
- run:
name: Install Application Dependencies
-1
View File
@@ -51,7 +51,6 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
+13 -13
View File
@@ -5,30 +5,30 @@
version: '3'
services:
base:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
USER_UID: "1000"
USER_GID: "1000"
image: base:latest
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
USER_UID: "1000"
USER_GID: "1000"
volumes:
- ..:/workspace:cached
@@ -40,7 +40,7 @@ services:
network_mode: service:db
db:
image: pgvector/pgvector:pg16
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
-14
View File
@@ -4,8 +4,6 @@ module.exports = {
'prettier',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
],
overrides: [
{
@@ -231,18 +229,6 @@ module.exports = {
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'],
'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
},
settings: {
'vue-i18n': {
localeDir: './app/javascript/*/i18n/**.json',
},
},
env: {
browser: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

+3 -1
View File
@@ -23,10 +23,12 @@ jobs:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
+1 -1
View File
@@ -16,7 +16,7 @@ on:
jobs:
nightly:
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: get installer
-140
View File
@@ -1,140 +0,0 @@
# #
# # This action will publish Chatwoot EE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot EE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
- name: Set Docker Tags
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG
+19 -101
View File
@@ -5,7 +5,6 @@
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
@@ -15,30 +14,20 @@ on:
- v*
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
@@ -49,97 +38,26 @@ jobs:
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: Set Docker Tags
- name: set docker tag
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
fi
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}
+9 -2
View File
@@ -1,3 +1,9 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
@@ -12,7 +18,7 @@ jobs:
runs-on: ubuntu-20.04
services:
postgres:
image: pgvector/pgvector:pg15
image: postgres:15.3
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
@@ -38,6 +44,7 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -47,7 +54,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
@@ -0,0 +1,89 @@
# #
# # This workflow will run specs related to response bot
# # This can only be activated in installations Where vector extension is available.
# #
name: Run Response Bot spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-20.04
services:
postgres:
image: ankane/pgvector
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: pnpm
run: pnpm install
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Enable ResponseBotService in installation
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+7 -3
View File
@@ -19,11 +19,13 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 23
node-version: 20
cache: 'pnpm'
- name: pnpm
@@ -37,7 +39,7 @@ jobs:
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
@@ -45,3 +47,5 @@ jobs:
- name: Size Check
run: pnpm run size
-40
View File
@@ -1,40 +0,0 @@
name: Test Docker Build
on:
pull_request:
branches:
- develop
- master
workflow_dispatch:
jobs:
test-build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: false
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
-1
View File
@@ -73,7 +73,6 @@ test/cypress/videos/*
#ignore files under .vscode directory
.vscode
.cursor
# yalc for local testing
.yalc
+8 -8
View File
@@ -1,11 +1,11 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# #!/bin/sh
# . "$(dirname "$0")/_/husky.sh"
# lint js and vue files
npx --no-install lint-staged
# # lint js and vue files
# npx --no-install lint-staged
# lint only staged ruby files
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# # lint only staged ruby files
# git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# stage rubocop changes to files
git diff --name-only --cached | xargs git add
# # stage rubocop changes to files
# git diff --name-only --cached | xargs git add
-1
View File
@@ -283,4 +283,3 @@ exclude:
- 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss'
- 'app/javascript/dashboard/assets/scss/_woot.scss'
+4 -7
View File
@@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.5.2'
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc'
@@ -138,7 +138,9 @@ gem 'procore-sift'
# parse email
gem 'email_reply_trimmer'
gem 'html2text'
# TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
# and this gem hasn't been updated for a while.
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
# to calculate working hours
gem 'working_hours'
@@ -173,11 +175,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'shopify_api'
### Gems required only in specific deployment environments ###
##############################################################
+21 -45
View File
@@ -22,6 +22,14 @@ GIT
devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 8.0.0)
GIT
remote: https://github.com/chatwoot/html2text_ruby
revision: cdbdbbbf898d846d0136d69d688a003c6b26074b
branch: chatwoot
specs:
html2text (0.3.1)
nokogiri (>= 1.13.6)
GEM
remote: https://rubygems.org/
specs:
@@ -178,7 +186,7 @@ GEM
database_cleaner-core (2.0.1)
datadog-ci (0.8.3)
msgpack
date (3.4.1)
date (3.3.4)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
@@ -223,7 +231,6 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
execjs (2.8.1)
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
@@ -272,8 +279,7 @@ GEM
googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.1)
gli (2.22.2)
ostruct
gli (2.21.1)
globalid (1.2.1)
activesupport (>= 6.1)
gmail_xoauth (0.4.3)
@@ -352,11 +358,8 @@ GEM
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@@ -378,8 +381,6 @@ GEM
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
iso-639 (0.3.8)
csv
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
@@ -485,7 +486,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.19)
net-imap (0.4.17)
date
net-protocol
net-pop (0.1.2)
@@ -501,14 +502,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.18.4)
nokogiri (1.17.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.4-arm64-darwin)
nokogiri (1.17.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-darwin)
nokogiri (1.17.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.4-x86_64-linux-gnu)
nokogiri (1.17.1-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -523,9 +524,6 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
@@ -544,7 +542,6 @@ GEM
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
parallel (1.23.0)
parser (3.2.2.1)
ast (~> 2.4.1)
@@ -567,7 +564,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.13)
rack (2.2.10)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -687,10 +684,6 @@ GEM
rubocop-rspec (2.21.0)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
@@ -717,7 +710,6 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
securerandom (0.4.1)
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -732,17 +724,6 @@ GEM
sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shopify_api (14.8.0)
activesupport
concurrent-ruby
hash_diff
httparty
jwt
oj
openssl
securerandom
sorbet-runtime
zeitwerk (~> 2.5)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.1)
@@ -765,17 +746,15 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
slack-ruby-client (2.5.2)
slack-ruby-client (2.2.0)
faraday (>= 2.0)
faraday-mashify
faraday-multipart
gli
hashie
logger
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
@@ -798,7 +777,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.3)
timeout (0.4.1)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
@@ -818,7 +797,7 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
uri (1.0.3)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -913,9 +892,8 @@ DEPENDENCIES
haikunator
hairtrigger
hashie
html2text
html2text!
image_processing
iso-639
jbuilder
json_refs
json_schemer
@@ -963,19 +941,17 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-openai
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.5.2)
slack-ruby-client (~> 2.2.0)
spring
spring-watcher-listen
squasher
+47 -64
View File
@@ -1,11 +1,22 @@
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/>
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
___
# Chatwoot
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
@@ -20,71 +31,41 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p>
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
## Features
---
Chatwoot supports the following conversation channels:
Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels.
- **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel.
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
### ✨ Captain AI Agent for Support
And more.
Supercharge your support with Captain, Chatwoots AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs).
### 💬 Omnichannel Support Desk
Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc.
### 📚 Help center portal
Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues.
### 🗂️ Other features
#### Collaboration & Productivity
- Private Notes and @mentions for internal team discussions.
- Labels to organize and categorize conversations.
- Keyboard Shortcuts and a Command Bar for quick navigation.
- Canned Responses to reply faster to frequently asked questions.
- Auto-Assignment to route conversations based on agent availability.
- Multi-lingual Support to serve customers in multiple languages.
- Custom Views and Filters for better inbox organization.
- Business Hours and Auto-Responders to manage response expectations.
- Teams and Automation tools for scaling support workflows.
- Agent Capacity Management to balance workload across the team.
#### Customer Data & Segmentation
- Contact Management with profiles and interaction history.
- Contact Segments and Notes for targeted communication.
- Campaigns to proactively engage customers.
- Custom Attributes for storing additional customer data.
- Pre-Chat Forms to collect user information before starting conversations.
#### Integrations
- Slack Integration to manage conversations directly from Slack.
- Dialogflow Integration for chatbot automation.
- Dashboard Apps to embed internal tools within Chatwoot.
- Shopify Integration to view and manage customer orders right within Chatwoot.
- Use Google Translate to translate messages from your customers in realtime.
- Create and manage Linear tickets within Chatwoot.
#### Reports & Insights
- Live View of ongoing conversations for real-time monitoring.
- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
- CSAT Reports to measure customer satisfaction.
- Downloadable Reports for offline analysis and reporting.
Other features include:
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labels to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation
@@ -126,15 +107,17 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
## Contributors
## Contributors ✨
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2024, Chatwoot Inc - Released under the MIT License.
+4 -4
View File
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
## Reporting a Vulnerability
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, contact security@chatwoot.com.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new)
> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
## Supported versions
| Version | Supported |
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
- Brute force attacks
- DNSSEC
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new).
If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
## Thanks
+1 -1
View File
@@ -1 +1 @@
3.2.0
3.0.0
+1 -1
View File
@@ -54,7 +54,7 @@ class ContactMergeAction
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.reload.destroy!
@mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
@@ -1,15 +1,15 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
button,
input[type="button"],
input[type="reset"],
input[type="submit"],
.button {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-flex;
display: inline-block;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
@@ -10,6 +10,7 @@
.icon-container {
margin-right: 2px;
}
.value-container {
-35
View File
@@ -64,40 +64,5 @@ class ContactInboxBuilder
inbox_id: @inbox.id,
source_id: @source_id
)
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox
retry
end
def update_old_contact_inbox
# The race condition occurs when theres a contact inbox with the
# same source ID but linked to a different contact. This can happen
# if the agent updates the contacts email or phone number, or
# if the contact is merged with another.
#
# We update the old contact inbox source_id to a random value to
# avoid disrupting the current flow. However, the root cause of
# this issue is a flaw in the contact inbox model design.
# Contact inbox is essentially tracking a session and is not
# needed for non-live chat channels.
raise ActiveRecord::RecordNotUnique unless allowed_channels?
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
return if contact_inbox.blank?
contact_inbox.update!(source_id: new_source_id)
end
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"whatsapp:#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
-2
View File
@@ -25,8 +25,6 @@ class NotificationBuilder
def build_notification
# Create conversation_creation notification only if user is subscribed to it
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
user.notifications.create!(
notification_type: notification_type,
@@ -2,38 +2,52 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
end
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def build_agent_stats(account_user)
user_id = account_user.user_id
{
id: user_id,
conversations_count: conversations_count[user_id] || 0,
resolved_conversations_count: resolved_count[user_id] || 0,
avg_resolution_time: avg_resolution_time[user_id],
avg_first_response_time: avg_first_response_time[user_id],
avg_reply_time: avg_reply_time[user_id]
}
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def group_by_key
:user_id
end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -1,50 +1,17 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
def build
load_data
prepare_report
end
private
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
def prepare_report
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,50 +0,0 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
end
end
def build_inbox_stats(inbox)
{
id: inbox.id,
conversations_count: conversations_count[inbox.id] || 0,
resolved_conversations_count: resolved_count[inbox.id] || 0,
avg_resolution_time: avg_resolution_time[inbox.id],
avg_first_response_time: avg_first_response_time[inbox.id],
avg_reply_time: avg_reply_time[inbox.id]
}
end
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
+34 -22
View File
@@ -1,37 +1,49 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def set_grouped_conversations_count
@grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
def set_grouped_avg_resolution_time
@grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
end
end
def build_team_stats(team)
{
id: team.id,
conversations_count: conversations_count[team.id] || 0,
resolved_conversations_count: resolved_count[team.id] || 0,
avg_resolution_time: avg_resolution_time[team.id],
avg_first_response_time: avg_first_response_time[team.id],
avg_reply_time: avg_reply_time[team.id]
}
@reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end
def group_by_key
'conversations.team_id'
end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end
@@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
scope.reporting_events.where(name: event_name, created_at: range)
end
def reporting_events
+7 -10
View File
@@ -2,9 +2,10 @@ class RoomChannel < ApplicationCable::Channel
def subscribed
# TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence
ensure_stream
current_user
current_account
ensure_stream
update_subscription
broadcast_presence
end
@@ -21,12 +22,12 @@ class RoomChannel < ApplicationCable::Channel
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data })
ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
end
def ensure_stream
stream_from pubsub_token
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User)
@pubsub_token = params[:pubsub_token]
stream_from @pubsub_token
end
def update_subscription
@@ -35,15 +36,11 @@ class RoomChannel < ApplicationCable::Channel
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end
def pubsub_token
@pubsub_token ||= params[:pubsub_token]
end
def current_user
@current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: pubsub_token).contact
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
else
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id])
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end
end
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
def index
@conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
end
private
@@ -68,7 +68,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
def index
result = conversation_finder.perform
@conversations = result[:conversations]
@@ -26,12 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
end
def show; end
@@ -53,7 +46,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
@@ -131,10 +123,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
params.permit(:priority)
end
def attachment_params
params.permit(:page)
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@@ -10,7 +10,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
def create
authorize @inbox, :create?
ActiveRecord::Base.transaction do
@inbox.add_members(agents_to_be_added_ids)
agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) }
end
fetch_updated_agents
end
@@ -24,7 +24,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
def destroy
authorize @inbox, :destroy?
ActiveRecord::Base.transaction do
@inbox.remove_members(params[:user_ids])
params[:user_ids].map { |user_id| @inbox.remove_member(user_id) }
end
head :ok
end
@@ -41,8 +41,8 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
# the missing ones are the agents which are to be deleted from the inbox
# the new ones are the agents which are to be added to the inbox
ActiveRecord::Base.transaction do
@inbox.add_members(agents_to_be_added_ids)
@inbox.remove_members(agents_to_be_removed_ids)
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
end
end
@@ -0,0 +1,78 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
def proxy
request_url = build_request_url(request_path)
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
def copilot
request_url = build_request_url(build_request_path("/assistants/#{hook.settings['assistant_id']}/copilot"))
params = {
previous_messages: copilot_params[:previous_messages],
conversation_history: conversation_history,
message: copilot_params[:message]
}
response = HTTParty.send(:post, request_url, body: params.to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def build_request_path(route)
"api/accounts/#{hook.settings['account_id']}#{route}"
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
build_request_path(request_route)
end
def build_request_url(request_path)
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def conversation_history
conversation = Current.account.conversations.find_by!(display_id: copilot_params[:conversation_id])
conversation.to_llm_text
end
def copilot_params
params.permit(:previous_messages, :conversation_id, :message)
end
def permitted_params
params.permit(:method, :route, body: {})
end
end
@@ -1,11 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def teams
teams = linear_processor_service.teams
@@ -96,8 +90,4 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end
end
@@ -1,111 +0,0 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
customers = fetch_customers
return render json: { orders: [] } if customers.empty?
orders = fetch_orders(customers.first['id'])
render json: { orders: orders }
rescue ShopifyAPI::Errors::HttpResponseError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@hook.destroy!
head :ok
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
end
def fetch_hook
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
end
def fetch_customers
query = []
query << "email:#{contact.email}" if contact.email.present?
query << "phone:#{contact.phone_number}" if contact.phone_number.present?
shopify_client.get(
path: 'customers/search.json',
query: {
query: query.join(' OR '),
fields: 'id,email,phone'
}
).body['customers'] || []
end
def fetch_orders(customer_id)
orders = shopify_client.get(
path: 'orders.json',
query: {
customer_id: customer_id,
status: 'any',
fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
}
).body['orders'] || []
orders.map do |order|
order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}")
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
end
def validate_contact
return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?)
render json: { error: 'Contact information missing' },
status: :unprocessable_entity
end
end
@@ -9,14 +9,14 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def create
ActiveRecord::Base.transaction do
@team_members = @team.add_members(members_to_be_added_ids)
@team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) }
end
end
def update
ActiveRecord::Base.transaction do
@team.add_members(members_to_be_added_ids)
@team.remove_members(members_to_be_removed_ids)
members_to_be_added_ids.each { |user_id| @team.add_member(user_id) }
members_to_be_removed_ids.each { |user_id| @team.remove_member(user_id) }
end
@team_members = @team.members
render action: 'create'
@@ -24,7 +24,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def destroy
ActiveRecord::Base.transaction do
@team.remove_members(params[:user_ids])
params[:user_ids].map { |user_id| @team.remove_member(user_id) }
end
head :ok
end
@@ -2,10 +2,6 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
@campaigns = @web_widget
.inbox
.campaigns
.where(enabled: true, account_id: @web_widget.inbox.account_id)
.includes(:sender)
@campaigns = @web_widget.inbox.campaigns.where(enabled: true)
end
end
@@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
@inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob })
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
end
end
@@ -1,64 +0,0 @@
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
before_action :set_group_scope, only: [:grouped_conversation_metrics]
before_action :check_authorization
def conversation_metrics
render json: {
open: @conversations.open.count,
unattended: @conversations.open.unattended.count,
unassigned: @conversations.open.unassigned.count,
pending: @conversations.pending.count
}
end
def grouped_conversation_metrics
count_by_group = @conversations.open.group(@group_scope).count
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
group_metrics = count_by_group.map do |group_id, count|
metric = {
open: count,
unattended: unattended_by_group[group_id] || 0,
unassigned: unassigned_by_group[group_id] || 0
}
metric[@group_scope] = group_id
metric
end
render json: group_metrics
end
private
def check_authorization
authorize :report, :view?
end
def set_group_scope
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
team_id
assignee_id
].include?(permitted_params[:group_by])
@group_scope = permitted_params[:group_by]
end
def team
return unless permitted_params[:team_id]
@team ||= Current.account.teams.find(permitted_params[:team_id])
end
def load_conversations
scope = Current.account.conversations
scope = scope.where(team_id: team.id) if team.present?
@conversations = scope
end
def permitted_params
params.permit(:team_id, :group_by)
end
end
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
before_action :prepare_builder_params, only: [:agent, :team]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,10 +10,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private
def check_authorization
@@ -30,7 +26,8 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
render json: builder.build
data = builder.build
render json: data
end
def permitted_params
+14 -19
View File
@@ -5,11 +5,10 @@ module SwitchLocale
def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= params[:locale]
locale ||= locale_from_params
locale ||= locale_from_custom_domain
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
locale ||= locale_from_env_variable
set_locale(locale, &)
end
@@ -33,30 +32,26 @@ module SwitchLocale
end
def set_locale(locale, &)
safe_locale = validate_and_get_locale(locale)
# if locale is empty, use default_locale
locale ||= I18n.default_locale
# Ensure locale won't bleed into other requests
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
I18n.with_locale(safe_locale, &)
I18n.with_locale(locale, &)
end
def validate_and_get_locale(locale)
return I18n.default_locale.to_s if locale.blank?
available_locales = I18n.available_locales.map(&:to_s)
locale_without_variant = locale.split('_')[0]
if available_locales.include?(locale)
locale
elsif available_locales.include?(locale_without_variant)
locale_without_variant
else
I18n.default_locale.to_s
end
def locale_from_params
I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil
end
def locale_from_account(account)
return unless account
account.locale
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
end
def locale_from_env_variable
return unless ENV.fetch('DEFAULT_LOCALE', nil)
I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil
end
end
+2 -6
View File
@@ -7,17 +7,13 @@ class DashboardController < ActionController::Base
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
before_action :ensure_html_format
layout 'vueapp'
def index; end
private
def ensure_html_format
head :not_acceptable unless request.format.html?
end
def set_global_config
@global_config = GlobalConfig.get(
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
@@ -36,7 +32,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
'CSML_EDITOR_HOST'
).merge(app_config)
end
@@ -1,73 +0,0 @@
class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
)
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
end
private
def oauth_client
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://api.linear.app',
token_url: '/oauth/token',
authorize_url: '/oauth/authorize'
}
)
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
end
def account
@account ||= Account.find(account_id)
end
def account_id
return unless params[:state]
verify_linear_token(params[:state])
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -6,25 +6,16 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles_count = @articles.count
@articles = @portal.articles.published
search_articles
order_by_sort_param
limit_results
@articles.page(list_params[:page]) if list_params[:page].present?
end
def show; end
private
def limit_results
return if list_params[:per_page].blank?
per_page = [list_params[:per_page].to_i, 100].min
per_page = 25 if per_page < 1
@articles = @articles.page(list_params[:page]).per(per_page)
end
def search_articles
@articles = @articles.search(list_params) if list_params.present?
end
@@ -53,7 +44,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page)
params.permit(:query, :locale, :sort, :status)
end
def permitted_params
@@ -1,6 +1,4 @@
class Public::Api::V1::Portals::BaseController < PublicController
include SwitchLocale
before_action :show_plain_layout
before_action :set_color_scheme
before_action :set_global_config
@@ -29,7 +27,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
locale_without_variant = params[:locale].split('_')[0]
is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale])
is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant)
if is_locale_available
@locale = params[:locale]
elsif is_locale_variant_available
@locale = locale_without_variant
end
I18n.with_locale(@locale, &)
end
@@ -39,12 +44,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
end
@locale = validate_and_get_locale(article_locale)
@locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
end
I18n.with_locale(@locale, &)
end
@@ -1,72 +0,0 @@
class Shopify::CallbacksController < ApplicationController
include Shopify::IntegrationHelper
def show
verify_account!
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: '/shopify/callback'
)
handle_response
rescue StandardError => e
Rails.logger.error("Shopify callback error: #{e.message}")
redirect_to "#{redirect_uri}?error=true"
end
private
def verify_account!
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
end
def handle_response
account.hooks.create!(
app_id: 'shopify',
access_token: parsed_body['access_token'],
status: 'enabled',
reference_id: params[:shop],
settings: {
scope: parsed_body['scope']
}
)
redirect_to shopify_integration_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def oauth_client
OAuth2::Client.new(
client_id,
client_secret,
{
site: "https://#{params[:shop]}",
authorize_url: '/admin/oauth/authorize',
token_url: '/admin/oauth/access_token'
}
)
end
def account
@account ||= Account.find(@account_id)
end
def account_id
@account_id ||= params[:state].split('_').first
end
def shopify_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
end
def redirect_uri
return shopify_integration_url if account
ENV.fetch('FRONTEND_URL', nil)
end
end
@@ -35,14 +35,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'shopify'
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
@@ -1,6 +1,6 @@
class Twilio::CallbackController < ApplicationController
def create
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
::Twilio::IncomingMessageService.new(params: permitted_params).perform
head :no_content
end
@@ -1,6 +1,6 @@
class Twilio::DeliveryStatusController < ApplicationController
def create
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
::Twilio::DeliveryStatusService.new(params: permitted_params).perform
head :no_content
end
@@ -2,12 +2,6 @@ class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
return
end
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
@@ -19,15 +13,4 @@ class Webhooks::WhatsappController < ActionController::API
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
def inactive_whatsapp_number?
phone_number = params[:phone_number]
return false if phone_number.blank?
inactive_numbers = GlobalConfig.get_value('INACTIVE_WHATSAPP_NUMBERS').to_s
return false if inactive_numbers.blank?
inactive_numbers_array = inactive_numbers.split(',').map(&:strip)
inactive_numbers_array.include?(phone_number)
end
end
+1 -5
View File
@@ -78,11 +78,7 @@ class AccountDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {
active: ->(resources) { resources.where(status: :active) },
suspended: ->(resources) { resources.where(status: :suspended) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how accounts are displayed
# across all pages of the admin dashboard.
+1 -6
View File
@@ -94,12 +94,7 @@ class UserDashboard < Administrate::BaseDashboard
# COLLECTION_FILTERS = {
# open: ->(resources) { resources.where(open: true) }
# }.freeze
COLLECTION_FILTERS = {
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
}.freeze
COLLECTION_FILTERS = {}.freeze
# Overwrite this method to customize how users are displayed
# across all pages of the admin dashboard.
-2
View File
@@ -22,5 +22,3 @@ class AsyncDispatcher < BaseDispatcher
]
end
end
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')
@@ -2,6 +2,6 @@ require 'administrate/field/base'
class Enterprise::AccountLimitsField < Administrate::Field::Base
def to_s
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
data.present? ? data.to_json : { agents: nil, inboxes: nil }.to_json
end
end
+1 -3
View File
@@ -32,7 +32,6 @@ class ConversationFinder
def initialize(current_user, params)
@current_user = current_user
@current_account = current_user.account
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
@params = params
end
@@ -87,8 +86,7 @@ class ConversationFinder
end
def find_all_conversations
@conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless @is_admin
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
filter_by_conversation_type if params[:conversation_type]
@conversations
end
@@ -94,8 +94,7 @@ module Api::V2::Accounts::HeatmapHelper
end
def since_timestamp(date)
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
(date - number_of_days).to_i.to_s
(date - 6.days).to_i.to_s
end
def until_timestamp(date)
+27 -39
View File
@@ -1,70 +1,58 @@
module Api::V2::Accounts::ReportsHelper
def generate_agents_report
reports = V2::Reports::AgentSummaryBuilder.new(
account: Current.account,
params: build_params(type: :agent)
).build
Current.account.users.map do |agent|
report = reports.find { |r| r[:id] == agent.id }
[agent.name] + generate_readable_report_metrics(report)
agent_report = report_builder({ type: :agent, id: agent.id }).summary
[agent.name] + generate_readable_report_metrics(agent_report)
end
end
def generate_inboxes_report
reports = V2::Reports::InboxSummaryBuilder.new(
account: Current.account,
params: build_params(type: :inbox)
).build
Current.account.inboxes.map do |inbox|
report = reports.find { |r| r[:id] == inbox.id }
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(report)
inbox_report = generate_report({ type: :inbox, id: inbox.id })
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
end
end
def generate_teams_report
reports = V2::Reports::TeamSummaryBuilder.new(
account: Current.account,
params: build_params(type: :team)
).build
Current.account.teams.map do |team|
report = reports.find { |r| r[:id] == team.id }
[team.name] + generate_readable_report_metrics(report)
team_report = report_builder({ type: :team, id: team.id }).summary
[team.name] + generate_readable_report_metrics(team_report)
end
end
def generate_labels_report
Current.account.labels.map do |label|
label_report = report_builder({ type: :label, id: label.id }).short_summary
label_report = generate_report({ type: :label, id: label.id })
[label.title] + generate_readable_report_metrics(label_report)
end
end
private
def build_params(base_params)
base_params.merge(
{
since: params[:since],
until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
def report_builder(report_params)
V2::ReportBuilder.new(
Current.account,
report_params.merge(
{
since: params[:since],
until: params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
)
end
def report_builder(report_params)
V2::ReportBuilder.new(Current.account, build_params(report_params))
def generate_report(report_params)
report_builder(report_params).short_summary
end
def generate_readable_report_metrics(report)
private
def generate_readable_report_metrics(report_metric)
[
report[:conversations_count],
Reports::TimeFormatPresenter.new(report[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report[:avg_reply_time]).format,
report[:resolved_conversations_count]
report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
report_metric[:resolutions_count]
]
end
end
@@ -1,4 +1,4 @@
module Filters::FilterHelper
module FilterHelper
def build_condition_query(model_filters, query_hash, current_index)
current_filter = model_filters[query_hash['attribute_key']]
@@ -81,26 +81,4 @@ module Filters::FilterHelper
def default_filter(query_hash, filter_operator_value)
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
end
def validate_single_condition(condition)
return if condition['query_operator'].nil?
return if condition['query_operator'].empty?
operator = condition['query_operator'].upcase
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
end
def conversation_status_values(values)
return Conversation.statuses.values if values.include?('all')
values.map { |x| Conversation.statuses[x.to_sym] }
end
def conversation_priority_values(values)
values.map { |x| Conversation.priorities[x.to_sym] }
end
def message_type_values(values)
values.map { |x| Message.message_types[x.to_sym] }
end
end
-47
View File
@@ -1,47 +0,0 @@
module Linear::IntegrationHelper
# Generates a signed JWT token for Linear integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_linear_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Linear token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Linear JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_linear_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Linear token: #{e.message}")
nil
end
end
+2 -1
View File
@@ -5,7 +5,8 @@ module PortalHelper
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def generate_gradient_to_bottom(theme)
-58
View File
@@ -1,58 +0,0 @@
module Shopify::IntegrationHelper
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
# Generates a signed JWT token for Shopify integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_shopify_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Shopify token: #{e.message}")
nil
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Shopify JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_shopify_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_id
@client_id ||= GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil)
end
def client_secret
@client_secret ||= GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(
token,
secret,
true,
{
algorithm: 'HS256',
verify_expiration: true
}
).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Shopify token: #{e.message}")
nil
end
end
@@ -6,47 +6,4 @@ module SuperAdmin::AccountFeaturesHelper
def self.account_premium_features
account_features.filter { |feature| feature['premium'] }.pluck('name')
end
# Returns a hash mapping feature names to their display names
def self.feature_display_names
account_features.each_with_object({}) do |feature, hash|
hash[feature['name']] = feature['display_name']
end
end
def self.filter_internal_features(features)
return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
features.except(*internal_features)
end
def self.filter_deprecated_features(features)
deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name')
features.except(*deprecated_features)
end
def self.sort_and_transform_features(features, display_names)
features.sort_by { |key, _| display_names[key] || key }
.to_h
.transform_keys { |key| [key, display_names[key]] }
end
def self.partition_features(features)
filtered = filter_internal_features(features)
filtered = filter_deprecated_features(filtered)
display_names = feature_display_names
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
[
sort_and_transform_features(regular, display_names),
sort_and_transform_features(premium, display_names)
]
end
def self.filtered_features(features)
regular, premium = partition_features(features)
regular.merge(premium)
end
end
+1 -9
View File
@@ -14,7 +14,6 @@ import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -38,15 +37,8 @@ export default {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
};
return { router, store, currentAccountId: accountId };
},
data() {
return {
@@ -1,19 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
},
});
}
}
export default new CaptainAssistant();
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class CaptainBulkActionsAPI extends ApiClient {
constructor() {
super('captain/bulk_actions', { accountScoped: true });
}
}
export default new CaptainBulkActionsAPI();
@@ -1,20 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainDocument extends ApiClient {
constructor() {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
},
});
}
}
export default new CaptainDocument();
@@ -1,26 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainInboxes extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId } = {}) {
return axios.get(`${this.url}/${assistantId}/inboxes`);
}
create(params = {}) {
const { assistantId, inboxId } = params;
return axios.post(`${this.url}/${assistantId}/inboxes`, {
inbox: { inbox_id: inboxId },
});
}
delete(params = {}) {
const { assistantId, inboxId } = params;
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
}
}
export default new CaptainInboxes();
@@ -1,22 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainResponses extends ApiClient {
constructor() {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, documentId, status } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
document_id: documentId,
status,
},
});
}
}
export default new CaptainResponses();
@@ -133,14 +133,6 @@ class ConversationApi extends ApiClient {
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
}
export default new ConversationApi();
+6 -4
View File
@@ -33,10 +33,12 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,
});
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
requestCaptainCopilot(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body);
}
}
@@ -1,17 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class ShopifyAPI extends ApiClient {
constructor() {
super('integrations/shopify', { accountScoped: true });
}
getOrders(contactId) {
return axios.get(`${this.url}/orders`, {
params: { contact_id: contactId },
});
}
}
export default new ShopifyAPI();
@@ -1,20 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class LiveReportsAPI extends ApiClient {
constructor() {
super('live_reports', { accountScoped: true, apiVersion: 'v2' });
}
getConversationMetric(params = {}) {
return axios.get(`${this.url}/conversation_metrics`, { params });
}
getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) {
return axios.get(`${this.url}/grouped_conversation_metrics`, {
params: { group_by: groupBy },
});
}
}
export default new LiveReportsAPI();
+2 -2
View File
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
getConversationTrafficCSV() {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
params: { timezone_offset: getTimeOffset() },
});
}
+3 -6
View File
@@ -14,29 +14,26 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1 }) {
contacts({ q }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
},
});
}
conversations({ q, page = 1 }) {
conversations({ q }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
},
});
}
messages({ q, page = 1 }) {
messages({ q }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
},
});
}
@@ -1,40 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
}
getTeamReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/team`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getAgentReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/agent`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
getInboxReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/inbox`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -10,7 +10,7 @@
&.no-margin {
.mx-input {
margin-bottom: 0 !important;
@apply mb-0;
}
}
@@ -4,6 +4,7 @@
@apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message {
@include normal-shadow;
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before {
@@ -1,7 +1,79 @@
@import 'dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
//borders
@mixin border-nil() {
border-color: transparent;
border: 0;
}
@mixin thin-border($color) {
border: 1px solid $color;
}
@mixin custom-border-bottom($size, $color) {
border-bottom: $size solid $color;
}
@mixin custom-border-top($size, $color) {
border-top: $size solid $color;
}
@mixin border-normal() {
@apply border border-slate-50 dark:border-slate-700;
}
@mixin border-normal-left() {
@apply border-l border-slate-50 dark:border-slate-700;
}
@mixin border-normal-top() {
@apply border-t border-slate-50 dark:border-slate-700;
}
@mixin border-normal-right() {
@apply border-r border-slate-50 dark:border-slate-700;
}
@mixin border-normal-bottom() {
@apply border-b border-slate-50 dark:border-slate-700;
}
@mixin border-light() {
@apply border border-slate-25 dark:border-slate-700;
}
@mixin border-light-left() {
@apply border-l border-slate-25 dark:border-slate-700;
}
@mixin border-light-top() {
@apply border-t border-slate-25 dark:border-slate-700;
}
@mixin border-light-right() {
@apply border-r border-slate-25 dark:border-slate-700;
}
@mixin border-light-bottom() {
@apply border-b border-slate-25 dark:border-slate-700;
}
// background
@mixin background-gray() {
background: $color-background;
}
@mixin background-light() {
@apply bg-slate-50 dark:bg-slate-800;
}
@mixin background-white() {
@apply bg-white dark:bg-slate-900;
}
// input form
@mixin ghost-input() {
box-shadow: none;
@@ -15,6 +87,65 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
}
}
// flex-layout
@mixin space-between() {
display: flex;
justify-content: space-between;
}
@mixin space-between-column() {
@include space-between;
flex-direction: column;
}
@mixin space-between-row() {
@include space-between;
flex-direction: row;
}
@mixin flex-shrink() {
flex: 0 0 auto;
max-width: 100%;
}
@mixin flex-weight($value) {
// Grab flex-grow for older browsers.
$flex-grow: nth($value, 1);
// 2009
@include prefixer(box-flex, $flex-grow, webkit moz spec);
// 2011 (IE 10), 2012
@include prefixer(flex, $value, webkit moz ms spec);
}
// full height
@mixin full-height() {
height: 100%;
}
@mixin round-corner() {
border-radius: 1000px;
}
@mixin scroll-on-hover() {
overflow: hidden;
&:hover {
overflow-y: auto;
}
}
@mixin horizontal-scroll() {
overflow-y: auto;
}
@mixin elegant-card() {
@include normal-shadow;
border-radius: $space-small;
}
@mixin color-spinner() {
@keyframes spinner {
to {
@@ -99,3 +230,17 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
border-left: $size solid transparent;
}
}
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin three-column-grid($column-one-width: 16rem,
$column-three-width: 16rem) {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr);
}
@@ -1,208 +0,0 @@
// scss-lint:disable PropertySortOrder
@layer base {
// NEXT COLORS START
:root {
// slate
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
// slate
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
// NEXT COLORS END
+180 -14
View File
@@ -5,9 +5,6 @@
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
// Next Colors
@import 'next-colors';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@@ -57,19 +54,188 @@
@apply text-n-blue-text;
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.dark .custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
}
// scss-lint:disable PropertySortOrder
@layer base {
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.8;
}
/* NEXT COLORS END */
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
@@ -1,7 +1,7 @@
.dropdown-pane {
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
@apply border rounded-lg hidden relative invisible shadow-lg border-slate-25 dark:border-slate-700 box-content p-2 w-fit z-[9999];
&.dropdown-pane--open {
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
@apply bg-white absolute dark:bg-slate-800 block visible;
}
}
@@ -1,13 +1,13 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
@apply text-woot-600 dark:text-woot-600;
}
&:hover {
@apply bg-n-slate-3;
@apply bg-slate-50 dark:bg-slate-700;
&::after {
@apply text-n-blue-text;
@apply text-woot-500 dark:text-woot-500;
}
}
}
@@ -18,16 +18,20 @@
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
@apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
@apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
}
.multiselect__tags {
@apply border-0;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
@apply border-woot-500 dark:border-woot-500;
}
}
@@ -40,7 +44,7 @@
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
}
.multiselect__content {
@@ -57,19 +61,15 @@
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
@apply bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
@apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
&::after {
@apply bg-transparent text-center text-n-slate-12;
@apply bg-woot-50 dark:bg-woot-600 text-slate-600 dark:text-slate-200;
}
}
@@ -78,17 +78,17 @@
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
@apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
&.multiselect__option--highlight:hover {
@apply bg-transparent;
@apply bg-woot-75 dark:bg-woot-600;
&::after {
@apply bg-transparent;
}
&::after:hover {
@apply text-n-slate-12 bg-transparent;
@apply text-slate-800 dark:text-slate-100;
}
}
}
@@ -96,27 +96,23 @@
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
@apply border-0 border-none;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
@apply text-slate-400 dark:text-slate-400 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
@apply bg-slate-50 dark:bg-slate-800 mt-1 text-slate-800 dark:text-slate-100 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@@ -129,7 +125,7 @@
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
@@ -147,11 +143,11 @@
}
> .multiselect__tags {
@apply outline-transparent;
@apply border-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
@apply border-woot-500 dark:border-woot-500;
}
}
}
@@ -183,7 +179,7 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
}
.multiselect__input {
@@ -191,7 +187,7 @@
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
}
.multiselect__placeholder {
@@ -212,24 +208,6 @@
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@@ -239,16 +217,16 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
margin-bottom: 0px !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
@apply items-center flex m-0 text-sm py-1 px-0.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900;
}
.multiselect__placeholder {
@@ -6,209 +6,3 @@
body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
@layer base {
// FIXME: Use a common color file for all packs
// scss-lint:disable PropertySortOrder
:root {
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -1,22 +1,22 @@
// scss-lint:disable QualifyingElement
// Base typography
// -------------------------
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-medium text-n-slate-12;
@apply font-medium text-slate-800 dark:text-slate-50;
}
p {
text-rendering: optimizeLegibility;
@apply mb-2 leading-[1.65] text-sm;
a {
@apply text-n-brand dark:text-n-brand cursor-pointer;
@apply text-woot-500 dark:text-woot-500 cursor-pointer;
}
}
@@ -41,82 +41,64 @@ dl:not(.reset-base) {
}
// Form elements
// -------------------------
label {
@apply text-n-slate-12 block m-0 leading-7 text-sm font-medium;
@apply text-slate-800 dark:text-slate-200 block m-0 leading-7 text-sm font-medium;
&.error {
input {
@apply mb-1;
}
}
}
.input-wrap,
.help-text {
@apply text-n-slate-11 text-sm font-medium;
@apply text-slate-800 dark:text-slate-100 text-sm font-medium;
.help-text {
@apply font-normal text-slate-600 dark:text-slate-400;
}
}
// Focus outline removal
.button,
textarea {
textarea,
input:focus {
outline: none;
}
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {
@apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed;
}
.field-error {
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
}
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
// Inputs
input[type='text']:not(.reset-base),
input[type='number']:not(.reset-base),
input[type='password']:not(.reset-base),
input[type='date']:not(.reset-base),
input[type='email']:not(.reset-base),
input[type='url']:not(.reset-base) {
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
&[disabled] {
@apply field-disabled;
}
&.error {
@apply field-error mb-1;
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
}
}
input[type='file']:not(.multiselect__input) {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
input[type='file'] {
@apply bg-white dark:bg-slate-800 leading-[1.15] mb-4;
}
// Select
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-position: right -1rem center;
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;
&[disabled] {
@apply field-disabled;
}
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
}
// Textarea
textarea {
@apply field-base h-16;
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-16 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
&[disabled] {
@apply field-disabled;
}
}
// Add mb-1 when .help-text exists within the same label container
label:has(.help-text) {
input,
textarea,
select {
margin-bottom: 0.25rem !important;
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
}
}
@@ -127,41 +109,18 @@ label:has(.help-text) {
}
}
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
textarea,
select {
@apply field-error;
}
.formkit-message {
@apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
}
}
.error {
#{$form-input-selector},
input,
input:not([type]),
textarea,
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply field-error;
}
// Only add mb-1 when .message exists within the same .error container
// And exclude no-margin from the margin-bottom
&:has(.message) {
input:not(.no-margin),
textarea,
select {
margin-bottom: 0.25rem !important;
}
@apply border border-solid border-red-400 dark:border-red-400 mb-1;
}
.message {
@apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
@apply text-red-400 dark:text-red-400 block text-sm mb-2.5 w-full;
}
}
@@ -171,7 +130,7 @@ label:has(.help-text) {
}
.error {
@apply text-n-ruby-9 dark:text-n-ruby-9;
@apply border-red-400 dark:border-red-400;
}
}
@@ -182,16 +141,11 @@ code {
@apply text-xs border-0;
&.hljs {
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5;
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5;
.hljs-number,
.hljs-string {
@apply text-n-ruby-9 dark:text-n-ruby-9;
}
.hljs-name,
.hljs-tag {
@apply text-n-slate-11;
@apply text-red-800 dark:text-red-400;
}
}
}
@@ -31,7 +31,7 @@ button {
}
.button {
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
@apply items-center bg-woot-500 dark:bg-woot-500 px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
.button__content {
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
@@ -42,10 +42,8 @@ button {
}
}
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
&:hover {
@apply bg-woot-600 dark:bg-woot-600;
}
&:disabled,
@@ -54,23 +52,23 @@ button {
}
&.success {
@apply bg-n-teal-9 text-white dark:text-white;
@apply bg-[#44ce4b] dark:bg-[#44ce4b] text-white dark:text-white;
}
&.secondary {
@apply bg-n-solid-3 text-white dark:text-white;
@apply bg-slate-700 dark:bg-slate-600 text-white dark:text-white;
}
&.primary {
@apply bg-n-brand text-white dark:text-white;
@apply bg-woot-500 dark:bg-woot-500 text-white dark:text-white;
}
&.clear {
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
@apply text-woot-500 dark:text-woot-500 bg-transparent dark:bg-transparent;
}
&.alert {
@apply bg-n-ruby-9 text-white dark:text-white;
@apply bg-red-500 dark:bg-red-500 text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
@@ -78,7 +76,7 @@ button {
}
&.warning {
@apply bg-n-amber-9 text-white dark:text-white;
@apply bg-[#ffc532] dark:bg-[#ffc532] text-white dark:text-white;
&.clear {
@apply bg-transparent dark:bg-transparent;
@@ -117,74 +115,114 @@ button {
}
&.hollow {
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
&.secondary {
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
@apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
}
&.success {
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
@apply text-green-700 dark:text-green-400 border-green-100 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-800;
}
&.alert {
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
@apply text-red-700 dark:text-red-400 border-red-100 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-800;
}
&.warning {
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
@apply text-yellow-600 dark:text-yellow-600 border-yellow-600 dark:border-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-800;
}
&:hover {
@apply bg-woot-75 dark:bg-woot-800 border-slate-100 dark:border-woot-600 dark:text-woot-400;
&.secondary {
@apply border-slate-100 dark:border-slate-700 text-slate-800 dark:text-slate-100;
}
&.success {
@apply border-slate-100 dark:border-slate-700 text-green-800 dark:text-green-100;
}
&.alert {
@apply border-slate-100 dark:border-slate-700 text-red-700 dark:text-red-100;
}
&.warning {
@apply border-slate-100 dark:border-slate-700 text-yellow-700 dark:text-yellow-500;
}
}
}
// Smooth style
&.smooth {
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
@apply bg-woot-50 dark:bg-woot-800 text-woot-700 dark:text-woot-100 hover:text-woot-700 dark:hover:text-woot-700 hover:bg-woot-100 dark:hover:bg-woot-900;
&.secondary {
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
@apply bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800;
}
&.success {
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
@apply bg-green-50 dark:bg-green-700 text-green-700 dark:text-green-100 hover:bg-green-100 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
}
&.alert {
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
@apply bg-red-50 dark:bg-red-700 dark:bg-opacity-50 text-red-700 dark:text-red-100 hover:bg-red-100 dark:hover:bg-red-800 dark:hover:bg-opacity-30;
}
&.warning {
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
@apply bg-yellow-100 dark:bg-yellow-100 text-yellow-700 dark:text-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-200;
}
}
&.clear {
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
@apply text-woot-500 dark:text-woot-500;
&.secondary {
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
@apply text-slate-700 dark:text-slate-100;
}
&.success {
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
@apply text-green-700 dark:text-green-100;
}
&.alert {
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
@apply text-red-700 dark:text-red-100;
}
&.warning {
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
@apply text-yellow-700 dark:text-yellow-600;
}
&:hover {
@apply hover:bg-woot-50 dark:hover:bg-woot-900/50 hover:text-woot-500 dark:hover:text-woot-100;
&.secondary {
@apply hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-slate-800 dark:hover:text-slate-100;
}
&.success {
@apply hover:bg-green-50 dark:hover:bg-green-800 hover:text-green-800 dark:hover:text-green-100;
}
&.alert {
@apply hover:bg-red-50 dark:hover:bg-red-800 hover:text-red-700 dark:hover:text-red-100;
}
&.warning {
@apply hover:bg-yellow-100 dark:hover:bg-yellow-800 hover:text-yellow-700 dark:hover:text-yellow-600;
}
}
&:active {
&.secondary {
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
@apply active:bg-slate-100 dark:active:bg-slate-900;
}
}
&:focus {
&.secondary {
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
@apply focus:bg-slate-50 dark:focus:bg-slate-700;
}
}
}
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-n-weak;
@apply border-b border-slate-50 dark:border-slate-800/50;
}
.tabs--container--compact.tab--chat-type {
@@ -42,7 +42,7 @@
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@@ -56,22 +56,22 @@
&:hover,
&:focus {
a {
@apply text-n-slate-12;
@apply text-slate-800 dark:text-slate-100;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-n-brand text-n-blue-text;
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
}
.badge {
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
}
}
}
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
</span>
</div>
<div
v-dompurify-html="formatMessage(message, false, false, false)"
v-dompurify-html="formatMessage(message)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="w-full max-w-[960px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -21,11 +21,11 @@ const addCampaign = async campaignDetails => {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.SUCCESS_MESSAGE'));
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE');
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
@@ -40,7 +40,7 @@ const handleSubmit = campaignDetails => {
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
@@ -39,7 +39,7 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
@@ -4,10 +4,6 @@ defineProps({
type: String,
default: 'col',
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
@@ -22,11 +18,10 @@ const handleClick = () => {
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-6',
]"
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
@click="handleClick"
>
<slot />
@@ -18,11 +18,6 @@ const route = useRoute();
const showDropdown = ref(false);
// Store the currently hovered label's ID
// Using JS state management instead of CSS :hover / group hover
// This will solve the flickering issue when hovering over the last label item
const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
@@ -42,7 +37,7 @@ const labelMenuItems = computed(() => {
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'contactLabel',
action: 'addLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
@@ -54,7 +49,7 @@ const fetchLabels = async contactId => {
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ value }) => {
const handleLabelAction = async ({ action, value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
@@ -64,15 +59,16 @@ const handleLabelAction = async ({ value }) => {
if (!selectedLabel) return;
let updatedLabels;
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
if (action === 'addLabel') {
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
}
await store.dispatch('contactLabels/update', {
@@ -86,10 +82,6 @@ const handleLabelAction = async ({ value }) => {
}
};
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
};
watch(
() => props.contactId,
(newVal, oldVal) => {
@@ -103,31 +95,11 @@ onMounted(() => {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
// This ensures all labels return to their default state
hoveredLabel.value = null;
};
const handleLabelHover = labelId => {
// Added this to prevent flickering on when showing remove button on hover
// If the label item is at end of the line, it will show the remove button
// when hovering over the last label item
hoveredLabel.value = labelId;
};
</script>
<template>
<div class="flex flex-wrap items-center gap-2" @mouseleave="handleMouseLeave">
<LabelItem
v-for="label in savedLabels"
:key="label.id"
:label="label"
:is-hovered="hoveredLabel === label.id"
@remove="handleRemoveLabel"
@hover="handleLabelHover(label.id)"
/>
<div class="flex flex-wrap items-center gap-2">
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="handleLabelAction"

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