Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub fcdc9d4388 Merge branch 'develop' into chore/upgrade-packages-2-25 2025-02-11 15:00:49 +05:30
PranavandGitHub 4b12a8a51e chore: Add more conversation events for reload (#10877)
Followup PR for https://github.com/chatwoot/chatwoot/pull/10876. This PR
just adds all the events related to conversation update to be reloaded
before sending it to the UI.
2025-02-11 00:33:45 -08:00
Muhsin KelothandGitHub f639d8ca51 Merge branch 'develop' into chore/upgrade-packages-2-25 2025-02-11 13:20:46 +05:30
PranavandGitHub 3c78d25306 chore: Reload conversation data in ActionCableBroadcastJob before sending (#10876)
During high-traffic periods, events may appear out of order, causing the
conversation job to queue outdated data, which can lead to issues in the
UI. This update ensures that only the latest available data is sent to the UI.

The conversation object is refreshed before sending it to the UI.
2025-02-10 23:16:15 -08:00
PranavandGitHub 8faccba052 chore: Update the precision of the updated_at timestamp in conversation model (#10875)
Use to_f instead of to_i to preserve the millisecond precision in the UI.
2025-02-10 20:22:11 -08:00
PranavandGitHub 02000de905 chore: Add updated_at attribute to the conversation event (#10873)
This PR adds updated_at attribute to the conversation event.
2025-02-10 19:33:26 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d7c0507e33 chore(deps): Bump net-imap from 0.4.17 to 0.4.19 (#10871)
Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.17 to
0.4.19.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/ruby/net-imap/releases">net-imap's
releases</a>.</em></p>
<blockquote>
<h2>v0.4.19</h2>
<h2>What's Changed</h2>
<h3>🔒 Security Fix</h3>
<p>Fixes CVE-2025-25186 (GHSA-7fc5-f82f-cx69): A malicious server can
exhaust client memory by sending <code>APPENDUID</code> or
<code>COPYUID</code> responses with very large <code>uid-set</code>
ranges. <code>Net::IMAP::UIDPlusData</code> expands these ranges into
arrays of integers.</p>
<h4>Fix with minor API changes</h4>
<p>Set <code>config.parser_use_deprecated_uidplus_data</code> to
<code>false</code> to replace <code>UIDPlusData</code> with
<code>AppendUIDData</code> and <code>CopyUIDData</code>. These classes
store their UIDs as <code>Net::IMAP::SequenceSet</code> objects
(<em>not</em> expanded into arrays of integers). Code that does not
handle <code>APPENDUID</code> or <code>COPYUID</code> responses should
not see any difference. Code that does handle these responses
<em>may</em> need to be updated.</p>
<p>For v0.3.8, this option is not available
For v0.4.19, the default value is <code>true</code>.
For v0.5.6, the default value is <code>:up_to_max_size</code>.
For v0.6.0, the only allowed value will be <code>false</code>
<em>(<code>UIDPlusData</code> will be removed from v0.6)</em>.</p>
<h4>Mitigate with backward compatible API</h4>
<p>Adjust <code>config.parser_max_deprecated_uidplus_data_size</code> to
limit the maximum <code>UIDPlusData</code> UID set size.
When <code>config.parser_use_deprecated_uidplus_data == true</code>,
larger sets will crash.
When <code>config.parser_use_deprecated_uidplus_data ==
:up_to_max_size</code>, larger sets will use <code>AppendUIDData</code>
or <code>CopyUIDData</code>.</p>
<p>For v0.3,8, this limit is <em>hard-coded</em> to 10,000.
For v0.4.19, this limit defaults to 1000.
For v0.5.6, this limit defaults to 100.
For v0.6.0, the only allowed value will be <code>0</code>
<em>(<code>UIDPlusData</code> will be removed from v0.6)</em>.</p>
<h4>Please Note: unhandled responses</h4>
<p>If the client does not add response handlers to prune unhandled
responses, a malicious server can still eventually exhaust all client
memory, by repeatedly sending malicious responses. However,
<code>net-imap</code> has always retained unhandled responses, and it
has always been necessary for long-lived connections to prune these
responses. This is not significantly different from connecting to a
trusted server with a long-lived connection. To limit the maximum number
of retained responses, a simple handler might look something like the
following:</p>
<pre lang="ruby"><code>limit = 1000
imap.add_response_handler do |resp|
  next unless resp.respond_to?(:name) &amp;&amp; resp.respond_to?(:data)
  name = resp.name
code = resp.data.code&amp;.name if
resp.data.in?(Net::IMAP::ResponseText)
  imap.responses(name) { _1.slice!(0...-limit) }
  imap.responses(code) { _1.slice!(0...-limit) }
end
</code></pre>
<h3>Added</h3>
<ul>
<li>🔧 ResponseParser config is mutable and non-global (backports <a
href="https://redirect.github.com/ruby/net-imap/issues/381">#381</a>) by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/382">ruby/net-imap#382</a></li>
<li>✨ SequenceSet ordered entries methods (backports to v0.4-stable) by
<a href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/402">ruby/net-imap#402</a>
Backports the following:
<ul>
<li>✨ Add SequenceSet methods for querying about duplicates by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/384">ruby/net-imap#384</a></li>
<li>✨ Add <code>SequenceSet#each_ordered_number</code> by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/386">ruby/net-imap#386</a></li>
<li>✨ Add <code>SequenceSet#find_ordered_index</code> by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/396">ruby/net-imap#396</a></li>
<li>✨ Add <code>SequenceSet#ordered_at</code> by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/397">ruby/net-imap#397</a></li>
</ul>
</li>
<li>✨ Backport UIDPlusData, AppendUIDData, CopyUIDData to v0.4 by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/404">ruby/net-imap#404</a>
Backports the following:
<ul>
<li>✨ Add AppendUIDData and CopyUIDData classes by <a
href="https://github.com/nevans"><code>@​nevans</code></a> in <a
href="https://redirect.github.com/ruby/net-imap/pull/400">ruby/net-imap#400</a></li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ruby/net-imap/commit/4c4ed09997ccd108a50fd7b30edb1b32806a162f"><code>4c4ed09</code></a>
🔖 Bump version to 0.4.19</li>
<li><a
href="https://github.com/ruby/net-imap/commit/c8c5a643739d2669f0c9a6bb9770d0c045fd74a3"><code>c8c5a64</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/ruby/net-imap/commit/abff00fd700bd71ece3b3f841d4428028f16b8bb"><code>abff00f</code></a>
🔧 Add <code>:up_to_max_size</code> config for UIDPlusData</li>
<li><a
href="https://github.com/ruby/net-imap/commit/34a1f27a457be5236848543f3221b33c2494a34c"><code>34a1f27</code></a>
🔧 Add config option for max UIDPlusData size</li>
<li><a
href="https://github.com/ruby/net-imap/commit/6613d57e8eb885d65903819a2f83919a164a3680"><code>6613d57</code></a>
🔒 Limit exponential memory usage to parse uid-set</li>
<li><a
href="https://github.com/ruby/net-imap/commit/e4d57b1e000794882d5bca0f4cf7aa0658a83da7"><code>e4d57b1</code></a>
🔀 Merge pull request <a
href="https://redirect.github.com/ruby/net-imap/issues/404">#404</a>
from ruby/backport-0.4-uidplus-deprecation</li>
<li><a
href="https://github.com/ruby/net-imap/commit/d32320a74980a8754c5da3389693af8f597ab39c"><code>d32320a</code></a>
🐛 Fix missing <code>Data.define</code> for new classes</li>
<li><a
href="https://github.com/ruby/net-imap/commit/3c592fc98c26f11043a2b8e112bc91f31d7efad0"><code>3c592fc</code></a>
🔧🗑️ Deprecate UIDPlusData, with config to upgrade</li>
<li><a
href="https://github.com/ruby/net-imap/commit/7e58ef35fae7b52eb24b39c87f5b7fc69fa3e757"><code>7e58ef3</code></a>
✨ Add CopyUIDData (to replace UIDPlusData)</li>
<li><a
href="https://github.com/ruby/net-imap/commit/4c601c3a8468104eba5f9ee474f753064bf62115"><code>4c601c3</code></a>
✨ Add AppendUIDData (to replace UIDPlusData)</li>
<li>Additional commits viewable in <a
href="https://github.com/ruby/net-imap/compare/v0.4.17...v0.4.19">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=net-imap&package-manager=bundler&previous-version=0.4.17&new-version=0.4.19)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-10 17:35:12 -08:00
Shivam Mishra a7f0476e19 feat: update depedencies 2025-02-10 14:42:44 +05:30
Shivam Mishra 51e0c1b9d9 feat: upgade icons 2025-02-10 14:30:32 +05:30
Shivam Mishra a8c57f692e feat: move material-symbols to dev dependency 2025-02-10 14:29:45 +05:30
Sivin VargheseandGitHub e97e68b1ba fix: Message signature is not appending (#10855)
# Pull Request Template

## Description

**Issue:** The message signature wasn't being appended to new email
conversations when a target inbox was selected.

**Solution:** To address this, a reusable `handleSignatureSetup`
function was created to manage the signature logic. The same logic was
applied in both cases, when the inbox selection changed (using `watch`)
and during the initial load (using `mounted`).

Fixes
https://linear.app/chatwoot/issue/CW-4005/allow-to-activate-the-message-signature-for-new-email-conversations-by
https://github.com/chatwoot/chatwoot/issues/10836

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Steps to reproduce**:
https://github.com/chatwoot/chatwoot/issues/10836#issuecomment-2637354304

### Loom video

**Before**

https://www.loom.com/share/ccf597cfa8d94d0eaff1222102901d2c?sid=abfea42b-425e-446e-8e92-99359b786607

**After**

https://www.loom.com/share/d9deddfcf8de48ab87e31911dfb774d8?sid=c1aac19b-b243-428e-9a9f-2ad9f4efe49c


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-02-07 16:28:27 +05:30
be1999e7f8 fix: re-rendering of components when shifting from the unread list to the read list (#10835)
Fixes https://github.com/chatwoot/chatwoot/issues/10812

Demo


https://github.com/user-attachments/assets/a0f7eb64-8f6e-4992-a163-c972e85fb205

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-02-07 08:54:25 +05:30
Vishnu NarayananandGitHub c3601e16cf chore: bump up cwctl version to 3.2.0 (#10850) 2025-02-06 17:44:18 +05:30
Sivin VargheseandGitHub dc728faafb feat: Adds support for telegram contact sharing (#10841)
# Pull Request Template

## Description

This PR adds support for displaying shared contacts in a Telegram
channel.

**NB:** Tested with both old and new bubbles. 
Multiple numbers for a single contact are not supported at this time,
but multiple contacts are supported.
In the future, we can add support for displaying contact names as well.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/95efadace3194887bc0663c53e7c08bc?sid=a5c27176-3dd8-456c-80b9-c63dbb89dca1


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-02-06 14:23:08 +05:30
2a365bf19e feat: show email subject in conversation search results (#10843)
# Pull Request Template

## Description

This addresses #10842. It exposes `additional_attributes` in the
conversations search endpoint, uses it in
`SearchResultConversationsList` to pass
`conversation.additional_attributes?.mail_subject` down to
`SearchResultConversationItem`, which in turn displays it.

Fixes #10842

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

I have tested this locally by searching for conversations. See this
screenshot where I searched for "noreply":
![Screenshot from 2025-02-05
11-04-54](https://github.com/user-attachments/assets/689e3e99-c20b-48a7-9c3e-35d45ffeafc1)

I would love to add automated tests but I’m not sure how to do that.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-02-06 10:38:57 +05:30
d5ecbba71f fix: onboarding/index.html.erb unclosed HTML tags (#10838)
# Pull Request Template

## Description

This was not really an issue because HTML is permissive and auto-closes
these when the parent is closed, but it’s cleaner to do it.
It was also showing errors if you open the project in an IDE.

## Type of change

- Chore

## How Has This Been Tested?

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-02-06 09:43:38 +05:30
Sivin VargheseandGitHub abcff9883f feat: Order previous conversations by last activity (#10825) 2025-02-05 19:50:25 -08:00
Shivam MishraandGitHub d463a5cc30 fix: incorrect sender name on email meta (#10837)
Fixes https://github.com/chatwoot/chatwoot/issues/10807
2025-02-06 08:52:02 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>PranavPranav
c750413094 chore(deps): Bump vitest from 2.0.1 to 3.0.5 (#10839)
Bumps
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
from 2.0.1 to 3.0.5

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-02-05 12:48:27 -08:00
f2a7e1da6b fix: Corepack pnpm issue (#10840)
So, a while back Circle CI builds and Heroku builds started to fail. 

From all the threads I read, it seems like the [npm registry rotated
it's signing
keys](https://github.com/pnpm/pnpm/issues/9014#issuecomment-2616589753)
New pnpm versions were signed with the new key. Corepack, however,
bundles a static set of trusted keys (from Node’s release), so it
continued verifying signatures only against the old key. When it
encountered packages signed with the new key, Corepack’s integrity check
failed with “Cannot find matching keyid” errors.This mismatch caused
Corepack’s integrity check to fail with “Cannot find matching keyid”
errors.

Workarounds include the following

1. Updating Corepack (to 0.31.0), they [upgraded their
package](https://github.com/nodejs/corepack/releases/tag/v0.31.0) to
include the new integrity check keys. But we seldom control what's going
on with the CI, also, updating this across our scripts is going to be a
painful task. Besides Heroku has [made some
fixes](https://github.com/heroku/buildpacks-nodejs/pull/1010) around
this
2. Disabling integrity checks 🔥 #YOLO
3. Pinning `pnpm` to older versions, or pinning it to a newer version
with the checksum in place.

Doing the third one here, running `corepack use pnpm@9.15.5` fixes this,
[ref](https://github.com/pnpm/pnpm/issues/9014#issuecomment-2623761494)

We can get rid of this over time as CDN caches used by build systems are
refreshed. But the change in this PR is not disruptive in anyway, only
rigidly secure.

Fixes: https://github.com/chatwoot/chatwoot/issues/10832

---

Here are the threads to follow 

- https://github.com/pnpm/pnpm/issues/9014
- https://github.com/pnpm/pnpm/issues/9029
- https://github.com/nodejs/corepack/issues/612
- https://github.com/nodejs/corepack/issues/616
- https://github.com/heroku/buildpacks-nodejs/pull/1010

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2025-02-05 10:12:29 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
41c7683e04 chore(deps-dev): bump vite from 5.4.8 to 5.4.12 (#10744)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite)
from 5.4.8 to 5.4.12.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v5.4.12</h2>
<p>This version contains a breaking change due to security fixes. See <a
href="https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6">https://github.com/vitejs/vite/security/advisories/GHSA-vg6x-rcgg-rjx6</a>
for more details.</p>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.11</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/ecd2375460edb4ae258fed4abe6c6f6ed7323b23/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.10</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.10/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v5.4.9</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.9/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted -->5.4.12 (2025-01-20)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix!: check host header to prevent DNS rebinding attacks and
introduce <code>server.allowedHosts</code> (<a
href="https://github.com/vitejs/vite/commit/9da4abc8dde7f032ca1f23f425c2060b9b9ebd34">9da4abc</a>)</li>
<li>fix!: default <code>server.cors: false</code> to disallow fetching
from untrusted origins (<a
href="https://github.com/vitejs/vite/commit/dfea38f1ff9f6fc0f0ca57927c527b0b9ffd2210">dfea38f</a>)</li>
<li>fix: verify token for HMR WebSocket connection (<a
href="https://github.com/vitejs/vite/commit/b71a5c89a1b4b913813ae665e6e04dd9d18c189c">b71a5c8</a>)</li>
<li>chore: add deps update changelog (<a
href="https://github.com/vitejs/vite/commit/ecd2375460edb4ae258fed4abe6c6f6ed7323b23">ecd2375</a>)</li>
</ul>
<h2><!-- raw HTML omitted -->5.4.11 (2024-11-11)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix(deps): update dependencies of postcss-modules (<a
href="https://github.com/vitejs/vite/commit/ceb15db613d107e29f7cc1d441364f7b5c831ed3">ceb15db</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18617">#18617</a></li>
</ul>
<h2><!-- raw HTML omitted -->5.4.10 (2024-10-23)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18367">#18367</a>,augment
hash for CSS files to prevent chromium erroring by loading previous fil
(<a
href="https://github.com/vitejs/vite/commit/7d1a3bcc436e1697b314bdc9d24c948664a1afb7">7d1a3bc</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18367">#18367</a>
<a
href="https://redirect.github.com/vitejs/vite/issues/18412">#18412</a></li>
</ul>
<h2><!-- raw HTML omitted -->5.4.9 (2024-10-14)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix: bump launch-editor-middleware to v2.9.1 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18348">#18348</a>)
(<a
href="https://github.com/vitejs/vite/commit/508d9ab83412c36e33f4c4ca57b891171429cdd3">508d9ab</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18348">#18348</a></li>
<li>fix(css): fix lightningcss dep url resolution with custom root (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18125">#18125</a>)
(<a
href="https://github.com/vitejs/vite/commit/eae00b561e04f1fe1679d3acf4f88b3c42019e4d">eae00b5</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18125">#18125</a></li>
<li>fix(data-uri): only match ids starting with <code>data:</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18241">#18241</a>)
(<a
href="https://github.com/vitejs/vite/commit/96084d6e752c03332d101a50bce161a8e3f311cc">96084d6</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18241">#18241</a></li>
<li>fix(deps): bump tsconfck (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18322">#18322</a>)
(<a
href="https://github.com/vitejs/vite/commit/dc5434ce8781d206bcc4b55e90201691125e662c">dc5434c</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18322">#18322</a></li>
<li>fix(hmr): don't try to rewrite imports for direct CSS soft
invalidation (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18252">#18252</a>)
(<a
href="https://github.com/vitejs/vite/commit/851b258c346fdddd4467a12f41189b7855df8c43">851b258</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18252">#18252</a></li>
<li>fix(ssr): (backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18150">#18150</a>)
fix source map remapping with multiple sources (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18204">#18204</a>)
(<a
href="https://github.com/vitejs/vite/commit/262a8796d4be2c4b9c812f203ea9177f42360b13">262a879</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18204">#18204</a></li>
<li>chore: update all url references of vitejs.dev to vite.dev (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18276">#18276</a>)
(<a
href="https://github.com/vitejs/vite/commit/c23558a7af341d13f0c9da691047713965bc7e7d">c23558a</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18276">#18276</a></li>
<li>chore: update license copyright (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18278">#18278</a>)
(<a
href="https://github.com/vitejs/vite/commit/1864eb17b21ef21564bd66c6f6a30c2c495e2d4e">1864eb1</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18278">#18278</a></li>
<li>docs: update homepage (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18274">#18274</a>)
(<a
href="https://github.com/vitejs/vite/commit/ae4416349e1a373023d0e9e05955d96ae5fa9ab2">ae44163</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/18274">#18274</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/f428aa9af8534b214abb09fe4456653eb09913e7"><code>f428aa9</code></a>
release: v5.4.12</li>
<li><a
href="https://github.com/vitejs/vite/commit/9da4abc8dde7f032ca1f23f425c2060b9b9ebd34"><code>9da4abc</code></a>
fix!: check host header to prevent DNS rebinding attacks and introduce
`serve...</li>
<li><a
href="https://github.com/vitejs/vite/commit/b71a5c89a1b4b913813ae665e6e04dd9d18c189c"><code>b71a5c8</code></a>
fix: verify token for HMR WebSocket connection</li>
<li><a
href="https://github.com/vitejs/vite/commit/dfea38f1ff9f6fc0f0ca57927c527b0b9ffd2210"><code>dfea38f</code></a>
fix!: default <code>server.cors: false</code> to disallow fetching from
untrusted origins</li>
<li><a
href="https://github.com/vitejs/vite/commit/ecd2375460edb4ae258fed4abe6c6f6ed7323b23"><code>ecd2375</code></a>
chore: add deps update changelog</li>
<li><a
href="https://github.com/vitejs/vite/commit/c54c860f9d90e4074e5321648f9c5ee9fbda7038"><code>c54c860</code></a>
release: v5.4.11</li>
<li><a
href="https://github.com/vitejs/vite/commit/5f52bc8b9e4090cdcaf3f704278db30dafc825cc"><code>5f52bc8</code></a>
release: v5.4.10</li>
<li><a
href="https://github.com/vitejs/vite/commit/7d1a3bcc436e1697b314bdc9d24c948664a1afb7"><code>7d1a3bc</code></a>
fix: backport <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18367">#18367</a>,augment
hash for CSS files to prevent chromium erroring ...</li>
<li><a
href="https://github.com/vitejs/vite/commit/898d61f94b4316993963f593644821aae221d375"><code>898d61f</code></a>
release: v5.4.9</li>
<li><a
href="https://github.com/vitejs/vite/commit/508d9ab83412c36e33f4c4ca57b891171429cdd3"><code>508d9ab</code></a>
fix: bump launch-editor-middleware to v2.9.1 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/18348">#18348</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v5.4.12/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.8&new-version=5.4.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 18:13:48 -08:00
PranavandGitHub 1b1ba3f8dd fix: Update the photo/video caption when an update event is received (#10804)
The update ensures proper handling of text updates in photo/video
messages by accounting for the caption attribute in addition to the text
attribute. This change enables consistent processing across both
messages.

Fixes https://github.com/chatwoot/chatwoot/issues/10760

Note: TIL, you can update the video/photo you’ve sent on Telegram, not
just the text. Currently, we’re not handling this. To support it, we
need to parse the payload and update the attachments accordingly. This
could be taken as a followup.
2025-02-03 12:44:10 -08:00
Sivin VargheseandGitHub bd94e5062d chore: Search improvements (#10801)
- Adds pagination support for search.
- Use composition API on all search related component.
- Minor UI improvements.
- Adds missing specs

Loom video
https://www.loom.com/share/5b01afa5c9204e7d97ff81b215621dde?sid=82ca6d22-ca8c-4d5e-8740-ba06ca4051ba
2025-02-03 19:34:50 +05:30
Sojan JoseandGitHub 3fb77fe806 chore: Resolve flaky spec for Contact country sorting (#10810)
We have been encountering errors in the community pipeline for the
contacts sort by country spec. Upon investigation, it was discovered
that the spec assumes the country code is used for sorting. However, the
sorting actually relies on the country attribute.

The payload from a previous spec run indicates that none of the contact
objects include the country attribute. This fix addresses the issue by
aligning the spec with the actual implementation logic.

Here’s an example payload from the previous spec run for reference:
<img width="1063" alt="Screenshot 2025-01-31 at 6 17 44 PM"
src="https://github.com/user-attachments/assets/df9b26e8-c5d0-432a-9d2a-16f76cb66967"
/>
2025-02-03 16:54:13 +05:30
PranavandGitHub c7d259d5fd chore: Update the behavior of Captain resolutions (#10794)
This PR ensures that only conversations from quick conversation channels
are resolved, avoiding resolutions on the email channel (we still need
to improve the UX here). It also updates the FAQ generation logic,
limiting it to conversations that had at least one human interaction.
2025-02-03 16:25:08 +05:30
Sivin VargheseandGitHub 5905b5301d fix: Prevent compose modal from closing when creating a link (#10809) 2025-02-03 15:08:20 +05:30
Sivin VargheseandGitHub c18452f6b6 chore: Use getFileInfo helper from utils (#10819)
# Pull Request Template

## Description
The PR includes the usage of `getFileInfo` helper from utils
https://github.com/chatwoot/utils/pull/40.

Fixes
https://github.com/chatwoot/chatwoot/pull/10806#discussion_r1937797905

## How Has This Been Tested?

**Screenshot**
<img width="490" alt="image"
src="https://github.com/user-attachments/assets/f0788e89-b670-47da-b0ca-3765eb424be0"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-02-03 14:13:01 +05:30
EvansandGitHub c403f6872c docs: Update copyright year to 2025 (#10817)
Update copyright year to 2025

# Pull Request Template

## Description

This PR updates the copyright year in the README

## Type of change

Please delete options that are not relevant.

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-02-01 19:44:57 +05:30
Sivin VargheseandGitHub 70919d184d fix: Update min-length validation for the contact to support names with single characters (#10813)
This PR updates the contact form validation to allow a minimum length of one character for the first name.

Fixes
https://linear.app/chatwoot/issue/CW-3993/update-the-validation-on-contact-form-to-support-just-one-character

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?



https://github.com/user-attachments/assets/2b95af80-ee70-4f4b-8d0d-b01779db9d94




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-31 14:39:32 -08:00
Sivin VargheseandGitHub 24257f9771 chore: Always display the extension of an attached file (#10806) 2025-01-31 18:28:28 +05:30
Sivin VargheseandGitHub c3aab44b5f feat: Add unread badge to sidebar for inbox view (#10803)
# Pull Request Template

## Description

This PR adds a badge to the icon in the sidebar group header and an
unread highlight for the inbox view.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/1a880aa16de249a1836b8d014325c067?sid=87cc7b64-667b-4066-8993-d33abccf7b67


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-31 15:14:10 +05:30
Shivam MishraandGitHub b811c27ab5 feat: Hide empty folders from sidebar (#10786)
This PR updates the sidebar to hide empty folders. So, in case the user
has not created any folders, or teams, the section in the sidebar will
be hidden
2025-01-30 17:03:12 -08:00
Vishnu NarayananandGitHub 55f1690d9e fix: docker github action for ce images (#10800)
- Fix the docker tag issue during push stage for ce image build
2025-01-30 15:52:01 +05:30
Sivin VargheseandGitHub 2c75ccb004 feat: Ability to rearrange attributes in sidebar (#10784) 2025-01-30 15:24:02 +05:30
Shivam MishraandGitHub d997734837 revert: Next bubble improvements (#10795) 2025-01-29 20:04:45 -08:00
Sivin VargheseandGitHub 2c1a8e59f5 chore: Next bubble improvements (#10759) 2025-01-29 22:56:08 +05:30
Vishnu NarayananandGitHub f6d87d0e6e feat: switch to native arm64 runners for docker ce images (#10789)
Emulated docker arm64 builds are failing for community images. This PR

- Switches gh action to use native arm64 runners
- Merges amd64 and arm64 images to create a multi-arch docker image

Fixes https://github.com/chatwoot/chatwoot/issues/10790
2025-01-29 22:53:29 +05:30
Vishnu NarayananandGitHub 56874c442c feat: add OOMPolicy for sidekiq systemd service (#10772)
- Add OOM policy for `sidekiq` `systemd` service
- This ensures `sidekiq` does not consume all the memory available in
the system
2025-01-29 17:27:58 +05:30
eb908d9c03 fix: Download file CORS issue (#10787)
# Pull Request Template

## Description
Ref: https://github.com/chatwoot/utils/pull/39

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-01-29 15:32:13 +05:30
Shivam MishraandGitHub 5ccae73f8a fix: Update captain billing colors (#10782) 2025-01-28 13:50:50 -08:00
Sivin VargheseandGitHub 80e6a2a479 chore: Disable audio alert for pending status conversation (#10777)
# Pull Request Template

## Description

This PR disables audio alerts for conversations with a status of
"pending." It applies to all notification event types, ensuring that
audio will not play for conversations marked as "pending."

Fixes
https://linear.app/chatwoot/issue/CW-3951/audio-alerts-shouldnt-be-triggered-in-conversation-is-in-pending-state

## Type of change

- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## How Has This Been Tested?

Steps:
1. Create a conversation.
2. Then mark the conversation as pending.
3. Send message.
4. Make sure audio notification are enabled.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-28 18:49:12 +05:30
PranavandGitHub cf0975ad94 fix: Use current_available instead available to compute the document limit (#10776)
The available limit for documents is now stored in
captain.documents.current_available. This PR fixes the limit sent on
crawler job.
2025-01-28 10:55:46 +05:30
cb42be8e65 feat(v4): Update the report pages to show aggregate values (#10766)
This PR updates the report pages for agents, inboxes, and teams by
replacing charts with aggregate values (under a feature flag). Users can
click on any item to view more details if needed. Most users seem to
prefer aggregate values, so this change will likely stay.

The PR also includes a few fixes:

- The summary reports now use the same logic for both the front-end and
CSV exports.
- Fixed an issue where a single quote was being added to values with
hyphens in CSV files. Now, ‘n/a’ is used when no value is available.
- Fixed a bug where the average value was calculated incorrectly when
multiple accounts were present.

These changes should make reports easier to use and more consistent.

### Agents:

<img width="1438" alt="Screenshot 2025-01-26 at 10 47 18 AM"
src="https://github.com/user-attachments/assets/bf2fcebc-6207-4701-9703-5c2110b7b8a0"
/>

### Inboxes
<img width="1438" alt="Screenshot 2025-01-26 at 10 47 10 AM"
src="https://github.com/user-attachments/assets/b83e1cf2-fd14-4e8e-8dcd-9033404a9f22"
/>


### Teams: 
<img width="1436" alt="Screenshot 2025-01-26 at 10 47 01 AM"
src="https://github.com/user-attachments/assets/96b1ce07-f557-42ca-8143-546a111d6458"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-01-28 09:19:18 +05:30
Sivin VargheseandGitHub 9cee8a1713 fix: CORS issue with file download (#10755)
# Pull Request Template

## Description
https://github.com/chatwoot/utils/pull/38

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-27 14:33:12 +05:30
Sivin VargheseandGitHub d925729444 chore: Disable account switcher for single-account users (#10768) 2025-01-27 13:19:48 +05:30
ef7bf66476 feat: Add frontend changes for Captain limits (#10749)
This PR introduces several improvements to the Captain AI dashboard
section:

- New billing page, with new colors, layout and meters for Captain usage
- Updated the base paywall component to use new colors
- Updated PageLayout.vue, it's more generic and can be used for other
pages as well
   - Use flags to toggle empty state and loading state
- Add prop for `featureFlag` to show the paywall slot based on feature
enabled on account
- Update `useAccount` to add a `isCloudFeatureEnabled`
- **Removed feature flag checks from captain route definitions**, so the
captain entry will always be visible on the sidebar
- Add banner to Captain pages for the following cases
   - Responses usage is over 80%
   - Documents limit is fully exhausted


### Screenshots

<details><summary>Free plan</summary>
<p>

![CleanShot 2025-01-22 at 18 37
11@2x](https://github.com/user-attachments/assets/17d3ddba-9095-4e81-9b6f-45b5f69e6a3f)
![CleanShot 2025-01-22 at 18 37
04@2x](https://github.com/user-attachments/assets/df9bb0a6-085f-45da-97d4-74cbcc33fc7e)


</p>
</details> 

<details><summary>Paid plan</summary>
<p>

![CleanShot 2025-01-22 at 18 36
45@2x](https://github.com/user-attachments/assets/a7ccf9d4-143b-49e4-8149-83c7a7985023)

![CleanShot 2025-01-22 at 20 23
57@2x](https://github.com/user-attachments/assets/c6ce35ba-e537-486d-85c8-4cc2d4e76438)


</p>
</details>

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-01-24 09:21:09 -08:00
Sivin VargheseandGitHub b429ce0ad5 fix: Prevent template variables from becoming links (#10725)
# Pull Request Template

## Description

**Issue**
This PR fixes template variables in messages (e.g., {{customer.name}})
that were being incorrectly converted to clickable links by the
`MessageFormatter's linkify` functionality. This caused formatting
issues and broken links.

**Solution**
Added a `linkify` parameter to `MessageFormatter` to optionally disable
link conversion

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Screenshots**

**Before**
<img width="1012" alt="image"
src="https://github.com/user-attachments/assets/70abb238-b4d9-439d-9e51-c7513cf482fb"
/>


**After**
<img width="1012" alt="image"
src="https://github.com/user-attachments/assets/387acb74-674e-4b26-85cc-2d7190d256b1"
/>




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-23 18:18:14 +05:30
Sivin VargheseandGitHub d3d39a81d6 fix: Prevent duplicate chat creation in the web widget during latency (#10745) 2025-01-23 12:53:28 +05:30
be8205657e fix: Added authentication to FireCrawl API, remove unused RobinAI references (#10737)
- Fixed Firecrawl webhook payloads to ensure proper data handling and
delivery.
- Removed unused Robin AI code to improve codebase cleanliness and
maintainability.
- Implement authentication for the Firecrawl endpoint to improve
security. A key is generated to secure the webhook URLs from FireCrawl.

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-01-22 18:14:25 -08:00
Shivam MishraandGitHub 3b366f43e6 feat: setup captain limits (#10713)
This pull request introduces several changes to implement and manage
usage limits for the Captain AI service. The key changes include adding
configuration for plan limits, updating error messages, modifying
controllers and models to handle usage limits, and updating tests to
ensure the new functionality works correctly.

## Implementation Checklist

- [x] Ability to configure captain limits per check
- [x] Update response for `usage_limits` to include captain limits
- [x] Methods to increment or reset captain responses limits in the
`limits` column for the `Account` model
- [x] Check documents limit using a count query
- [x] Ensure Captain hand-off if a limit is reached
- [x] Ensure limits are enforced for Copilot Chat
- [x] Ensure limits are reset when stripe webhook comes in 
- [x] Increment usage for FAQ generation and Contact notes
- [x] Ensure documents limit is enforced

These changes ensure that the Captain AI service operates within the defined usage limits for different subscription plans, providing appropriate error messages and handling when limits are exceeded.
2025-01-23 01:23:18 +05:30
David KubešandGitHub 52362ec1ea fix: country selection (#10670) 2025-01-22 21:30:51 +05:30
Sivin VargheseandGitHub 38e46bbb6c chore: Adds loader for attachment download buttons (#10748) 2025-01-22 18:29:57 +05:30
Sivin VargheseandGitHub 137c6f8a75 fix: Context menu and its submenu boundary overflow (#10729)
# Pull Request Template

## Description

This PR adds smart positioning to prevent context menu and submenu from
overflowing screen boundaries. The context menu and its submenu now
dynamically adjust their position when there isn't enough space.


Fixes
https://linear.app/chatwoot/issue/CW-3936/assign-to-agent-ui-element-is-drawn-off-canvas
https://github.com/chatwoot/chatwoot/issues/10727

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?
**Loom video**

**Before**

https://www.loom.com/share/6c267dde103c4f2281ed5b4d006a43b1?sid=047966f5-d6d3-4de6-ade8-270141e6e215

**After**

https://www.loom.com/share/5599c6061bb74c3ea527dd06960d3189?sid=ffb01149-bb7a-449e-b0ec-02cc191f472b


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-22 17:56:43 +05:30
PranavandGitHub 3a950dbdbb fix: Use documentable instead of document (#10743)
Fixes
https://linear.app/chatwoot/issue/CW-3950/nomethoderror-undefined-method-document-for-an-instance-of
2025-01-21 19:43:20 -08:00
451c28a7a1 feat: add prompt suggestions and June events (#10726)
This PR adds the following two features

1. Prompt suggestions to get started with Copilot Chat
2. June events for each action

![CleanShot 2025-01-20 at 21 00
52@2x](https://github.com/user-attachments/assets/d73e7982-0f78-4d85-873e-da2c16762688)

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-01-21 22:52:42 +05:30
Sivin VargheseandGitHub 0021a7d8e5 chore: Design improvements (#10732) 2025-01-21 17:03:15 +05:30
615a0c69fe chore: Help center improvements (#10712)
# Pull Request Template

## Description

Fixes https://linear.app/chatwoot/issue/CW-3913/issues-with-help-center

**Fixes included**
1. > The default locale that is selected should the portal default
locale.

Now, we update the last active locale in UI settings after changing the
selected locale from the article page header. This ensures that we see
the last active locale-based categories on the category page and
remember it when we return. Initially, it’s the default locale.
     
2. > I cannot switch to a different locale if there are no articles in
the portal

Now, the `v-if` condition that checked for the presence of articles in
portal has been removed. Additionally, the locale length checks for the
showing dropdown have been removed, allows locale switching even if
article is not preset.
     
3. > Create or updating the article is quite painful, see the video 

Removed the `quickSave` and `saveAndSyncDebounced` usage for a newly
creating article.

4. > I cannot see the articles if I delete the English locale
(irrespective of what I choose as default locale)

Now, the last active locale in UI settings will automatically update to
the default locale when the last active locale is deleted.

5. > Set a new default locale other than `en` and delete the `en` locale
preset in the portal. Then, adding a new locale will automatically set
`en` as the default locale, even if the `en` locale not preset in the
portal.

    Now, we pass default locale when we add a new locale.

6. Adds search for all dropdown menus
7. Update article count in realtime.


## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Check this linear issues**
https://linear.app/chatwoot/issue/CW-3913/issues-with-help-center


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-01-21 13:50:01 +05:30
PranavandGitHub 1528473dd0 fix(ce): Check the edition before running the data migration (#10728)
Fixes #10721

This PR addresses an issue where the data migration in the CE edition
was failing due to missing models. The change ensures that the migration
process runs only when the deployment is using the EE image.
2025-01-21 10:00:37 +05:30
Sivin VargheseandGitHub 05c16b49dd fix: Vite dev build fails due to sass (#10716)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-3853/vite-dev-build-fails-due-to-dart-sass

**Other issue**

| First Header  | Second Header |
| ------------- | ------------- |
|
![image](https://github.com/user-attachments/assets/e99ac2bc-0b8a-44d1-aa58-9e10e00b5ad5)
|
![image](https://github.com/user-attachments/assets/c987f70c-f5f5-4606-a498-00a6fe6b6525)
|



## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-20 14:34:33 +05:30
Sivin VargheseandGitHub a8ecbd3919 chore: Auto-fetch previous page on last item deletion (#10714)
# Pull Request Template

## Description

This PR fixes an issue where deleting the last item on the last page of
responses/documents, would show an empty state instead of loading the
previous page.

Fixes
> If you have pending responses spanning 2 or more pages .. and you
delete the last response in the last page.. instead of showing the
previous page the system show empty state.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/b0e89f774ccd45dab0e8dba2c34bd1ac?sid=d9923bcd-5030-42d9-9b7f-170df5297cfd


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-01-20 14:34:23 +05:30
Shivam MishraandGitHub c25a75f497 feat: check if item is present (#10715) 2025-01-18 18:31:07 +05:30
5046dc5727 feat: add CSAT & Form bubble (#10711)
![CleanShot 2025-01-17 at 14 02
40@2x](https://github.com/user-attachments/assets/7a19c4d7-7548-4b6a-92bb-0ddba181c428)

![CleanShot 2025-01-17 at 14 39
50@2x](https://github.com/user-attachments/assets/4251c944-2d1f-4cb5-817a-6352a644b743)

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-01-17 15:30:52 +05:30
Sivin VargheseandGitHub 3da97d97be fix: Download button opens URL instead of downloading (#10710) 2025-01-17 14:34:44 +05:30
Sojan f2ff3b42e3 Merge branch 'release/4.0.1' into develop 2025-01-17 01:13:32 +05:30
242 changed files with 6143 additions and 3223 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '20.12'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
+12 -12
View File
@@ -5,30 +5,30 @@
version: '3'
services:
base:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000"
USER_GID: "1000"
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000"
USER_GID: "1000"
USER_UID: '1000'
USER_GID: '1000'
volumes:
- ..:/workspace:cached
+1 -3
View File
@@ -23,12 +23,10 @@ jobs:
bundler-cache: true
- uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+98 -20
View File
@@ -5,6 +5,7 @@
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
@@ -12,23 +13,32 @@ on:
- master
tags:
- v*
# pull_request:
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs:
build:
runs-on: ubuntu-latest
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 }} # ref_name to get tags/branches
GIT_REF: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Strip enterprise code
run: |
@@ -39,29 +49,97 @@ jobs:
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag
- name: Set Docker Tags
run: |
# Replace forward slashes with hyphens in the ref name
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
echo "DOCKER_TAG=chatwoot/chatwoot:$SANITIZED_REF-ce" >> $GITHUB_ENV
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
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-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: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64, linux/arm64
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
tags: ${{ env.DOCKER_TAG }}
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
+1 -2
View File
@@ -38,7 +38,6 @@ 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 }}
@@ -48,7 +47,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+3 -7
View File
@@ -19,13 +19,11 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 23
cache: 'pnpm'
- name: pnpm
@@ -39,7 +37,7 @@ jobs:
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
@@ -47,5 +45,3 @@ jobs:
- name: Size Check
run: pnpm run size
+3 -3
View File
@@ -186,7 +186,7 @@ GEM
database_cleaner-core (2.0.1)
datadog-ci (0.8.3)
msgpack
date (3.3.4)
date (3.4.1)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
@@ -487,7 +487,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.17)
net-imap (0.4.19)
date
net-protocol
net-pop (0.1.2)
@@ -782,7 +782,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.1)
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
+1 -1
View File
@@ -120,4 +120,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<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-2024, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
3.1.0
3.2.0
@@ -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)
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@@ -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(id: :desc).limit(20)
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
end
private
@@ -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 }.to_json
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
end
end
+39 -27
View File
@@ -1,58 +1,70 @@
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|
agent_report = report_builder({ type: :agent, id: agent.id }).summary
[agent.name] + generate_readable_report_metrics(agent_report)
report = reports.find { |r| r[:id] == agent.id }
[agent.name] + generate_readable_report_metrics(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|
inbox_report = generate_report({ type: :inbox, id: inbox.id })
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(inbox_report)
report = reports.find { |r| r[:id] == inbox.id }
[inbox.name, inbox.channel&.name] + generate_readable_report_metrics(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|
team_report = report_builder({ type: :team, id: team.id }).summary
[team.name] + generate_readable_report_metrics(team_report)
report = reports.find { |r| r[:id] == team.id }
[team.name] + generate_readable_report_metrics(report)
end
end
def generate_labels_report
Current.account.labels.map do |label|
label_report = generate_report({ type: :label, id: label.id })
label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report)
end
end
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])
}
)
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])
}
)
end
def generate_report(report_params)
report_builder(report_params).short_summary
def report_builder(report_params)
V2::ReportBuilder.new(Current.account, build_params(report_params))
end
private
def generate_readable_report_metrics(report_metric)
def generate_readable_report_metrics(report)
[
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]
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]
]
end
end
+6 -3
View File
@@ -14,26 +14,29 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q }) {
contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
},
});
}
conversations({ q }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
},
});
}
messages({ q }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
},
});
}
@@ -0,0 +1,40 @@
/* 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();
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
v-dompurify-html="formatMessage(message, false, false, false)"
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">
@@ -1,7 +1,7 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
@@ -35,7 +35,7 @@ const FORM_CONFIG = {
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' },
COUNTRY: { field: 'additionalAttributes.countryCode' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
@@ -74,7 +74,7 @@ const defaultState = {
const state = reactive({ ...defaultState });
const validationRules = {
firstName: { required, minLength: minLength(2) },
firstName: { required },
email: { email },
};
@@ -123,7 +123,7 @@ const prepareStateBasedOnProps = () => {
};
const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name }))
countries.map(({ name, id }) => ({ label: name, value: id }))
);
const editDetailsForm = computed(() =>
@@ -205,8 +205,8 @@ const getMessageType = key => {
};
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.name === value);
state.additionalAttributes.countryCode = selectedCountry?.id || '';
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
emit('update', state);
};
@@ -242,7 +242,7 @@ defineExpose({
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
@@ -59,6 +59,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact"
@click="handleDialogConfirm"
/>
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
@@ -14,6 +15,8 @@ const props = defineProps({
const { t } = useI18n();
const { uiSettings } = useUISettings();
const searchQuery = ref('');
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
@@ -46,20 +49,49 @@ const processContactAttributes = (
}, []);
};
const sortAttributesOrder = computed(
() =>
uiSettings.value.conversation_elements_order_conversation_contact_panel ??
[]
);
const sortByUISettings = attributes => {
// Get saved order from UI settings
// Same as conversation panel contact attribute order
const order = sortAttributesOrder.value;
// If no order defined, return original array
if (!order?.length) return attributes;
const orderMap = new Map(order.map((key, index) => [key, index]));
// Sort attributes based on their position in saved order
return [...attributes].sort((a, b) => {
// Get positions, use Infinity if not found in order (pushes to end)
const aPos = orderMap.get(a.attributeKey) ?? Infinity;
const bPos = orderMap.get(b.attributeKey) ?? Infinity;
return aPos - bPos;
});
};
const usedAttributes = computed(() => {
return processContactAttributes(
const attributes = processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
return sortByUISettings(attributes);
});
const unusedAttributes = computed(() => {
return processContactAttributes(
const attributes = processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
return sortByUISettings(attributes);
});
const filteredUnusedAttributes = computed(() => {
@@ -1,4 +1,6 @@
<script setup>
import Policy from 'dashboard/components/policy.vue';
defineProps({
title: {
type: String,
@@ -8,6 +10,10 @@ defineProps({
type: String,
required: true,
},
actionPerms: {
type: Array,
default: () => [],
},
});
</script>
@@ -16,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
@@ -39,7 +45,9 @@ defineProps({
{{ subtitle }}
</p>
</div>
<slot name="actions" />
<Policy :permissions="actionPerms">
<slot name="actions" />
</Policy>
</div>
</div>
</div>
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[960px] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3">
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
@@ -36,6 +36,8 @@ const emit = defineEmits([
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
@@ -52,21 +54,32 @@ const quickSave = debounce(
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
};
const articleTitle = computed({
get: () => props.article.title,
set: value => {
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
quickSave({ content });
saveAndSyncDebounced({ content });
handleSave({ content });
},
});
@@ -200,7 +200,8 @@ onMounted(() => {
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -231,7 +232,8 @@ onMounted(() => {
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
show-search
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
ARTICLE_TABS,
CATEGORY_ALL,
@@ -37,6 +38,7 @@ const emit = defineEmits([
const route = useRoute();
const { t } = useI18n();
const { updateUISettings } = useUISettings();
const isCategoryMenuOpen = ref(false);
const isLocaleMenuOpen = ref(false);
@@ -111,13 +113,12 @@ const localeMenuItems = computed(() => {
}));
});
const hasMoreThanOneLocaleMenuItems = computed(() => {
return localeMenuItems.value?.length > 1;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
updateUISettings({
last_active_locale_code: value,
});
};
const handleCategoryAction = ({ value }) => {
@@ -143,7 +144,7 @@ const handleTabChange = value => {
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<div v-if="hasMoreThanOneLocaleMenuItems" class="relative group">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
@@ -157,6 +158,7 @@ const handleTabChange = value => {
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
@@ -177,6 +179,7 @@ const handleTabChange = value => {
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
@@ -99,11 +99,19 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
const updateMeta = () => {
const updatePortalMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { portalSlug, locale });
};
const updateArticlesMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('articles/updateArticleMeta', {
portalSlug,
locale,
});
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -127,7 +135,8 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateMeta();
await updateArticlesMeta();
await updatePortalMeta();
} catch (error) {
const errorMessage =
error?.message ||
@@ -91,10 +91,7 @@ const articlesCount = computed(() => {
});
const showArticleHeaderControls = computed(
() =>
!hasNoArticlesInPortal.value &&
!props.isCategoryArticles &&
!isSwitchingPortal.value
() => !props.isCategoryArticles && !isSwitchingPortal.value
);
const showCategoryHeaderControls = computed(
@@ -141,6 +141,7 @@ const handleBreadcrumbClick = () => {
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
@@ -49,8 +49,11 @@ const onCreate = async () => {
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: { allowed_locales: updatedLocales },
portalSlug: props.portal?.slug,
config: {
allowed_locales: updatedLocales,
default_locale: props.portal?.meta?.default_locale,
},
});
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
@@ -2,6 +2,7 @@
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -20,6 +21,7 @@ const props = defineProps({
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -56,26 +58,40 @@ const changeDefaultLocale = ({ localeCode }) => {
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
};
const deletePortalLocale = ({ localeCode }) => {
const updateLastActivePortal = async localeCode => {
const { last_active_locale_code: lastActiveLocaleCode } =
uiSettings.value || {};
const defaultLocale = props.portal.meta.default_locale;
// Update UI settings only if deleting locale matches the last active locale in UI settings.
if (localeCode === lastActiveLocaleCode) {
await updateUISettings({
last_active_locale_code: defaultLocale,
});
}
};
const deletePortalLocale = async ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({
await updatePortalLocales({
newAllowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
await updateLastActivePortal(localeCode);
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
@@ -171,6 +171,10 @@ watch(
{ immediate: true, deep: true }
);
const handleClickOutside = () => {
showComposeNewConversation.value = false;
};
onMounted(() => resetContacts());
const keyboardEvents = {
@@ -188,7 +192,12 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
:class="{
'z-40': showComposeNewConversation,
@@ -77,7 +77,7 @@ const toggleMessageSignature = () => {
setSignature();
};
// Added this watch to dynamically set signature.
// Added this watch to dynamically set signature on target inbox change.
// Only targetInbox has value and is Advance Editor(used by isEmailOrWebWidgetInbox)
// Set the signature only if the inbox based flag is true
watch(
@@ -86,7 +86,8 @@ watch(
nextTick(() => {
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
});
}
},
{ immediate: true }
);
const onClickInsertEmoji = emoji => {
@@ -0,0 +1,73 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
color: {
type: String,
default: 'slate',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
},
actionLabel: {
type: String,
default: null,
},
});
const emit = defineEmits(['action']);
const bannerClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
});
const buttonClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-4 text-n-blue-11',
};
return classMap[props.color];
});
const triggerAction = () => {
emit('action');
};
</script>
<template>
<div
class="text-sm rounded-xl flex items-center justify-between gap-2 border"
:class="[
bannerClass,
{
'py-2 px-3': !actionLabel,
'pl-3 p-2': actionLabel,
},
]"
>
<div>
<slot />
</div>
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
:class="buttonClass"
@click="triggerAction"
>
{{ actionLabel }}
</button>
</div>
</div>
</template>
@@ -1,8 +1,12 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({
const { featureFlag } = defineProps({
currentPage: {
type: Number,
default: 1,
@@ -19,10 +23,26 @@ defineProps({
type: String,
default: '',
},
buttonPolicy: {
type: Array,
default: () => [],
},
buttonLabel: {
type: String,
default: '',
},
featureFlag: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
showPaginationFooter: {
type: Boolean,
default: true,
@@ -30,6 +50,11 @@ defineProps({
});
const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { isCloudFeatureEnabled } = useAccount();
const showPaywall = computed(() => {
return !isCloudFeatureEnabled(featureFlag);
});
const handleButtonClick = () => {
emit('click');
@@ -52,16 +77,19 @@ const handlePageChange = event => {
<slot name="headerTitle" />
</span>
<div
v-if="!showPaywall"
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
<Policy :permissions="buttonPolicy">
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
</Policy>
<slot name="action" />
</div>
</div>
@@ -69,7 +97,21 @@ const handlePageChange = event => {
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
<slot name="controls" />
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="showPaywall">
<slot name="paywall" />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
<slot />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -28,31 +29,41 @@ const props = defineProps({
});
const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
value: 'viewConnectedInboxes',
action: 'viewConnectedInboxes',
icon: 'i-lucide-link',
},
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
value: 'viewConnectedInboxes',
action: 'viewConnectedInboxes',
icon: 'i-lucide-link',
},
];
if (checkPermissions(['administrator'])) {
allOptions.push(
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
}
);
}
return allOptions;
});
const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt));
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -32,25 +33,33 @@ const props = defineProps({
});
const emit = defineEmits(['action']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
];
if (checkPermissions(['administrator'])) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
});
}
return allOptions;
});
const createdAt = computed(() => dynamicTime(props.createdAt));
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
const props = defineProps({
@@ -76,8 +77,9 @@ const handleAction = ({ action, value }) => {
{{ inboxName }}
</span>
<div class="flex items-center gap-2">
<div
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
@@ -93,7 +95,7 @@ const handleAction = ({ action, value }) => {
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</Policy>
</div>
</div>
</CardLayout>
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
id: {
@@ -107,8 +108,9 @@ const handleDocumentableClick = () => {
{{ question }}
</span>
<div v-if="!compact" class="flex items-center gap-2">
<div
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
@@ -124,7 +126,7 @@ const handleDocumentableClick = () => {
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAssistantAction($event)"
/>
</div>
</Policy>
</div>
</div>
<span class="text-n-slate-11 text-sm line-clamp-5">
@@ -20,6 +20,8 @@ const props = defineProps({
},
});
const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const deleteDialogRef = ref(null);
@@ -30,6 +32,7 @@ const deleteEntity = async payload => {
try {
await store.dispatch(`captain${props.type}/delete`, payload);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.ERROR_MESSAGE`));
@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => {
return currentUser.value.type === 'SuperAdmin';
});
const { accountId, isOnChatwootCloud } = useAccount();
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
</script>
<template>
<div
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
feature-prefix="CAPTAIN"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="openBilling"
/>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useRouter } from 'vue-router';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const router = useRouter();
const { accountId } = useAccount();
const { documentLimits, fetchLimits } = useCaptain();
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const showBanner = computed(() => {
if (!documentLimits.value) return false;
const { currentAvailable } = documentLimits.value;
return currentAvailable === 0;
});
onMounted(fetchLimits);
</script>
<template>
<Banner
v-show="showBanner"
color="amber"
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@action="openBilling"
>
{{ $t('CAPTAIN.BANNER.DOCUMENTS') }}
</Banner>
</template>
@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -15,6 +15,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -0,0 +1,42 @@
<script setup>
import { onMounted, computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useRouter } from 'vue-router';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const router = useRouter();
const { accountId } = useAccount();
const { responseLimits, fetchLimits } = useCaptain();
const openBilling = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const showBanner = computed(() => {
if (!responseLimits.value) return false;
const { consumed, totalCount } = responseLimits.value;
if (!consumed || !totalCount) return false;
return consumed / totalCount > 0.8;
});
onMounted(fetchLimits);
</script>
<template>
<Banner
v-show="showBanner"
color="amber"
:action-label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
@action="openBilling"
>
{{ $t('CAPTAIN.BANNER.RESPONSES') }}
</Banner>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import { nextTick, ref, watch } from 'vue';
import Icon from '../icon/Icon.vue';
const props = defineProps({
supportAgent: {
@@ -24,13 +28,24 @@ const props = defineProps({
},
});
const emit = defineEmits(['sendMessage']);
const emit = defineEmits(['sendMessage', 'reset']);
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', opt.prompt);
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
@@ -40,6 +55,21 @@ const scrollToBottom = async () => {
}
};
const promptOptions = [
{
label: 'Summarize this conversation',
prompt: `Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent`,
},
{
label: 'Suggest an answer',
prompt: `Analyze the customer’s inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information.`,
},
{
label: 'Rate this conversation',
prompt: `Review the conversation to see how well it meets the customer’s needs. Share a rating out of 5 based on tone, clarity, and effectiveness.`,
},
];
watch(
[() => props.messages, () => props.isCaptainTyping],
() => {
@@ -50,7 +80,7 @@ watch(
</script>
<template>
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
<div class="flex flex-col h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
@@ -67,7 +97,32 @@ watch(
<CopilotLoader v-if="isCaptainTyping" />
</div>
<CopilotInput class="mx-3 mt-px mb-4" @send="sendMessage" />
<div>
<div v-if="!messages.length" class="flex-1 px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ prompt.label }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
<div class="mx-3 mt-px mb-2 flex flex-col items-end flex-1">
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
<CopilotInput class="mb-1 flex-1 w-full" @send="sendMessage" />
</div>
</div>
</div>
</template>
@@ -1,8 +1,12 @@
<script setup>
import { computed } from 'vue';
import { emitter } from 'shared/helpers/mitt';
import { useTrack } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
@@ -18,6 +22,11 @@ const props = defineProps({
},
});
const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content);
return formatter.formattedMessage;
});
const insertIntoRichEditor = computed(() => {
return [INBOX_TYPES.WEB, INBOX_TYPES.EMAIL].includes(
props.conversationInboxType
@@ -30,6 +39,7 @@ const useCopilotResponse = () => {
} else {
emitter.emit(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, props.message?.content);
}
useTrack(COPILOT_EVENTS.USE_CAPTAIN_RESPONSE);
};
</script>
@@ -43,9 +53,7 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<div class="break-words">
{{ message.content }}
</div>
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
<div class="flex flex-row mt-1">
<Button
:label="$t('CAPTAIN.COPILOT.USE')"
@@ -34,6 +34,8 @@ import UnsupportedBubble from './bubbles/Unsupported.vue';
import ContactBubble from './bubbles/Contact.vue';
import DyteBubble from './bubbles/Dyte.vue';
import LocationBubble from './bubbles/Location.vue';
import CSATBubble from './bubbles/CSAT.vue';
import FormBubble from './bubbles/Form.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
@@ -260,6 +262,16 @@ const componentToRender = computed(() => {
if (emailInboxTypes.includes(props.messageType)) return EmailBubble;
}
if (props.contentType === CONTENT_TYPES.INPUT_CSAT) {
return CSATBubble;
}
if (
[CONTENT_TYPES.INPUT_SELECT, CONTENT_TYPES.FORM].includes(props.contentType)
) {
return FormBubble;
}
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble;
}
@@ -15,18 +15,14 @@ import { useCamelCase } from 'dashboard/composables/useTransformKeys';
* @property {Array} messages - Array of all messages [These are not in camelcase]
*/
const props = defineProps({
readMessages: {
type: Array,
default: () => [],
},
unReadMessages: {
type: Array,
default: () => [],
},
currentUserId: {
type: Number,
required: true,
},
firstUnreadId: {
type: Number,
default: null,
},
isAnEmailChannel: {
type: Boolean,
default: false,
@@ -41,12 +37,8 @@ const props = defineProps({
},
});
const unread = computed(() => {
return useCamelCase(props.unReadMessages, { deep: true });
});
const read = computed(() => {
return useCamelCase(props.readMessages, { deep: true });
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
/**
@@ -108,26 +100,18 @@ const getInReplyToMessage = parentMessage => {
<template>
<ul class="px-4 bg-n-background">
<slot name="beforeAll" />
<template v-for="(message, index) in read" :key="message.id">
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, read)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
<template v-for="(message, index) in allMessages" :key="message.id">
<slot
v-if="firstUnreadId && message.id === firstUnreadId"
name="unreadBadge"
/>
</template>
<slot name="beforeUnread" />
<template v-for="(message, index) in unread" :key="message.id">
<Message
v-bind="message"
:is-email-inbox="isAnEmailChannel"
:in-reply-to="getInReplyToMessage(message)"
:group-with-next="shouldGroupWithNext(index, unread)"
:group-with-next="shouldGroupWithNext(index, allMessages)"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
:is-email-inbox="isAnEmailChannel"
data-clarity-mask="True"
/>
</template>
@@ -0,0 +1,45 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
const { contentAttributes } = useMessageContext();
const { t } = useI18n();
const response = computed(() => {
return contentAttributes.value?.submittedValues?.csatSurveyResponse ?? {};
});
const isRatingSubmitted = computed(() => {
return !!response.value.rating;
});
const rating = computed(() => {
if (isRatingSubmitted.value) {
return CSAT_RATINGS.find(
csatOption => csatOption.value === response.value.rating
);
}
return null;
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
</dt>
<dd>{{ t(rating.translationKey) }}</dd>
<dt v-if="response.feedbackMessage" class="text-n-slate-11 italic mt-2">
{{ t('CONVERSATION.FEEDBACK_TITLE') }}
</dt>
<dd>{{ response.feedbackMessage }}</dd>
</dl>
</BaseBubble>
</template>
@@ -26,7 +26,18 @@ const ccEmail = computed(() => {
});
const senderName = computed(() => {
return sender.value.name ?? '';
const fromEmailAddress = fromEmail.value[0] ?? '';
const senderEmail = sender.value.email ?? '';
if (!fromEmailAddress && !senderEmail) return null;
// if the sender of the conversation and the sender of this particular
// email are the same, only then we return the sender name
if (fromEmailAddress === senderEmail) {
return sender.value.name;
}
return null;
});
const bccEmail = computed(() => {
@@ -59,11 +70,19 @@ const showMeta = computed(() => {
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
<template v-if="showMeta">
<div v-if="fromEmail[0]">
<span :class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'">
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
<div
v-if="fromEmail[0]"
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
>
<template v-if="senderName">
<span>
{{ senderName }}
</span>
&lt;{{ fromEmail[0] }}&gt;
</template>
<template v-else>
{{ fromEmail[0] }}
</template>
</div>
<div v-if="toEmail.length">
{{ $t('EMAIL_HEADER.TO') }}: {{ toEmail.join(', ') }}
@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CONTENT_TYPES } from '../constants.js';
import { useMessageContext } from '../provider.js';
const { content, contentAttributes, contentType } = useMessageContext();
const { t } = useI18n();
const formValues = computed(() => {
if (contentType.value === CONTENT_TYPES.FORM) {
const { items, submittedValues = [] } = contentAttributes.value;
if (submittedValues.length) {
return submittedValues.map(submittedValue => {
const item = items.find(
formItem => formItem.name === submittedValue.name
);
return {
title: submittedValue.value,
value: submittedValue.value,
label: item?.label,
};
});
}
return [];
}
if (contentType.value === CONTENT_TYPES.INPUT_SELECT) {
const [item] = contentAttributes.value?.submittedValues ?? [];
if (!item) return [];
return [
{
title: item.title,
value: item.value,
label: '',
},
];
}
return [];
});
</script>
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<span v-dompurify-html="content" :title="content" />
<dl v-if="formValues.length" class="mt-4">
<template v-for="item in formValues" :key="item.title">
<dt class="text-n-slate-11 italic mt-2">
{{ item.label || t('CONVERSATION.RESPONSE') }}
</dt>
<dd>{{ item.title }}</dd>
</template>
</dl>
<div v-else class="my-2 font-medium">
{{ t('CONVERSATION.NO_RESPONSE') }}
</div>
</BaseBubble>
</template>
@@ -1,13 +1,19 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useMessageContext } from '../provider.js';
import { downloadFile } from '@chatwoot/utils';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
const attachment = computed(() => {
@@ -16,6 +22,7 @@ const attachment = computed(() => {
const hasError = ref(false);
const showGallery = ref(false);
const isDownloading = ref(false);
const handleError = () => {
hasError.value = true;
@@ -23,16 +30,15 @@ const handleError = () => {
};
const downloadAttachment = async () => {
const response = await fetch(attachment.value.dataUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `attachment${attachment.value.extension || ''}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
const { fileType, dataUrl, extension } = attachment.value;
try {
isDownloading.value = true;
await downloadFile({ url: dataUrl, type: fileType, extension });
} catch (error) {
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
} finally {
isDownloading.value = false;
}
};
</script>
@@ -66,7 +72,9 @@ const downloadAttachment = async () => {
slate
icon="i-lucide-download"
class="opacity-60"
@click="downloadAttachment"
:is-loading="isDownloading"
:disabled="isDownloading"
@click.stop="downloadAttachment"
/>
</div>
</div>
@@ -2,6 +2,7 @@
import { computed, onMounted, useTemplateRef, ref } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
const { attachment } = defineProps({
attachment: {
@@ -37,9 +38,10 @@ onMounted(() => {
});
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const toggleMute = () => {
@@ -48,7 +50,7 @@ const toggleMute = () => {
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value.currentTime;
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
@@ -73,17 +75,8 @@ const onEnd = () => {
};
const downloadAudio = async () => {
const response = await fetch(timeStampURL.value);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
const filename = timeStampURL.value.split('/').pop().split('?')[0] || 'audio';
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
const { fileType, dataUrl, extension } = attachment;
downloadFile({ url: dataUrl, type: fileType, extension });
};
</script>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getFileInfo } from '@chatwoot/utils';
import FileIcon from 'next/icon/FileIcon.vue';
import Icon from 'next/icon/Icon.vue';
@@ -14,17 +15,20 @@ const { attachment } = defineProps({
const { t } = useI18n();
const fileName = computed(() => {
const url = attachment.dataUrl;
if (url) {
const filename = url.substring(url.lastIndexOf('/') + 1);
return filename || t('CONVERSATION.UNKNOWN_FILE_TYPE');
}
return t('CONVERSATION.UNKNOWN_FILE_TYPE');
const fileDetails = computed(() => {
return getFileInfo(attachment?.dataUrl || '');
});
const fileType = computed(() => {
return fileName.value.split('.').pop();
const displayFileName = computed(() => {
const { base, type } = fileDetails.value;
const truncatedName = (str, maxLength, hasExt) =>
str.length > maxLength
? `${str.substring(0, maxLength).trimEnd()}${hasExt ? '..' : '...'}`
: str;
return type
? `${truncatedName(base, 12, true)}.${type}`
: truncatedName(base, 14, false);
});
const textColorClass = computed(() => {
@@ -47,21 +51,25 @@ const textColorClass = computed(() => {
zip: 'dark:text-[#EDEEF0] text-[#2F265F]',
};
return colorMap[fileType.value] || 'text-n-slate-12';
return colorMap[fileDetails.value.type] || 'text-n-slate-12';
});
</script>
<template>
<div
class="h-9 bg-n-alpha-white gap-2 items-center flex px-2 rounded-lg border border-n-container"
class="h-9 bg-n-alpha-white gap-2 overflow-hidden items-center flex px-2 rounded-lg border border-n-container"
>
<FileIcon class="flex-shrink-0" :file-type="fileType" />
<span class="mr-1 max-w-32 truncate" :class="textColorClass">
{{ fileName }}
<FileIcon class="flex-shrink-0" :file-type="fileDetails.type" />
<span
class="flex-1 min-w-0 text-sm max-w-36"
:title="fileDetails.name"
:class="textColorClass"
>
{{ displayFileName }}
</span>
<a
v-tooltip="t('CONVERSATION.DOWNLOAD')"
class="flex-shrink-0 h-9 grid place-content-center cursor-pointer text-n-slate-11"
class="flex-shrink-0 size-9 grid place-content-center cursor-pointer text-n-slate-11 hover:text-n-slate-12 transition-colors"
:href="attachment.dataUrl"
rel="noreferrer noopener nofollow"
target="_blank"
@@ -63,7 +63,7 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center"
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -36,6 +37,18 @@ const toggleShortcutModalFn = show => {
}
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showV4Routes = computed(() => {
return isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.REPORT_V4
);
});
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
@@ -77,6 +90,59 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const newReportRoutes = [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports_index'),
activeOn: ['agent_reports_show'],
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports_index'),
activeOn: ['inbox_reports_show'],
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports_index'),
activeOn: ['team_reports_show'],
},
];
const oldReportRoutes = [
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
];
const reportRoutes = computed(() =>
showV4Routes.value ? newReportRoutes : oldReportRoutes
);
const menuItems = computed(() => {
return [
{
@@ -85,6 +151,9 @@ const menuItems = computed(() => {
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
activeOn: ['inbox_view', 'inbox_view_conversation'],
getterKeys: {
badge: 'notifications/getHasUnreadNotifications',
},
},
{
name: 'Conversation',
@@ -171,20 +240,24 @@ const menuItems = computed(() => {
name: 'Captain',
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
showOnlyOnCloud: true,
children: [
{
name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
showOnlyOnCloud: true,
to: accountScopedRoute('captain_assistants_index'),
},
{
name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
showOnlyOnCloud: true,
to: accountScopedRoute('captain_documents_index'),
},
{
name: 'Responses',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
showOnlyOnCloud: true,
to: accountScopedRoute('captain_responses_index'),
},
],
@@ -261,31 +334,12 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_CONVERSATION'),
to: accountScopedRoute('conversation_reports'),
},
...reportRoutes.value,
{
name: 'Reports CSAT',
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
{
name: 'Reports SLA',
label: t('SIDEBAR.REPORTS_SLA'),
@@ -455,6 +509,7 @@ const menuItems = computed(() => {
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
icon: 'i-lucide-credit-card',
showOnlyOnCloud: true,
to: accountScopedRoute('billing_settings_index'),
},
],
@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
@@ -19,6 +20,12 @@ const { accountId, currentAccount } = useAccount();
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const userAccounts = useMapGetter('getUserAccounts');
const showAccountSwitcher = computed(
() => userAccounts.value.length > 1 && currentAccount.value.name
);
const onChangeAccount = newId => {
const accountUrl = `/app/accounts/${newId}/dashboard`;
window.location.href = accountUrl;
@@ -37,9 +44,14 @@ const emitNewAccount = () => {
:data-account-id="accountId"
aria-haspopup="listbox"
aria-controls="account-options"
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
class="flex items-center gap-2 justify-between w-full rounded-lg px-2"
:class="[
isOpen && 'bg-n-alpha-1',
showAccountSwitcher
? 'hover:bg-n-alpha-1 cursor-pointer'
: 'cursor-default',
]"
@click="() => showAccountSwitcher && toggle()"
>
<span
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
@@ -49,13 +61,14 @@ const emitNewAccount = () => {
</span>
<span
v-if="showAccountSwitcher"
aria-hidden="true"
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
/>
</button>
</template>
<DropdownBody class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
@@ -15,6 +15,7 @@ const props = defineProps({
to: { type: Object, default: null },
activeOn: { type: Array, default: () => [] },
children: { type: Array, default: undefined },
getterKeys: { type: Object, default: () => ({}) },
});
const {
@@ -23,6 +24,7 @@ const {
resolvePath,
resolvePermissions,
resolveFeatureFlag,
isOnChatwootCloud,
isAllowed,
} = useSidebarContext();
@@ -41,6 +43,7 @@ const hasChildren = computed(
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
// If a item has no link, it means it's just a subgroup header
// So we don't need to check for permissions here, because there's nothing to
// access here anyway
@@ -141,6 +144,7 @@ onMounted(async () => {
:name
:label
:to
:getter-keys="getterKeys"
:is-active="isActive"
:has-active-child="hasActiveChild"
:expandable="hasChildren"
@@ -1,7 +1,8 @@
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import Icon from 'next/icon/Icon.vue';
defineProps({
const props = defineProps({
to: { type: [Object, String], default: '' },
label: { type: String, default: '' },
icon: { type: [String, Object], default: '' },
@@ -9,9 +10,12 @@ defineProps({
isExpanded: { type: Boolean, default: false },
isActive: { type: Boolean, default: false },
hasActiveChild: { type: Boolean, default: false },
getterKeys: { type: Object, default: () => ({}) },
});
const emit = defineEmits(['toggle']);
const showBadge = useMapGetter(props.getterKeys.badge);
</script>
<template>
@@ -28,7 +32,13 @@ const emit = defineEmits(['toggle']);
}"
@click.stop="emit('toggle')"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
<div v-if="icon" class="relative flex items-center gap-2">
<Icon v-if="icon" :icon="icon" class="size-4" />
<span
v-if="showBadge"
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
@@ -9,18 +9,28 @@ const props = defineProps({
to: { type: [String, Object], required: true },
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
showOnlyOnCloud: { type: Boolean, default: false },
component: { type: Function, default: null },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
const { resolvePermissions, resolveFeatureFlag, isOnChatwootCloud } =
useSidebarContext();
const allowedToShow = computed(() => {
if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
return true;
});
const shouldRenderComponent = computed(() => {
return typeof props.component === 'function' || isVNode(props.component);
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Policy
v-if="allowedToShow"
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
@@ -2,7 +2,6 @@
import { computed, ref } from 'vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core';
@@ -15,19 +14,17 @@ const props = defineProps({
activeChild: { type: Object, default: undefined },
});
const { isAllowed } = useSidebarContext();
const { isAllowed, isOnChatwootCloud } = useSidebarContext();
const scrollableContainer = ref(null);
const accessibleItems = computed(() =>
props.children.filter(child => isAllowed(child.to))
props.children.filter(child => {
if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false;
return child.to && isAllowed(child.to);
})
);
const hasAccessibleItems = computed(() => {
if (props.children.length === 0) {
// cases like segment, folder and labels where users can create new items
return true;
}
return accessibleItems.value.length > 0;
});
@@ -52,7 +49,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
class="my-1"
/>
<ul class="m-0 list-none reset-base relative group">
<ul v-if="children.length" class="m-0 list-none reset-base relative group">
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
@@ -61,16 +58,13 @@ useEventListener(scrollableContainer, 'scroll', () => {
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}"
>
<template v-if="children.length">
<SidebarGroupLeaf
v-for="child in children"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
/>
</template>
<SidebarGroupEmptyLeaf v-else v-show="isExpanded" class="ml-3 rtl:mr-3" />
<SidebarGroupLeaf
v-for="child in children"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
/>
</div>
<div
v-if="isScrollable && isExpanded"
@@ -1,4 +1,5 @@
import { inject, provide } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { useRouter } from 'vue-router';
@@ -11,6 +12,8 @@ export function useSidebarContext() {
}
const router = useRouter();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { checkFeatureAllowed, checkPermissions } = usePolicy();
const resolvePath = to => {
@@ -41,6 +44,7 @@ export function useSidebarContext() {
resolvePermissions,
resolveFeatureFlag,
isAllowed,
isOnChatwootCloud,
};
}
@@ -18,6 +18,10 @@ const messages = ref([]);
const isCaptainTyping = ref(false);
const handleReset = () => {
messages.value = [];
};
const sendMessage = async message => {
// Add user message
messages.value.push({
@@ -62,5 +66,6 @@ const sendMessage = async message => {
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
@send-message="sendMessage"
@reset="handleReset"
/>
</template>
@@ -11,6 +11,7 @@ const reports = accountId => ({
'agent_reports',
'label_reports',
'inbox_reports',
'inbox_reports_show',
'team_reports',
'sla_reports',
],
@@ -40,7 +40,7 @@ const headerClass = computed(() =>
:style="{
width: `${header.getSize()}px`,
}"
class="text-left py-3 px-5 font-normal text-sm"
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
:class="headerClass"
@click="header.column.getCanSort() && header.column.toggleSorting()"
>
@@ -1,34 +1,67 @@
<script setup>
import { ref, computed, onMounted, nextTick, defineEmits } from 'vue';
import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
const { x, y } = defineProps({
const props = defineProps({
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
});
const emit = defineEmits(['close']);
const left = ref(x);
const top = ref(y);
const menuRef = useTemplateRef('menuRef');
const style = computed(() => ({
top: top.value + 'px',
left: left.value + 'px',
}));
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH;
return {
left: Math.max(0, left),
top: Math.max(0, top),
};
};
const position = computed(() => {
if (!menuRef.value) return { top: `${props.y}px`, left: `${props.x}px` };
const { left, top } = calculatePosition(
props.x,
props.y,
menuWidth.value,
menuHeight.value,
windowWidth.value,
windowHeight.value
);
return {
top: `${top}px`,
left: `${left}px`,
};
});
const target = ref();
onMounted(() => {
nextTick(() => {
target.value.focus();
});
nextTick(() => menuRef.value?.focus());
});
</script>
<template>
<Teleport to="body">
<div
ref="target"
ref="menuRef"
class="fixed outline-none z-[9999] cursor-pointer"
:style="style"
:style="position"
tabindex="0"
@blur="emit('close')"
>
@@ -37,8 +37,10 @@ const buttonStyleClass = props.compact
>
<Icon
icon="i-lucide-chevron-left"
class="size-5 ltr:-ml-1 rtl:-mr-1"
:class="props.compact ? 'text-n-slate-11' : 'text-n-blue-text'"
class="ltr:-ml-1 rtl:-mr-1"
:class="
props.compact ? 'text-n-slate-11 size-4' : 'text-n-blue-text size-5'
"
/>
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
@@ -243,6 +243,15 @@ export default {
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
isInstagramDM() {
return this.conversationType === 'instagram_direct_message';
},
@@ -492,12 +501,11 @@ export default {
<NextMessageList
v-if="showNextBubbles"
class="conversation-panel"
:read-messages="readMessages"
:un-read-messages="unReadMessages"
:current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id"
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="currentChat ? currentChat.messages : []"
:messages="getMessages"
>
<template #beforeAll>
<transition name="slide-up">
@@ -507,15 +515,10 @@ export default {
</li>
</transition>
</template>
<template #beforeUnread>
<template #unreadBadge>
<li v-show="unreadMessageCount != 0" class="unread--toast">
<span>
{{ unreadMessageCount > 9 ? '9+' : unreadMessageCount }}
{{
unreadMessageCount > 1
? $t('CONVERSATION.UNREAD_MESSAGES')
: $t('CONVERSATION.UNREAD_MESSAGE')
}}
{{ unreadMessageLabel }}
</span>
</li>
</template>
@@ -1,9 +1,14 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
const props = defineProps({
@@ -20,6 +25,8 @@ const props = defineProps({
const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false });
const { t } = useI18n();
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = {
@@ -32,6 +39,7 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
const isDownloading = ref(false);
const zoomScale = ref(1);
const activeAttachment = ref({});
const activeFileType = ref('');
@@ -116,15 +124,20 @@ const onClickChangeAttachment = (attachment, index) => {
zoomScale.value = 1;
};
const onClickDownload = () => {
const { file_type: type, data_url: url } = activeAttachment.value;
const onClickDownload = async () => {
const { file_type: type, data_url: url, extension } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
try {
isDownloading.value = true;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('GALLERY_VIEW.ERROR_DOWNLOADING'));
} finally {
isDownloading.value = false;
}
};
const onRotate = type => {
@@ -164,6 +177,12 @@ const onZoom = scale => {
};
const onClickZoomImage = () => {
// If already at max zoom, clicking should zoom out to minimum
if (zoomScale.value >= MAX_ZOOM_LEVEL) {
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// Otherwise zoom in
onZoom(0.1);
};
@@ -213,7 +232,6 @@ onMounted(() => {
:on-close="onClose"
>
<div
v-on-clickaway="onClose"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
@click="onClose"
>
@@ -258,63 +276,54 @@ onMounted(() => {
<div
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
>
<woot-button
<NextButton
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="zoom-in"
icon="i-lucide-zoom-in"
slate
ghost
@click="onZoom(0.1)"
/>
<woot-button
<NextButton
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="zoom-out"
icon="i-lucide-zoom-out"
slate
ghost
@click="onZoom(-0.1)"
/>
<woot-button
<NextButton
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-counter-clockwise"
icon="i-lucide-rotate-ccw"
slate
ghost
@click="onRotate('counter-clockwise')"
/>
<woot-button
<NextButton
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-clockwise"
icon="i-lucide-rotate-cw"
slate
ghost
@click="onRotate('clockwise')"
/>
<woot-button
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-download"
<NextButton
icon="i-lucide-download"
slate
ghost
:is-loading="isDownloading"
:disabled="isDownloading"
@click="onClickDownload"
/>
<woot-button
size="large"
color-scheme="secondary"
variant="clear"
icon="dismiss"
@click="onClose"
/>
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
</div>
</div>
<div class="flex items-center justify-center w-full h-full">
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<woot-button
<NextButton
v-if="hasMoreThanOneAttachment"
class="z-10"
size="large"
variant="smooth"
color-scheme="primary"
icon="chevron-left"
icon="i-lucide-chevron-left"
class="z-10 disabled:pointer-events-auto"
blue
faded
lg
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
@@ -356,14 +365,14 @@ onMounted(() => {
</div>
</div>
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<woot-button
<NextButton
v-if="hasMoreThanOneAttachment"
class="z-10"
size="large"
variant="smooth"
color-scheme="primary"
icon="i-lucide-chevron-right"
class="z-10 disabled:pointer-events-auto"
blue
faded
lg
:disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
@@ -1,20 +1,45 @@
<script>
export default {
props: {
option: {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useWindowSize, useElementBounding } from '@vueuse/core';
defineProps({
option: {
type: Object,
default: () => {},
},
};
subMenuAvailable: {
type: Boolean,
default: true,
},
});
const menuRef = useTemplateRef('menuRef');
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { bottom, right } = useElementBounding(menuRef);
// Vertical position
const verticalPosition = computed(() => {
const SUBMENU_HEIGHT = 240; // 15rem in pixels
const spaceBelow = windowHeight.value - bottom.value;
return spaceBelow < SUBMENU_HEIGHT ? 'bottom-0' : 'top-0';
});
// Horizontal position
const horizontalPosition = computed(() => {
const SUBMENU_WIDTH = 240;
const spaceRight = windowWidth.value - right.value;
return spaceRight < SUBMENU_WIDTH ? 'right-full' : 'left-full';
});
const submenuPosition = computed(() => [
verticalPosition.value,
horizontalPosition.value,
]);
</script>
<template>
<div
ref="menuRef"
class="text-slate-800 dark:text-slate-100 menu-with-submenu min-width-calc w-full p-1 flex items-center h-7 rounded-md relative bg-n-alpha-3/50 backdrop-blur-[100px] justify-between hover:bg-n-brand/10 cursor-pointer dark:hover:bg-n-solid-3"
:class="!subMenuAvailable ? 'opacity-50 cursor-not-allowed' : ''"
>
@@ -25,7 +50,8 @@ export default {
<fluent-icon icon="chevron-right" size="12" />
<div
v-if="subMenuAvailable"
class="submenu bg-n-alpha-3 backdrop-blur-[100px] p-1 shadow-lg rounded-md absolute left-full top-0 hidden min-h-min max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
class="submenu bg-n-alpha-3 backdrop-blur-[100px] p-1 shadow-lg rounded-md absolute hidden max-h-[15rem] overflow-y-auto overflow-x-hidden cursor-pointer"
:class="submenuPosition"
>
<slot />
</div>
@@ -13,10 +13,14 @@ export function useAccount() {
*/
const route = useRoute();
const getAccountFn = useMapGetter('accounts/getAccount');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const accountId = computed(() => {
return Number(route.params.accountId);
});
const currentAccount = computed(() => getAccountFn.value(accountId.value));
/**
@@ -28,6 +32,10 @@ export function useAccount() {
return `/app/accounts/${accountId.value}/${url}`;
};
const isCloudFeatureEnabled = feature => {
return isFeatureEnabledonAccount.value(currentAccount.value.id, feature);
};
const accountScopedRoute = (name, params, query) => {
return {
name,
@@ -42,5 +50,7 @@ export function useAccount() {
currentAccount,
accountScopedUrl,
accountScopedRoute,
isCloudFeatureEnabled,
isOnChatwootCloud,
};
}
@@ -0,0 +1,46 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
const documentLimits = computed(() => {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
const responseLimits = computed(() => {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
const fetchLimits = () => {
store.dispatch('accounts/limits');
};
return {
captainEnabled,
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
};
}
+1
View File
@@ -33,4 +33,5 @@ export const FEATURE_FLAGS = {
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
};
@@ -100,6 +100,12 @@ export const OPEN_AI_EVENTS = Object.freeze({
DISMISS_AI_SUGGESTION: 'OpenAI: Dismiss AI suggestions',
});
export const COPILOT_EVENTS = Object.freeze({
SEND_SUGGESTED: 'Copilot: Send suggested message',
SEND_MESSAGE: 'Copilot: Sent a message',
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
});
export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar',
});
@@ -3,6 +3,7 @@ import {
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
class AudioNotificationStore {
constructor(store) {
@@ -18,6 +19,15 @@ class AudioNotificationStore {
return mineConversation.some(conv => conv.unread_count > 0);
};
isMessageFromPendingConversation = (message = {}) => {
const { conversation_id: conversationId } = message || {};
if (!conversationId) return false;
const activeConversation =
this.store.getters.getConversationById(conversationId);
return activeConversation?.status === wootConstants.STATUS_TYPE.PENDING;
};
isMessageFromCurrentConversation = message => {
return this.store.getters.getSelectedChat?.id === message.conversation_id;
};
@@ -172,6 +172,12 @@ export class DashboardAudioNotificationHelper {
return;
}
// If the conversation status is pending, then dismiss the alert
// This case is common for all audio event types
if (this.store.isMessageFromPendingConversation(message)) {
return;
}
// If the message is sent by the current user then dismiss the alert
if (isMessageFromCurrentUser(message, this.currentUser.id)) {
return;
@@ -4,6 +4,8 @@ import {
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
vi.mock('dashboard/helper/permissionsHelper', () => ({
getUserPermissions: vi.fn(),
}));
@@ -18,6 +20,7 @@ describe('AudioNotificationStore', () => {
getMineChats: vi.fn(),
getSelectedChat: null,
getCurrentAccountId: 1,
getConversationById: vi.fn(),
},
};
audioNotificationStore = new AudioNotificationStore(store);
@@ -59,6 +62,63 @@ describe('AudioNotificationStore', () => {
});
});
describe('isMessageFromPendingConversation', () => {
it('should return true when conversation status is pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.PENDING,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(true);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation status is not pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.OPEN,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation is not found', () => {
store.getters.getConversationById.mockReturnValue(null);
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when message has no conversation_id', () => {
const message = {};
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
it('should return false when message is null or undefined', () => {
expect(
audioNotificationStore.isMessageFromPendingConversation(null)
).toBe(false);
expect(
audioNotificationStore.isMessageFromPendingConversation(undefined)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
});
describe('isMessageFromCurrentConversation', () => {
it('should return true when message is from selected chat', () => {
store.getters.getSelectedChat = { id: 6179 };
@@ -18,6 +18,7 @@ const FEATURE_HELP_URLS = {
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
};
export function getHelpUrlForFeature(featureName) {
@@ -99,6 +99,9 @@
},
"fallback": {
"CONTENT": "has shared a url"
},
"contact": {
"CONTENT": "Shared contact"
}
},
"CHAT_SORT_BY_FILTER": {
@@ -54,6 +54,7 @@
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
"RESPONSE": "Response",
"RATING_TITLE": "Rating",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
@@ -352,5 +353,11 @@
"ONE": "{user} is typing",
"TWO": "{user} and {secondUser} are typing",
"MULTIPLE": "{user} and {count} others are typing"
},
"COPILOT": {
"TRY_THESE_PROMPTS": "Try these prompts"
},
"GALLERY_VIEW": {
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
}
}
@@ -306,7 +306,24 @@
"SEND_MESSAGE": "Send message...",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this"
"USE": "Use this",
"RESET": "Reset"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
"AVAILABLE_ON": "Captain is not available on the free plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"BANNER": {
"RESPONSES": "You've used over 80% of your response limit. To continue using Captain AI, please upgrade.",
"DOCUMENTS": "Document limit reached. Upgrade to continue using Captain AI."
},
"FORM": {
"CANCEL": "Cancel",
@@ -363,7 +380,7 @@
},
"EMPTY_STATE": {
"TITLE": "No assistants available",
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations. Click the button below to get started."
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations."
}
},
"DOCUMENTS": {
@@ -405,13 +422,13 @@
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant. Click the button below to get started."
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant."
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE" : {
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"DELETE": {
@@ -421,7 +438,7 @@
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
},
"FILTER" :{
"FILTER": {
"ASSISTANT": "Assistant: {selected}",
"STATUS": "Status: {selected}",
"ALL_ASSISTANTS": "All"
@@ -469,7 +486,7 @@
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually. Click the button below to create your first FAQ."
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually."
}
},
"INBOXES": {
@@ -500,7 +517,7 @@
},
"EMPTY_STATE": {
"TITLE": "No Connected Inboxes",
"SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you. Click the button below to set it up now."
"SUBTITLE": "Connecting an inbox allows the assistant to handle initial questions from your customers before transferring them to you."
}
}
}
@@ -124,6 +124,7 @@
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
@@ -258,6 +259,7 @@
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
@@ -325,6 +327,7 @@
},
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
@@ -538,5 +541,15 @@
},
"VIEW_DETAILS": "View Details"
}
},
"SUMMARY_REPORTS": {
"INBOX": "Inbox",
"AGENT": "Agent",
"TEAM": "Team",
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
"RESOLUTION_COUNT": "Resolution Count",
"CONVERSATIONS": "No. of conversations"
}
}
@@ -11,7 +11,10 @@
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"VIEW_MORE": "View more",
"LOAD_MORE": "Load more",
"SEARCHING_DATA": "Searching",
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus",
@@ -21,6 +24,7 @@
"READ_MORE": "Read more",
"WROTE": "wrote:",
"FROM": "from",
"EMAIL": "email"
"EMAIL": "email",
"EMAIL_SUBJECT": "subject"
}
}
@@ -194,7 +194,7 @@
"SIDEBAR_ITEMS": {
"CHANGE_AVAILABILITY_STATUS": "Change",
"CHANGE_ACCOUNTS": "Switch account",
"SWITCH_WORKSPACE": "Switch workspace",
"SWITCH_ACCOUNT": "Switch account",
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
@@ -265,7 +265,7 @@
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES" : "FAQs",
"CAPTAIN_RESPONSES": "FAQs",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -327,15 +327,27 @@
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
"CURRENT_PLAN": {
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses"
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on"
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
"TITLE": "Manage your subscription",
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"CAPTAIN": {
"TITLE": "Captain",
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
@@ -1,73 +1,59 @@
<script>
<script setup>
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ReadMore from './ReadMore.vue';
export default {
components: {
ReadMore,
const props = defineProps({
author: {
type: String,
default: '',
},
props: {
author: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
setup() {
const { formatMessage, highlightContent } = useMessageFormatter();
return {
formatMessage,
highlightContent,
};
searchTerm: {
type: String,
default: '',
},
data() {
return {
isOverflowing: false,
};
},
computed: {
messageContent() {
return this.formatMessage(this.content);
},
},
mounted() {
this.$watch(() => {
return this.$refs.messageContainer;
}, this.setOverflow);
});
this.$nextTick(this.setOverflow);
},
methods: {
setOverflow() {
const wrap = this.$refs.messageContainer;
if (wrap) {
const message = wrap.querySelector('.message-content');
this.isOverflowing = message.offsetHeight > 150;
}
},
escapeHtml(html) {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
},
prepareContent(content = '') {
const escapedText = this.escapeHtml(content);
return this.highlightContent(
escapedText,
this.searchTerm,
'searchkey--highlight'
);
},
},
const { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
const isOverflowing = ref(false);
const setOverflow = () => {
const wrap = messageContainer.value;
if (wrap) {
const message = wrap.querySelector('.message-content');
isOverflowing.value = message.offsetHeight > 150;
}
};
const escapeHtml = html => {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
};
const prepareContent = (content = '') => {
const escapedText = escapeHtml(content);
return highlightContent(
escapedText,
props.searchTerm,
'searchkey--highlight'
);
};
onMounted(() => {
watch(() => {
return messageContainer.value;
}, setOverflow);
nextTick(setOverflow);
});
</script>
<template>
@@ -1,4 +1,6 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
shrink: {
type: Boolean,
@@ -18,17 +20,18 @@ defineEmits(['expand']);
>
<slot />
<div
class="bg-n-slate-3 rounded-md dark:bg-n-solid-3 absolute left-0 right-0 z-20 mx-auto mt-0 max-w-max bottom-2 backdrop-blur[100px]"
v-if="shrink"
class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent from-n-background flex items-end justify-center pb-2"
>
<woot-button
v-if="shrink"
size="tiny"
variant="smooth"
color-scheme="primary"
<NextButton
:label="$t('SEARCH.READ_MORE')"
icon="i-lucide-chevrons-down"
blue
xs
faded
class="backdrop-filter backdrop-blur-[2px]"
@click.prevent="$emit('expand')"
>
{{ $t('SEARCH.READ_MORE') }}
</woot-button>
/>
</div>
</div>
</div>
@@ -1,52 +1,51 @@
<script>
export default {
emits: ['search'],
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { debounce } from '@chatwoot/utils';
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
document.addEventListener('keydown', this.handler);
},
unmounted() {
document.removeEventListener('keydown', this.handler);
},
methods: {
handler(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
this.$refs.searchInput.focus();
} else if (
e.key === 'Escape' &&
document.activeElement.tagName === 'INPUT'
) {
e.preventDefault();
this.$refs.searchInput.blur();
}
},
debounceSearch(e) {
this.searchQuery = e.target.value;
clearTimeout(this.debounce);
this.debounce = setTimeout(async () => {
if (this.searchQuery.length > 2 || this.searchQuery.match(/^[0-9]+$/)) {
this.$emit('search', this.searchQuery);
} else {
this.$emit('search', '');
}
}, 500);
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
},
const emit = defineEmits(['search']);
const searchQuery = ref('');
const isInputFocused = ref(false);
const searchInput = useTemplateRef('searchInput');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
};
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = e => {
searchQuery.value = e.target.value;
debouncedEmit(searchQuery.value);
};
const onFocus = () => {
isInputFocused.value = true;
};
const onBlur = () => {
isInputFocused.value = false;
};
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
@@ -76,7 +75,7 @@ export default {
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="debounceSearch"
@input="onInput"
/>
<woot-label
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
@@ -1,35 +0,0 @@
<template>
<div class="search-input-box">
<woot-button
class="hollow"
size="small"
color-scheme="secondary"
is-expanded
>
<div class="search-input">
<fluent-icon icon="search" size="14px" class="search--icon" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap search-placeholder"
>
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
</span>
</div>
</woot-button>
</div>
</template>
<style lang="scss" scoped>
.search-input-box {
@apply p-2;
}
.search--icon {
@apply flex-shrink-0 text-slate-500 dark:text-slate-300;
}
.search-placeholder {
@apply text-slate-500 dark:text-slate-300;
}
.search-input {
@apply flex justify-center items-center gap-1;
}
</style>
@@ -1,6 +1,8 @@
<script setup>
import { frontendURL } from 'dashboard/helper/URLHelper';
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: {
@@ -37,28 +39,27 @@ const navigateTo = computed(() => {
<template>
<router-link
:to="navigateTo"
class="flex items-center p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<woot-thumbnail :src="thumbnail" :username="name" size="24px" />
<div class="ml-2 rtl:mr-2 rtl:ml-0">
<h5 class="text-sm name text-n-slate-12 dark:text-n-slate-12">
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-0.5"
/>
<div class="ml-2 rtl:mr-2 min-w-0 rtl:ml-0">
<h5 class="text-sm name truncate min-w-0 text-n-slate-12">
{{ name }}
</h5>
<p
class="flex items-center gap-1 m-0 text-sm text-slate-600 dark:text-slate-200"
class="grid items-center m-0 gap-1 text-sm grid-cols-[minmax(0,1fr)_auto_auto]"
>
<span v-if="email" class="email text-n-slate-12 dark:text-n-slate-12">{{
email
}}</span>
<span
v-if="phone"
class="separator text-n-slate-10 dark:text-n-slate-10"
>
•
</span>
<span v-if="phone" class="phone text-n-slate-12 dark:text-n-slate-12">
{{ phone }}
<span v-if="email" class="truncate text-n-slate-12" :title="email">
{{ email }}
</span>
<span v-if="phone" class="text-n-slate-10">•</span>
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
</p>
</div>
</router-link>
@@ -1,38 +1,29 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultContactItem from './SearchResultContactItem.vue';
export default {
components: {
SearchResultSection,
SearchResultContactItem,
defineProps({
contacts: {
type: Array,
default: () => [],
},
props: {
contacts: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
};
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
@@ -43,7 +34,7 @@ export default {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="contacts.length" class="space-y-1.5">
<ul v-if="contacts.length" class="space-y-1.5 list-none">
<li v-for="contact in contacts" :key="contact.id">
<SearchResultContactItem
:id="contact.id"
@@ -1,72 +1,95 @@
<script>
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
export default {
components: {
InboxName,
const props = defineProps({
id: {
type: Number,
default: 0,
},
props: {
id: {
type: Number,
default: 0,
},
inbox: {
type: Object,
default: () => ({}),
},
name: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
inbox: {
type: Object,
default: () => ({}),
},
computed: {
navigateTo() {
const params = {};
if (this.messageId) {
params.messageId = this.messageId;
}
return frontendURL(
`accounts/${this.accountId}/conversations/${this.id}`,
params
);
},
createdAtTime() {
return dynamicTime(this.createdAt);
},
name: {
type: String,
default: '',
},
};
email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
emailSubject: {
type: String,
default: '',
},
});
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = dynamicTime(props.createdAt);
const infoItems = computed(() => [
{
label: 'SEARCH.FROM',
value: props.name,
show: !!props.name,
},
{
label: 'SEARCH.EMAIL',
value: props.email,
show: !!props.email,
},
{
label: 'SEARCH.EMAIL_SUBJECT',
value: props.emailSubject,
show: !!props.emailSubject,
},
]);
const visibleInfoItems = computed(() =>
infoItems.value.filter(item => item.show)
);
</script>
<template>
<router-link
:to="navigateTo"
class="flex p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3"
class="flex p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<div
class="flex items-center justify-center flex-shrink-0 w-6 h-6 rounded bg-n-brand/10 dark:bg-n-brand/40 text-n-blue-text dark:text-n-blue-text"
>
<fluent-icon icon="chat-multiple" :size="14" />
</div>
<Avatar
name="chats"
:size="24"
icon-name="i-lucide-messages-square"
class="[&>span]:rounded"
/>
<div class="flex-grow min-w-0 ml-2">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center min-w-0 justify-between gap-1 mb-1">
<div class="flex">
<woot-label
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
@@ -83,36 +106,24 @@ export default {
/>
</div>
</div>
<div>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<span
class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<div class="flex gap-2">
<div class="flex flex-wrap gap-x-2 gap-y-1.5">
<h5
v-if="name"
class="m-0 text-sm text-n-slate-12 dark:text-n-slate-12"
v-for="item in visibleInfoItems"
:key="item.label"
class="m-0 text-sm min-w-0 text-n-slate-12 dark:text-n-slate-12 truncate"
>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ $t('SEARCH.FROM') }}:
{{ $t(item.label) }}:
</span>
{{ name }}
</h5>
<h5
v-if="email"
class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 whitespace-nowrap text-ellipsis"
>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ $t('SEARCH.EMAIL') }}:
</span>
{{ email }}
{{ item.value }}
</h5>
</div>
<slot />
@@ -1,37 +1,36 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { defineProps, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
export default {
components: {
SearchResultSection,
SearchResultConversationItem,
const props = defineProps({
conversations: {
type: Array,
default: () => [],
},
props: {
conversations: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
};
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
const conversationsWithSubject = computed(() => {
return props.conversations.map(conversation => ({
...conversation,
mail_subject: conversation.additional_attributes?.mail_subject || '',
}));
});
</script>
<template>
@@ -39,11 +38,14 @@ export default {
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
:empty="!conversations.length"
:query="query"
:show-title="showTitle || isFetching"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="conversations.length" class="space-y-1.5">
<li v-for="conversation in conversations" :key="conversation.id">
<ul v-if="conversations.length" class="space-y-1.5 list-none">
<li
v-for="conversation in conversationsWithSubject"
:key="conversation.id"
>
<SearchResultConversationItem
:id="conversation.id"
:name="conversation.contact.name"
@@ -51,6 +53,7 @@ export default {
:account-id="accountId"
:inbox="conversation.inbox"
:created-at="conversation.created_at"
:email-subject="conversation.mail_subject"
/>
</li>
</ul>
@@ -1,45 +1,37 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue';
export default {
components: {
SearchResultConversationItem,
SearchResultSection,
MessageContent,
defineProps({
messages: {
type: Array,
default: () => [],
},
props: {
messages: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
methods: {
getName(message) {
return message && message.sender && message.sender.name
? message.sender.name
: this.$t('SEARCH.BOT_LABEL');
},
showTitle: {
type: Boolean,
default: true,
},
});
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const getName = message => {
return message && message.sender && message.sender.name
? message.sender.name
: t('SEARCH.BOT_LABEL');
};
</script>
@@ -51,7 +43,7 @@ export default {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="messages.length" class="space-y-1.5">
<ul v-if="messages.length" class="space-y-1.5 list-none">
<li v-for="message in messages" :key="message.id">
<SearchResultConversationItem
:id="message.conversation_id"
@@ -32,14 +32,14 @@ const titleCase = computed(() => props.title.toLowerCase());
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
</div>
<slot />
<woot-loading-state
v-if="isFetching"
:message="$t('SEARCH.SEARCHING_DATA')"
:message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
/>
<slot v-else />
<div
v-if="empty && !isFetching"
class="flex items-center justify-center px-4 py-6 m-2 rounded-md bg-n-slate-2 dark:bg-n-solid-3"
class="flex items-center justify-center px-4 py-6 m-2 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="mx-2 my-0 text-center text-n-slate-11">
@@ -1,39 +1,38 @@
<script>
export default {
props: {
tabs: {
type: Array,
default: () => [],
},
selectedTab: {
type: Number,
default: 0,
},
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
tabs: {
type: Array,
default: () => [],
},
emits: ['tabChange'],
data() {
return {
activeTab: 0,
};
},
watch: {
selectedTab(value, oldValue) {
if (value !== oldValue) {
this.activeTab = this.selectedTab;
}
},
},
methods: {
onTabChange(index) {
this.activeTab = index;
this.$emit('tabChange', this.tabs[index].key);
},
selectedTab: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['tabChange']);
const activeTab = ref(props.selectedTab);
watch(
() => props.selectedTab,
(value, oldValue) => {
if (value !== oldValue) {
activeTab.value = props.selectedTab;
}
}
);
const onTabChange = index => {
activeTab.value = index;
emit('tabChange', props.tabs[index].key);
};
</script>
<template>
<div class="tab-container">
<div class="mt-1 border-b border-n-weak">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item
v-for="(item, index) in tabs"
@@ -41,13 +40,8 @@ export default {
:index="index"
:name="item.name"
:count="item.count"
:show-badge="item.showBadge"
/>
</woot-tabs>
</div>
</template>
<style lang="scss" scoped>
.tab-container {
@apply mt-1 border-b border-solid border-slate-100 dark:border-slate-800/50;
}
</style>
@@ -1,12 +1,9 @@
<script>
import SearchHeader from './SearchHeader.vue';
import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import Policy from 'dashboard/components/policy.vue';
import { useI18n } from 'vue-i18n';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -16,197 +13,233 @@ import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
components: {
SearchHeader,
SearchTabs,
SearchResultContactsList,
SearchResultConversationsList,
SearchResultMessagesList,
Policy,
ButtonV4,
},
data() {
return {
selectedTab: 'all',
query: '',
contactPermissions: CONTACT_PERMISSIONS,
conversationPermissions: CONVERSATION_PERMISSIONS,
rolePermissions: ROLES,
};
},
import Policy from 'dashboard/components/policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SearchHeader from './SearchHeader.vue';
import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
contactRecords: 'conversationSearch/getContactRecords',
conversationRecords: 'conversationSearch/getConversationRecords',
messageRecords: 'conversationSearch/getMessageRecords',
uiFlags: 'conversationSearch/getUIFlags',
}),
contacts() {
return this.contactRecords.map(contact => ({
...contact,
type: 'contact',
}));
},
conversations() {
return this.conversationRecords.map(conversation => ({
...conversation,
type: 'conversation',
}));
},
messages() {
return this.messageRecords.map(message => ({
...message,
type: 'message',
}));
},
all() {
return [...this.contacts, ...this.conversations, ...this.messages];
},
filterContacts() {
return this.selectedTab === 'contacts' || this.isSelectedTabAll;
},
filterConversations() {
return this.selectedTab === 'conversations' || this.isSelectedTabAll;
},
filterMessages() {
return this.selectedTab === 'messages' || this.isSelectedTabAll;
},
userPermissions() {
return getUserPermissions(this.currentUser, this.currentAccountId);
},
totalSearchResultsCount() {
const permissionCounts = {
contacts: {
permissions: [...this.rolePermissions, this.contactPermissions],
count: () => this.contacts.length,
},
conversations: {
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
count: () => this.conversations.length + this.messages.length,
},
};
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const filteredCounts = filterItemsByPermission(
permissionCounts,
this.userPermissions,
item => item.permissions,
(_, item) => item.count
);
const PER_PAGE = 15; // Results per page
const selectedTab = ref('all');
const query = ref('');
const pages = ref({
contacts: 1,
conversations: 1,
messages: 1,
});
return filteredCounts.reduce((total, count) => total + count(), 0);
},
tabs() {
const allTabsConfig = {
all: {
key: 'all',
name: this.$t('SEARCH.TABS.ALL'),
count: this.totalSearchResultsCount,
permissions: [
this.contactPermissions,
...this.rolePermissions,
...this.conversationPermissions,
],
},
contacts: {
key: 'contacts',
name: this.$t('SEARCH.TABS.CONTACTS'),
count: this.contacts.length,
permissions: [...this.rolePermissions, this.contactPermissions],
},
conversations: {
key: 'conversations',
name: this.$t('SEARCH.TABS.CONVERSATIONS'),
count: this.conversations.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
messages: {
key: 'messages',
name: this.$t('SEARCH.TABS.MESSAGES'),
count: this.messages.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
};
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
);
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
return filterItemsByPermission(
allTabsConfig,
this.userPermissions,
item => item.permissions
);
},
activeTabIndex() {
const index = this.tabs.findIndex(tab => tab.key === this.selectedTab);
return index >= 0 ? index : 0;
},
showEmptySearchResults() {
return (
this.totalSearchResultsCount === 0 &&
this.uiFlags.isSearchCompleted &&
!this.uiFlags.isFetching &&
this.query
);
},
showResultsSection() {
return (
(this.uiFlags.isSearchCompleted &&
this.totalSearchResultsCount !== 0) ||
this.uiFlags.isFetching
);
},
isSelectedTabAll() {
return this.selectedTab === 'all';
},
const addTypeToRecords = (records, type) =>
records.value.map(item => ({ ...item, type }));
const mappedContacts = computed(() =>
addTypeToRecords(contactRecords, 'contact')
);
const mappedConversations = computed(() =>
addTypeToRecords(conversationRecords, 'conversation')
);
const mappedMessages = computed(() =>
addTypeToRecords(messageRecords, 'message')
);
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
const sliceRecordsIfAllTab = items =>
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
const filterByTab = tab =>
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
const filterContacts = filterByTab('contacts');
const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages');
const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
);
const TABS_CONFIG = {
all: {
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => null, // No count for all tab
},
unmounted() {
this.query = '';
this.$store.dispatch('conversationSearch/clearSearchResults');
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => mappedContacts.value.length,
},
mounted() {
this.$store.dispatch('conversationSearch/clearSearchResults');
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedConversations.value.length,
},
methods: {
onSearch(q) {
this.selectedTab = 'all';
this.query = q;
if (!q) {
this.$store.dispatch('conversationSearch/clearSearchResults');
return;
}
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
this.$store.dispatch('conversationSearch/fullSearch', { q });
},
onBack() {
if (window.history.length > 2) {
this.$router.go(-1);
} else {
this.$router.push({ name: 'home' });
}
},
messages: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedMessages.value.length,
},
};
const tabs = computed(() => {
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
key,
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
count: config.count(),
showBadge: key !== 'all',
permissions: config.permissions,
}));
return filterItemsByPermission(
configs,
userPermissions.value,
item => item.permissions
);
});
const totalSearchResultsCount = computed(() => {
const permissionCounts = {
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length,
},
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
};
return filterItemsByPermission(
permissionCounts,
userPermissions.value,
item => item.permissions,
(_, item) => item.count
).reduce((total, count) => total + count(), 0);
});
const activeTabIndex = computed(() => {
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
return index >= 0 ? index : 0;
});
const isFetchingAny = computed(() => {
const { contact, message, conversation, isFetching } = uiFlags.value;
return (
isFetching ||
contact.isFetching ||
message.isFetching ||
conversation.isFetching
);
});
const showEmptySearchResults = computed(
() =>
totalSearchResultsCount.value === 0 &&
uiFlags.value.isSearchCompleted &&
isSelectedTabAll.value &&
!isFetchingAny.value &&
query.value
);
const showResultsSection = computed(
() =>
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
isFetchingAny.value ||
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
);
const showLoadMore = computed(() => {
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
return false;
const records = {
contacts: mappedContacts.value,
conversations: mappedConversations.value,
messages: mappedMessages.value,
}[selectedTab.value];
return (
records?.length > 0 &&
records.length === pages.value[selectedTab.value] * PER_PAGE
);
});
const showViewMore = computed(() => ({
// Hide view more button if the number of records is less than 5
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
conversations:
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
}));
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
const onSearch = q => {
query.value = q;
clearSearchResult();
if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
};
const onBack = () => {
if (window.history.length > 2) {
router.go(-1);
} else {
router.push({ name: 'home' });
}
clearSearchResult();
};
const loadMore = () => {
const SEARCH_ACTIONS = {
contacts: 'conversationSearch/contactSearch',
conversations: 'conversationSearch/conversationSearch',
messages: 'conversationSearch/messageSearch',
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
const tab = selectedTab.value;
pages.value[tab] += 1;
store.dispatch(SEARCH_ACTIONS[tab], {
q: query.value,
page: pages.value[tab],
});
};
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
});
onUnmounted(() => {
query.value = '';
store.dispatch('conversationSearch/clearSearchResults');
});
</script>
<template>
<div class="flex flex-col w-full bg-n-background">
<div class="flex p-4">
<ButtonV4
:label="$t('GENERAL_SETTINGS.BACK')"
<div class="flex flex-col w-full h-full bg-n-background">
<div class="flex w-full p-4">
<NextButton
:label="t('GENERAL_SETTINGS.BACK')"
icon="i-lucide-chevron-left"
faded
primary
@@ -214,73 +247,119 @@ export default {
@click="onBack"
/>
</div>
<section
class="flex my-0 p-4 relative mx-auto max-w-[45rem] min-h-[20rem] flex-col w-full h-full bg-n-background"
>
<header>
<SearchHeader @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@tab-change="tab => (selectedTab = tab)"
/>
</header>
<div class="flex-grow h-full px-2 py-0 overflow-y-auto">
<div v-if="showResultsSection">
<Policy :permissions="[...rolePermissions, contactPermissions]">
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-4xl mx-auto">
<div class="flex flex-col w-full px-4">
<SearchHeader @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@tab-change="tab => (selectedTab = tab)"
/>
</div>
<div
v-else-if="showEmptySearchResults"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="m-2 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
<div
v-else
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
>
<p class="text-center margin-bottom-0">
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
</p>
<p class="m-2 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE_DEFAULT') }}
</p>
</div>
<div class="flex-grow w-full h-full overflow-y-auto">
<div class="w-full max-w-4xl mx-auto px-4 pb-6">
<div v-if="showResultsSection">
<Policy
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.contacts"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'contacts'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.messages"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'messages'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.conversations"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'conversations'"
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
<NextButton
v-if="!isSelectedTabAll"
:label="t(`SEARCH.LOAD_MORE`)"
icon="i-lucide-cloud-download"
slate
sm
faded
@click="loadMore"
/>
</div>
</div>
<div
v-else-if="showEmptySearchResults"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
<div
v-else-if="!query"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
>
<p class="text-center margin-bottom-0">
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
</p>
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
</p>
</div>
</div>
</div>
</section>
@@ -1,13 +1,15 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
@@ -73,29 +75,37 @@ onMounted(() => store.dispatch('captainAssistants/get'));
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
:button-label="$t('CAPTAIN.ASSISTANTS.ADD_NEW')"
:button-policy="['administrator']"
:show-pagination-footer="false"
:is-fetching="isFetching"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:is-empty="!assistants.length"
@click="handleCreate"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="assistants.length" class="flex flex-col gap-4">
<AssistantCard
v-for="assistant in assistants"
:id="assistant.id"
:key="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
@action="handleAction"
/>
</div>
<template #emptyState>
<AssistantPageEmptyState @click="handleCreate" />
</template>
<AssistantPageEmptyState v-else @click="handleCreate" />
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<AssistantCard
v-for="assistant in assistants"
:id="assistant.id"
:key="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
@action="handleAction"
/>
</div>
</template>
<DeleteDialog
v-if="selectedAssistant"
@@ -6,11 +6,11 @@ import {
useStoreGetters,
} from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConnectInboxDialog from 'dashboard/components-next/captain/pageComponents/inbox/ConnectInboxDialog.vue';
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue';
@@ -67,19 +67,16 @@ onMounted(() =>
</script>
<template>
<div
v-if="isFetchingAssistant"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<PageLayout
v-else
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
:button-policy="['administrator']"
:is-fetching="isFetchingAssistant || isFetching"
:is-empty="!captainInboxes.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:show-pagination-footer="false"
@click="handleCreate"
>
<template #headerTitle>
<template v-if="!isFetchingAssistant" #headerTitle>
<div class="flex flex-row items-center gap-4">
<BackButton compact />
<span class="flex items-center gap-1 text-lg">
@@ -89,23 +86,22 @@ onMounted(() =>
</span>
</div>
</template>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="captainInboxes.length" class="flex flex-col gap-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
@action="handleAction"
/>
</div>
<InboxPageEmptyState v-else @click="handleCreate" />
<template #emptyState>
<InboxPageEmptyState @click="handleCreate" />
</template>
<template #body>
<div class="flex flex-col gap-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
@action="handleAction"
/>
</div>
</template>
<DeleteDialog
v-if="selectedInbox"
@@ -1,4 +1,4 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
// import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
@@ -11,7 +11,6 @@ export const routes = [
component: AssistantIndex,
name: 'captain_assistants_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
@@ -22,7 +21,6 @@ export const routes = [
component: AssistantInboxesIndex,
name: 'captain_assistants_inboxes_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
@@ -31,7 +29,6 @@ export const routes = [
component: DocumentsIndex,
name: 'captain_documents_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
@@ -40,7 +37,6 @@ export const routes = [
component: ResponsesIndex,
name: 'captain_responses_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},

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