Compare commits

...
Author SHA1 Message Date
Pranav 9e6346326d Merge branch 'develop' into design-updates 2024-12-11 19:53:08 -08:00
Sivin VargheseandGitHub 86d37622c8 chore: Remove unused files in contact (#10570) 2024-12-11 19:29:24 -08:00
Shivam MishraandGitHub 99c699ea34 fix: links rendering in sidebar profile (#10574)
This pull request includes several changes to the `DropdownItem.vue` and `SidebarProfileMenu.vue` components to improve the handling of links.

Earlier we passed the link `/super_admin` to RouterLink directly, which would trigger validations internally and the dropdown item would not render in case of any errors. This PR fixes this by handling the native links appropriately

Fixes #10571
2024-12-11 17:42:46 -08:00
PranavandGitHub a3290bfd42 fix: Update design of the conversation sidebar, chatlist (#10572)
- Increase the sidebar and chatlist width on screens bigger than 2xl
- Fix background of the copilot sidebar
- Increase the breakpoint for hiding chatlist from 1024 to 1200
2024-12-11 08:06:21 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ccb117d36a chore(deps): bump actionpack from 7.0.8.5 to 7.0.8.7 (#10569)
Bumps [actionpack](https://github.com/rails/rails) from 7.0.8.5 to
7.0.8.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails/releases">actionpack's
releases</a>.</em></p>
<blockquote>
<h2>7.0.8.7</h2>
<h2>Active Support</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Model</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Record</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action View</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Pack</h2>
<ul>
<li>
<p>Add validation to content security policies to disallow spaces and
semicolons.
Developers should use multiple arguments, and different directive
methods instead.</p>
<p>[CVE-2024-54133]</p>
<p><em>Gannon McGibbon</em></p>
</li>
</ul>
<h2>Active Job</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Mailer</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Cable</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Storage</h2>
<ul>
<li>No changes.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rails/rails/commit/778eab826538be6da355d848aecaea2245e3b8ce"><code>778eab8</code></a>
Preparing for 7.0.8.7 release</li>
<li><a
href="https://github.com/rails/rails/commit/cb16a3bb515b5d769f73926d9757270ace691f1d"><code>cb16a3b</code></a>
Add CSP directive validation</li>
<li><a
href="https://github.com/rails/rails/commit/bc979c5cf4e29e2aba95b7b13ca53e7a02fddee2"><code>bc979c5</code></a>
Preparing for 7.0.8.6 release</li>
<li>See full diff in <a
href="https://github.com/rails/rails/compare/v7.0.8.5...v7.0.8.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actionpack&package-manager=bundler&previous-version=7.0.8.5&new-version=7.0.8.7)](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>
2024-12-10 18:53:43 -08:00
PranavandGitHub 10a0333980 feat(ee): Add copilot integration (v1) to the conversation sidebar (#10566) 2024-12-10 15:36:48 -08:00
9a405d65ba fix: RemoveStaleRedisKeys service (#10562)
Fixes issues with RemoveStaleRedisKeys service

Co-authored-by: Sojan <sojan@pepalo.com>
2024-12-11 03:38:25 +08:00
Sivin VargheseandGitHub 1b0e94ec95 feat: Flag icon component (#10564) 2024-12-10 11:53:24 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aebcbb63e4 chore(deps): bump nanoid from 3.3.7 to 3.3.8 (#10565)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/ai/nanoid/blob/main/CHANGELOG.md">nanoid's
changelog</a>.</em></p>
<blockquote>
<h2>3.3.8</h2>
<ul>
<li>Fixed a way to break Nano ID by passing non-integer size (by <a
href="https://github.com/myndzi"><code>@​myndzi</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/ai/nanoid/commit/3044cd5e73f4cf31795f61f6e6b961c8c0a5c744"><code>3044cd5</code></a>
Release 3.3.8 version</li>
<li><a
href="https://github.com/ai/nanoid/commit/4fe34959c34e5b3573889ed4f24fe91d1d3e7231"><code>4fe3495</code></a>
Update size limit</li>
<li><a
href="https://github.com/ai/nanoid/commit/d643045f40d6dc8afa000a644d857da1436ed08c"><code>d643045</code></a>
Fix pool pollution, infinite loop (<a
href="https://redirect.github.com/ai/nanoid/issues/510">#510</a>)</li>
<li>See full diff in <a
href="https://github.com/ai/nanoid/compare/3.3.7...3.3.8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nanoid&package-manager=npm_and_yarn&previous-version=3.3.7&new-version=3.3.8)](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>
2024-12-09 17:48:46 -08:00
472f6d9345 feat: Ability to lock the conversation to a single thread in API channels (#10329)
Added the possibility to mark as a single conversation in the API type
inbox. This allows the conversation builder to search for the last
conversation.
I thought about searching for the last conversation with created_at:
desc order, as is done in some channels... but I didn't change the way
the conversation is searched.

Fixes: #7726 

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-12-09 23:36:17 +08:00
Shivam MishraandGitHub 2ce7c8b845 fix: sidebar collapsed on reload (#10561)
When reloading a page, the sidebar item that is actively selected is
collapsed by default. This PR fixes it by expanding it on reload
2024-12-09 15:59:26 +05:30
a11746106f feat: Update design for report pages (#10506)
<img width="1440" alt="Screenshot 2024-11-26 at 8 38 57 PM"
src="https://github.com/user-attachments/assets/f752157c-6134-42cb-8211-ce636ea9e4d6">
<img width="1439" alt="Screenshot 2024-11-26 at 8 40 47 PM"
src="https://github.com/user-attachments/assets/580b1f61-68bc-489b-9081-b0aeb402f31d">

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-12-05 10:01:07 -08:00
Pranav 1f75bed30f Merge branch 'develop' into design-updates 2024-12-05 09:57:22 -08:00
Pranav 2989fab4d1 Merge branch 'develop' into design-updates 2024-11-20 20:59:22 -08:00
Pranav bf3eace7a2 Update billing 2024-11-20 18:38:07 -08:00
Pranav 226d0db11c Update design 2024-11-20 18:31:24 -08:00
Shivam MishraandPranav 82c73f18d9 Update design 2024-11-20 17:13:18 -08:00
Pranav 16432a6ebc Design Updates 2024-11-20 15:56:14 -08:00
158 changed files with 1806 additions and 4865 deletions
+58 -58
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
actioncable (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailbox (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.5)
actionpack (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailer (7.0.8.7)
actionpack (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionpack (7.0.8.7)
actionview (= 7.0.8.7)
activesupport (= 7.0.8.7)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.5)
actionpack (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actiontext (7.0.8.7)
actionpack (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
actionview (7.0.8.7)
activesupport (= 7.0.8.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.5)
activesupport (= 7.0.8.5)
activejob (7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.3.6)
activemodel (7.0.8.5)
activesupport (= 7.0.8.5)
activerecord (7.0.8.5)
activemodel (= 7.0.8.5)
activesupport (= 7.0.8.5)
activemodel (7.0.8.7)
activesupport (= 7.0.8.7)
activerecord (7.0.8.7)
activemodel (= 7.0.8.7)
activesupport (= 7.0.8.7)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activesupport (= 7.0.8.5)
activestorage (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activesupport (= 7.0.8.7)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.5)
activesupport (7.0.8.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -472,7 +472,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.1)
minitest (5.25.4)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -502,14 +502,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.8)
nokogiri (1.17.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.8-arm64-darwin)
nokogiri (1.17.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.8-x86_64-darwin)
nokogiri (1.17.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.8-x86_64-linux)
nokogiri (1.17.1-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -581,20 +581,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.5)
actioncable (= 7.0.8.5)
actionmailbox (= 7.0.8.5)
actionmailer (= 7.0.8.5)
actionpack (= 7.0.8.5)
actiontext (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activemodel (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
rails (7.0.8.7)
actioncable (= 7.0.8.7)
actionmailbox (= 7.0.8.7)
actionmailer (= 7.0.8.7)
actionpack (= 7.0.8.7)
actiontext (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activemodel (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
bundler (>= 1.15.0)
railties (= 7.0.8.5)
railties (= 7.0.8.7)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -602,9 +602,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
railties (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -2,10 +2,22 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
before_action :hook
def proxy
request_url = build_request_url(request_path)
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
def copilot
request_url = build_request_url(build_request_path("/assistants/#{hook.settings['assistant_id']}/copilot"))
params = {
previous_messages: copilot_params[:previous_messages],
conversation_history: conversation_history,
message: copilot_params[:message]
}
response = HTTParty.send(:post, request_url, body: params.to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def headers
@@ -17,15 +29,19 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
}
end
def build_request_path(route)
"api/accounts/#{hook.settings['account_id']}#{route}"
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
build_request_path(request_route)
end
def request_url
def build_request_url(request_path)
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
@@ -47,6 +63,15 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def conversation_history
conversation = Current.account.conversations.find_by!(display_id: copilot_params[:conversation_id])
conversation.to_llm_text
end
def copilot_params
params.permit(:previous_messages, :conversation_id, :message)
end
def permitted_params
params.permit(:method, :route, body: {})
end
@@ -36,6 +36,10 @@ class IntegrationsAPI extends ApiClient {
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
requestCaptainCopilot(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body);
}
}
export default new IntegrationsAPI();
@@ -72,6 +72,19 @@
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
@@ -147,6 +160,19 @@
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
@@ -18,7 +18,7 @@
}
&.multiselect--disabled {
@apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
@apply opacity-50 border border-n-weak rounded-md cursor-not-allowed;
.multiselect__select {
@apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
@@ -44,7 +44,7 @@
}
.multiselect__content-wrapper {
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
@apply bg-white dark:bg-slate-900 border border-solid border-n-weak text-slate-800 dark:text-slate-100;
}
.multiselect__content {
@@ -96,7 +96,7 @@
}
.multiselect__tags {
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
@apply bg-n-slate-1 border border-solid border-n-weak m-0 min-h-[2.875rem] pt-0;
input {
@apply border-0 border-none;
@@ -121,7 +121,7 @@
.multiselect__input {
@include ghost-input;
@apply text-sm h-[2.875rem] mb-0 p-0;
@apply text-sm !h-[2.375rem] mb-0 !py-0 ;
}
.multiselect__single {
@@ -179,7 +179,7 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@@ -74,7 +74,10 @@ input[type='password']:not(.reset-base),
input[type='date']:not(.reset-base),
input[type='email']:not(.reset-base),
input[type='url']:not(.reset-base) {
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
@apply block box-border w-full transition-colors;
@apply focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out];
@apply h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md font-normal text-sm;
@apply bg-n-slate-1 text-n-slate-12 border border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 focus:border-n-brand dark:focus:border-n-brand;
&[disabled] {
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
@@ -90,7 +93,9 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-position: right -1rem center;
background-size: 9px 6px;
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2;
@apply rounded-md w-full text-base font-normal appearance-none transition-colors;
@apply text-sm duration-[0.25s] ease-[ease-in-out] bg-n-slate-1 text-n-slate-12 border border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 focus:border-n-brand dark:focus:border-n-brand;
}
// Textarea
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-slate-50 dark:border-slate-800/50;
@apply border-b border-n-weak;
}
.tabs {
@@ -19,22 +19,12 @@
@apply items-center rounded-none cursor-pointer flex h-auto justify-center min-w-[2rem];
}
// Tab chat type
.tab--chat-type {
@apply flex;
.tabs-title {
a {
@apply text-base font-medium py-3;
}
}
}
.tabs-title {
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-slate-50 dark:bg-slate-800 rounded-md text-slate-600 dark:text-slate-100 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
@apply bg-n-slate-3 rounded-xl text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@@ -48,22 +38,22 @@
&:hover,
&:focus {
a {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-11;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-slate-500 dark:text-slate-200 text-sm top-[1px] relative;
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-10 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-woot-500 text-woot-500 dark:text-woot-500;
@apply border-b border-n-brand text-n-brand;
}
.badge {
@apply bg-woot-50 dark:bg-woot-500 text-woot-500 dark:text-woot-50 dark:bg-opacity-40;
@apply bg-n-slate-3 text-n-brand;
}
}
}
@@ -6,6 +6,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -56,13 +57,19 @@ const countryDetails = computed(() => {
if (!activeCountry) return null;
const parts = [
activeCountry.emoji,
city ? `${city},` : null,
activeCountry.name,
].filter(Boolean);
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
return parts.length ? parts.join(' ') : null;
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
const handleFormUpdate = updatedData => {
@@ -114,8 +121,12 @@ const onClickViewDetails = () => emit('showContact', props.id);
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="countryDetails" class="text-sm truncate text-n-slate-11">
{{ countryDetails }}
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
@@ -106,10 +106,15 @@ const onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.SUCCESS_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useAlert(
error.message ??
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.ERROR_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
@@ -0,0 +1,60 @@
<script setup>
import { ref } from 'vue';
import Copilot from './Copilot.vue';
const supportAgent = {
available_name: 'Pranav Raj',
avatar_url:
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
};
const messages = ref([
{
id: 1,
role: 'user',
content: 'Hi there! How can I help you today?',
},
{
id: 2,
role: 'assistant',
content:
"Hello! I'm the AI assistant. I'll be helping the support team today.",
},
]);
const isCaptainTyping = ref(false);
const sendMessage = message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
// Simulate AI response
isCaptainTyping.value = true;
setTimeout(() => {
isCaptainTyping.value = false;
messages.value.push({
id: messages.value.length + 1,
role: 'assistant',
content: 'This is a simulated AI response.',
});
}, 2000);
};
</script>
<template>
<Story
title="Captain/Copilot"
:layout="{ type: 'grid', width: '400px', height: '800px' }"
>
<Copilot
:support-agent="supportAgent"
:messages="messages"
:is-captain-typing="isCaptainTyping"
@send-message="sendMessage"
/>
</Story>
</template>
@@ -0,0 +1,68 @@
<script setup>
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';
const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: {
type: Array,
default: () => [],
},
isCaptainTyping: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['sendMessage']);
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
await nextTick();
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
};
watch(
[() => props.messages, () => props.isCaptainTyping],
() => {
scrollToBottom();
},
{ deep: true }
);
</script>
<template>
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 overflow-y-auto py-4 space-y-6 px-4">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<CopilotInput class="mx-3 mb-4 mt-px" @send="sendMessage" />
</div>
</template>
@@ -0,0 +1,31 @@
<script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({
message: {
type: Object,
required: true,
},
supportAgent: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
:name="supportAgent.available_name"
:src="supportAgent.avatar_url"
:size="24"
rounded-full
/>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
name="Captain Copilot"
icon-name="i-woot-captain"
:size="24"
rounded-full
/>
<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>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { ref } from 'vue';
const emit = defineEmits(['send']);
const message = ref('');
const sendMessage = () => {
if (message.value.trim()) {
emit('send', message.value);
message.value = '';
}
};
</script>
<template>
<form
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
@submit.prevent="sendMessage"
>
<input
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
@keyup.enter="sendMessage"
/>
<button
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
type="submit"
>
<i class="i-ph-arrow-up" />
</button>
</form>
</template>
@@ -0,0 +1,12 @@
<script setup>
import CopilotLoader from './CopilotLoader.vue';
</script>
<template>
<Story
title="Captain/CopilotLoader"
:layout="{ type: 'grid', width: '400px', height: '800px' }"
>
<CopilotLoader />
</Story>
</template>
@@ -0,0 +1,22 @@
<script>
// Copilot Loader Component
</script>
<template>
<div class="flex justify-start">
<div class="flex items-center space-x-2">
<span class="text-n-iris-11 font-medium">
{{ $t('CAPTAIN.COPILOT.LOADER') }}
</span>
<div class="flex space-x-1">
<div
class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce [animation-delay:-0.3s]"
/>
<div
class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce [animation-delay:-0.15s]"
/>
<div class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce" />
</div>
</div>
</div>
</template>
@@ -6,7 +6,8 @@ import { useDropdownContext } from './provider.js';
const props = defineProps({
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
link: { type: String, default: '' },
link: { type: [String, Object], default: '' },
nativeLink: { type: Boolean, default: false },
click: { type: Function, default: null },
preserveOpen: { type: Boolean, default: false },
});
@@ -18,7 +19,13 @@ defineOptions({
const { closeMenu } = useDropdownContext();
const componentIs = computed(() => {
if (props.link) return 'router-link';
if (props.link) {
if (props.nativeLink && typeof props.link === 'string') {
return 'a';
}
return 'router-link';
}
if (props.click) return 'button';
return 'div';
@@ -41,7 +48,8 @@ const triggerClick = () => {
:class="{
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
}"
:href="props.link || null"
:href="componentIs === 'a' ? props.link : null"
:to="componentIs === 'router-link' ? props.link : null"
@click="triggerClick"
>
<slot>
@@ -0,0 +1,24 @@
<script setup>
import { h, defineProps } from 'vue';
const props = defineProps({
country: { type: String, required: true },
squared: { type: Boolean, default: false },
});
const renderFlag = () => {
const classes = ['fi', `fi-${props.country.toLowerCase()}`, 'flex-shrink-0'];
if (props.squared) {
classes.push('fis');
}
return h('span', { class: classes });
};
</script>
<template>
<component :is="renderFlag" />
</template>
<style>
@import 'flag-icons/css/flag-icons.min.css';
</style>
@@ -0,0 +1,93 @@
<script setup>
import { ref } from 'vue';
import Flag from '../Flag.vue';
import countries from 'shared/constants/countries';
const BasicTemplate = {
components: { Flag },
props: {
country: {
type: String,
default: 'us',
},
squared: {
type: Boolean,
default: false,
},
},
template: `
<div class="flex items-center gap-4 p-4 border rounded border-n-weak">
<Flag :country="country" :squared="squared" />
</div>
`,
};
const SizeVariants = {
components: { Flag },
setup() {
const isSquared = ref(false);
return { isSquared };
},
template: `
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2">
<input type="checkbox" v-model="isSquared">
Squared flags
</label>
<div class="flex items-center gap-4 p-4 border rounded border-n-weak">
<Flag country="in" class="!size-4" :squared="isSquared" />
<Flag country="in" class="!size-6" :squared="isSquared" />
<Flag country="in" class="!size-8" :squared="isSquared" />
<Flag country="in" class="!size-10" :squared="isSquared" />
</div>
</div>
`,
};
const AllFlags = {
components: { Flag },
setup() {
const isSquared = ref(false);
return { countries, isSquared };
},
template: `
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2">
<input type="checkbox" v-model="isSquared">
Squared flags
</label>
<div class="grid grid-cols-2 gap-4 p-4 border rounded border-n-strong md:grid-cols-3 lg:grid-cols-4">
<div
v-for="country in countries"
:key="country.id"
class="flex items-center gap-2 px-4 py-2 border rounded border-n-strong"
>
<Flag
:country="country.id"
:squared="isSquared"
class="size-6"
/>
<span class="text-sm">{{ country.name }}</span>
</div>
</div>
</div>
`,
};
</script>
<template>
<Story title="Components/Flag">
<Variant title="Basic Usage">
<BasicTemplate country="us" :squared="false" />
</Variant>
<Variant title="Size Variants">
<SizeVariants />
</Variant>
<Variant title="All Flags">
<AllFlags />
</Variant>
</Story>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, onMounted, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -113,6 +113,13 @@ const toggleTrigger = () => {
}
setExpandedItem(props.name);
};
onMounted(async () => {
await nextTick();
if (hasActiveChild.value) {
setExpandedItem(props.name);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
@@ -21,7 +20,6 @@ defineOptions({
});
const { t } = useI18n();
const router = useRouter();
const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser');
@@ -49,9 +47,7 @@ const menuItems = computed(() => {
show: true,
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen',
click: () => {
router.push({ name: 'profile_settings_index' });
},
link: { name: 'profile_settings_index' },
},
{
show: true,
@@ -66,15 +62,16 @@ const menuItems = computed(() => {
show: true,
label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book',
click: () => {
window.open('https://www.chatwoot.com/hc/user-guide/en', '_blank');
},
link: 'https://www.chatwoot.com/hc/user-guide/en',
nativeLink: true,
target: '_blank',
},
{
show: currentUser.value.type === 'SuperAdmin',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
icon: 'i-lucide-castle',
link: '/super_admin',
nativeLink: true,
target: '_blank',
},
{
@@ -782,10 +782,10 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
class="flex flex-col flex-shrink-0 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
]"
>
<slot />
@@ -916,12 +916,3 @@ watch(conversationFilters, (newVal, oldVal) => {
</Teleport>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.flex-basis-clamp {
flex-basis: clamp(20rem, 4vw + 21.25rem, 27.5rem);
}
}
</style>
+12 -6
View File
@@ -75,9 +75,9 @@ onMounted(() => {
@mousedown="handleMouseDown"
>
<div
class="relative max-h-full overflow-auto bg-white shadow-md modal-container rtl:text-right dark:bg-slate-800 skip-context-menu"
class="relative max-h-full overflow-auto shadow-xl modal-container rtl:text-right bg-n-alpha-3 backdrop-blur-[100px] skip-context-menu"
:class="{
'rounded-xl w-[37.5rem]': !fullWidth,
'rounded-xl w-[36rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
@@ -102,38 +102,44 @@ onMounted(() => {
<style lang="scss">
.modal-mask {
@apply flex items-center justify-center bg-modal-backdrop-light dark:bg-modal-backdrop-dark z-[9990] h-full left-0 fixed top-0 w-full;
.modal-container {
&.medium {
@apply max-w-[80%] w-[56.25rem];
}
// .content-box {
// @apply h-auto p-0;
// }
.content {
@apply p-8;
@apply p-6;
}
form,
.modal-content {
@apply pt-4 pb-8 px-8 self-center;
a {
@apply p-4;
}
}
}
}
.modal-big {
@apply w-full;
}
.modal-mask.right-aligned {
@apply justify-end;
.modal-container {
@apply rounded-none h-full w-[30rem];
}
}
.modal-enter,
.modal-leave {
@apply opacity-0;
}
.modal-enter .modal-container,
.modal-leave .modal-container {
transform: scale(1.1);
@@ -23,13 +23,13 @@ export default {
<template>
<div
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row"
class="ml-0 mr-0 flex py-8 w-full flex-col xl:flex-row"
:class="{
'border-b border-solid border-slate-50 dark:border-slate-700/30':
showBorder,
}"
>
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12">
<div class="w-full xl:w-1/3 min-w-0 xl:max-w-[40%] pr-12">
<p
v-if="title"
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
@@ -48,7 +48,7 @@ export default {
{{ note }}
</p>
</div>
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]">
<div class="w-full xl:w-2/3 min-w-0 xl:max-w-[50%]">
<slot />
</div>
</div>
@@ -0,0 +1,58 @@
<script setup>
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import { useMapGetter } from 'dashboard/composables/store';
import { ref } from 'vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const currentUser = useMapGetter('getCurrentUser');
const messages = ref([]);
const isCaptainTyping = ref(false);
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
isCaptainTyping.value = true;
try {
const { data } = await IntegrationsAPI.requestCaptainCopilot({
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
conversation_id: props.conversationId,
});
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
}
};
</script>
<template>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
@send-message="sendMessage"
/>
</template>
@@ -89,7 +89,7 @@ export default {
@apply bg-woot-500 dark:bg-woot-500;
}
& + .item {
&+.item {
&::before {
@apply bg-woot-500 dark:bg-woot-500;
}
@@ -201,10 +201,10 @@ export default {
<style lang="scss" scoped>
.filter {
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
@apply p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
&.is-a-macro {
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none;
@apply mb-0 p-0 border-0 rounded-none;
}
}
@@ -244,6 +244,7 @@ export default {
@apply mb-0;
}
}
.filter__answer {
&.answer--text-input {
@apply mb-0;
@@ -270,9 +271,11 @@ export default {
.multiselect {
@apply mb-0;
}
.action-message {
@apply mt-2 mx-0 mb-0;
}
// Prosemirror does not have a native way of hiding the menu bar, hence
::v-deep .ProseMirror-menubar {
@apply hidden;
@@ -23,14 +23,12 @@ const goBack = () => {
}
};
const buttonStyleClass = props.compact
? 'text-sm text-slate-600 dark:text-slate-300'
: 'text-base text-woot-500 dark:text-woot-500';
const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
</script>
<template>
<button
class="flex items-center p-0 font-normal cursor-pointer"
class="flex items-center p-0 font-normal cursor-pointer text-n-slate-11"
:class="buttonStyleClass"
@click.capture="goBack"
>
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 tab--chat-type"
class="w-full px-4 py-0 font-medium [&_a]:text-base [&_.tabs]:p-0"
@change="onTabChange"
>
<woot-tabs-item
@@ -60,13 +60,3 @@ useKeyboardEvents(keyboardEvents);
/>
</woot-tabs>
</template>
<style scoped lang="scss">
.tab--chat-type {
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -14,16 +14,11 @@ export default {
</script>
<template>
<div
class="bg-slate-25 dark:bg-slate-900 pt-4 pb-0 px-8 border-b border-solid border-slate-50 dark:border-slate-800/50"
>
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
<div class="pb-0 px-8 border-b border-n-weak">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
>
<p v-if="headerContent" class="w-full text-n-slate-11 text-sm mb-2">
{{ headerContent }}
</p>
<slot />
@@ -1,407 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import FilterInputBox from '../FilterInput/Index.vue';
import languages from './advancedFilterItems/languages';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
import { useTrack } from 'dashboard/composables';
export default {
components: {
FilterInputBox,
},
props: {
onClose: {
type: Function,
default: () => {},
},
initialFilterTypes: {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
activeFolderName: {
type: String,
default: '',
},
isFolderView: {
type: Boolean,
default: false,
},
},
emits: ['applyFilter', 'updateFolder'],
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeFolderNewName: this.activeFolderName,
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
allCustomAttributes: [],
attributeModel: 'conversation_attribute',
filtersFori18n: 'FILTER',
validationErrors: {},
};
},
computed: {
...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters',
}),
filterModalHeaderTitle() {
return !this.isFolderView
? this.$t('FILTER.TITLE')
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
},
filterModalSubTitle() {
return !this.isFolderView
? this.$t('FILTER.SUBTITLE')
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters];
} else if (!this.isFolderView) {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
attribute_model: 'standard',
});
}
},
methods: {
getOperatorTypes(key) {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
},
customAttributeInputType(key) {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
},
getAttributeModel(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
getInputType(key, operator) {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.filterOperators;
},
statusFilterItems() {
return {
open: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
},
resolved: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
},
pending: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
},
snoozed: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
},
all: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
},
};
},
getDropdownValues(type) {
const statusFilters = this.statusFilterItems();
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type &&
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox) {
return [
{
id: true,
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
},
{
id: false,
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
},
];
}
const isCustomAttributeList = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
if (isCustomAttributeList) {
return allCustomAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
}
switch (type) {
case 'status':
return [
...Object.keys(statusFilters).map(status => {
return {
id: status,
name: statusFilters[status].TEXT,
};
}),
];
case 'assignee_id':
return this.$store.getters['agents/getAgents'];
case 'contact':
return this.$store.getters['contacts/getContacts'];
case 'inbox_id':
return this.$store.getters['inboxes/getInboxes'];
case 'team_id':
return this.$store.getters['teams/getTeams'];
case 'campaign_id':
return this.$store.getters['campaigns/getAllCampaigns'].map(i => {
return {
id: i.id,
name: i.title,
};
});
case 'labels':
return this.$store.getters['labels/getLabels'].map(i => {
return {
id: i.title,
name: i.title,
};
});
case 'browser_language':
return languages;
case 'country_code':
return countries;
default:
return undefined;
}
},
appendNewFilter() {
if (this.isFolderView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
if (this.appliedFilters.length <= 1) {
useAlert(this.$t('FILTER.FILTER_DELETE_ERROR'));
} else {
this.appliedFilters.splice(index, 1);
}
},
submitFilterQuery() {
this.validationErrors = validateConversationOrContactFilters(
this.appliedFilters
);
if (Object.keys(this.validationErrors).length === 0) {
this.$store.dispatch(
'setConversationFilters',
JSON.parse(JSON.stringify(this.appliedFilters))
);
this.$emit('applyFilter', this.appliedFilters);
useTrack(CONVERSATION_EVENTS.APPLY_FILTER, {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
query_operator: filter.query_operator,
})),
});
}
},
updateSavedCustomViews() {
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
).filterOperators[0].value;
this.appliedFilters[index].values = '';
},
showUserInput(operatorType) {
if (operatorType === 'is_present' || operatorType === 'is_not_present')
return false;
return true;
},
},
};
</script>
<template>
<div>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p class="text-slate-600 dark:text-slate-200">
{{ filterModalSubTitle }}
</p>
</woot-modal-header>
<div class="p-8">
<div v-if="isFolderView">
<label class="input-label" :class="{ error: !activeFolderNewName }">
{{ $t('FILTER.FOLDER_LABEL') }}
<input
v-model="activeFolderNewName"
type="text"
class="bg-white folder-input border-slate-75 dark:border-slate-600 dark:bg-slate-900 text-slate-900 dark:text-slate-100"
/>
<span v-if="!activeFolderNewName" class="message">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="mb-1">
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
</label>
</div>
<div
class="p-4 mb-4 border border-solid rounded-lg bg-slate-25 dark:bg-slate-900 border-slate-50 dark:border-slate-700/50"
>
<FilterInputBox
v-for="(filter, i) in appliedFilters"
:key="i"
v-model="appliedFilters[i]"
:filter-groups="filterGroups"
:input-type="
getInputType(
appliedFilters[i].attribute_key,
appliedFilters[i].filter_operator
)
"
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
grouped-filters
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
@reset-filter="resetFilter(i, appliedFilters[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<woot-button
icon="add"
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewFilter"
>
{{ $t('FILTER.ADD_NEW_FILTER') }}
</woot-button>
</div>
</div>
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isFolderView"
:disabled="!activeFolderNewName"
@click="updateSavedCustomViews"
>
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.folder-input {
@apply w-[50%];
}
</style>
@@ -1,14 +1,14 @@
<script>
import { mapGetters } from 'vuex';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ContactPanel,
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
@@ -98,8 +98,10 @@ export default {
<template>
<div
class="conversation-details-wrap bg-slate-25 dark:bg-slate-800"
:class="{ 'with-border-right': !isOnExpandedLayout }"
class="conversation-details-wrap bg-n-background"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
>
<ConversationHeader
v-if="currentChat.id"
@@ -138,17 +140,11 @@ export default {
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div
v-show="showContactPanel"
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"
>
<ContactPanel
v-if="showContactPanel"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="onToggleContactPanel"
/>
</div>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -165,10 +161,6 @@ export default {
<style lang="scss" scoped>
.conversation-details-wrap {
@apply flex flex-col min-w-0 w-full;
&.with-border-right {
@apply border-r border-slate-50 dark:border-slate-700;
}
}
.dashboard-app--tabs {
@@ -180,10 +172,4 @@ export default {
}
}
}
.conversation-sidebar-wrap {
&::v-deep .contact--panel {
@apply w-full h-full max-w-full;
}
}
</style>
@@ -0,0 +1,79 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const emit = defineEmits(['toggleContactPanel']);
const getters = useStoreGetters();
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const showCopilotTab = computed(() => {
return captainIntegration.value && captainIntegration.value.enabled;
});
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[300px] w-[300px] 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="overflow-auto flex flex-1">
<ContactPanel
v-if="!activeTab"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
</template>
@@ -468,9 +468,7 @@ export default {
size="tiny"
color-scheme="secondary"
class="box-border fixed z-10 bg-white border border-r-0 border-solid rounded-bl-calc rtl:rotate-180 rounded-tl-calc dark:bg-slate-700 border-slate-50 dark:border-slate-600"
:class="
isInboxView ? 'top-52 md:top-40' : 'top-[9.5rem] md:top-[6.25rem]'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
:icon="isRightOrLeftIcon"
@click="onToggleContactPanel"
/>
@@ -558,6 +556,7 @@ export default {
<style scoped>
@tailwind components;
@layer components {
.rounded-bl-calc {
border-bottom-left-radius: calc(1.5rem + 1px);
@@ -36,7 +36,7 @@ defineProps({
</div>
<div class="mt-auto">
<p
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
class="text-base text-slate-800 dark:text-slate-100 font-semibold tracking-[0.3px]"
>
{{ title }}
</p>
@@ -32,7 +32,7 @@ const greetingMessage = computed(() => {
>
<div class="col-span-full self-start">
<p
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
class="text-xl font-semibold text-slate-900 dark:text-white tracking-[0.3px]"
>
{{ greetingMessage }}
</p>
@@ -35,7 +35,7 @@ export default {
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1024,
SMALL_SCREEN_BREAKPOINT: 1200,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -1,46 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
validateConversationOrContactFilters,
validateAutomation,
} from '../validations';
describe('validateConversationOrContactFilters', () => {
it('should return no errors for valid filters', () => {
const validFilters = [
{ attribute_key: 'name', filter_operator: 'contains', values: 'John' },
{ attribute_key: 'email', filter_operator: 'is_present' },
];
const errors = validateConversationOrContactFilters(validFilters);
expect(errors).toEqual({});
});
it('should return errors for invalid filters', () => {
const invalidFilters = [
{ attribute_key: '', filter_operator: 'contains', values: 'John' },
{ attribute_key: 'email', filter_operator: '' },
{ attribute_key: 'age', filter_operator: 'equals' },
];
const errors = validateConversationOrContactFilters(invalidFilters);
expect(errors).toEqual({
filter_0: 'ATTRIBUTE_KEY_REQUIRED',
filter_1: 'FILTER_OPERATOR_REQUIRED',
filter_2: 'VALUE_REQUIRED',
});
});
it('should validate days_before operator correctly', () => {
const filters = [
{ attribute_key: 'date', filter_operator: 'days_before', values: '0' },
{ attribute_key: 'date', filter_operator: 'days_before', values: '999' },
{ attribute_key: 'date', filter_operator: 'days_before', values: '500' },
];
const errors = validateConversationOrContactFilters(filters);
expect(errors).toEqual({
filter_0: 'VALUE_MUST_BE_BETWEEN_1_AND_998',
filter_1: 'VALUE_MUST_BE_BETWEEN_1_AND_998',
});
});
});
import { validateAutomation } from '../validations';
describe('validateAutomation', () => {
it('should return no errors for a valid automation', () => {
@@ -65,29 +65,6 @@ export const validateSingleFilter = filter => {
return null;
};
/**
* Validates filters for conversations or contacts.
*
* @param {Array} filters - An array of filter objects to validate.
* @param {string} filters[].attribute_key - The key of the attribute to filter on.
* @param {string} filters[].filter_operator - The operator to use for filtering.
* @param {string|number} [filters[].values] - The value(s) to filter by (required for most operators).
*
* @returns {Object} An object containing any validation errors, keyed by filter index.
*/
export const validateConversationOrContactFilters = filters => {
const errors = {};
filters.forEach((filter, index) => {
const error = validateSingleFilter(filter);
if (error) {
errors[`filter_${index}`] = error;
}
});
return errors;
};
// ------------------------------------------------------------------
// ---------------------- Automation Validation ---------------------
// ------------------------------------------------------------------
@@ -57,46 +57,6 @@
"TITLE": "Edit contact",
"DESC": "Edit contact details"
},
"CREATE_CONTACT": {
"BUTTON_LABEL": "New Contact",
"TITLE": "Create new contact",
"DESC": "Add basic information details about the contact."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "Cancel"
},
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
"ERROR_MESSAGE": "There was an error, please try again",
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
},
"DELETE_NOTE": {
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you want sure to delete this note?",
"YES": "Yes, Delete it",
"NO": "No, Keep it"
}
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
@@ -224,79 +184,14 @@
}
},
"CONTACTS_PAGE": {
"HEADER": "Contacts",
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "Search",
"SEARCH_INPUT_PLACEHOLDER": "Search for contacts",
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": {
"LOADING_MESSAGE": "Loading contacts...",
"404": "No contacts matches your search 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": {
"NAME": "Name",
"PHONE_NUMBER": "Phone Number",
"CONVERSATIONS": "Conversations",
"LAST_ACTIVITY": "Last Activity",
"CREATED_AT": "Created At",
"COUNTRY": "Country",
"CITY": "City",
"SOCIAL_PROFILES": "Social Profiles",
"COMPANY": "Company",
"EMAIL_ADDRESS": "Email Address"
},
"VIEW_DETAILS": "View details"
}
},
"CONTACT_PROFILE": {
"BACK_BUTTON": "Contacts",
"LOADING": "Loading contact profile..."
},
"REMINDER": {
"ADD_BUTTON": {
"BUTTON": "Add",
"TITLE": "Shift + Enter to create a task"
},
"FOOTER": {
"DUE_DATE": "Due date",
"LABEL_TITLE": "Set type"
}
},
"NOTES": {
"FETCHING_NOTES": "Fetching notes...",
"NOT_AVAILABLE": "There are no notes created for this contact",
"HEADER": {
"TITLE": "Notes"
},
"LIST": {
"LABEL": "added a note"
},
"ADD": {
"BUTTON": "Add",
"PLACEHOLDER": "Add a note",
"TITLE": "Shift + Enter to create a note"
},
"CONTENT_HEADER": {
"DELETE": "Delete note"
}
},
"EVENTS": {
"HEADER": {
"TITLE": "Activities"
},
"BUTTON": {
"PILL_BUTTON_NOTES": "notes",
"PILL_BUTTON_EVENTS": "events",
"PILL_BUTTON_CONVO": "conversations"
"SOCIAL_PROFILES": "Social Profiles"
}
}
},
"CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Add attributes",
"BUTTON": "Add custom attribute",
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
"SHOW_MORE": "Show all attributes",
"SHOW_LESS": "Show less attributes",
@@ -219,6 +219,10 @@
"DELETE": "Delete",
"CANCEL": "Cancel"
}
},
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
}
},
"EMAIL_TRANSCRIPT": {
@@ -299,5 +299,13 @@
"ERROR": "There was an error unlinking the issue, please try again"
}
}
},
"CAPTAIN": {
"NAME": "Captain",
"COPILOT": {
"SEND_MESSAGE": "Send message...",
"LOADER": "Captain is thinking",
"YOU": "You"
}
}
}
@@ -1,109 +0,0 @@
<script>
import Modal from 'dashboard/components/Modal.vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
export default {
components: {
Modal,
},
props: {
show: {
type: Boolean,
default: false,
},
isCreating: {
type: Boolean,
default: false,
},
},
emits: ['create', 'cancel', 'update:show'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
attributeValue: '',
attributeName: '',
};
},
validations: {
attributeName: {
required,
minLength: minLength(2),
},
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
attributeNameError() {
if (this.v$.attributeName.$error) {
return this.$t('CUSTOM_ATTRIBUTES.FORM.NAME.ERROR');
}
return '';
},
},
methods: {
addCustomAttribute() {
this.$emit('create', {
attributeValue: this.attributeValue,
attributeName: this.attributeName || '',
});
this.reset();
this.$emit('cancel');
},
onClose() {
this.reset();
this.$emit('cancel');
},
reset() {
this.attributeValue = '';
this.attributeName = '';
},
},
};
</script>
<template>
<Modal v-model:show="localShow" :on-close="onClose">
<woot-modal-header
:header-title="$t('CUSTOM_ATTRIBUTES.ADD.TITLE')"
:header-content="$t('CUSTOM_ATTRIBUTES.ADD.DESC')"
/>
<form class="w-full" @submit.prevent="addCustomAttribute">
<woot-input
v-model="attributeName"
:class="{ error: v$.attributeName.$error }"
class="w-full"
:error="attributeNameError"
:label="$t('CUSTOM_ATTRIBUTES.FORM.NAME.LABEL')"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.NAME.PLACEHOLDER')"
@blur="v$.attributeName.$touch"
@input="v$.attributeName.$touch"
/>
<woot-input
v-model="attributeValue"
class="w-full"
:label="$t('CUSTOM_ATTRIBUTES.FORM.VALUE.LABEL')"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.VALUE.PLACEHOLDER')"
/>
<div class="flex items-center justify-end gap-2 px-0 py-2">
<woot-button
:is-disabled="v$.attributeName.$invalid || isCreating"
:is-loading="isCreating"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.CREATE') }}
</woot-button>
<woot-button variant="clear" @click.prevent="onClose">
{{ $t('CUSTOM_ATTRIBUTES.FORM.CANCEL') }}
</woot-button>
</div>
</form>
</Modal>
</template>
@@ -1,131 +0,0 @@
<script>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
export default {
components: {
EmojiOrIcon,
},
props: {
label: { type: String, required: true },
icon: { type: String, default: '' },
emoji: { type: String, default: '' },
value: { type: [String, Number], default: '' },
showEdit: { type: Boolean, default: false },
},
emits: ['update'],
data() {
return {
isEditing: false,
editedValue: this.value,
};
},
methods: {
focusInput() {
this.$refs.inputfield.focus();
},
onEdit() {
this.isEditing = true;
this.$nextTick(() => {
this.focusInput();
});
},
onUpdate() {
this.isEditing = false;
this.$emit('update', this.editedValue);
},
},
};
</script>
<template>
<div class="contact-attribute">
<div class="title-wrap">
<h4 class="text-sm title">
<div class="title--icon">
<EmojiOrIcon :icon="icon" :emoji="emoji" />
</div>
{{ label }}
</h4>
</div>
<div v-show="isEditing">
<div class="mb-2 w-full flex items-center">
<input
ref="inputfield"
v-model="editedValue"
type="text"
class="!h-8 ltr:rounded-r-none rtl:rounded-l-none !mb-0 !text-sm"
autofocus="true"
@keyup.enter="onUpdate"
/>
<div>
<woot-button
size="small"
icon="ion-checkmark"
class="rounded-l-none rtl:rounded-r-none"
@click="onUpdate"
/>
</div>
</div>
</div>
<div
v-show="!isEditing"
class="value--view"
:class="{ 'is-editable': showEdit }"
>
<p class="value">
{{ value || '---' }}
</p>
<woot-button
v-if="showEdit"
variant="clear link"
size="small"
color-scheme="secondary"
icon="edit"
class-names="edit-button"
@click="onEdit"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.contact-attribute {
margin-bottom: var(--space-small);
}
.title-wrap {
display: flex;
align-items: center;
margin-bottom: var(--space-mini);
}
.title {
display: flex;
align-items: center;
margin: 0;
}
.title--icon {
width: var(--space-two);
}
.edit-button {
display: none;
}
.value--view {
display: flex;
&.is-editable:hover {
.value {
background: var(--color-background);
}
.edit-button {
display: block;
}
}
}
.value {
display: inline-block;
min-width: var(--space-mega);
border-radius: var(--border-radius-small);
word-break: break-all;
margin: 0 var(--space-smaller) 0 var(--space-normal);
padding: var(--space-micro) var(--space-smaller);
}
</style>
@@ -1,115 +0,0 @@
<script>
import Attribute from './ContactAttribute.vue';
export default {
components: { Attribute },
props: {
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['update', 'createAttribute'],
computed: {
additionalAttributes() {
return this.contact.additional_attributes || {};
},
company() {
const { company = {} } = this.contact;
return company;
},
customAttributes() {
const { custom_attributes: customAttributes = {} } = this.contact;
return customAttributes;
},
customAttributekeys() {
return Object.keys(this.customAttributes).filter(key => {
const value = this.customAttributes[key];
return value !== null && value !== undefined;
});
},
},
methods: {
onEmailUpdate(value) {
this.$emit('update', { email: value });
},
onPhoneUpdate(value) {
this.$emit('update', { phone_number: value });
},
onLocationUpdate(value) {
this.$emit('update', { location: value });
},
handleCustomCreate() {
this.$emit('createAttribute');
},
onCustomAttributeUpdate(key, value) {
this.$emit('update', { custom_attributes: { [key]: value } });
},
},
};
</script>
<template>
<div class="contact-fields">
<h3 class="text-lg title">
{{ $t('CONTACTS_PAGE.FIELDS') }}
</h3>
<Attribute
:label="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
icon="mail"
emoji=""
:value="contact.email"
show-edit
@update="onEmailUpdate"
/>
<Attribute
:label="$t('CONTACT_PANEL.PHONE_NUMBER')"
icon="call"
emoji=""
:value="contact.phone_number"
show-edit
@update="onPhoneUpdate"
/>
<Attribute
v-if="additionalAttributes.location"
:label="$t('CONTACT_PANEL.LOCATION')"
icon="map"
emoji="🌍"
:value="additionalAttributes.location"
show-edit
@update="onLocationUpdate"
/>
<div
v-for="attribute in customAttributekeys"
:key="attribute"
class="custom-attribute--row"
>
<Attribute
:label="attribute"
icon="chevron-right"
:value="customAttributes[attribute]"
show-edit
@update="value => onCustomAttributeUpdate(attribute, value)"
/>
</div>
<woot-button
size="small"
variant="link"
icon="add"
@click="handleCustomCreate"
>
{{ $t('CUSTOM_ATTRIBUTES.ADD.TITLE') }}
</woot-button>
</div>
</template>
<style scoped lang="scss">
.contact-fields {
margin-top: var(--space-medium);
}
.title {
margin-bottom: var(--space-normal);
}
</style>
@@ -1,106 +0,0 @@
<script>
import EditContact from 'dashboard/routes/dashboard/conversation/contact/EditContact.vue';
import NewConversation from 'dashboard/routes/dashboard/conversation/contact/NewConversation.vue';
import AddCustomAttribute from 'dashboard/modules/contact/components/AddCustomAttribute.vue';
import ContactIntro from './ContactIntro.vue';
import ContactFields from './ContactFields.vue';
export default {
components: {
AddCustomAttribute,
ContactFields,
ContactIntro,
EditContact,
NewConversation,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
},
data() {
return {
showCustomAttributeModal: false,
showEditModal: false,
showConversationModal: false,
};
},
computed: {
enableNewConversation() {
return this.contact && this.contact.id;
},
},
methods: {
toggleCustomAttributeModal() {
this.showCustomAttributeModal = !this.showCustomAttributeModal;
},
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
},
createCustomAttribute(data) {
const { id } = this.contact;
const { attributeValue, attributeName } = data;
const updatedFields = {
id,
custom_attributes: {
[attributeName]: attributeValue,
},
};
this.updateContact(updatedFields);
},
updateField(data) {
const { id } = this.contact;
const updatedFields = {
id,
...data,
};
this.updateContact(updatedFields);
},
updateContact(contactItem) {
this.$store.dispatch('contacts/update', contactItem);
},
},
};
</script>
<template>
<div class="panel">
<ContactIntro
:contact="contact"
@message="toggleConversationModal"
@edit="toggleEditModal"
/>
<ContactFields
:contact="contact"
@update="updateField"
@create-attribute="toggleCustomAttributeModal"
/>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<NewConversation
v-if="enableNewConversation"
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
<AddCustomAttribute
:show="showCustomAttributeModal"
@cancel="toggleCustomAttributeModal"
@create="createCustomAttribute"
/>
</div>
</template>
<style scoped lang="scss">
.panel {
padding: var(--space-normal) var(--space-normal);
}
</style>
@@ -1,50 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import NoteList from './components/NoteList.vue';
export default {
components: {
NoteList,
},
props: {
contactId: {
type: Number,
required: true,
},
},
computed: {
...mapGetters({ uiFlags: 'contactNotes/getUIFlags' }),
notes() {
return this.$store.getters['contactNotes/getAllNotesByContact'](
this.contactId
);
},
},
mounted() {
this.fetchContactNotes();
},
methods: {
fetchContactNotes() {
const { contactId } = this;
if (contactId) this.$store.dispatch('contactNotes/get', { contactId });
},
onAdd(content) {
const { contactId } = this;
this.$store.dispatch('contactNotes/create', { content, contactId });
},
onDelete(noteId) {
const { contactId } = this;
this.$store.dispatch('contactNotes/delete', { noteId, contactId });
},
},
};
</script>
<template>
<NoteList
:is-fetching="uiFlags.isFetching"
:notes="notes"
@add="onAdd"
@delete="onDelete"
/>
</template>
@@ -1,62 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const emit = defineEmits(['add']);
const noteContent = ref('');
const buttonDisabled = computed(() => noteContent.value === '');
const onAdd = () => {
if (noteContent.value !== '') {
emit('add', noteContent.value);
}
noteContent.value = '';
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100"
>
<WootMessageEditor
v-model="noteContent"
class="input--note"
:placeholder="$t('NOTES.ADD.PLACEHOLDER')"
:enable-suggestions="false"
/>
<div class="flex justify-end w-full">
<woot-button
color-scheme="warning"
:title="$t('NOTES.ADD.TITLE')"
:is-disabled="buttonDisabled"
@click="onAdd"
>
{{ $t('NOTES.ADD.BUTTON') }} {{ '(⌘⏎)' }}
</woot-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.input--note {
&::v-deep .ProseMirror-menubar {
padding: 0;
margin-top: var(--space-minus-small);
}
&::v-deep .ProseMirror-woot-style {
max-height: 22.5rem;
}
}
</style>
@@ -1,139 +0,0 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { dynamicTime } from 'shared/helpers/timeHelper';
export default {
components: {
Thumbnail,
},
props: {
id: {
type: Number,
default: 0,
},
note: {
type: String,
default: '',
},
user: {
type: Object,
default: () => {},
},
createdAt: {
type: Number,
default: 0,
},
},
emits: ['delete'],
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showDeleteModal: false,
};
},
computed: {
readableTime() {
return dynamicTime(this.createdAt);
},
noteAuthor() {
return this.user || {};
},
noteAuthorName() {
return this.noteAuthor.name || this.$t('APP_GLOBAL.DELETED_USER');
},
},
methods: {
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
onDelete() {
this.$emit('delete', this.id);
},
confirmDeletion() {
this.onDelete();
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
},
},
};
</script>
<template>
<div
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100 note-wrap"
>
<div class="flex items-end justify-between gap-1 text-xs">
<div class="flex items-center">
<Thumbnail
:title="noteAuthorName"
:src="noteAuthor.thumbnail"
:username="noteAuthorName"
size="20px"
/>
<div class="my-0 mx-1 p-0.5 flex flex-row gap-1">
<span class="font-medium text-slate-800 dark:text-slate-100">
{{ noteAuthorName }}
</span>
<span class="text-slate-700 dark:text-slate-100">
{{ $t('NOTES.LIST.LABEL') }}
</span>
<span class="font-medium text-slate-700 dark:text-slate-100">
{{ readableTime }}
</span>
</div>
</div>
<div class="flex invisible actions">
<woot-button
v-tooltip="$t('NOTES.CONTENT_HEADER.DELETE')"
variant="smooth"
size="tiny"
icon="delete"
color-scheme="secondary"
@click="toggleDeleteModal"
/>
</div>
<woot-delete-modal
v-if="showDeleteModal"
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_NOTE.CONFIRM.TITLE')"
:message="$t('DELETE_NOTE.CONFIRM.MESSAGE')"
:confirm-text="$t('DELETE_NOTE.CONFIRM.YES')"
:reject-text="$t('DELETE_NOTE.CONFIRM.NO')"
/>
</div>
<p
v-dompurify-html="formatMessage(note || '')"
class="mt-4 note__content"
/>
</div>
</template>
<style lang="scss" scoped>
// For RTL direction view
.app-rtl--wrapper {
.note__content {
::v-deep {
p {
unicode-bidi: plaintext;
}
}
}
}
.note-wrap:hover {
.actions {
@apply visible;
}
}
</style>
@@ -1,61 +0,0 @@
<script>
import AddNote from './AddNote.vue';
import ContactNote from './ContactNote.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
AddNote,
ContactNote,
Spinner,
},
props: {
notes: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
},
emits: ['add', 'edit', 'delete'],
methods: {
onAddNote(value) {
this.$emit('add', value);
},
onEditNote(value) {
this.$emit('edit', value);
},
onDeleteNote(value) {
this.$emit('delete', value);
},
},
};
</script>
<template>
<div>
<AddNote @add="onAddNote" />
<ContactNote
v-for="note in notes"
:id="note.id"
:key="note.id"
:note="note.content"
:user="note.user"
:created-at="note.created_at"
@edit="onEditNote"
@delete="onDeleteNote"
/>
<div v-if="isFetching" class="text-center p-4 text-base">
<Spinner size="" />
<span>{{ $t('NOTES.FETCHING_NOTES') }}</span>
</div>
<div v-else-if="!notes.length" class="text-center p-4 text-base">
<span>{{ $t('NOTES.NOT_AVAILABLE') }}</span>
</div>
</div>
</template>
@@ -1,140 +0,0 @@
<script>
export default {
props: {
options: {
type: Array,
default: () => [],
},
},
emits: ['add'],
data() {
return {
content: '',
date: '',
label: '',
};
},
computed: {
buttonDisabled() {
return this.content && this.date === '';
},
},
methods: {
resetValue() {
this.content = '';
this.date = '';
},
optionSelected(event) {
this.label = event.target.value;
},
onAdd() {
const task = {
content: this.content,
date: this.date,
label: this.label,
};
this.$emit('add', task);
this.resetValue();
},
},
};
</script>
<template>
<div class="wrap">
<div class="input-select-wrap">
<textarea
v-model="content"
class="input--reminder"
@keydown.enter.shift.exact="onAdd"
/>
<div class="select-wrap">
<div class="select">
<div class="input-group">
<i class="ion-android-calendar input-group-label" />
<input
v-model="date"
type="text"
:placeholder="$t('REMINDER.FOOTER.DUE_DATE')"
class="input-group-field"
/>
</div>
<div class="task-wrap">
<select class="task__type" @change="optionSelected($event)">
<option value="" disabled selected>
{{ $t('REMINDER.FOOTER.LABEL_TITLE') }}
</option>
<option
v-for="option in options"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
</div>
</div>
<woot-button
size="tiny"
color-scheme="primary"
class-names="add-button"
:title="$t('REMINDER.ADD_BUTTON.TITLE')"
:is-disabled="buttonDisabled"
@click="onAdd"
>
{{ $t('REMINDER.ADD_BUTTON.BUTTON') }}
</woot-button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrap {
display: flex;
margin-bottom: var(--space-smaller);
width: 100%;
.input-select-wrap {
padding: var(--space-small) var(--space-small);
width: 100%;
.input--reminder {
font-size: var(--font-size-mini);
margin-bottom: var(--space-small);
resize: none;
}
.select-wrap {
display: flex;
justify-content: space-between;
.select {
display: flex;
}
}
.input-group {
margin-bottom: 0;
font-size: var(--font-size-mini);
.input-group-field {
height: var(--space-medium);
font-size: var(--font-size-micro);
}
}
.task-wrap {
.task__type {
margin: 0 0 0 var(--space-smaller);
height: var(--space-medium);
width: fit-content;
padding: 0 var(--space-two) 0 var(--space-smaller);
font-size: var(--font-size-micro);
}
}
}
}
</style>
@@ -1,175 +0,0 @@
<script>
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from 'dashboard/routes/dashboard/conversation/ContactConversations.vue';
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue';
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
components: {
AccordionItem,
ContactConversations,
ContactInfo,
ContactLabel,
CustomAttributes,
Draggable,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
onClose: {
type: Function,
default: () => {},
},
showAvatar: {
type: Boolean,
default: true,
},
showCloseButton: {
type: Boolean,
default: true,
},
},
setup() {
const {
updateUISettings,
isContactSidebarItemOpen,
contactSidebarItemsOrder,
toggleSidebarUIState,
} = useUISettings();
return {
updateUISettings,
isContactSidebarItemOpen,
contactSidebarItemsOrder,
toggleSidebarUIState,
};
},
data() {
return {
dragEnabled: true,
contactSidebarItems: [],
dragging: false,
};
},
computed: {
hasContactAttributes() {
const { custom_attributes: customAttributes } = this.contact;
return customAttributes && Object.keys(customAttributes).length;
},
},
mounted() {
this.contactSidebarItems = this.contactSidebarItemsOrder;
},
methods: {
onDragEnd() {
this.dragging = false;
this.updateUISettings({
contact_sidebar_items_order: this.contactSidebarItems,
});
},
},
};
</script>
<template>
<div
class="relative w-1/4 h-full min-h-screen overflow-y-auto text-sm bg-white dark:bg-slate-900 border-slate-50 dark:border-slate-800/50"
:class="showAvatar ? 'border-l border-solid ' : 'border-r border-solid'"
>
<ContactInfo
:show-close-button="showCloseButton"
:show-avatar="showAvatar"
:contact="contact"
close-icon-name="dismiss"
@panel-close="onClose"
@toggle-panel="onClose"
/>
<div class="list-group">
<Draggable
:list="contactSidebarItems"
:disabled="!dragEnabled"
ghost-class="ghost"
item-key="name"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div :key="element.name" class="list-group-item">
<div v-if="element.name === 'contact_attributes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')"
compact
@toggle="
value => toggleSidebarUIState('is_ct_custom_attr_open', value)
"
>
<CustomAttributes
:contact-id="contact.id"
attribute-type="contact_attribute"
attribute-from="contact_panel"
:custom-attributes="contact.custom_attributes"
:empty-state-message="
$t('CONTACT_PANEL.SIDEBAR_SECTIONS.NO_RECORDS_FOUND')
"
/>
</AccordionItem>
</div>
<div v-if="element.name === 'contact_labels'">
<AccordionItem
:title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CONTACT_LABELS')"
:is-open="isContactSidebarItemOpen('is_ct_labels_open')"
@toggle="
value => toggleSidebarUIState('is_ct_labels_open', value)
"
>
<ContactLabel :contact-id="contact.id" class="contact-labels" />
</AccordionItem>
</div>
<div v-if="element.name === 'previous_conversation'">
<AccordionItem
:title="
$t('CONTACT_PANEL.SIDEBAR_SECTIONS.PREVIOUS_CONVERSATIONS')
"
:is-open="isContactSidebarItemOpen('is_ct_prev_conv_open')"
compact
@toggle="
value => toggleSidebarUIState('is_ct_prev_conv_open', value)
"
>
<ContactConversations
v-if="contact.id"
:contact-id="contact.id"
conversation-id=""
/>
</AccordionItem>
</div>
</div>
</template>
</Draggable>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep {
.contact--profile {
@apply pb-3 mb-4;
}
}
.list-group {
.list-group-item {
@apply bg-white dark:bg-slate-900;
}
}
.conversation--details {
@apply py-0 px-4;
}
</style>
@@ -1,85 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import LabelSelector from 'dashboard/components/widgets/LabelSelector.vue';
export default {
components: { LabelSelector },
props: {
contactId: {
type: [String, Number],
required: true,
},
},
computed: {
savedLabels() {
const availableContactLabels = this.$store.getters[
'contactLabels/getContactLabels'
](this.contactId);
return this.allLabels.filter(({ title }) =>
availableContactLabels.includes(title)
);
},
...mapGetters({
allLabels: 'labels/getLabels',
}),
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.fetchLabels(newContactId);
}
},
},
mounted() {
const { contactId } = this;
this.fetchLabels(contactId);
},
methods: {
async onUpdateLabels(selectedLabels) {
try {
await this.$store.dispatch('contactLabels/update', {
contactId: this.contactId,
labels: selectedLabels,
});
} catch (error) {
useAlert(this.$t('CONTACT_PANEL.LABELS.CONTACT.ERROR'));
}
},
addItem(value) {
const result = this.savedLabels.map(item => item.title);
result.push(value.title);
this.onUpdateLabels(result);
},
removeItem(value) {
const result = this.savedLabels
.map(label => label.title)
.filter(label => label !== value);
this.onUpdateLabels(result);
},
async fetchLabels(contactId) {
if (!contactId) {
return;
}
this.$store.dispatch('contactLabels/get', contactId);
},
},
};
</script>
<template>
<LabelSelector
:all-labels="allLabels"
:saved-labels="savedLabels"
@add="addItem"
@remove="removeItem"
/>
</template>
@@ -1,368 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import FilterInputBox from '../../../../components/widgets/FilterInput/Index.vue';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from '../contactFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
import { useTrack } from 'dashboard/composables';
export default {
components: {
FilterInputBox,
},
props: {
onClose: {
type: Function,
default: () => {},
},
initialFilterTypes: {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
isSegmentsView: {
type: Boolean,
default: false,
},
activeSegmentName: {
type: String,
default: '',
},
},
emits: ['applyFilter', 'clearFilters', 'updateSegment'],
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'CONTACTS_FILTER',
attributeModel: 'contact_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeSegmentNewName: this.activeSegmentName,
filterTypes: this.initialFilterTypes,
filterGroups: [],
allCustomAttributes: [],
filterAttributeGroups,
attributeModel: 'contact_attribute',
filtersFori18n: 'CONTACTS_FILTER',
validationErrors: {},
};
},
computed: {
...mapGetters({
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
}),
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
filterModalHeaderTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.TITLE')
: this.$t('CONTACTS_FILTER.EDIT_CUSTOM_SEGMENT');
},
filterModalSubTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.SUBTITLE')
: this.$t('CONTACTS_FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
if (this.getAppliedContactFilters.length && !this.isSegmentsView) {
this.appliedFilters = [...this.getAppliedContactFilters];
} else if (!this.isSegmentsView) {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
attribute_model: 'standard',
});
}
},
methods: {
getOperatorTypes(key) {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
},
customAttributeInputType(key) {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
},
getAttributeModel(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
getInputType(key, operator) {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.filterOperators;
},
getDropdownValues(type) {
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type &&
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox || type === 'blocked') {
return [
{
id: true,
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
},
{
id: false,
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
},
];
}
const isCustomAttributeList = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
if (isCustomAttributeList) {
return allCustomAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
}
switch (type) {
case 'country_code':
return countries;
default:
return undefined;
}
},
appendNewFilter() {
if (this.isSegmentsView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
if (this.appliedFilters.length <= 1) {
useAlert(this.$t('CONTACTS_FILTER.FILTER_DELETE_ERROR'));
} else {
this.appliedFilters.splice(index, 1);
}
},
submitFilterQuery() {
this.validationErrors = validateConversationOrContactFilters(
this.appliedFilters
);
if (Object.keys(this.validationErrors).length === 0) {
this.$store.dispatch(
'contacts/setContactFilters',
JSON.parse(JSON.stringify(this.appliedFilters))
);
this.$emit('applyFilter', this.appliedFilters);
useTrack(CONTACTS_EVENTS.APPLY_FILTER, {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
query_operator: filter.query_operator,
})),
});
}
},
updateSegment() {
this.$emit(
'updateSegment',
this.appliedFilters,
this.activeSegmentNewName
);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
).filterOperators[0].value;
this.appliedFilters[index].values = '';
},
showUserInput(operatorType) {
if (operatorType === 'is_present' || operatorType === 'is_not_present')
return false;
return true;
},
clearFilters() {
this.$emit('clearFilters');
this.onClose();
},
},
};
</script>
<template>
<div>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p class="text-slate-600 dark:text-slate-200">
{{ filterModalSubTitle }}
</p>
</woot-modal-header>
<div class="p-8">
<div v-if="isSegmentsView">
<label class="input-label" :class="{ error: !activeSegmentNewName }">
{{ $t('CONTACTS_FILTER.SEGMENT_LABEL') }}
<input
v-model="activeSegmentNewName"
type="text"
class="bg-white folder-input border-slate-75 dark:border-slate-600 dark:bg-slate-900 text-slate-900 dark:text-slate-100"
/>
<span v-if="!activeSegmentNewName" class="message">
{{ $t('CONTACTS_FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('CONTACTS_FILTER.SEGMENT_QUERY_LABEL') }}
</label>
</div>
<div
class="p-4 mb-4 border border-solid rounded-lg bg-slate-25 dark:bg-slate-900 border-slate-50 dark:border-slate-700/50"
>
<FilterInputBox
v-for="(filter, i) in appliedFilters"
:key="i"
v-model="appliedFilters[i]"
:filter-groups="filterGroups"
grouped-filters
:input-type="
getInputType(
appliedFilters[i].attribute_key,
appliedFilters[i].filter_operator
)
"
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
@reset-filter="resetFilter(i, appliedFilters[i])"
@remove-filter="removeFilter(i)"
/>
<div class="flex items-center gap-2 mt-4">
<woot-button
icon="add"
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewFilter"
>
{{ $t('CONTACTS_FILTER.ADD_NEW_FILTER') }}
</woot-button>
<woot-button
v-if="hasAppliedFilters && !isSegmentsView"
icon="subtract"
color-scheme="alert"
variant="smooth"
size="small"
@click="clearFilters"
>
{{ $t('CONTACTS_FILTER.CLEAR_ALL_FILTERS') }}
</woot-button>
</div>
</div>
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('CONTACTS_FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isSegmentsView"
:disabled="!activeSegmentNewName"
@click="updateSegment"
>
{{ $t('CONTACTS_FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('CONTACTS_FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.folder-input {
@apply w-[50%];
}
</style>
@@ -1,190 +0,0 @@
<script setup>
import { h, ref, computed, defineEmits } from 'vue';
import {
useVueTable,
createColumnHelper,
getCoreRowModel,
} from '@tanstack/vue-table';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
// Table components
import Table from 'dashboard/components/table/Table.vue';
import NameCell from './ContactsTable/NameCell.vue';
import EmailCell from './ContactsTable/EmailCell.vue';
import TelCell from './ContactsTable/TelCell.vue';
import CountryCell from './ContactsTable/CountryCell.vue';
import ProfilesCell from './ContactsTable/ProfilesCell.vue';
const props = defineProps({
contacts: {
type: Array,
default: () => [],
},
showSearchEmptyState: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['onSortChange']);
const { t } = useI18n();
const tableData = computed(() => {
if (props.isLoading) {
return [];
}
return props.contacts.map(item => {
// Note: The attributes used here is in snake case
// as it simplier the sort attribute calculation
const additional = item.additional_attributes || {};
const { last_activity_at: lastActivityAt } = item;
const { created_at: createdAt } = item;
return {
...item,
profiles: additional.social_profiles || {},
last_activity_at: lastActivityAt ? dynamicTime(lastActivityAt) : null,
created_at: createdAt ? dynamicTime(createdAt) : null,
};
});
});
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('name', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.NAME'),
cell: cellProps => h(NameCell, cellProps),
size: 250,
}),
columnHelper.accessor('email', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.EMAIL_ADDRESS'),
cell: cellProps => h(EmailCell, { email: cellProps.getValue() }),
size: 250,
}),
columnHelper.accessor('phone_number', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.PHONE_NUMBER'),
size: 200,
cell: cellProps =>
h(TelCell, {
phoneNumber: cellProps.getValue(),
defaultCountry: cellProps.row.original.country_code,
}),
}),
columnHelper.accessor('company_name', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.COMPANY'),
size: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('city', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.CITY'),
cell: defaulSpanRender,
size: 200,
}),
columnHelper.accessor('country', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.COUNTRY'),
size: 200,
cell: cellProps =>
h(CountryCell, {
countryCode: cellProps.row.original.country_code,
country: cellProps.getValue(),
}),
}),
columnHelper.accessor('profiles', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES'),
size: 200,
enableSorting: false,
cell: cellProps =>
h(ProfilesCell, {
profiles: cellProps.getValue(),
}),
}),
columnHelper.accessor('last_activity_at', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.LAST_ACTIVITY'),
size: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('created_at', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.CREATED_AT'),
size: 200,
cell: defaulSpanRender,
}),
];
// type ColumnSort = {
// id: string
// desc: boolean
// }
// type SortingState = ColumnSort[]
const sortingState = ref([{ last_activity_at: 'desc' }]);
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
enableMultiSort: false,
getCoreRowModel: getCoreRowModel(),
state: {
get sorting() {
return sortingState.value;
},
},
onSortingChange: updater => {
// onSortingChange returns a callback that allows us to trigger the sort when we need
// See more docs here: https://tanstack.com/table/latest/docs/api/features/sorting#onsortingchange
// IMO, I don't like this API, but it's what we have for now. Would be great if we could just listen the the changes
// to the sorting state and emit the event when it changes
// But we can easily wrap this later as a separate composable
const updatedSortState = updater(sortingState.value);
// we pick the first item from the array, as we are not using multi-sorting
const [sort] = updatedSortState;
if (sort) {
sortingState.value = updatedSortState;
emit('onSortChange', { [sort.id]: sort.desc ? 'desc' : 'asc' });
} else {
// If the sorting is empty, we reset to the default sorting
sortingState.value = [{ last_activity_at: 'desc' }];
emit('onSortChange', {});
}
},
});
</script>
<template>
<section class="flex-1 h-full overflow-auto bg-white dark:bg-slate-900">
<section class="overflow-x-auto">
<Table fixed :table="table" type="compact" />
</section>
<EmptyState
v-if="showSearchEmptyState"
:title="$t('CONTACTS_PAGE.LIST.404')"
/>
<EmptyState
v-else-if="!isLoading && !contacts.length"
:title="$t('CONTACTS_PAGE.LIST.NO_CONTACTS')"
/>
<div v-if="isLoading" class="flex items-center justify-center text-base">
<Spinner />
<span>{{ $t('CONTACTS_PAGE.LIST.LOADING_MESSAGE') }}</span>
</div>
</section>
</template>
@@ -1,36 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import { computed } from 'vue';
import { getCountryFlag } from 'dashboard/helper/flag';
const props = defineProps({
countryCode: {
type: String,
default: '',
},
country: {
type: String,
default: '',
},
});
const countryFlag = computed(() => {
if (!props.countryCode) {
return '';
}
return getCountryFlag(props.countryCode);
});
const formattedCountryName = computed(() => {
if (!props.country) {
return '';
}
return `${countryFlag.value} ${props.country}`;
});
</script>
<template>
<BaseCell :content="formattedCountryName" />
</template>
@@ -1,22 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
defineProps({
email: {
type: String,
required: true,
},
});
</script>
<template>
<BaseCell>
<a
target="_blank"
rel="noopener noreferrer nofollow"
class="text-woot-500 dark:text-woot-500"
:href="`mailto:${email}`"
>
{{ email }}
</a>
</BaseCell>
</template>
@@ -1,52 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { inject } from 'vue';
defineProps({
row: {
type: Object,
required: true,
},
});
const route = useRoute();
const openContactInfoPanel = inject('openContactInfoPanel');
const isRTL = useMapGetter('accounts/isRTL');
</script>
<template>
<BaseCell>
<woot-button
variant="clear"
class="!px-0"
@click="() => openContactInfoPanel(row.original.id)"
>
<div class="items-center flex" :class="{ 'flex-row-reverse': isRTL }">
<Thumbnail
size="32px"
:src="row.original.thumbnail"
:username="row.original.name"
:status="row.original.availability_status"
/>
<div class="items-start flex flex-col my-0 mx-2">
<h6 class="overflow-hidden text-base whitespace-nowrap text-ellipsis">
<router-link
:to="`/app/accounts/${route.params.accountId}/contacts/${row.original.id}`"
class="text-sm font-medium m-0 capitalize"
>
{{ row.original.name }}
</router-link>
</h6>
<button
class="button clear small link text-slate-600 dark:text-slate-200"
>
{{ $t('CONTACTS_PAGE.LIST.VIEW_DETAILS') }}
</button>
</div>
</div>
</woot-button>
</BaseCell>
</template>
@@ -1,33 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
profiles: {
type: Object,
required: true,
},
});
const filteredProfiles = computed(() =>
Object.keys(props.profiles).filter(profile => props.profiles[profile])
);
</script>
<template>
<BaseCell class="flex gap-2 items-center text-slate-300 dark:text-slate-400">
<template v-if="filteredProfiles.length">
<a
v-for="profile in filteredProfiles"
:key="profile"
:href="`https://${profile}.com/${profiles[profile]}`"
target="_blank"
class="hover:text-slate-500"
rel="noopener noreferrer nofollow"
>
<FluentIcon class="size-4" :icon="`brand-${profile}`" />
</a>
</template>
</BaseCell>
</template>
@@ -1,40 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import { computed } from 'vue';
import { parsePhoneNumber } from 'libphonenumber-js';
const props = defineProps({
phoneNumber: {
type: String,
default: '',
},
defaultCountry: {
type: String,
default: '',
},
});
const formattedNumber = computed(() => {
if (!props.phoneNumber) {
return '';
}
try {
const parsedNumber = parsePhoneNumber(
props.phoneNumber,
props.defaultCountry
);
if (parsedNumber) {
return parsedNumber.formatInternational();
}
return props.phoneNumber;
} catch (error) {
return props.phoneNumber;
}
});
</script>
<template>
<BaseCell :content="formattedNumber" />
</template>
@@ -1,475 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import ContactsHeader from './Header.vue';
import ContactsTable from './ContactsTable.vue';
import ContactInfoPanel from './ContactInfoPanel.vue';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact.vue';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import ImportContacts from './ImportContacts.vue';
import ContactsAdvancedFilters from './ContactsAdvancedFilters.vue';
import contactFilterItems from '../contactFilterItems';
import filterQueryGenerator from '../../../../helper/filterQueryGenerator';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import countries from 'shared/constants/countries.js';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import { useTrack } from 'dashboard/composables';
const DEFAULT_PAGE = 1;
const FILTER_TYPE_CONTACT = 1;
export default {
components: {
ContactsHeader,
ContactsTable,
TableFooter,
ContactInfoPanel,
CreateContact,
ImportContacts,
ContactsAdvancedFilters,
AddCustomViews,
DeleteCustomViews,
},
provide() {
return {
openContactInfoPanel: this.openContactInfoPanel,
};
},
props: {
label: { type: String, default: '' },
segmentsId: {
type: [String, Number],
default: 0,
},
},
data() {
return {
searchQuery: '',
showCreateModal: false,
showImportModal: false,
selectedContactId: '',
sortConfig: { last_activity_at: 'desc' },
showFiltersModal: false,
contactFilterItems: contactFilterItems.map(filter => ({
...filter,
attributeName: this.$t(
`CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}`
),
})),
segmentsQuery: {},
filterType: FILTER_TYPE_CONTACT,
showAddSegmentsModal: false,
showDeleteSegmentsModal: false,
appliedFilter: [],
};
},
computed: {
...mapGetters({
records: 'contacts/getContacts',
uiFlags: 'contacts/getUIFlags',
meta: 'contacts/getMeta',
segments: 'customViews/getContactCustomViews',
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
}),
showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
return hasEmptyResults;
},
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
hasActiveSegments() {
return this.activeSegment && this.segmentsId !== 0;
},
isContactAndLabelDashboard() {
return (
this.$route.name === 'contacts_dashboard' ||
this.$route.name === 'contacts_labels_dashboard'
);
},
pageTitle() {
if (this.hasActiveSegments) {
return this.activeSegment.name;
}
if (this.label) {
return `#${this.label}`;
}
return this.$t('CONTACTS_PAGE.HEADER');
},
selectedContact() {
if (this.selectedContactId) {
const contact = this.records.find(
item => this.selectedContactId === item.id
);
return contact;
}
return undefined;
},
showContactViewPane() {
return this.selectedContactId !== '';
},
wrapClass() {
return this.showContactViewPane ? 'w-[75%]' : 'w-full';
},
pageParameter() {
const selectedPageNumber = Number(this.$route.query?.page);
return !Number.isNaN(selectedPageNumber) &&
selectedPageNumber >= DEFAULT_PAGE
? selectedPageNumber
: DEFAULT_PAGE;
},
activeSegment() {
if (this.segmentsId) {
const [firstValue] = this.segments.filter(
view => view.id === Number(this.segmentsId)
);
return firstValue;
}
return undefined;
},
activeSegmentName() {
return this.activeSegment?.name;
},
},
watch: {
label() {
this.fetchContacts(DEFAULT_PAGE);
if (this.hasAppliedFilters) {
this.clearFilters();
}
},
activeSegment() {
if (this.hasActiveSegments) {
const payload = this.activeSegment.query;
this.fetchSavedFilteredContact(payload, DEFAULT_PAGE);
}
if (this.hasAppliedFilters && this.$route.name === 'contacts_dashboard') {
this.fetchFilteredContacts(DEFAULT_PAGE);
} else {
this.fetchContacts(DEFAULT_PAGE);
}
},
},
mounted() {
this.fetchContacts(this.pageParameter);
},
methods: {
updatePageParam(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
},
getSortAttribute() {
let sortAttr = Object.keys(this.sortConfig).reduce((acc, sortKey) => {
const sortOrder = this.sortConfig[sortKey];
if (sortOrder) {
const sortOrderSign = sortOrder === 'asc' ? '' : '-';
return `${sortOrderSign}${sortKey}`;
}
return acc;
}, '');
if (!sortAttr) {
this.sortConfig = { last_activity_at: 'desc' };
sortAttr = '-last_activity_at';
}
return sortAttr;
},
fetchContacts(page) {
if (this.isContactAndLabelDashboard) {
this.updatePageParam(page);
let value = '';
if (this.searchQuery.charAt(0) === '+') {
value = this.searchQuery.substring(1);
} else {
value = this.searchQuery;
}
const requestParams = {
page,
sortAttr: this.getSortAttribute(),
label: this.label,
};
if (!value) {
this.$store.dispatch('contacts/get', requestParams);
} else {
this.$store.dispatch('contacts/search', {
search: encodeURIComponent(value),
...requestParams,
});
}
}
},
fetchSavedFilteredContact(payload, page) {
if (this.hasActiveSegments) {
this.updatePageParam(page);
this.$store.dispatch('contacts/filter', {
queryPayload: payload,
page,
});
}
},
fetchFilteredContacts(page) {
if (this.hasAppliedFilters) {
const payload = this.segmentsQuery;
this.updatePageParam(page);
this.$store.dispatch('contacts/filter', {
queryPayload: payload,
page,
});
}
},
onInputSearch(event) {
const newQuery = event.target.value;
const refetchAllContacts = !!this.searchQuery && newQuery === '';
this.searchQuery = newQuery;
if (refetchAllContacts) {
this.fetchContacts(DEFAULT_PAGE);
}
},
onSearchSubmit() {
this.selectedContactId = '';
if (this.searchQuery) {
this.fetchContacts(DEFAULT_PAGE);
}
},
onPageChange(page) {
this.selectedContactId = '';
if (this.segmentsId !== 0) {
const payload = this.activeSegment.query;
this.fetchSavedFilteredContact(payload, page);
}
if (this.hasAppliedFilters) {
this.fetchFilteredContacts(page);
} else {
this.fetchContacts(page);
}
},
openContactInfoPanel(contactId) {
this.selectedContactId = contactId;
this.showContactInfoPanelPane = true;
},
closeContactInfoPanel() {
this.selectedContactId = '';
this.showContactInfoPanelPane = false;
},
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
},
onToggleSaveFilters() {
this.showAddSegmentsModal = true;
},
onCloseAddSegmentsModal() {
this.showAddSegmentsModal = false;
},
onToggleDeleteFilters() {
this.showDeleteSegmentsModal = true;
},
onCloseDeleteSegmentsModal() {
this.showDeleteSegmentsModal = false;
},
onToggleImport() {
this.showImportModal = !this.showImportModal;
},
onSortChange(params) {
this.sortConfig = params;
this.fetchContacts(this.meta.currentPage);
const sortBy =
Object.entries(params).find(pair => Boolean(pair[1])) || [];
useTrack(CONTACTS_EVENTS.APPLY_SORT, {
appliedOn: sortBy[0],
order: sortBy[1],
});
},
onToggleFilters() {
if (this.hasActiveSegments) {
this.initializeSegmentToFilterModal(this.activeSegment);
}
this.showFiltersModal = true;
},
closeAdvanceFiltersModal() {
this.showFiltersModal = false;
this.appliedFilter = [];
},
onApplyFilter(payload) {
this.closeContactInfoPanel();
this.segmentsQuery = filterQueryGenerator(payload);
this.$store.dispatch('contacts/filter', {
queryPayload: filterQueryGenerator(payload),
});
this.showFiltersModal = false;
},
onUpdateSegment(payload, segmentName) {
const payloadData = {
...this.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
clearFilters() {
this.$store.dispatch('contacts/clearContactFilters');
this.fetchContacts(this.pageParameter);
},
onExportSubmit() {
let query = { payload: [] };
if (this.hasActiveSegments) {
query = this.activeSegment.query;
} else if (this.hasAppliedFilters) {
query = filterQueryGenerator(this.getAppliedContactFilters);
}
try {
this.$store.dispatch('contacts/export', {
...query,
label: this.label,
});
useAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(error.message || this.$t('EXPORT_CONTACTS.ERROR_MESSAGE'));
}
},
setParamsForEditSegmentModal() {
// Here we are setting the params for edit segment modal to show the existing values.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit segment modal.
const params = {
countries: countries,
filterTypes: contactFilterItems,
allCustomAttributes:
this.$store.getters['attributes/getAttributesByModel'](
'contact_attribute'
),
};
return params;
},
initializeSegmentToFilterModal(activeSegment) {
// Here we are setting the params for edit segment modal.
// To show the existing values. when we click on edit segment button.
// Here we get the query from the active segment.
// And we are mapping the query to the actual values.
// To show in the edit segment modal by the help of generateValuesForEditCustomViews helper.
const query = activeSegment?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditSegmentModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
openSavedItemInSegment() {
const lastItemInSegments = this.segments[this.segments.length - 1];
const lastItemId = lastItemInSegments.id;
this.$router.push({
name: 'contacts_segments_dashboard',
params: { id: lastItemId },
});
},
openLastItemAfterDeleteInSegment() {
if (this.segments.length > 0) {
this.openSavedItemInSegment();
} else {
this.$router.push({ name: 'contacts_dashboard' });
this.fetchContacts(DEFAULT_PAGE);
}
},
},
};
</script>
<template>
<div class="flex flex-row w-full">
<div class="flex flex-col h-full" :class="wrapClass">
<ContactsHeader
:search-query="searchQuery"
:header-title="pageTitle"
:segments-id="segmentsId"
this-selected-contact-id=""
@on-input-search="onInputSearch"
@on-toggle-create="onToggleCreate"
@on-toggle-filter="onToggleFilters"
@on-search-submit="onSearchSubmit"
@on-toggle-import="onToggleImport"
@on-export-submit="onExportSubmit"
@on-toggle-save-filter="onToggleSaveFilters"
@on-toggle-delete-filter="onToggleDeleteFilters"
@on-toggle-edit-filter="onToggleFilters"
/>
<ContactsTable
:contacts="records"
:show-search-empty-state="showEmptySearchResult"
:is-loading="uiFlags.isFetching"
:active-contact-id="selectedContactId"
@on-sort-change="onSortChange"
/>
<TableFooter
class="border-t border-slate-75 dark:border-slate-700/50"
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
:page-size="15"
@page-change="onPageChange"
/>
</div>
<AddCustomViews
v-if="showAddSegmentsModal"
:custom-views-query="segmentsQuery"
:filter-type="filterType"
:open-last-saved-item="openSavedItemInSegment"
@close="onCloseAddSegmentsModal"
/>
<DeleteCustomViews
v-if="showDeleteSegmentsModal"
v-model:show="showDeleteSegmentsModal"
:active-custom-view="activeSegment"
:custom-views-id="segmentsId"
:active-filter-type="filterType"
:open-last-item-after-delete="openLastItemAfterDeleteInSegment"
@close="onCloseDeleteSegmentsModal"
/>
<ContactInfoPanel
v-if="showContactViewPane"
:contact="selectedContact"
:on-close="closeContactInfoPanel"
/>
<CreateContact :show="showCreateModal" @cancel="onToggleCreate" />
<woot-modal v-model:show="showImportModal" :on-close="onToggleImport">
<ImportContacts v-if="showImportModal" :on-close="onToggleImport" />
</woot-modal>
<woot-modal
v-model:show="showFiltersModal"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<ContactsAdvancedFilters
v-if="showFiltersModal"
:on-close="closeAdvanceFiltersModal"
:initial-filter-types="contactFilterItems"
:initial-applied-filters="appliedFilter"
:active-segment-name="activeSegmentName"
:is-segments-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@clear-filters="clearFilters"
/>
</woot-modal>
</div>
</template>
@@ -1,230 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
props: {
headerTitle: {
type: String,
default: '',
},
searchQuery: {
type: String,
default: '',
},
segmentsId: {
type: [String, Number],
default: 0,
},
},
emits: [
'onToggleSaveFilter',
'onToggleEditFilter',
'onToggleDeleteFilter',
'onToggleCreate',
'onToggleFilter',
'onToggleImport',
'onExportSubmit',
'onSearchSubmit',
'onInputSearch',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showCreateModal: false,
showImportModal: false,
};
},
computed: {
searchButtonClass() {
return this.searchQuery !== ''
? 'opacity-100 translate-x-0 visible'
: '-translate-x-px opacity-0 invisible';
},
...mapGetters({
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
}),
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
hasActiveSegments() {
return this.segmentsId !== 0;
},
exportDescription() {
return this.hasAppliedFilters
? this.$t('EXPORT_CONTACTS.CONFIRM.FILTERED_MESSAGE')
: this.$t('EXPORT_CONTACTS.CONFIRM.MESSAGE');
},
},
methods: {
onToggleSegmentsModal() {
this.$emit('onToggleSaveFilter');
},
onToggleEditSegmentsModal() {
this.$emit('onToggleEditFilter');
},
onToggleDeleteSegmentsModal() {
this.$emit('onToggleDeleteFilter');
},
toggleCreate() {
this.$emit('onToggleCreate');
},
toggleFilter() {
this.$emit('onToggleFilter');
},
toggleImport() {
this.$emit('onToggleImport');
},
async submitExport() {
const ok =
await this.$refs.confirmExportContactsDialog.showConfirmation();
if (ok) {
this.$emit('onExportSubmit');
}
},
submitSearch() {
this.$emit('onSearchSubmit');
},
inputSearch(event) {
this.$emit('onInputSearch', event);
},
},
};
</script>
<template>
<header
class="bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800"
>
<div class="flex justify-between w-full px-4 py-2">
<div class="flex flex-col items-center w-full gap-2 md:flex-row">
<div class="flex justify-between w-full gap-2">
<div
class="flex items-center justify-center max-w-full min-w-[6.25rem]"
>
<woot-sidemenu-icon />
<h1
class="m-0 mx-2 my-0 overflow-hidden text-xl text-slate-900 dark:text-slate-100 whitespace-nowrap text-ellipsis"
>
{{ headerTitle }}
</h1>
</div>
<div
class="max-w-[400px] min-w-[100px] flex items-center relative mx-2"
>
<div class="flex items-center absolute h-full left-2.5">
<fluent-icon
icon="search"
class="h-5 text-sm leading-9 text-slate-700 dark:text-slate-200"
/>
</div>
<input
type="text"
:placeholder="$t('CONTACTS_PAGE.SEARCH_INPUT_PLACEHOLDER')"
class="!pl-9 !pr-[3.75rem] !text-sm !w-full !h-[2.375rem] !m-0 border-slate-100 dark:border-slate-600"
:value="searchQuery"
@keyup.enter="submitSearch"
@input="inputSearch"
/>
<woot-button
:is-loading="false"
class="absolute h-8 px-2 py-0 ml-2 transition-transform duration-100 ease-linear clear right-1"
:class-names="searchButtonClass"
@click="submitSearch"
>
{{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
</woot-button>
</div>
</div>
<div class="flex gap-1">
<div v-if="hasActiveSegments" class="flex gap-2">
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="secondary"
icon="edit"
@click="onToggleEditSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_EDIT') }}
</woot-button>
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
</div>
<div v-if="!hasActiveSegments" class="relative">
<div
v-if="hasAppliedFilters"
class="absolute w-2 h-2 rounded-full top-1 right-3 bg-slate-500 dark:bg-slate-500"
/>
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="secondary"
data-testid="create-new-contact"
icon="filter"
@click="toggleFilter"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS') }}
</woot-button>
</div>
<woot-button
v-if="hasAppliedFilters && !hasActiveSegments"
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="alert"
variant="clear"
icon="save"
@click="onToggleSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_SAVE') }}
</woot-button>
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="success"
icon="person-add"
data-testid="create-new-contact"
@click="toggleCreate"
>
{{ $t('CREATE_CONTACT.BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="upload"
class="clear [&>span]:hidden xs:[&>span]:block"
@click="toggleImport"
>
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="download"
class="clear [&>span]:hidden xs:[&>span]:block"
@click="submitExport"
>
{{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</div>
<woot-confirm-modal
ref="confirmExportContactsDialog"
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
:description="exportDescription"
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
/>
</header>
</template>
@@ -1,96 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import Modal from '../../../../components/Modal.vue';
import { useTrack } from 'dashboard/composables';
export default {
components: {
Modal,
},
props: {
onClose: {
type: Function,
default: () => {},
},
},
data() {
return {
show: true,
file: '',
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
csvUrl() {
return '/downloads/import-contacts-sample.csv';
},
},
mounted() {
useTrack(CONTACTS_EVENTS.IMPORT_MODAL_OPEN);
},
methods: {
async uploadFile() {
try {
if (!this.file) return;
await this.$store.dispatch('contacts/import', this.file);
this.onClose();
useAlert(this.$t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message || this.$t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
},
handleFileUpload() {
this.file = this.$refs.file.files[0];
},
},
};
</script>
<template>
<Modal v-model:show="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="$t('IMPORT_CONTACTS.TITLE')">
<p>
{{ $t('IMPORT_CONTACTS.DESC') }}
<a :href="csvUrl" download="import-contacts-sample">{{
$t('IMPORT_CONTACTS.DOWNLOAD_LABEL')
}}</a>
</p>
</woot-modal-header>
<div class="flex flex-col p-8">
<div class="w-full">
<label>
<span>{{ $t('IMPORT_CONTACTS.FORM.LABEL') }}</span>
<input
id="file"
ref="file"
type="file"
accept="text/csv"
@change="handleFileUpload"
/>
</label>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<div class="w-full">
<woot-button
:disabled="uiFlags.isCreating || !file"
:loading="uiFlags.isCreating"
@click="uploadFile"
>
{{ $t('IMPORT_CONTACTS.FORM.SUBMIT') }}
</woot-button>
<button class="button clear" @click.prevent="onClose">
{{ $t('IMPORT_CONTACTS.FORM.CANCEL') }}
</button>
</div>
</div>
</div>
</div>
</Modal>
</template>
@@ -1,126 +0,0 @@
<script>
export default {
props: {
id: {
type: Number,
default: 0,
},
text: {
type: String,
default: '',
},
isCompleted: {
type: Boolean,
default: false,
},
date: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
},
emits: ['completed', 'edit', 'delete'],
methods: {
onClick() {
this.$emit('completed', this.isCompleted);
},
onEdit() {
this.$emit('edit', this.id);
},
onDelete() {
this.$emit('delete', this.id);
},
},
};
</script>
<template>
<div class="reminder-wrap">
<div class="status-wrap">
<input :checked="isCompleted" type="radio" @click="onClick" />
</div>
<div class="wrap">
<p class="content">
{{ text }}
</p>
<div class="footer">
<div class="meta">
<woot-label
:title="date"
description="date"
icon="ion-android-calendar"
color-scheme="secondary"
/>
<woot-label
:title="label"
description="label"
color-scheme="secondary"
/>
</div>
<div class="actions">
<woot-button
variant="smooth"
size="small"
icon="edit"
color-scheme="secondary"
class="action-button"
@click="onEdit"
/>
<woot-button
variant="smooth"
size="small"
icon="ion-trash-b"
color-scheme="secondary"
class="action-button"
@click="onDelete"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.reminder-wrap {
display: flex;
margin-bottom: var(--space-smaller);
.status-wrap {
padding: var(--space-small) var(--space-smaller);
margin-top: var(--space-smaller);
}
.wrap {
padding: var(--space-small);
.footer {
display: flex;
justify-content: space-between;
.meta {
display: flex;
}
}
}
}
.actions {
display: none;
.action-button {
margin-right: var(--space-small);
height: var(--space-medium);
width: var(--space-medium);
}
}
.reminder-wrap:hover {
.actions {
display: flex;
}
}
</style>
@@ -1,73 +0,0 @@
<script>
export default {
emits: ['notes', 'events', 'conversation'],
methods: {
onClickNotes() {
this.$emit('notes');
},
onClickEvents() {
this.$emit('events');
},
onClickConversation() {
this.$emit('conversation');
},
},
};
</script>
<template>
<div class="wrap">
<div class="header">
<h5 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('EVENTS.HEADER.TITLE') }}
</h5>
</div>
<div class="button-wrap">
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickNotes"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_NOTES') }}
</woot-button>
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickEvents"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_EVENTS') }}
</woot-button>
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickConversation"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_CONVO') }}
</woot-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrap {
width: 100%;
padding: var(--space-normal);
.button-wrap {
display: flex;
.pill-button {
margin-right: var(--space-small);
}
}
}
</style>
@@ -1,133 +0,0 @@
<!-- Unused file deprecated -->
<script>
import { dynamicTime } from 'shared/helpers/timeHelper';
export default {
props: {
eventType: {
type: String,
default: '',
},
eventPath: {
type: String,
default: '',
},
eventBody: {
type: String,
default: '',
},
timeStamp: {
type: Number,
default: 0,
},
},
emits: ['more'],
computed: {
readableTime() {
return dynamicTime(this.timeStamp);
},
},
methods: {
onClick() {
this.$emit('more');
},
},
};
</script>
<template>
<div class="timeline-card-wrap">
<div class="icon-chatbox">
<i class="ion-chatboxes" />
</div>
<div class="card-wrap">
<div class="header">
<div class="text-wrap">
<h6 class="text-sm">
{{ eventType }}
</h6>
<span class="event-path">{{ 'on' }} {{ eventPath }}</span>
</div>
<div class="date-wrap">
<span>{{ readableTime }}</span>
</div>
</div>
<div class="comment-wrap">
<p class="comment">
{{ eventBody }}
</p>
</div>
</div>
<div class="icon-more" @click="onClick">
<i class="ion-android-more-vertical" />
</div>
</div>
</template>
<style lang="scss" scoped>
.timeline-card-wrap {
display: flex;
width: 100%;
color: var(--color-body);
padding: var(--space-small);
.icon-chatbox {
width: var(--space-large);
height: var(--space-large);
border-radius: 50%;
display: flex;
flex-shrink: 0;
justify-content: center;
border: 1px solid var(--color-border);
background-color: var(--color-background);
.ion-chatboxes {
font-size: var(--font-size-default);
display: flex;
align-items: center;
}
}
.card-wrap {
display: flex;
flex-direction: column;
width: 100%;
padding: var(--space-smaller) var(--space-normal) 0;
.header {
display: flex;
justify-content: space-between;
.text-wrap {
display: flex;
}
.event-path {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
.date-wrap {
font-size: var(--font-size-micro);
}
}
.comment-wrap {
border: 1px solid var(--color-border-light);
.comment {
padding: var(--space-small);
font-size: var(--font-size-mini);
margin: 0;
}
}
}
.icon-more {
.ion-android-more-vertical {
font-size: var(--font-size-medium);
}
}
}
</style>
@@ -1,46 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import AddReminder from '../AddReminder.vue';
let wrapper;
describe('AddReminder', () => {
beforeEach(() => {
wrapper = shallowMount(AddReminder, {
mocks: {
$t: x => x,
$store: { getters: {}, state: {} },
},
stubs: { WootButton: { template: '<button />' } },
});
});
it('tests resetValue', () => {
const resetValue = vi.spyOn(wrapper.vm, 'resetValue');
wrapper.vm.content = 'test';
wrapper.vm.date = '08/11/2022';
wrapper.vm.resetValue();
expect(wrapper.vm.content).toEqual('');
expect(wrapper.vm.date).toEqual('');
expect(resetValue).toHaveBeenCalled();
});
it('tests optionSelected', () => {
const optionSelected = vi.spyOn(wrapper.vm, 'optionSelected');
wrapper.vm.label = '';
wrapper.vm.optionSelected({ target: { value: 'test' } });
expect(wrapper.vm.label).toEqual('test');
expect(optionSelected).toHaveBeenCalled();
});
it('tests onAdd', () => {
const onAdd = vi.spyOn(wrapper.vm, 'onAdd');
wrapper.vm.label = 'label';
wrapper.vm.content = 'content';
wrapper.vm.date = '08/11/2022';
wrapper.vm.onAdd();
expect(onAdd).toHaveBeenCalled();
expect(wrapper.emitted().add[0]).toEqual([
{ content: 'content', date: '08/11/2022', label: 'label' },
]);
});
});
@@ -1,67 +0,0 @@
<script>
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
export default {
props: {
customAttributes: {
type: Object,
default: () => ({}),
},
},
computed: {
listOfAttributes() {
return Object.keys(this.customAttributes).filter(key => {
const value = this.customAttributes[key];
return value !== null && value !== undefined && value !== '';
});
},
},
methods: {
valueWithLink(attribute) {
const parsedAttribute = this.parseAttributeToString(attribute);
const messageFormatter = new MessageFormatter(parsedAttribute);
return messageFormatter.formattedMessage;
},
parseAttributeToString(attribute) {
switch (typeof attribute) {
case 'string':
return attribute;
case 'object':
return JSON.stringify(attribute);
default:
return `${attribute}`;
}
},
},
};
</script>
<template>
<div class="custom-attributes--panel">
<div
v-for="attribute in listOfAttributes"
:key="attribute"
class="custom-attribute--row"
>
<div class="custom-attribute--row__attribute">
{{ attribute }}
</div>
<div>
<span v-dompurify-html="valueWithLink(customAttributes[attribute])" />
</div>
</div>
<p v-if="!listOfAttributes.length">
{{ $t('CUSTOM_ATTRIBUTES.NOT_AVAILABLE') }}
</p>
</div>
</template>
<style scoped>
.custom-attributes--panel {
margin-bottom: var(--space-normal);
}
.custom-attribute--row__attribute {
font-weight: 500;
}
</style>
@@ -92,9 +92,7 @@ onMounted(() => {
</script>
<template>
<div
class="overflow-y-auto bg-white border-l dark:bg-slate-900 text-slate-900 dark:text-slate-300 border-slate-50 dark:border-slate-800/50 rtl:border-l-0 rtl:border-r contact--panel"
>
<div class="w-full">
<ContactInfo
:contact="contact"
:channel-type="channelType"
@@ -193,7 +193,7 @@ export default {
</script>
<template>
<section class="bg-white conversation-page dark:bg-slate-900">
<section class="flex w-full h-full">
<ChatList
:show-conversation-list="showConversationList"
:conversation-inbox="inboxId"
@@ -219,11 +219,3 @@ export default {
<CmdBarConversationSnooze />
</section>
</template>
<style lang="scss" scoped>
.conversation-page {
display: flex;
width: 100%;
height: 100%;
}
</style>
@@ -9,7 +9,6 @@ import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import NewConversation from './NewConversation.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import { getCountryFlag } from 'dashboard/helper/flag';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
@@ -127,8 +126,12 @@ export default {
},
findCountryFlag(countryCode, cityAndCountry) {
try {
const countryFlag = countryCode ? getCountryFlag(countryCode) : '🌎';
return `${cityAndCountry} ${countryFlag}`;
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
@@ -171,7 +174,7 @@ export default {
</script>
<template>
<div class="relative items-center w-full p-4 bg-white dark:bg-slate-900">
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Thumbnail
@@ -87,10 +87,9 @@ export default {
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
>
{{ value }}
</span>
/>
<span v-else class="text-sm text-slate-300 dark:text-slate-600">{{
$t('CONTACT_PANEL.NOT_AVAILABLE')
}}</span>
@@ -1,62 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import ContactForm from './ContactForm.vue';
export default {
components: {
ContactForm,
},
props: {
show: {
type: Boolean,
default: false,
},
},
emits: ['cancel', 'update:show'],
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
await this.$store.dispatch('contacts/create', contactItem);
},
},
};
</script>
<template>
<woot-modal
v-model:show="localShow"
:on-close="onCancel"
modal-type="right-aligned"
>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CREATE_CONTACT.TITLE')"
:header-content="$t('CREATE_CONTACT.DESC')"
/>
<ContactForm
:in-progress="uiFlags.isCreating"
:on-submit="onSubmit"
@success="onSuccess"
@cancel="onCancel"
/>
</div>
</woot-modal>
</template>
@@ -54,11 +54,9 @@ export default {
<template>
<div
class="flex justify-between items-center h-14 min-h-[3.5rem] px-4 py-2 bg-white dark:bg-slate-900 border-b border-slate-50 dark:border-slate-800/50"
class="flex justify-between items-center h-20 min-h-[3.5rem] py-2 max-w-[960px] mx-auto w-full"
>
<h1
class="flex items-center mb-0 text-2xl text-slate-900 dark:text-slate-100"
>
<h1 class="flex items-center mb-0 text-xl font-medium text-n-slate-12">
<woot-sidemenu-icon v-if="showSidemenuIcon" />
<BackButton
v-if="showBackButton"
@@ -66,23 +64,20 @@ export default {
:back-url="backUrl"
class="ml-2 mr-4"
/>
<fluent-icon
v-if="icon"
:icon="icon"
:class="iconClass"
class="hidden ml-1 mr-2 rtl:ml-2 rtl:mr-1 md:block"
/>
<slot />
<span class="text-2xl font-medium text-slate-900 dark:text-slate-100">
<span
class="text-xl font-medium text-n-slate-12 font-interDisplay tracking-[0.3px]"
>
{{ headerTitle }}
</span>
</h1>
<slot name="action-button" />
<router-link
v-if="showNewButton && isAdmin"
:to="buttonRoute"
class="button success button--fixed-top px-3.5 py-1 rounded-[5px] flex gap-2"
class="button success px-3.5 py-1 rounded-[5px] flex gap-2"
>
<fluent-icon icon="add-circle" />
<fluent-icon icon="add" />
<span class="button__content">
{{ buttonText }}
</span>
@@ -20,10 +20,10 @@ defineProps({
</script>
<template>
<div class="flex flex-col w-full h-full gap-8 font-inter">
<div class="flex flex-col w-full h-full gap-8">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<div>
<div class="mx-auto max-w-[960px] w-full pb-10">
<slot name="preBody" />
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
@@ -8,9 +8,7 @@ defineProps({
</script>
<template>
<div
class="flex flex-col w-full h-full m-0 p-6 sm:py-8 lg:px-16 overflow-auto bg-white dark:bg-slate-900 font-inter"
>
<div class="flex flex-col w-full h-full m-0 overflow-auto bg-n-background">
<div class="flex items-start w-full max-w-6xl mx-auto">
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
@@ -22,9 +22,7 @@ const showNewButton = computed(
</script>
<template>
<div
class="flex flex-1 h-full justify-between flex-col m-0 bg-slate-25 dark:bg-slate-900 overflow-auto"
>
<div class="w-full h-full bg-n-background overflow-auto">
<SettingsHeader
button-route="new"
:icon="icon"
@@ -161,100 +161,104 @@ export default {
</script>
<template>
<div class="flex-grow flex-shrink min-w-0 p-6 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row p-4 border-b border-slate-25 dark:border-slate-800"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
<div
class="flex-grow flex-shrink min-w-0 p-6 overflow-auto shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5 max-w-[960px] w-full mx-auto mb-10"
>
<div v-if="!uiFlags.isFetchingItem">
<form @submit.prevent="updateAccount">
<div class="flex flex-row p-4 border-n-weak border-b">
<div
class="flex-grow-0 flex-shrink-0 flex-[30%] min-w-0 py-4 pr-6 pl-0"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
</div>
</div>
<h4 class="text-lg font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
<div
class="flex flex-row p-4 border-slate-25 dark:border-slate-700 text-black-900 dark:text-slate-300"
>
<woot-submit-button
class="button nice"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
:loading="isUpdating"
/>
</div>
</div>
</form>
<div class="flex flex-row p-4 text-n-slate-12">
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
class="flex-grow-0 flex-shrink-0 flex-[30%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
<h4 class="text-lg font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
@@ -265,7 +269,7 @@ export default {
<woot-code :script="getAccountId" />
</div>
</div>
<div class="p-4 text-sm text-center">
<div class="p-4 text-sm text-center text-n-slate-11">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
@@ -278,14 +282,7 @@ export default {
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div>
<woot-submit-button
class="button nice success button--fixed-top"
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
:loading="isUpdating"
/>
</form>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<woot-loading-state v-else />
</div>
</template>
@@ -45,7 +45,9 @@ export default {
</script>
<template>
<div class="flex-1 p-4 overflow-auto">
<div
class="flex-1 p-4 max-w-[960px] w-full mx-auto shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5"
>
<div class="flex flex-row gap-4">
<div class="w-full lg:w-3/5">
<woot-loading-state
@@ -76,7 +78,7 @@ export default {
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
icon="add"
>
<router-link :to="newAgentBotsURL" class="white-text">
{{ $t('AGENT_BOTS.ADD.TITLE') }}
@@ -46,11 +46,16 @@ export default {
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<div
class="max-w-[960px] mx-auto w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 mb-10"
>
<div class="flex flex-row">
<div class="w-[68%]">
<div class="h-[calc(100vh-56px)] relative">
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
<CsmlMonacoEditor
v-model="bot.csmlContent"
class="w-full h-full rounded-tl-xl"
/>
<div
v-if="v$.bot.csmlContent.$error"
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
@@ -2,6 +2,7 @@
import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import {
useStoreGetters,
@@ -149,20 +150,19 @@ const confirmDeletion = () => {
feature-name="agents"
>
<template #actions>
<woot-button
class="rounded-md button nice"
icon="add-circle"
<Button
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('AGENT_MGMT.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="divide-y divide-slate-75 dark:divide-slate-700">
<table class="divide-y divide-n-weak">
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
@@ -158,130 +158,126 @@ export default {
</script>
<template>
<woot-modal v-model:show="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="$t('ATTRIBUTES_MGMT.ADD.TITLE')" />
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="$t('ATTRIBUTES_MGMT.ADD.TITLE')" />
<form class="flex w-full" @submit.prevent="addAttributes">
<div class="w-full">
<label :class="{ error: v$.attributeModel.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.LABEL') }}
<select v-model="attributeModel">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.option }}
</option>
</select>
<span v-if="v$.attributeModel.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.ERROR') }}
</span>
<form class="flex w-full" @submit.prevent="addAttributes">
<div class="w-full">
<label :class="{ error: v$.attributeModel.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.LABEL') }}
<select v-model="attributeModel">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.option }}
</option>
</select>
<span v-if="v$.attributeModel.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.ERROR') }}
</span>
</label>
<woot-input
v-model="displayName"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: v$.displayName.$error }"
:error="
v$.displayName.$error
? $t('ATTRIBUTES_MGMT.ADD.FORM.NAME.ERROR')
: ''
"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
@update:model-value="onDisplayNameChange"
@blur="v$.displayName.$touch"
/>
<woot-input
v-model="attributeKey"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL')"
type="text"
:class="{ error: v$.attributeKey.$error }"
:error="v$.attributeKey.$error ? keyErrorMessage : ''"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.PLACEHOLDER')"
@blur="v$.attributeKey.$touch"
/>
<label :class="{ error: v$.description.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.LABEL') }}
<textarea
v-model="description"
rows="3"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.DESC.PLACEHOLDER')"
@blur="v$.description.$touch"
/>
<span v-if="v$.description.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.ERROR') }}
</span>
</label>
<label :class="{ error: v$.attributeType.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LABEL') }}
<select v-model="attributeType">
<option v-for="type in types" :key="type.id" :value="type.id">
{{ type.option }}
</option>
</select>
<span v-if="v$.attributeType.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span>
</label>
<div v-if="isAttributeTypeList" class="multiselect--wrap">
<label>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label>
<woot-input
v-model="displayName"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.LABEL')"
type="text"
:class="{ error: v$.displayName.$error }"
:error="
v$.displayName.$error
? $t('ATTRIBUTES_MGMT.ADD.FORM.NAME.ERROR')
: ''
"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
@update:model-value="onDisplayNameChange"
@blur="v$.displayName.$touch"
<multiselect
ref="tagInput"
v-model="values"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
label="name"
track-by="name"
:class="{ invalid: isMultiselectInvalid }"
:options="options"
multiple
taggable
@close="onTouch"
@tag="addTagValue"
/>
<woot-input
v-model="attributeKey"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL')"
type="text"
:class="{ error: v$.attributeKey.$error }"
:error="v$.attributeKey.$error ? keyErrorMessage : ''"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.PLACEHOLDER')"
@blur="v$.attributeKey.$touch"
/>
<label :class="{ error: v$.description.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.LABEL') }}
<textarea
v-model="description"
rows="3"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.DESC.PLACEHOLDER')"
@blur="v$.description.$touch"
/>
<span v-if="v$.description.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.ERROR') }}
</span>
<label v-show="isMultiselectInvalid" class="error-message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label>
<label :class="{ error: v$.attributeType.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LABEL') }}
<select v-model="attributeType">
<option v-for="type in types" :key="type.id" :value="type.id">
{{ type.option }}
</option>
</select>
<span v-if="v$.attributeType.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
</span>
</label>
<div v-if="isAttributeTypeList" class="multiselect--wrap">
<label>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
</label>
<multiselect
ref="tagInput"
v-model="values"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
"
label="name"
track-by="name"
:class="{ invalid: isMultiselectInvalid }"
:options="options"
multiple
taggable
@close="onTouch"
@tag="addTagValue"
/>
<label v-show="isMultiselectInvalid" class="error-message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
</label>
</div>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-submit-button
:disabled="isButtonDisabled"
:button-text="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</woot-modal>
<div v-if="isAttributeTypeText">
<input
v-model="regexEnabled"
type="checkbox"
@input="toggleRegexEnabled"
/>
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.ENABLE_REGEX.LABEL') }}
</div>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexPattern"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.LABEL')"
type="text"
:placeholder="
$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_PATTERN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAttributeTypeText && isRegexEnabled"
v-model="regexCue"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.LABEL')"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-submit-button
:disabled="isButtonDisabled"
:button-text="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
@@ -1,6 +1,8 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
import SettingsLayout from '../SettingsLayout.vue';
@@ -68,13 +70,12 @@ const onClickTabChange = index => {
feature-name="custom_attributes"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
<Button
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
@@ -83,7 +83,9 @@ const tableHeaders = computed(() => {
feature-name="audit_logs"
/>
<div class="mt-6 flex-1 text-slate-700 dark:text-slate-300">
<div
class="mt-6 flex-1 w-full max-w-[960px] mx-auto text-slate-700 dark:text-slate-300"
>
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('AUDIT_LOGS.LOADING')"
@@ -95,7 +97,7 @@ const tableHeaders = computed(() => {
{{ $t('AUDIT_LOGS.LIST.404') }}
</p>
<div v-else class="min-w-full overflow-x-auto">
<table class="divide-y divide-slate-75 dark:divide-slate-700">
<table class="divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
@@ -106,7 +108,7 @@ const tableHeaders = computed(() => {
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="py-4 pr-4 break-all whitespace-nowrap">
@@ -3,6 +3,8 @@ import { useAlert } from 'dashboard/composables';
import AddAutomationRule from './AddAutomationRule.vue';
import EditAutomationRule from './EditAutomationRule.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from '../SettingsLayout.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -185,18 +187,17 @@ const tableHeaders = computed(() => {
feature-name="automation"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
<Button
:label="$t('AUTOMATION.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('AUTOMATION.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-slate-75 dark:divide-slate-700">
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
@@ -207,7 +208,7 @@ const tableHeaders = computed(() => {
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<AutomationRuleRow
v-for="automation in records"
@@ -58,23 +58,24 @@ export default {
</script>
<template>
<div class="flex-1 p-6 overflow-auto dark:bg-slate-900">
<div class="flex-1 py-6 overflow-auto mx-auto max-w-[960px] w-full p-1">
<woot-loading-state v-if="uiFlags.isFetchingItem" />
<div v-else-if="!hasABillingPlan">
<p>{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}</p>
</div>
<div v-else class="w-full">
<div class="current-plan--details">
<h6>{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}</h6>
<div v-else class="w-full flex flex-col gap-5">
<div class="border-b border-n-weak pb-4">
<h6 class="text-n-slate-12">
{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}
</h6>
<div
v-dompurify-html="
formatMessage(
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
plan: planName,
quantity: subscribedQuantity,
})
)
"
v-dompurify-html="formatMessage(
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
plan: planName,
quantity: subscribedQuantity,
})
)
"
/>
</div>
<BillingItem
@@ -93,31 +94,3 @@ export default {
</div>
</div>
</template>
<style lang="scss">
.manage-subscription {
@apply bg-white dark:bg-slate-800 flex justify-between mb-2 py-6 px-4 items-center rounded-md border border-solid border-slate-75 dark:border-slate-700;
}
.current-plan--details {
@apply border-b border-solid border-slate-75 dark:border-slate-800 mb-4 pb-4;
h6 {
@apply text-slate-800 dark:text-slate-100;
}
p {
@apply text-slate-600 dark:text-slate-200;
}
}
.manage-subscription {
.manage-subscription--description {
@apply mb-0 text-slate-600 dark:text-slate-200;
}
h6 {
@apply text-slate-800 dark:text-slate-100;
}
}
</style>
@@ -1,40 +1,44 @@
<script>
export default {
props: {
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
buttonIcon: {
type: String,
default: 'edit',
},
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: {
type: String,
default: '',
},
emits: ['open'],
};
description: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
buttonIcon: {
type: String,
default: 'edit',
},
});
defineEmits(['open']);
</script>
<template>
<div class="manage-subscription">
<div
class="flex justify-between items-center shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5"
>
<div>
<h6>{{ title }}</h6>
<p class="manage-subscription--description">
<h6 class="text-n-slate-12">{{ title }}</h6>
<p class="mb-0 text-n-slate-11">
{{ description }}
</p>
</div>
<div>
<woot-button variant="smooth" :icon="buttonIcon" @click="$emit('open')">
{{ buttonLabel }}
</woot-button>
<Button
:label="buttonLabel"
variant="faded"
size="sm"
@click="$emit('open')"
/>
</div>
</div>
</template>
@@ -4,14 +4,12 @@ import { required, minLength } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton.vue';
import Modal from '../../../../components/Modal.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
export default {
name: 'AddCanned',
components: {
WootSubmitButton,
Modal,
WootMessageEditor,
},
props: {
@@ -82,58 +80,56 @@ export default {
</script>
<template>
<Modal v-model:show="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
:header-content="$t('CANNED_MGMT.ADD.DESC')"
/>
<form class="flex flex-col w-full" @submit.prevent="addCannedResponse()">
<div class="w-full">
<label :class="{ error: v$.shortCode.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
<input
v-model="shortCode"
type="text"
:placeholder="$t('CANNED_MGMT.ADD.FORM.SHORT_CODE.PLACEHOLDER')"
@blur="v$.shortCode.$touch"
/>
</label>
</div>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
:header-content="$t('CANNED_MGMT.ADD.DESC')"
/>
<form class="flex flex-col w-full" @submit.prevent="addCannedResponse()">
<div class="w-full">
<label :class="{ error: v$.shortCode.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
<input
v-model="shortCode"
type="text"
:placeholder="$t('CANNED_MGMT.ADD.FORM.SHORT_CODE.PLACEHOLDER')"
@blur="v$.shortCode.$touch"
/>
</label>
</div>
<div class="w-full">
<label :class="{ error: v$.content.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.CONTENT.LABEL') }}
</label>
<div class="editor-wrap">
<WootMessageEditor
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@blur="v$.content.$touch"
/>
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<WootSubmitButton
:disabled="
v$.content.$invalid ||
v$.shortCode.$invalid ||
addCanned.showLoading
"
:button-text="$t('CANNED_MGMT.ADD.FORM.SUBMIT')"
:loading="addCanned.showLoading"
<div class="w-full">
<label :class="{ error: v$.content.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.CONTENT.LABEL') }}
</label>
<div class="editor-wrap">
<WootMessageEditor
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@blur="v$.content.$touch"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('CANNED_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</form>
</div>
</Modal>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<WootSubmitButton
:disabled="
v$.content.$invalid ||
v$.shortCode.$invalid ||
addCanned.showLoading
"
:button-text="$t('CANNED_MGMT.ADD.FORM.SUBMIT')"
:loading="addCanned.showLoading"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('CANNED_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</form>
</div>
</template>
<style scoped lang="scss">
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
@@ -124,17 +125,15 @@ const tableHeaders = computed(() => {
feature-name="canned_responses"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
<Button
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddPopup"
>
{{ $t('CANNED_MGMT.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
<div class="mt-6 flex-1">
<div class="mt-6 flex-1 w-full max-w-[960px] mx-auto">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
@@ -145,10 +144,7 @@ const tableHeaders = computed(() => {
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<table v-else class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
@@ -174,7 +170,7 @@ const tableHeaders = computed(() => {
</th>
</thead>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr
v-for="(cannedItem, index) in records"
@@ -213,7 +209,6 @@ const tableHeaders = computed(() => {
</tbody>
</table>
</div>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<AddCanned :on-close="hideAddPopup" />
</woot-modal>
@@ -38,31 +38,14 @@ const openInNewTab = url => {
</script>
<template>
<div class="flex flex-col items-start w-full gap-2 pt-4">
<BackButton
v-if="backButtonLabel"
compact
:button-label="backButtonLabel"
/>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3">
<div
v-if="iconName"
class="flex items-center w-10 h-10 p-1 rounded-full bg-woot-25/60 dark:bg-woot-900/60"
>
<div
class="flex items-center justify-center w-full h-full rounded-full bg-woot-75/70 dark:bg-woot-800/40"
>
<fluent-icon
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
/>
</div>
</div>
<div class="flex flex-col items-start w-full gap-2 max-w-[960px] mx-auto">
<div
class="flex items-center justify-between w-full gap-4 h-20 border-b border-n-weak"
>
<div class="flex gap-3">
<BackButton v-if="backButtonLabel" compact />
<h1
class="text-2xl font-semibold font-interDisplay tracking-[0.3px] text-slate-900 dark:text-slate-25"
class="text-xl font-interDisplay tracking-[0.3px] font-medium text-n-slate-12"
>
{{ title }}
</h1>
@@ -72,9 +55,9 @@ const openInNewTab = url => {
<slot name="actions" />
</div>
</div>
<div class="flex flex-col gap-3 text-slate-600 dark:text-slate-300 w-full">
<div class="flex flex-col gap-4 text-n-slate-11 w-full pt-3">
<p
class="mb-0 text-base font-normal line-clamp-5 sm:line-clamp-none max-w-3xl tracking-[-0.1px]"
class="mb-0 text-sm font-normal line-clamp-5 sm:line-clamp-none max-w-3xl leading-6"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -84,14 +67,14 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="sm:inline-flex hidden gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium hover:underline"
class="sm:inline-flex hidden gap-1 w-fit items-center text-n-brand text-sm font-medium hover:underline"
>
{{ linkText }}
<fluent-icon
size="16"
icon="chevron-right"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
class="flex-shrink-0 text-n-brand"
/>
</a>
</CustomBrandPolicyWrapper>
@@ -105,7 +88,7 @@ const openInNewTab = url => {
v-if="helpURL && linkText"
color-scheme="secondary"
icon="arrow-outwards"
class="flex-row-reverse rounded-md min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
class="flex-row-reverse rounded-md text-sm min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
@click="openInNewTab(helpURL)"
>
{{ linkText }}
@@ -13,7 +13,7 @@ defineProps({
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between shadow-sm group bg-white border border-solid rounded-xl dark:bg-slate-800 border-slate-75 dark:border-slate-700/50 w-full"
class="flex relative flex-col sm:flex-row px-4 py-4 gap-4 sm:px-6 sm:py-5 justify-between shadow group bg-n-solid-2 rounded-xl outline-1 outline outline-n-container w-full"
>
<slot name="leftSection">
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
@@ -8,6 +8,7 @@ import CustomRolePaywall from './component/CustomRolePaywall.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore();
const { t } = useI18n();
@@ -134,14 +135,13 @@ const confirmDeletion = () => {
feature-name="canned_responses"
>
<template #actions>
<woot-button
class="rounded-md button nice"
icon="add-circle"
<Button
:label="$t('CUSTOM_ROLE.HEADER_BTN_TXT')"
icon="i-lucide-plus"
size="sm"
:disabled="isBehindAPaywall"
@click="openAddModal"
>
{{ $t('CUSTOM_ROLE.HEADER_BTN_TXT') }}
</woot-button>
/>
</template>
</BaseSettingsHeader>
</template>
@@ -150,7 +150,7 @@ const confirmDeletion = () => {
<CustomRolePaywall v-if="isBehindAPaywall" />
<table
v-else
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
class="min-w-full overflow-x-auto divide-y divide-n-weak"
>
<thead>
<th
@@ -26,7 +26,7 @@ const getFormattedPermissions = role => {
<template>
<tbody
class="divide-y divide-slate-50 dark:divide-slate-800 text-slate-700 dark:text-slate-300"
class="divide-y divide-n-weak text-slate-700 dark:text-slate-300"
>
<tr v-for="(customRole, index) in roles" :key="index">
<td
@@ -62,7 +62,7 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<form class="flex flex-wrap mx-0" @submit.prevent="addAgents()">
<div class="w-full">
@@ -62,17 +62,16 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class=" h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.AUTH.DESC'),
globalConfig.installationName
)
"
:header-content="useInstallationName(
$t('INBOX_MGMT.ADD.AUTH.DESC'),
globalConfig.installationName
)
"
/>
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
@@ -70,7 +70,7 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class=" h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<EmptyState
:title="$t('INBOX_MGMT.FINISH.TITLE')"
@@ -40,14 +40,16 @@ export default {
</script>
<template>
<div
class="flex flex-row overflow-auto p-4 h-full bg-slate-25 dark:bg-slate-800"
>
<woot-wizard
class="hidden md:block w-1/4"
:global-config="globalConfig"
:items="items"
/>
<router-view />
<div class="max-w-[960px] mx-auto w-full flex">
<div
class="flex flex-row overflow-auto shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5 mb-10"
>
<woot-wizard
class="hidden md:block w-1/4"
:global-config="globalConfig"
:items="items"
/>
<router-view />
</div>
</div>
</template>
@@ -8,9 +8,12 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import ChannelName from './components/ChannelName.vue';
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
const getters = useStoreGetters();
const store = useStore();
const router = useRouter();
const { t } = useI18n();
const { isAdmin } = useAdmin();
@@ -59,6 +62,10 @@ const openDelete = inbox => {
showDeletePopup.value = true;
selectedInbox.value = inbox;
};
const onClickNewInbox = () => {
router.push({ name: 'settings_inbox_new' });
};
</script>
<template>
@@ -75,21 +82,18 @@ const openDelete = inbox => {
feature-name="inboxes"
>
<template #actions>
<router-link
<Button
v-if="isAdmin"
class="button nice rounded-md"
:to="{ name: 'settings_inbox_new' }"
>
<fluent-icon icon="add-circle" />
{{ $t('SETTINGS.INBOXES.NEW_INBOX') }}
</router-link>
:label="$t('SETTINGS.INBOXES.NEW_INBOX')"
icon="i-lucide-plus"
size="sm"
@click="onClickNewInbox"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<table class="min-w-full overflow-x-auto divide-y divide-n-weak">
<tbody
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
>
@@ -161,9 +161,8 @@ export default {
inboxName() {
if (this.isATwilioSMSChannel || this.isATwilioWhatsAppChannel) {
return `${this.inbox.name} (${
this.inbox.messaging_service_sid || this.inbox.phone_number
})`;
return `${this.inbox.name} (${this.inbox.messaging_service_sid || this.inbox.phone_number
})`;
}
if (this.isAWhatsAppChannel) {
return `${this.inbox.name} (${this.inbox.phone_number})`;
@@ -175,7 +174,10 @@ export default {
},
canLocktoSingleConversation() {
return (
this.isASmsInbox || this.isAWhatsAppChannel || this.isAFacebookInbox
this.isASmsInbox ||
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAPIInbox
);
},
inboxNameLabel() {
@@ -289,8 +291,8 @@ export default {
greeting_message: this.greetingMessage || '',
portal_id: this.selectedPortalSlug
? this.portals.find(
portal => portal.slug === this.selectedPortalSlug
).id
portal => portal.slug === this.selectedPortalSlug
).id
: null,
lock_to_single_conversation: this.locktoSingleConversation,
sender_name_type: this.senderNameType,
@@ -359,7 +361,7 @@ export default {
<template>
<div
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto bg-white settings dark:bg-slate-800"
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 max-w-[960px] mx-auto shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 px-6 py-5 mb-10"
>
<SettingIntroBanner
:header-image="inbox.avatarUrl"
@@ -404,11 +406,10 @@ export default {
:class="{ error: v$.selectedInboxName.$error }"
:label="inboxNameLabel"
:placeholder="inboxNamePlaceHolder"
:error="
v$.selectedInboxName.$error
:error="v$.selectedInboxName.$error
? $t('INBOX_MGMT.ADD.CHANNEL_NAME.ERROR')
: ''
"
"
@blur="v$.selectedInboxName.$touch"
/>
<woot-input
@@ -416,17 +417,14 @@ export default {
v-model="webhookUrl"
class="w-3/4 pb-4"
:class="{ error: v$.webhookUrl.$error }"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.LABEL')
"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.PLACEHOLDER')
"
:error="
v$.webhookUrl.$error
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.LABEL')
"
:placeholder="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.PLACEHOLDER')
"
:error="v$.webhookUrl.$error
? $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.ERROR')
: ''
"
"
@blur="v$.webhookUrl.$touch"
/>
<woot-input
@@ -434,36 +432,31 @@ export default {
v-model="channelWebsiteUrl"
class="w-3/4 pb-4"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER')
"
:placeholder="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTitle"
class="w-3/4 pb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.PLACEHOLDER'
)
"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.LABEL')
"
:placeholder="$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.PLACEHOLDER'
)
"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline"
class="w-3/4 pb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
"
:placeholder="$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
"
/>
<label v-if="isAWebWidgetInbox" class="w-3/4 pb-4">
@@ -507,16 +500,14 @@ export default {
<div v-if="greetingEnabled" class="pb-4">
<GreetingsEditor
v-model="greetingMessage"
:label="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.PLACEHOLDER'
)
"
:label="$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.LABEL'
)
"
:placeholder="$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.PLACEHOLDER'
)
"
:richtext="!textAreaChannels"
/>
</div>
@@ -723,11 +714,10 @@ export default {
<input
ref="businessNameInput"
v-model="businessName"
:placeholder="
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.PLACEHOLDER'
)
"
:placeholder="$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.PLACEHOLDER'
)
"
class="mb-0"
type="text"
/>
@@ -64,7 +64,7 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class=" h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.API_CHANNEL.TITLE')"
@@ -77,9 +77,8 @@ export default {
<input
v-model="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
:placeholder="$t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
@blur="v$.channelName.$touch"
/>
<span v-if="v$.channelName.$error" class="message">{{
@@ -94,9 +93,8 @@ export default {
<input
v-model="webhookUrl"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.PLACEHOLDER')
"
:placeholder="$t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.PLACEHOLDER')
"
@blur="v$.webhookUrl.$touch"
/>
</label>

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