Compare commits

...
Author SHA1 Message Date
Pranav 5171c81384 Add elastic search to the query 2024-11-08 14:21:59 -08:00
54740e3bb9 fix: Update the translation for the text used in isTyping method (#10384)
This fix consists of translating the message when another user is typing on the other side.
---
Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-04 20:04:08 -08:00
PranavandGitHub 8cdbdaaa07 fix: Process attachments as regular attachments if the text/plain or text/html part is empty (#10379)
Some email clients automatically set Content-Disposition to inline for
specific content types, such as images. In cases where the email body is
empty, inline attachments may not display correctly due to our previous
implementation. Our assumption was that these attachments are referenced
within text/plain or text/html parts.

Customer-reported issues, especially with Apple Mail, show emails with
attachments marked as inline but without any corresponding text parts.
This leads to missing attachments even though would have processed the
attachment.

This update introduces a check for the presence of a text part. If none
exists, inline attachments are treated as regular attachments and added
to the external attachments array, ensuring that all attachments display
properly.

<details>
<summary><b>Script to update the existing emails that are already
available in the system</b></summary>

```rb
def update_content id
  message = Message.find id
  conversation = message.conversation
  message_id = message.source_id

  channel = message.inbox.channel

  authentication_type = 'XOAUTH2'
  imap_password = Google::RefreshOauthTokenService.new(channel: channel).access_token
  imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
  imap.authenticate(authentication_type, channel.imap_login, imap_password)
  imap.select('INBOX')

  results = imap.search(['HEADER', 'MESSAGE-ID', message_id])
  message_content = imap.fetch(results.first, 'RFC822').first.attr['RFC822']
  mail = MailPresenter.new(Mail.read_from_string(message_content))

  mail_content = if mail.text_content.present?
                   mail.text_content[:reply]
                 elsif mail.html_content.present?
                   mail.html_content[:reply]
                 end

  attachments = mail.attachments.last(Message::NUMBER_OF_PERMITTED_ATTACHMENTS)
  inline_attachments = attachments.select { |attachment| attachment[:original].inline? && mail_content.present? }
  regular_attachments = attachments - inline_attachments

  regular_attachments.each do |mail_attachment|
    attachment = message.attachments.new(
      account_id: conversation.account_id,
      file_type: 'file'
    )
    attachment.file.attach(mail_attachment[:blob])
  end

  message.save!
end
```
</details>
2024-11-04 10:25:01 +01:00
579efd933b feat(v4): Update the campaigns page design (#10371)
<img width="1439" alt="Screenshot 2024-10-30 at 8 58 12 PM"
src="https://github.com/user-attachments/assets/26231270-5e73-40fb-9efa-c661585ebe7c">


Fixes
https://linear.app/chatwoot/project/campaign-redesign-f82bede26ca7/overview

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-10-31 11:57:13 +05:30
6e6c5a2f02 refactor: use css only last item detection (#10363)
The last item in the sidebar top level group has an indicator specified,
the problem in our case is that the structure can be nested and have sub
groups. So selecting the last item correctly can be tricky.

Previous implementation relied on the using DOM queries to find the last
item from a flat list of children, it would trigger on a `watch`. This
was error-prone as well as non idiomatic. The new approach is CSS-only
and reduces the unnecessary compute required.

Codepen for reference: https://codepen.io/scmmishra/pen/yLmKNLW

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-10-31 09:39:18 +05:30
Shivam MishraandGitHub 2d35fa135b feat: Update colors (#10365) 2024-10-29 22:20:37 -07:00
Vishnu NarayananandGitHub 6da6a80ae7 chore: force pnpm install in vite docker (#10367)
- Force `pnpm install` during vite docker image
2024-10-29 17:13:17 +05:30
Vishnu NarayananandGitHub 87719a8fcd chore: fix pnpm path in rails and memory issue during vite build (#10366)
- Fix pnpm path in rails
- Fix memory issue during vite build


Fixes https://github.com/chatwoot/chatwoot/issues/10356
2024-10-29 16:41:12 +05:30
Sivin VargheseandGitHub aa57431c48 fix: Dropdown menu issues (#10364) 2024-10-29 16:10:35 +05:30
Vishnu NarayananandGitHub 55dfd7db50 fix: pnpm in vite docker (#10344)
- Fix pnpm path in vite docker
- Remove webpack files
- fFx vite server port
2024-10-29 15:16:10 +05:30
Sivin VargheseandGitHub 0689f59a05 feat: Update button component (#10362) 2024-10-29 14:00:24 +05:30
Sivin VargheseandGitHub f73798a1aa feat(v4): Help center portal redesign improvements (#10349) 2024-10-28 21:04:43 -07:00
Shivam MishraandGitHub 035a037313 fix: Use addEventListener instead of onmessage to listen to chatwoot-dashboard-app:fetch-info (#10342) 2024-10-28 20:43:47 -07:00
dependabot[bot]andGitHub c6c36b1b36 chore(deps): bump rexml from 3.3.6 to 3.3.9 (#10361) 2024-10-28 20:42:11 -07:00
7ba6c1d87d fix: Fix the issues with the new sidebar (#10348)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-28 20:41:43 -07:00
6df2d76c1e feat: new colors (#10352)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2024-10-28 14:27:08 +05:30
80c9434069 feat(v4): Auto-navigate to first menu item on group menu open(#10350)
Ensures users are seamlessly directed to the first available menu item upon opening a group, improving UX by reducing unnecessary clicks. This change enhances navigation flow within groups.

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-25 13:01:29 -07:00
Sivin VargheseandGitHub 73b6e2cf37 fix: Agents list in bulk action is not loading (#10347)
# Pull Request Template

## Description

This PR fixes the issue where the bulk action inbox assignable agent
list was not showing.

The issue started after merging this [feat: Vite+Vue 3
PR](https://github.com/chatwoot/chatwoot/pull/10047 ).

**Cause of issue**
Previously, `selectedInboxes` was accessed from the `ChatList.vue`
component. However, after moving the bulk action logic from mixin to the
`useBulkActions.js` composable, we were still referencing
`selectedInboxes` from the `ChatList.vue` component, even though it was
being set in the composable. This caused the API failed to load the
assignable agent list.


Ref:https://github.com/chatwoot/chatwoot/blob/develop/app/javascript/dashboard/composables/chatlist/useBulkActions.js#L18

**Solution**
Removed the usage of `selectedInboxes` from the `ChatList.vue` component
ref and using `selectedInboxes` ref directly from the
`useBulkActions.js`

Fixes
https://linear.app/chatwoot/issue/CW-3696/bulk-action-agent-list-is-not-loading

## Type of change

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

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/21e3835b3db04e34b94531ec128b586b?sid=beda60f0-1c8e-457b-b617-379d4af91873


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-10-24 11:51:40 +05:30
a3855a8d1d feat(v4): Update the help center portal design (#10296)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-23 22:09:36 -07:00
6d3ecfe3c1 feat: Add new sidebar for Chatwoot V4 (#10291)
This PR has the initial version of the new sidebar targeted for the next major redesign of the app. This PR includes the following changes

- Components in the `layouts-next` and `base-next` directories in `dashboard/components`
- Two generic components `Avatar` and `Icon`
- `SidebarGroup` component to manage expandable sidebar groups with nested navigation items. This includes handling active states, transitions, and permissions.
- `SidebarGroupHeader` component to display the header of each navigation group with optional icons and active state indication.
- `SidebarGroupLeaf` component for individual navigation items within a group, supporting icons and active state.
- `SidebarGroupSeparator` component to visually separate nested navigation items. (They look a lot like header)
- `SidebarGroupEmptyLeaf` component to render empty state of any navigation groups.

----

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-23 18:32:37 -07:00
601a0f8a76 fix: ip-lookup database lazy loading for all environments (#8052)
The current task for loading `GeoLite2-City.mmdb` doesn't work for all build types. This PR addresses this and move the task to initializer to ensure consistency across environments. 

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sojan Jose <sojan.official@gmail.com>
2024-10-22 23:18:30 -07:00
Vishnu Narayanan c49f5ed800 Merge branch 'hotfix/3.14.1' into develop 2024-10-22 16:04:38 +05:30
Vishnu Narayanan 5133f4e579 Bump version to 3.14.1 2024-10-22 16:03:22 +05:30
Shivam MishraandVishnu Narayanan c8657c55a8 fix: parsing of @ in i18n values (#10334)
Vue i18n has a new [linked message
syntax.](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#linked-messages)
When it encounters `@` it assumes that we're trying to use a linked
message. And tries to parse it as such, in any case, it breaks since the
syntax is not valid and the params are not present. So it causes an
error. This works on dev but on production the error is bubbled up to
the top and rendering breaks.

A lot of folks use Chatwoot with default locale set in the env, this
surfaced the issue for the languages for which the syntax was not
updated

Fixes: https://github.com/chatwoot/chatwoot/issues/10313
2024-10-22 16:02:10 +05:30
Shivam MishraandGitHub 2a832f8ed5 fix: parsing of @ in i18n values (#10334)
Vue i18n has a new [linked message
syntax.](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#linked-messages)
When it encounters `@` it assumes that we're trying to use a linked
message. And tries to parse it as such, in any case, it breaks since the
syntax is not valid and the params are not present. So it causes an
error. This works on dev but on production the error is bubbled up to
the top and rendering breaks.

A lot of folks use Chatwoot with default locale set in the env, this
surfaced the issue for the languages for which the syntax was not
updated

Fixes: https://github.com/chatwoot/chatwoot/issues/10313
2024-10-22 15:43:01 +05:30
Vishnu NarayananandGitHub 35a1dcce43 feat: switch docker dev from webpack to vite (#10322)
# Pull Request Template

## Description

- Fix docker development 
- Switch from webpack to vite 

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-10-22 14:40:30 +05:30
Sivin VargheseandGitHub 3fe771df6f fix: Modal in the context menu disappears unless hovered (#10333)
# Pull Request Template

## Description

This PR resolves the issue where the modal in the context menu
disappears when not being hovered over.

**Cause of issue.**
The problem occurred because the modal-related component was placed
inside `MessageContextMenu.vue`, and the parent wrapper was using the
classes `group-hover:visible invisible`. This caused the modal to only
appear when the message item, where the context menu was opened, was
hovered over.

**Solution**
To fix this, I removed the `group-hover:visible` invisible class from
the parent wrapper and moved it into the `woot-button` within the
`MessageContextMenu.vue` component. Additionally, I added a nested group
with the class group/context-menu, allowing the focus to remain on the
context menu itself.

Fixes
https://linear.app/chatwoot/issue/PR-1415/modal-in-the-context-menu-disappears-unless-hovered

## Type of change

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

## How Has This Been Tested?

**Loom video**

**Before**

https://www.loom.com/share/458f90708664493c86e909a56869d065?sid=0564a508-09a5-4e73-800b-8042140a22ba

**After**

https://www.loom.com/share/c119936d181d406d89468f9482ef6b81?sid=5cf3b1b4-6c66-4f8c-8f93-a62465a93b57

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-10-22 13:26:56 +05:30
Shivam MishraandGitHub 4e64097421 fix: solid colors (#10321)
Fix mismatch in CSS vars for `amber` and `blue` solid colors
2024-10-18 15:59:21 +05:30
Sivin VargheseandGitHub dff57013c3 feat: Add article empty state component (#10278) 2024-10-17 19:56:38 -07:00
Sivin VargheseandGitHub a37d44758b feat: Add portal empty state (#10277) 2024-10-17 15:51:24 -07:00
Sojan c796832a58 Merge branch 'release/3.14.0' into develop 2024-10-16 21:46:11 -07:00
Sojan 604c592e89 Merge branch 'release/3.14.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-10-16 21:45:47 -07:00
Sojan fe2d91e0d3 Bump version to 3.14.0 2024-10-16 21:45:13 -07:00
Vishnu NarayananandGitHub 6d40986a64 fix: cwctl upgrade in ubuntu 24.04 (#10305)
- fix pip install during cwctl upgrade in `ubuntu 24.04`
- add logs during pnpm installation
- fix `pnpm` installation during upgrade

Ref
> PEP 668 specification in Ubuntu 24.04, which marks the Python
environment as "externally managed." This means it restricts using pip
for system-wide package installations to avoid conflicts with
system-managed packages.
2024-10-17 09:46:22 +05:30
PranavandGitHub 35b21c1cea feat: Add Spinner to new components (#10303)
- Add Spinner to new components
2024-10-16 17:53:46 -07:00
Sivin VargheseandGitHub 306a6c6ce0 feat: Add the new portal settings page (#10282) 2024-10-16 10:51:13 -07:00
Sivin VargheseandGitHub 902a9aa7d7 feat: Add the new design for edit article page (#10285) 2024-10-16 10:50:44 -07:00
Vishnu NarayananandGitHub 6a383dc3ca chore: increment cwctl version (#10299)
- increment cwctl version
2024-10-16 17:50:07 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e2db5b8cff chore(deps): bump actionmailer from 7.0.8.4 to 7.0.8.5 (#10294)
Bumps [actionmailer](https://github.com/rails/rails) from 7.0.8.4 to
7.0.8.5.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails/releases">actionmailer's
releases</a>.</em></p>
<blockquote>
<h2>7.0.8.5</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>Avoid regex backtracking in HTTP Token authentication</p>
<p>[CVE-2024-47887]</p>
</li>
<li>
<p>Avoid regex backtracking in query parameter filtering</p>
<p>[CVE-2024-41128]</p>
</li>
</ul>
<h2>Active Job</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Mailer</h2>
<ul>
<li>
<p>Avoid regex backtracking in <code>block_format</code> helper</p>
<p>[CVE-2024-47889]</p>
</li>
</ul>
<h2>Action Cable</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Storage</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rails/rails/commit/f61f4ef957f80e1668797fce8a2393f3edb7ed76"><code>f61f4ef</code></a>
Preparing for 7.0.8.5 release</li>
<li><a
href="https://github.com/rails/rails/commit/d666c965e358525fd6dc83233cb92cd87db82c81"><code>d666c96</code></a>
Update CHANGELOGs</li>
<li><a
href="https://github.com/rails/rails/commit/30abd6bd715de0bfc9d3936bd2747874169d6292"><code>30abd6b</code></a>
Merge pull request <a
href="https://redirect.github.com/rails/rails/issues/52962">#52962</a>
from rails/rm-releser</li>
<li><a
href="https://github.com/rails/rails/commit/0e5694f4d32544532d2301a9b4084eacb6986e94"><code>0e5694f</code></a>
Avoid backtracking in ActionMailer block_format</li>
<li><a
href="https://github.com/rails/rails/commit/40d8b920e385c7aaa54262b07c1376f3fbcc6e83"><code>40d8b92</code></a>
Merge pull request <a
href="https://redirect.github.com/rails/rails/issues/51510">#51510</a>
from fatkodima/remove-ostruct</li>
<li>See full diff in <a
href="https://github.com/rails/rails/compare/v7.0.8.4...v7.0.8.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actionmailer&package-manager=bundler&previous-version=7.0.8.4&new-version=7.0.8.5)](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-10-15 20:46:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
544fe2b913 chore(deps): bump actiontext from 7.0.8.4 to 7.0.8.5 (#10293)
Bumps [actiontext](https://github.com/rails/rails) from 7.0.8.4 to
7.0.8.5.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rails/rails/releases">actiontext's
releases</a>.</em></p>
<blockquote>
<h2>7.0.8.5</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>Avoid regex backtracking in HTTP Token authentication</p>
<p>[CVE-2024-47887]</p>
</li>
<li>
<p>Avoid regex backtracking in query parameter filtering</p>
<p>[CVE-2024-41128]</p>
</li>
</ul>
<h2>Active Job</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Action Mailer</h2>
<ul>
<li>
<p>Avoid regex backtracking in <code>block_format</code> helper</p>
<p>[CVE-2024-47889]</p>
</li>
</ul>
<h2>Action Cable</h2>
<ul>
<li>No changes.</li>
</ul>
<h2>Active Storage</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rails/rails/commit/f61f4ef957f80e1668797fce8a2393f3edb7ed76"><code>f61f4ef</code></a>
Preparing for 7.0.8.5 release</li>
<li><a
href="https://github.com/rails/rails/commit/d666c965e358525fd6dc83233cb92cd87db82c81"><code>d666c96</code></a>
Update CHANGELOGs</li>
<li><a
href="https://github.com/rails/rails/commit/30abd6bd715de0bfc9d3936bd2747874169d6292"><code>30abd6b</code></a>
Merge pull request <a
href="https://redirect.github.com/rails/rails/issues/52962">#52962</a>
from rails/rm-releser</li>
<li><a
href="https://github.com/rails/rails/commit/727b0946c3cab04b825c039435eac963d4e91822"><code>727b094</code></a>
ActionText: Avoid backtracing in plain_text_for_blockquote_node</li>
<li>See full diff in <a
href="https://github.com/rails/rails/compare/v7.0.8.4...v7.0.8.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actiontext&package-manager=bundler&previous-version=7.0.8.4&new-version=7.0.8.5)](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-10-15 20:46:07 -07:00
a04f8182a2 feat: Add new Locale page (#10275)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-15 19:21:15 -07:00
6d6dc0c153 feat: Add the new design for the portal category page (#10274)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-15 13:17:28 -07:00
Sivin VargheseandGitHub 32a9d5b0ce feat: Add a base layout component for the empty states (#10276) 2024-10-15 13:12:32 -07:00
Sivin VargheseandGitHub 0082c6adb9 feat: Add new Inline Input component (#10281) 2024-10-15 13:11:38 -07:00
Sivin VargheseandGitHub 431d533635 feat: Add new Avatar component (#10280)
**Screenshot from story**

**Light**
<img width="949" alt="image"
src="https://github.com/user-attachments/assets/b4a61e64-7c1d-408a-9009-13fa1ad43b67">



**Dark**
<img width="949" alt="image"
src="https://github.com/user-attachments/assets/21496540-aea5-4ca6-a92d-e7935b5e03d1">
2024-10-15 13:11:08 -07:00
Sivin VargheseandGitHub 5fd389e15d feat: Add the new design for article page (#10273) 2024-10-15 13:08:04 -07:00
Sivin VargheseandGitHub 44be3c9eec feat: Add Help Center layout with portal switcher component (#10272) 2024-10-15 13:07:14 -07:00
Sivin VargheseandGitHub 7be1ecaf96 feat: Add new ComboBox component (#10267) 2024-10-15 13:03:06 -07:00
Sivin VargheseandGitHub f13644245e feat: Add the new Dialog component (#10266) 2024-10-15 13:01:57 -07:00
Sivin VargheseandGitHub 62f4f127aa feat: Add new breadcrumb component (#10268) 2024-10-15 12:59:50 -07:00
Arunim ChaudharyandGitHub 392e58b0be fix: Use pnpm in make setup command (#10289) 2024-10-15 12:57:29 -07:00
Shivam MishraandGitHub 3a0fd9b777 feat: Add support for new colors (#10287)
This PR adds new colors based on the new design targeted for v4. 

Some usage guidelines

- All new colors are prefixed with `n` so to use solid, we use
`bg-n-solid-1`
- To use slate use `text-n-slate-12` it automatically toggles between
radix `slate` and `slateDark`
- The `weak` and `strong` colors are specifically used for borders
2024-10-14 21:13:51 -07:00
Sivin VargheseandGitHub dec637ab8a feat: Add new pagination component (#10263) 2024-10-14 21:06:54 -07:00
e0ef007047 fix: Fix Sentry issues from Vite migration (#10262)
Fixes the following issues

- https://chatwoot-p3.sentry.io/issues/5966466083
- https://chatwoot-p3.sentry.io/issues/5966497518
- https://chatwoot-p3.sentry.io/issues/5966555379

For the first one, I am not sure if the fix is 100% correct, since I was
not able to reproduce, but I am confident it will work.

For both, 1st and 2nd issues, the problem came from the fact that we set
individual records to `undefined` when the intent was to remove it,
explicitly using delete fixes the issue.

### Whats up with the store changes?

Glad you asked, this comes down to Vue reactivity, previously Vue didn't
track undefined strictly, it just kinda ignored it, in Vue 3, the
reactivity changed significantly when they introduced ES6 proxies. The
Proxy tracks all property changes, including those set to undefined, so
properties remain enumerable.

So to delete a record, we actually have to the delete it using the
delete keyword, or replace the parent object with a new object splicing
the object to be deleted out.

I am assuming it worked earlier because VueX 3 reactivity was using
Object.defineProperty. Setting it to undefined might have "deleted" it
earlier

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-14 10:44:59 -07:00
Sivin VargheseandGitHub 593270d10b feat: Add the locale card component (#10270) 2024-10-11 15:52:03 -07:00
Sivin VargheseandGitHub 6986d34bd9 feat: Add the new Category card component (#10271) 2024-10-11 15:51:32 -07:00
Sivin VargheseandGitHub 694302b930 feat: Add the new Article card component (#10269) 2024-10-11 15:50:54 -07:00
1e9959bb65 feat: Update the design for the dropdown menu component (#10265)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-11 15:46:13 -07:00
ed9dc6d419 feat: Add the design for the new tab component (#10261)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-11 15:27:29 -07:00
Sivin VargheseandGitHub 2d5afef7c2 feat: Update the design for text area component (#10260) 2024-10-11 15:22:56 -07:00
1fc06f8959 feat: Add the design for the new Input component (#10258)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-11 15:13:17 -07:00
16c6ef0e11 feat: Add the update design for the button component (#10257)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-10-11 15:11:16 -07:00
Sivin VargheseandGitHub 01cc46b318 fix: Cannot read properties of undefined (reading '$touch') (#10264) 2024-10-10 21:29:43 -07:00
4c7a539f9d feat: use iife build for sdk (#10255)
Vite uses `Rollup` for bundling, when building the sdk, we effectively
run a separate vite config, with `Library Mode`. When migrating from
Webpack to Vite, I selected `umd` format, i.e. Universal Module
Definition, which works as `amd`, `cjs` and `iife` all in one. However a
lot of Chatwoot users ran into issues where UMD sdk.js won't work.
Especially so when used with Google Tag Manager.

As a hotfix we moved the format from `umd` to `cjs`. Here's the thing
CJS is supposed to be used for Node packages. But for some-reason it was
working on browsers too, its no surprising, since the output is a valid
JS and the code we wrote was written for the browser.

There's a catch though, when minifying, esbuild would use tokens like
`$` and `_`, since `CJS` build is not scoped, unlike a `UMD` file, or
(spoiler alert) `IIFE`. Any declarations would be global, and websites
using `jQuery` (uff, culture) and `underscore-js` would break. We pushed
another hotfix disabling the name replacement in `esbuild` unless we
test out `IIFE` builds (which is this PR)

This PR fixes this by using `IIFE` instead, it is always scoped in a
function, so it never binds things globally, unless specifically written
to do so (example. `window.$chatwoot`).

I've tested this SDK on Safari, Chrome and iOS Safari on paperlayer test
site, it seems to be working fine. The sdk build is also scoped
correctly.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-10 12:14:36 -07:00
Muhsin KelothandGitHub a2f32f7232 feat: Add telegram user name in contact details (#10259)
Fixes https://linear.app/chatwoot/issue/CW-3648/chore-show-telegram-user-name-next-to-the-contact-details-if-available
2024-10-10 09:35:08 -07:00
PranavandGitHub 220a947380 feat: Add histoire for component playground (#10256)
We will use
[histoire](https://histoire.dev/guide/vue3/getting-started.html) for
component development. I've locked the version to 0.17.15 as it had
issues in the latest versions.

Run the following commands to start the development server.

```bash
# Start the development server
pnpm story:dev

# Build the assets to deploy it to website
pnpm story:build

# View the production version of the build
pnpm story:preview
```
2024-10-09 22:10:53 -07:00
PranavandGitHub 8505aa48c3 fix: Use native a tag for https URL in the sidebar (#10254)
This PR updates the sidebar component to use a native <a> tag for the Help Center URL component. It also updates the build pipeline to use the esbuild options minifyIdentifiers and keepNames set to true.
2024-10-09 21:04:04 -07:00
Muhsin KelothandGitHub b49eaa5c45 fix: Search linear issues (#10253)
In the `DropdownList.vue` component, the `onSearch` function was not properly passing the search value to the parent component. This resulted in the `onSearch` event in parent components (such as `LinkIssue.vue`) receiving an undefined value instead of the actual search term.


https://github.com/chatwoot/chatwoot/blob/f18ed01eb7725954fc9c0d45201ac3c53cd9855b/app/javascript/dashboard/components/ui/Dropdown/DropdownList.vue#L45-L52

The issue was resolved by modifying the `onSearch` function in `DropdownList.vue` to correctly pass the search value to the `debouncedEmit` function:
2024-10-09 20:47:50 -07:00
Shivam MishraandGitHub f18ed01eb7 feat: use of imap login as default if present (#10249)
When moving form using Gmail Legacy auth to using OAuth, we need the
email address that will be used to connect. This is because we need to
store this email address in the cache and reuse when we get the callback
to find the associated inbox.

However there are cases where the imap login might be
`support@company.com` and the email used to communicate will be
`contact@company.com` (Probably an alias) In that case, we need to send
the correct email address to Chatwoot when re-authenticating

At the moment, we used the inbox email. This PR adds a check that
defaults to to `imap_login` if that is available and imap is enabled

This PR also fixes an unrelated problem where the email inbox creation
flow was not working

---

Tested it, it is working correctly

![CleanShot 2024-10-09 at 14 23
47@2x](https://github.com/user-attachments/assets/0e2cb6c8-1224-4b45-b34a-7b19611249bc)
2024-10-09 15:01:11 +05:30
aa5fa0c758 feat: Use CJS build for SDK (#10247)
The UMD build was causing issues for a few customers, this PR reverts to
using CJS like used in Webpack 4 before the vite migration

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-10-09 14:09:52 +05:30
Shivam MishraandGitHub 42eca69763 fix: Import for vue-letter (#10246) 2024-10-08 09:40:37 -07:00
Shivam MishraandGitHub 3a78192e74 fix: Resolve accountId from the route, initialize route-sync before the app is loaded (#10245)
On production on multiple instances it may happen that the UI is
rendered in correctly, with a lot of options in the sidebar not
available. On further investigation I found out that the feature flag
checks were disabling multiple of those, and also we could see many
correlated errors that pointed towards missing information.

So, there were two problems here

1. The `vuex-router-sync` was not very reliable in some cases
2. In `App.vue` the watch on `currentAccountId` didn't always trigger.

## Fix Tested on Staging

Basically tried to reload the page ~50 times with cache enabled,
disabled, throttling, navigating different pages.


https://www.loom.com/share/1bb27294aa364ac4acfb647780d6385a?sid=87e31330-8cb7-4ded-8616-5e95e2ae3516

<details><summary>

#### What I thought was the fix

</summary>
<p>

### My chain of actions

Replacing vuex-router-sync at first worked fine, but then I saw it was
still failing in some cases, I assumed (I was half-correct tho) that the
rendering of the `App.vue` and syncing of the route to the store was not
happening in a synchronous pattern. So I decided, let's not rely on the
store when the route is directly available in the App context.

Following this, I refactored `useAccount` composable to use `useRoute`
directly, instead of the store, and then replaced the getter inside
`App.vue`. What this did was surface the issue but more consistently 🤯

I saw the watcher, added some console logs, and turns out it was not
getting triggered in all those cases. So I added an `immediate` to it.
And viola, it works!

At the moment, this is deployed to staging and seems to be working
correctly. But we still need to verify it for sure, since how this issue
was surfaced is still a mystery. All we know is that it shows up when
the widget is also loaded alongside the app (if it loads before or after
the app, it works fine)

### What about the route in the store?

Well I have used the `route` usage there with fallback to the store
state. Since Vuex exists in the app context, the route should always be
available to it. But after today I have lost all trust in JavaScript and
will worship rails until end of my life, so I added that in a
`try-catch` block, logged the error to Sentry

</p>
</details> 

## Here's the real fix

If you read the explanation I wrote earlier, I thought I fixed the
issue, but then the chat list navigation completely broke. So I removed
the custom route sync implementation and added the original package
back. Turns out the vuex-router-sync earlier was placed after the app
was initalized, however for it to work, the vue app context is not
required. And it's best to run it before the app is even bootstrapped,
so I added it back and placed it correctly.

So the following changes fixes this problem

1. Hoisting the `sync` function call to before we call `createApp` this
ensures that the stores and route hooks are in place before even the app
is created
2. Ensuring the `initializeAccount` is run immediately when watching
`currentAccountId`
4. Source `currentAccountId` for critical top of the tree components
directly from the route instead of the store
2024-10-08 09:25:51 -07:00
Sojan fd01a5056a Merge branch 'release/3.13.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-09-17 16:47:37 -07:00
Sojan d70ba8ff40 Merge branch 'release/3.12.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-08-19 15:54:36 -07:00
358 changed files with 15767 additions and 7485 deletions
+14 -6
View File
@@ -12,6 +12,20 @@ module.exports = {
'vitest-globals/env': true,
},
},
{
files: ['**/*.story.vue'],
rules: {
'vue/no-undef-components': [
'error',
{
ignorePatterns: ['Variant', 'Story'],
},
],
// Story files can have static strings, it doesn't need to handle i18n always.
'vue/no-bare-strings-in-template': 'off',
'no-console': 'off',
},
},
],
plugins: ['html', 'prettier'],
parserOptions: {
@@ -223,11 +237,5 @@ module.exports = {
globals: {
bus: true,
vi: true,
// beforeEach: true,
// afterEach: true,
// test: true,
// describe: true,
// it: true,
// expect: true,
},
};
+3
View File
@@ -175,6 +175,9 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'opensearch-ruby'
gem 'searchkick'
### Gems required only in specific deployment environments ###
##############################################################
+65 -59
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
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)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.4)
actionpack (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activesupport (= 7.0.8.4)
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)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.4)
actionview (= 7.0.8.4)
activesupport (= 7.0.8.4)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
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.4)
actionpack (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
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)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.4)
activesupport (= 7.0.8.4)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
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.4)
activesupport (= 7.0.8.4)
activejob (7.0.8.5)
activesupport (= 7.0.8.5)
globalid (>= 0.3.6)
activemodel (7.0.8.4)
activesupport (= 7.0.8.4)
activerecord (7.0.8.4)
activemodel (= 7.0.8.4)
activesupport (= 7.0.8.4)
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)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activesupport (= 7.0.8.4)
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)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.4)
activesupport (7.0.8.5)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -373,7 +373,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.5)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -479,7 +479,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.14)
net-imap (0.4.17)
date
net-protocol
net-pop (0.1.2)
@@ -532,6 +532,9 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -557,7 +560,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.9)
rack (2.2.10)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -574,20 +577,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.4)
actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.4)
actiontext (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activemodel (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
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)
bundler (>= 1.15.0)
railties (= 7.0.8.4)
railties (= 7.0.8.5)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -595,9 +598,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -633,8 +636,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.6)
strscan
rexml (3.3.9)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -704,6 +706,9 @@ GEM
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.4.0)
activemodel (>= 6.1)
hashie
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
@@ -764,7 +769,6 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
@@ -912,6 +916,7 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -937,6 +942,7 @@ DEPENDENCIES
rubocop-rspec
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
+2 -2
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup:
gem install bundler
bundle install
yarn install
pnpm install
db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
@@ -30,7 +30,7 @@ server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn:
bundle && yarn
bundle && pnpm install
run:
@if [ -f ./.overmind.sock ]; then \
@@ -6,13 +6,15 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def index
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
set_article_count
@articles = @articles.search(list_params)
@articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page)
@articles.order_by_position.page(@current_page)
else
@all_articles.order_by_updated_at.page(@current_page)
@articles.order_by_updated_at.page(@current_page)
end
end
@@ -43,6 +45,19 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
private
def set_article_count
# Search the params without status and author_id, use this to
# compute mine count published draft etc
base_search_params = list_params.except(:status, :author_id)
@articles = @portal_articles.search(base_search_params)
@articles_count = @articles.count
@mine_articles_count = @articles.search_by_author(Current.user.id).count
@published_articles_count = @articles.published.count
@draft_articles_count = @articles.draft.count
@archived_articles_count = @articles.archived.count
end
def fetch_article
@article = @portal.articles.find(params[:id])
end
@@ -53,9 +68,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
:description,
{ tags: [] }]
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -20,7 +20,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def create
@portal = Current.account.portals.build(portal_params)
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
@@ -28,7 +28,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
@portal.update!(portal_params.merge(live_chat_widget_params)) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
process_attached_logo if params[:blob_id].present?
rescue StandardError => e
@@ -70,11 +70,21 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
{ allowed_locales: [] }] }
:account_id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} if permitted_params[:inbox_id].blank?
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
@@ -1,6 +1,15 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
def find_channel_by_email
# find by imap_login first, and then by email
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
return imap_channel if imap_channel
Channel::Email.find_by(email: users_data['email'], account: account)
end
private
def provider_name
+5 -1
View File
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
channel_email = find_channel_by_email
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
@@ -39,6 +39,10 @@ class OauthCallbackController < ApplicationController
[channel_email.inbox, channel_exists]
end
def find_channel_by_email
Channel::Email.find_by(email: users_data['email'], account: account)
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
@@ -30,7 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content)
end
+10 -6
View File
@@ -13,6 +13,7 @@ import { useStore } from 'dashboard/composables/store';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -35,8 +36,9 @@ export default {
setup() {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
return { router, store };
return { router, store, currentAccountId: accountId };
},
data() {
return {
@@ -52,7 +54,6 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -69,10 +70,13 @@ export default {
this.showAddAccountModal = true;
}
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
currentAccountId: {
immediate: true,
handler() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
},
mounted() {
@@ -52,12 +52,13 @@ class ArticlesAPI extends PortalsAPI {
}
createArticle({ portalSlug, articleObj }) {
const { content, title, author_id, category_id } = articleObj;
const { content, title, authorId, categoryId, locale } = articleObj;
return axios.post(`${this.url}/${portalSlug}/articles`, {
content,
title,
author_id,
category_id,
author_id: authorId,
category_id: categoryId,
locale,
});
}
+173 -1
View File
@@ -46,8 +46,168 @@
@apply hidden;
}
.n-blue-border {
@apply border-n-blue-border;
}
.n-blue-text {
@apply text-n-blue-text;
}
// scss-lint:disable PropertySortOrder
@layer base {
// scss-lint:disable PropertySortOrder
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.1;
}
body.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
@@ -390,3 +550,15 @@
--color-orange-900: 255 224 194;
}
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
@@ -0,0 +1,141 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
import SMSCampaignDetails from './SMSCampaignDetails.vue';
const props = defineProps({
title: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
isLiveChatType: {
type: Boolean,
default: false,
},
isEnabled: {
type: Boolean,
default: false,
},
status: {
type: String,
default: '',
},
sender: {
type: Object,
default: null,
},
inbox: {
type: Object,
default: null,
},
scheduledAt: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const { formatMessage } = useMessageFormatter();
const isActive = computed(() =>
props.isLiveChatType ? props.isEnabled : props.status !== STATUS_COMPLETED
);
const statusTextColor = computed(() => ({
'text-n-teal-11': isActive.value,
'text-n-slate-12': !isActive.value,
}));
const campaignStatus = computed(() => {
if (props.isLiveChatType) {
return props.isEnabled
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
});
</script>
<template>
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
<template #header>
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between gap-3 w-fit">
<span
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
>
{{ title }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
<LiveChatCampaignDetails
v-if="isLiveChatType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
/>
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
/>
</div>
</template>
</CardLayout>
</template>
@@ -0,0 +1,54 @@
<script setup>
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
sender: {
type: Object,
default: null,
},
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
});
const { t } = useI18n();
const senderName = computed(
() => props.sender?.name || t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.BOT')
);
const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Thumbnail
:author="sender || { name: senderName }"
:name="senderName"
:src="senderThumbnailSrc"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
defineProps({
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
scheduledAt: {
type: Number,
default: 0,
},
});
const { t } = useI18n();
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.SENT_FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.ON') }}
</span>
<span class="flex-1 text-sm font-medium truncate text-n-slate-12">
{{ messageStamp(new Date(scheduledAt), 'LLL d, h:mm a') }}
</span>
</template>
@@ -0,0 +1,52 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = () => {
emit('click');
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
<slot name="action" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-4">
<slot name="default" />
</div>
</main>
</section>
</template>
@@ -0,0 +1,212 @@
export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Chatbot Assistance',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Alexa Rivera',
},
message: 'Hello! 👋 Need help with our chatbot features? Feel free to ask!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://www.chatwoot.com/features/chatbot/',
time_on_page: 10,
},
trigger_only_during_business_hours: true,
created_at: '2024-10-24T13:10:26.636Z',
updated_at: '2024-10-24T13:10:26.636Z',
},
{
id: 2,
title: 'Pricing Information Support',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Jamie Lee',
},
message: 'Hello! 👋 Any questions on pricing? Im here to help!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://www.chatwoot.com/pricings',
time_on_page: 10,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:11:08.763Z',
updated_at: '2024-10-24T13:11:08.763Z',
},
{
id: 3,
title: 'Product Setup Assistance',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Chatwoot',
},
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://{*.}?chatwoot.com/apps/account/*/settings/inboxes/new/',
time_on_page: 10,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:11:44.285Z',
updated_at: '2024-10-24T13:11:44.285Z',
},
{
id: 4,
title: 'General Assistance Campaign',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Chris Barlow',
},
message:
'Hi there! 👋 Im here for any questions you may have. Lets chat!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://siv.com',
time_on_page: 200,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-29T19:54:33.741Z',
updated_at: '2024-10-29T19:56:26.296Z',
},
];
export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Customer Feedback Request',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message:
'Hello! Enjoying our product? Share your feedback on G2 and earn a $25 Amazon coupon: https://chwt.app/g2-review',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1729775588,
audience: [
{ id: 4, type: 'Label' },
{ id: 5, type: 'Label' },
{ id: 6, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:13:08.496Z',
updated_at: '2024-10-24T13:15:38.698Z',
},
{
id: 2,
title: 'Welcome New Customer',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Welcome aboard! 🎉 Let us know if you have any questions.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1729732500,
audience: [
{ id: 1, type: 'Label' },
{ id: 6, type: 'Label' },
{ id: 5, type: 'Label' },
{ id: 2, type: 'Label' },
{ id: 4, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:14:00.168Z',
updated_at: '2024-10-24T13:15:38.707Z',
},
{
id: 3,
title: 'New Business Welcome',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Hello! Were excited to have your business with us!',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1730368440,
audience: [
{ id: 1, type: 'Label' },
{ id: 3, type: 'Label' },
{ id: 6, type: 'Label' },
{ id: 4, type: 'Label' },
{ id: 2, type: 'Label' },
{ id: 5, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-30T07:54:49.915Z',
updated_at: '2024-10-30T07:54:49.915Z',
},
{
id: 4,
title: 'New Member Onboarding',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Welcome to the team! Reach out if you have questions.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1730304840,
audience: [
{ id: 1, type: 'Label' },
{ id: 3, type: 'Label' },
{ id: 6, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-29T16:14:10.374Z',
updated_at: '2024-10-30T16:15:03.157Z',
},
];
@@ -0,0 +1,39 @@
<script setup>
import { ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
is-live-chat-type
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,38 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,39 @@
<script setup>
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
campaigns: {
type: Array,
required: true,
},
isLiveChatType: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete']);
const handleEdit = campaign => emit('edit', campaign);
const handleDelete = campaign => emit('delete', campaign);
</script>
<template>
<div class="flex flex-col gap-4">
<CampaignCard
v-for="campaign in campaigns"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
/>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedCampaign: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const deleteCampaign = async id => {
if (!id) return;
try {
await store.dispatch('campaigns/delete', id);
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
await deleteCampaign(props.selectedCampaign.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CAMPAIGN.CONFIRM_DELETE.TITLE')"
:description="t('CAMPAIGN.CONFIRM_DELETE.DESCRIPTION')"
:confirm-button-label="t('CAMPAIGN.CONFIRM_DELETE.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
const props = defineProps({
selectedCampaign: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const liveChatCampaignFormRef = ref(null);
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isUpdatingCampaign = computed(() => uiFlags.value.isUpdating);
const isInvalidForm = computed(
() => liveChatCampaignFormRef.value?.isSubmitDisabled
);
const selectedCampaignId = computed(() => props.selectedCampaign.id);
const updateCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/update', {
id: selectedCampaignId.value,
...campaignDetails,
});
useAlert(t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.SUCCESS_MESSAGE'));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = () => {
updateCampaign(liveChatCampaignFormRef.value.prepareCampaignDetails());
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAMPAIGN.LIVE_CHAT.EDIT.TITLE')"
:is-loading="isUpdatingCampaign"
:disable-confirm-button="isUpdatingCampaign || isInvalidForm"
overflow-y-auto
@confirm="handleSubmit"
>
<template #form>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleClose = () => emit('close');
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
handleClose();
};
</script>
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
</h3>
<LiveChatCampaignForm
mode="create"
@submit="handleSubmit"
@cancel="handleClose"
/>
</div>
</template>
@@ -0,0 +1,323 @@
<script setup>
import { reactive, computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { URLPattern } from 'urlpattern-polyfill';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
selectedCampaign: {
type: Object,
default: () => ({}),
},
showActionButtons: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const store = useStore();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
inboxes: useMapGetter('inboxes/getWebsiteInboxes'),
};
const senderList = ref([]);
const initialState = {
title: '',
message: '',
inboxId: null,
senderId: 0,
enabled: true,
triggerOnlyDuringBusinessHours: false,
endPoint: '',
timeOnPage: 10,
};
const state = reactive({ ...initialState });
const urlValidators = {
shouldBeAValidURLPattern: value => {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch {
return false;
}
},
shouldStartWithHTTP: value =>
value ? value.startsWith('https://') || value.startsWith('http://') : false,
};
const validationRules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
senderId: { required },
endPoint: { required, ...urlValidators },
timeOnPage: { required },
};
const v$ = useVuelidate(validationRules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const sendersAndBotList = computed(() => [
{ value: 0, label: 'Bot' },
...mapToOptions(senderList.value, 'id', 'name'),
]);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.LIVE_CHAT.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
endPoint: getErrorMessage('endPoint', 'END_POINT'),
timeOnPage: getErrorMessage('timeOnPage', 'TIME_ON_PAGE'),
sender: getErrorMessage('senderId', 'SENT_BY'),
}));
const resetState = () => Object.assign(state, initialState);
const handleCancel = () => emit('cancel');
const handleInboxChange = async inboxId => {
if (!inboxId) {
senderList.value = [];
return;
}
try {
const response = await store.dispatch('inboxMembers/get', { inboxId });
senderList.value = response?.data?.payload ?? [];
} catch (error) {
senderList.value = [];
useAlert(
error?.response?.message ??
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE')
);
}
};
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
sender_id: state.senderId || null,
enabled: state.enabled,
trigger_only_during_business_hours: state.triggerOnlyDuringBusinessHours,
trigger_rules: {
url: state.endPoint,
time_on_page: state.timeOnPage,
},
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
if (props.mode === 'create') {
resetState();
handleCancel();
}
};
const updateStateFromCampaign = campaign => {
if (!campaign) return;
const {
title,
message,
inbox: { id: inboxId },
sender,
enabled,
trigger_only_during_business_hours: triggerOnlyDuringBusinessHours,
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
} = campaign;
Object.assign(state, {
title,
message,
inboxId,
senderId: sender?.id ?? 0,
enabled,
triggerOnlyDuringBusinessHours,
endPoint,
timeOnPage,
});
};
watch(
() => state.inboxId,
newInboxId => {
if (newInboxId) {
handleInboxChange(newInboxId);
}
},
{ immediate: true }
);
watch(
() => props.selectedCampaign,
newCampaign => {
if (props.mode === 'edit' && newCampaign) {
updateStateFromCampaign(newCampaign);
}
},
{ immediate: true }
);
defineExpose({ prepareCampaignDetails, isSubmitDisabled });
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<Editor
v-model="state.message"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.PLACEHOLDER')"
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="sentBy" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.LABEL') }}
</label>
<ComboBox
id="sentBy"
v-model="state.senderId"
:options="sendersAndBotList"
:has-error="!!formErrors.sender"
:disabled="!state.inboxId"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.sender"
/>
</div>
<Input
v-model="state.endPoint"
type="url"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.PLACEHOLDER')"
:message="formErrors.endPoint"
:message-type="formErrors.endPoint ? 'error' : 'info'"
/>
<Input
v-model="state.timeOnPage"
type="number"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.LABEL')"
:placeholder="
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.PLACEHOLDER')
"
:message="formErrors.timeOnPage"
:message-type="formErrors.timeOnPage ? 'error' : 'info'"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-2.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TITLE') }}
</legend>
<label class="flex items-center gap-2">
<input v-model="state.enabled" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.ENABLED') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.triggerOnlyDuringBusinessHours" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{
t(
'CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TRIGGER_ONLY_BUSINESS_HOURS'
)
}}
</span>
</label>
</fieldset>
<div
v-if="showActionButtons"
class="flex items-center justify-between w-full gap-3"
>
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="
t(`CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.${mode.toUpperCase()}`)
"
class="w-full"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -0,0 +1,49 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import SMSCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
</h3>
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -0,0 +1,189 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getSMSInboxes'),
};
const initialState = {
title: '',
message: '',
inboxId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const rules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.SMS.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.message"
:label="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.PLACEHOLDER')"
show-character-count
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.SMS.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -0,0 +1,23 @@
<script setup>
const props = defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
</script>
<template>
<div
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
@click="handleClick"
>
<slot name="header" />
<slot name="footer" />
</div>
</template>
@@ -0,0 +1,173 @@
<script setup>
import { computed, ref, watch } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
});
const emit = defineEmits(['update:modelValue']);
const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length);
const messageClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'text-n-ruby-9 dark:text-n-ruby-9';
case 'success':
return 'text-green-500 dark:text-green-400';
default:
return 'text-n-slate-11 dark:text-n-slate-11';
}
});
const handleInput = value => {
if (!props.disabled) {
emit('update:modelValue', value);
}
};
const handleFocus = () => {
if (!props.disabled) {
isFocused.value = true;
}
};
const handleBlur = () => {
if (!props.disabled) {
isFocused.value = false;
}
};
watch(
() => props.modelValue,
newValue => {
if (props.maxLength && props.showCharacterCount) {
if (characterCount.value >= props.maxLength) {
emit('update:modelValue', newValue.slice(0, props.maxLength));
}
}
}
);
</script>
<template>
<div class="flex flex-col min-w-0 gap-1">
<label v-if="label" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ label }}
</label>
<div
class="flex flex-col w-full gap-2 px-3 py-3 transition-all duration-500 ease-in-out border rounded-lg editor-wrapper bg-n-alpha-black2"
:class="[
{
'cursor-not-allowed opacity-50 pointer-events-none !bg-n-alpha-black2 disabled:border-n-weak dark:disabled:border-n-weak':
disabled,
'border-n-brand dark:border-n-brand': isFocused,
'hover:border-n-slate-6 dark:hover:border-n-slate-6 border-n-weak dark:border-n-weak':
!isFocused && messageType !== 'error',
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9':
messageType === 'error' && !isFocused,
},
]"
>
<WootEditor
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
:disabled="disabled"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
/>
<div
v-if="showCharacterCount"
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-n-slate-10">
{{ characterCount }} / {{ maxLength }}
</span>
</div>
</div>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>
<style lang="scss" scoped>
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
.empty-node {
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11 !important;
}
}
}
}
}
}
</style>
@@ -0,0 +1,47 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
});
</script>
<template>
<section
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
>
<div class="flex flex-col items-center justify-center gap-6">
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
>
{{ title }}
</h2>
<p
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>
</div>
<slot name="actions" />
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,87 @@
<script setup>
import ArticleCard from './ArticleCard.vue';
const articles = [
{
id: 1,
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: 1729048936,
author: {
name: 'John',
thumbnail: 'https://i.pravatar.cc/300',
},
category: {
title: 'Marketing',
slug: 'marketing',
icon: '📈',
},
views: 400,
},
{
id: 2,
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: 1729048936,
author: {
name: 'John',
thumbnail: 'https://i.pravatar.cc/300',
},
category: {
title: 'Development',
slug: 'development',
icon: '🛠️',
},
views: 1400,
},
{
id: 3,
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: 1729048936,
author: {
name: 'Fernando',
thumbnail: 'https://i.pravatar.cc/300',
},
category: {
title: 'Finance',
slug: 'finance',
icon: '💰',
},
views: 4300,
},
];
const category = {
name: 'Marketing',
slug: 'marketing',
icon: '📈',
};
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/HelpCenter/ArticleCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Article Card">
<div
v-for="(article, index) in articles"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<ArticleCard
:id="article.id"
:title="article.title"
:status="article.status"
:author="article.author"
:category="category"
:views="article.views"
:updated-at="article.updatedAt"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,198 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
status: {
type: String,
required: true,
},
author: {
type: Object,
default: null,
},
category: {
type: Object,
required: true,
},
views: {
type: Number,
required: true,
},
updatedAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
acc[key] = { ...item, label: t(item.label) };
return acc;
},
{}
);
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
const statusTextColor = computed(() => {
switch (props.status) {
case 'archived':
return 'text-n-slate-12';
case 'draft':
return 'text-n-amber-11';
default:
return 'text-n-teal-11';
}
});
const statusText = computed(() => {
switch (props.status) {
case 'archived':
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.ARCHIVED');
case 'draft':
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.DRAFT');
default:
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.STATUS.PUBLISHED');
}
});
const categoryName = computed(() => {
if (props.category?.slug) {
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
);
});
const authorName = computed(() => {
return props.author?.name || props.author?.availableName || '-';
});
const authorThumbnailSrc = computed(() => {
return props.author?.thumbnail;
});
const lastUpdatedAt = computed(() => {
return dynamicTime(props.updatedAt);
});
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
emit('articleAction', { action, value, id: props.id });
};
const handleClick = id => {
emit('openArticle', id);
};
</script>
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ statusText }}
</span>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Thumbnail
:author="author"
:name="authorName"
:src="authorThumbnailSrc"
/>
<span class="text-sm text-n-slate-11">
{{ authorName }}
</span>
</div>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
{{
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
count: views,
})
}}
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
</template>
</CardLayout>
</template>
@@ -0,0 +1,48 @@
<script setup>
import CategoryCard from './CategoryCard.vue';
const categories = [
{
id: 1,
title: 'Getting started',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: 5,
slug: 'getting-started',
icon: '🚀',
},
{
id: 2,
title: 'Marketing',
description: '',
articlesCount: 4,
slug: 'marketing',
icon: '📈',
},
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/HelpCenter/CategoryCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Category Card">
<div
v-for="(category, index) in categories"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<CategoryCard
:id="category.id"
:slug="category.slug"
:title="category.title"
:description="category.description"
:articles-count="category.articlesCount"
:icon="category.icon"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,137 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
articlesCount: {
type: Number,
required: true,
},
slug: {
type: String,
required: true,
},
});
const emit = defineEmits(['click', 'action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const categoryMenuItems = [
{
label: 'Edit',
action: 'edit',
value: 'edit',
icon: 'i-lucide-pencil',
},
{
label: 'Delete',
action: 'delete',
value: 'delete',
icon: 'i-lucide-trash',
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
const hasDescription = computed(() => {
return props.description.length > 0;
});
const handleClick = slug => {
emit('click', slug);
};
const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
toggleDropdown(false);
};
</script>
<template>
<CardLayout>
<template #header>
<div class="flex gap-2">
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
variant="ghost"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="categoryMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
@action="handleAction"
/>
</div>
</div>
</div>
</template>
<template #footer>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
>
{{ description }}
</span>
</template>
</CardLayout>
</template>
@@ -0,0 +1,16 @@
<script setup>
import ArticleEmptyState from './ArticleEmptyState.vue';
</script>
<template>
<Story
title="Components/HelpCenter/EmptyState/ArticleEmptyState"
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Article Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<ArticleEmptyState />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,56 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import articleContent from 'dashboard/components-next/HelpCenter/EmptyState/Portal/portalEmptyStateContent.js';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
showButton: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ArticleCard
v-for="(article, index) in articleContent.slice(0, 5)"
:id="article.id"
:key="`article-${index}`"
:title="article.title"
:status="article.status"
:updated-at="article.updatedAt"
:author="article.author"
:category="article.category"
:views="article.views"
/>
</div>
</template>
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,49 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
import categoryContent from 'dashboard/components-next/HelpCenter/EmptyState/Category/categoryEmptyStateContent.js';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px">
<div class="space-y-4">
<CategoryCard
v-for="category in categoryContent"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
/>
</div>
<div class="space-y-4">
<CategoryCard
v-for="category in categoryContent.reverse()"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
/>
</div>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,142 @@
export default [
{
id: 1,
name: 'Getting Started',
icon: '🚀',
description: 'Quick guides to help new users onboard.',
slug: 'getting-started',
meta: {
articles_count: 5,
},
},
{
id: 2,
name: 'Advanced Features',
icon: '💡',
description: 'Explore advanced features for power users.',
slug: 'advanced-features',
meta: {
articles_count: 8,
},
},
{
id: 3,
name: 'FAQs',
icon: '❓',
description: 'Commonly asked questions and helpful answers.',
slug: 'faqs',
meta: {
articles_count: 3,
},
},
{
id: 4,
name: 'Troubleshooting',
icon: '🛠️',
description: 'Resolve common issues with step-by-step guidance.',
slug: 'troubleshooting',
meta: {
articles_count: 6,
},
},
{
id: 5,
name: 'Community Guidelines',
icon: '👥',
description: 'Rules and practices for community engagement.',
slug: 'community-guidelines',
meta: {
articles_count: 2,
},
},
{
id: 6,
name: 'Account Management',
icon: '🔑',
description: 'Manage your account and settings efficiently.',
slug: 'account-management',
meta: {
articles_count: 7,
},
},
{
id: 7,
name: 'Security Tips',
icon: '🔒',
description: 'Best practices for securing your account.',
slug: 'security-tips',
meta: {
articles_count: 4,
},
},
{
id: 8,
name: 'Integrations',
icon: '🔗',
description: 'Connect to third-party services and tools easily.',
slug: 'integrations',
meta: {
articles_count: 9,
},
},
{
id: 9,
name: 'Billing & Payments',
icon: '💳',
description: 'Manage your billing and payment details seamlessly.',
slug: 'billing-payments',
meta: {
articles_count: 5,
},
},
{
id: 10,
name: 'Customization',
icon: '🎨',
description: 'Personalize and customize your user experience.',
slug: 'customization',
meta: {
articles_count: 7,
},
},
{
id: 11,
name: 'Notifications',
icon: '🔔',
description: 'Adjust your notification settings and preferences.',
slug: 'notifications',
meta: {
articles_count: 3,
},
},
{
id: 12,
name: 'Privacy',
icon: '🛡️',
description: 'Understand how your data is collected and used.',
slug: 'privacy',
meta: {
articles_count: 2,
},
},
{
id: 13,
name: 'Mobile App',
icon: '📱',
description: 'Guides for using the mobile app effectively.',
slug: 'mobile-app',
meta: {
articles_count: 6,
},
},
{
id: 14,
name: 'Beta Features',
icon: '🧪',
description: 'Learn about new experimental features in beta.',
slug: 'beta-features',
meta: {
articles_count: 4,
},
},
];
@@ -0,0 +1,16 @@
<script setup>
import PortalEmptyState from './PortalEmptyState.vue';
</script>
<template>
<Story
title="Components/HelpCenter/EmptyState/PortalEmptyState"
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Portal Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<PortalEmptyState />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,72 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import articleContent from './portalEmptyStateContent';
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
const createPortalDialogRef = ref(null);
const openDialog = () => {
createPortalDialogRef.value.dialogRef.open();
};
const router = useRouter();
const onPortalCreate = ({ slug: portalSlug, locale }) => {
router.push({
name: 'portals_articles_index',
params: { portalSlug, locale },
});
};
</script>
<template>
<EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px">
<div class="space-y-4">
<ArticleCard
v-for="(article, index) in articleContent"
:id="article.id"
:key="`article-${index}`"
:title="article.title"
:status="article.status"
:updated-at="article.updatedAt"
:author="article.author"
:category="article.category"
:views="article.views"
/>
</div>
<div class="space-y-4">
<ArticleCard
v-for="(article, index) in articleContent.reverse()"
:id="article.id"
:key="`article-${index}`"
:title="article.title"
:status="article.status"
:updated-at="article.updatedAt"
:author="article.author"
:category="article.category"
:views="article.views"
/>
</div>
</div>
</template>
<template #actions>
<Button
:label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
icon="i-lucide-plus"
@click="openDialog"
/>
<CreatePortalDialog
ref="createPortalDialogRef"
@create="onPortalCreate"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,172 @@
export default [
{
id: 1,
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Michael' },
category: {
slug: 'configuration',
icon: '📦',
name: 'Setup & Configuration',
},
views: 3400,
},
{
id: 2,
title: 'Setting up your first Help Center portal',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'John' },
category: { slug: 'onboarding', icon: '🧑‍🍳', name: 'Onboarding' },
views: 400,
},
{
id: 3,
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'Fernando' },
category: { slug: 'best-practices', icon: '⛺️', name: 'Best Practices' },
views: 400,
},
{
id: 4,
title: 'Customizing the appearance of your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Jane' },
category: { slug: 'design', icon: '🎨', name: 'Design' },
views: 400,
},
{
id: 5,
title: 'Integrating your Help Center with third-party tools',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Sarah' },
category: {
slug: 'integrations',
icon: '🔗',
name: 'Integrations',
},
views: 2800,
},
{
id: 6,
title: 'Managing user permissions in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Alex' },
category: {
slug: 'administration',
icon: '🔐',
name: 'Administration',
},
views: 1200,
},
{
id: 7,
title: 'Creating and managing FAQ sections',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Emily' },
category: {
slug: 'content-management',
icon: '📝',
name: 'Content Management',
},
views: 5600,
},
{
id: 8,
title: 'Implementing search functionality in your Help Center',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'David' },
category: {
slug: 'features',
icon: '🔍',
name: 'Features',
},
views: 1800,
},
{
id: 9,
title: 'Analyzing Help Center usage metrics',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Rachel' },
category: {
slug: 'analytics',
icon: '📊',
name: 'Analytics',
},
views: 3200,
},
{
id: 10,
title: 'Setting up multilingual support in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Carlos' },
category: {
slug: 'localization',
icon: '🌍',
name: 'Localization',
},
views: 900,
},
{
id: 11,
title: 'Creating interactive tutorials for your products',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Olivia' },
category: {
slug: 'education',
icon: '🎓',
name: 'Education',
},
views: 4100,
},
{
id: 12,
title: 'Implementing a feedback system in your Help Center',
status: 'draft',
updatedAt: 1729205669,
author: { availableName: 'Nathan' },
category: {
slug: 'user-engagement',
icon: '💬',
name: 'User Engagement',
},
views: 750,
},
{
id: 13,
title: 'Optimizing Help Center content for SEO',
status: 'published',
updatedAt: 1729205669,
author: { availableName: 'Sophia' },
category: {
slug: 'seo',
icon: '🚀',
name: 'SEO',
},
views: 2900,
},
{
id: 14,
title: 'Creating a knowledge base for internal teams',
status: 'archived',
updatedAt: 1729205669,
author: { availableName: 'Daniel' },
category: {
slug: 'internal-resources',
icon: '🏢',
name: 'Internal Resources',
},
views: 1500,
},
];
@@ -0,0 +1,113 @@
<script setup>
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
defineProps({
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showHeaderTitle: {
type: Boolean,
default: true,
},
showPaginationFooter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update:currentPage']);
const route = useRoute();
const createPortalDialogRef = ref(null);
const showPortalSwitcher = ref(false);
const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const activePortalName = computed(() => {
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
?.name;
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
const togglePortalSwitcher = () => {
showPortalSwitcher.value = !showPortalSwitcher.value;
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
variant="ghost"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
/>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
</div>
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-3">
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
<!-- Do not remove this slot. It can be used to add dialogs. -->
<slot />
</section>
</template>
@@ -0,0 +1,29 @@
<script setup>
import LocaleCard from './LocaleCard.vue';
const locales = [
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/HelpCenter/LocaleCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Locale Card">
<div class="px-10 py-4 bg-white dark:bg-slate-900">
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
<LocaleCard
:locale="locale.name"
:is-default="locale.isDefault"
:article-count="locale.articleCount"
:category-count="locale.categoryCount"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,118 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
locale: {
type: String,
required: true,
},
isDefault: {
type: Boolean,
required: true,
},
localeCode: {
type: String,
required: true,
},
articleCount: {
type: Number,
required: true,
},
categoryCount: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showDropdownMenu, toggleDropdown] = useToggle();
const localeMenuItems = computed(() =>
LOCALE_MENU_ITEMS.map(item => ({
...item,
label: t(item.label),
disabled: props.isDefault,
}))
);
const handleAction = ({ action, value }) => {
emit('action', { action, value });
toggleDropdown(false);
};
</script>
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{ locale }} ({{ localeCode }})
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
articleCount
)
}}
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
categoryCount
)
}}
</span>
</div>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
</div>
</div>
</template>
</CardLayout>
</template>
@@ -0,0 +1,154 @@
<script setup>
import { computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'saveArticle',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
]);
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => props.article.title,
set: value => {
saveArticle({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
saveArticle({ content });
},
});
const onClickGoBack = () => {
emit('goBack');
};
const setAuthorId = authorId => {
emit('setAuthor', authorId);
};
const setCategoryId = categoryId => {
emit('setCategory', categoryId);
};
const previewArticle = () => {
emit('previewArticle');
};
</script>
<template>
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
<template #header-actions>
<ArticleEditorHeader
:is-updating="isUpdating"
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
@go-back="onClickGoBack"
@preview-article="previewArticle"
/>
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
<TextArea
v-model="articleTitle"
auto-height
min-height="4rem"
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
/>
<ArticleEditorControls
:article="article"
@save-article="saveArticle"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
</div>
<FullEditor
v-model="articleContent"
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
:placeholder="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
/>
</template>
</HelpCenterLayout>
</template>
<style lang="scss" scoped>
::v-deep {
.ProseMirror .empty-node::before {
@apply text-slate-200 dark:text-slate-500 text-base;
}
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
display: none; // Hide by default
}
.editor-root .has-selection {
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-1 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
}
}
}
}
}
</style>
@@ -0,0 +1,264 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['saveArticle', 'setAuthor', 'setCategory']);
const { t } = useI18n();
const route = useRoute();
const openAgentsList = ref(false);
const openCategoryList = ref(false);
const openProperties = ref(false);
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const agents = useMapGetter('agents/getAgents');
const categories = useMapGetter('categories/allCategories');
const currentUserId = useMapGetter('getCurrentUserID');
const isNewArticle = computed(() => !props.article?.id);
const currentUser = computed(() =>
agents.value.find(agent => agent.id === currentUserId.value)
);
const categorySlugFromRoute = computed(() => route.params.categorySlug);
const author = computed(() => {
if (isNewArticle.value) {
return selectedAuthorId.value
? agents.value.find(agent => agent.id === selectedAuthorId.value)
: currentUser.value;
}
return props.article?.author || null;
});
const authorName = computed(
() => author.value?.name || author.value?.available_name || '-'
);
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
const agentList = computed(() => {
return (
agents.value
?.map(({ name, id, thumbnail }) => ({
label: name,
value: id,
thumbnail: { name, src: thumbnail },
isSelected: props.article?.author?.id
? id === props.article.author.id
: id === (selectedAuthorId.value || currentUserId.value),
action: 'assignAuthor',
}))
// Sort the list by isSelected first, then by name(label)
.toSorted((a, b) => {
if (a.isSelected !== b.isSelected) {
return Number(b.isSelected) - Number(a.isSelected);
}
return a.label.localeCompare(b.label);
}) ?? []
);
});
const hasAgentList = computed(() => {
return agents.value?.length > 1;
});
const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (categorySlugFromRoute.value) {
const categoryFromSlug = assignCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
);
});
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon }) => ({
label: name,
value: id,
emoji: icon,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
action: 'assignCategory',
}))
// Sort categories by isSelected
.toSorted((a, b) => Number(b.isSelected) - Number(a.isSelected))
);
});
const hasCategoryMenuItems = computed(() => {
return categoryList.value?.length > 0;
});
const handleArticleAction = ({ action, value }) => {
const actions = {
assignAuthor: () => {
if (isNewArticle.value) {
selectedAuthorId.value = value;
emit('setAuthor', value);
} else {
emit('saveArticle', { author_id: value });
}
openAgentsList.value = false;
},
assignCategory: () => {
if (isNewArticle.value) {
selectedCategoryId.value = value;
emit('setCategory', value);
} else {
emit('saveArticle', { category_id: value });
}
openCategoryList.value = false;
},
};
actions[action]?.();
};
const updateMeta = meta => {
emit('saveArticle', { meta });
};
onMounted(() => {
if (categorySlugFromRoute.value && isNewArticle.value) {
// Assign category from slug if there is one
const categoryFromSlug = findCategoryFromSlug(categorySlugFromRoute.value);
if (categoryFromSlug) {
handleArticleAction({
action: 'assignCategory',
value: categoryFromSlug?.id,
});
}
}
});
</script>
<template>
<div class="flex items-center gap-4">
<div class="relative flex items-center gap-2">
<OnClickOutside @trigger="openAgentsList = false">
<Button
variant="ghost"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<Thumbnail
:author="author"
:name="authorName"
:size="20"
:src="authorThumbnailSrc"
/>
<span
v-if="author"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{ author.available_name }}
</span>
</Button>
<DropdownMenu
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
class="!px-2 font-normal hover:!bg-transparent"
@click="openCategoryList = !openCategoryList"
>
<span
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
</span>
</Button>
<DropdownMenu
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-52"
@action="handleArticleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="relative">
<OnClickOutside @trigger="openProperties = false">
<Button
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES')
"
icon="i-lucide-plus"
variant="ghost"
:disabled="isNewArticle"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
/>
<ArticleEditorProperties
v-if="openProperties"
:article="article"
class="right-0 z-[100] mt-2 xl:left-0 top-full"
@save-article="updateMeta"
@close="openProperties = false"
/>
</OnClickOutside>
</div>
</div>
</template>
@@ -0,0 +1,177 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import {
ARTICLE_EDITOR_STATUS_OPTIONS,
ARTICLE_STATUSES,
ARTICLE_MENU_ITEMS,
} from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
isUpdating: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
status: {
type: String,
default: '',
},
articleId: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['goBack', 'previewArticle']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isArticlePublishing = ref(false);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
return statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
value,
action: 'update-status',
icon,
};
});
});
const statusText = computed(() =>
t(
`HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.STATUS.${props.isUpdating ? 'SAVING' : 'SAVED'}`
)
);
const onClickGoBack = () => emit('goBack');
const previewArticle = () => emit('previewArticle');
const getStatusMessage = (status, isSuccess) => {
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
const statusMap = {
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
};
return statusMap[status]
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
: '';
};
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
}
const { portalSlug } = route.params;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: props.articleId,
status,
});
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
isArticlePublishing.value = false;
}
};
</script>
<template>
<div class="flex items-center justify-between h-20">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.BACK_TO_ARTICLES')"
icon="i-lucide-chevron-left"
variant="link"
color="slate"
size="sm"
class="ltr:pl-3 rtl:pr-3"
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-slate-500 dark:text-slate-400"
>
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
size="sm"
:disabled="!articleId"
@click="previewArticle"
/>
<div class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing
"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
<Button
icon="i-lucide-chevron-down"
size="sm"
:disabled="!articleId"
class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu"
/>
<DropdownMenu
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="updateArticleStatus($event)"
/>
</OnClickOutside>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,134 @@
<script setup>
import { reactive, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
article: {
type: Object,
required: true,
},
});
const emit = defineEmits(['saveArticle', 'close']);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const { t } = useI18n();
const state = reactive({
title: '',
description: '',
tags: [],
});
const updateState = () => {
state.title = props.article.meta?.title || '';
state.description = props.article.meta?.description || '';
state.tags = props.article.meta?.tags || [];
};
watch(
state,
newState => {
saveArticle({
title: newState.title,
description: newState.description,
tags: newState.tags,
});
},
{ deep: true }
);
onMounted(() => {
updateState();
});
</script>
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.ARTICLE_PROPERTIES'
)
}}
</h3>
<Button
icon="i-lucide-x"
size="sm"
variant="ghost"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
</div>
<div class="flex flex-col gap-2">
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
>
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION'
)
}}
</label>
<TextArea
v-model="state.description"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[220px]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]"
auto-height
min-height="3rem"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<InlineInput
v-model="state.title"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE_PLACEHOLDER'
)
"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
}}
</label>
<TagInput
v-model="state.tags"
:placeholder="
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[224px]"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,194 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { OnClickOutside } from '@vueuse/components';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_TABS_OPTIONS,
} from 'dashboard/helper/portalHelper';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
categories: {
type: Array,
required: true,
},
allowedLocales: {
type: Array,
required: true,
},
meta: {
type: Object,
required: true,
},
});
const emit = defineEmits([
'tabChange',
'localeChange',
'categoryChange',
'newArticle',
]);
const route = useRoute();
const { t } = useI18n();
const isCategoryMenuOpen = ref(false);
const isLocaleMenuOpen = ref(false);
const countKey = tab => {
if (tab.value === 'all') {
return 'articlesCount';
}
return `${tab.value}ArticlesCount`;
};
const tabs = computed(() => {
return ARTICLE_TABS_OPTIONS.map(tab => ({
label: t(`HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.TABS.${tab.key}`),
value: tab.value,
count: props.meta[countKey(tab)],
}));
});
const activeTabIndex = computed(() => {
const tabParam = route.params.tab || ARTICLE_TABS.ALL;
return tabs.value.findIndex(tab => tab.value === tabParam);
});
const activeCategoryName = computed(() => {
const activeCategory = props.categories.find(
category => category.slug === route.params.categorySlug
);
if (activeCategory) {
const { icon, name } = activeCategory;
return `${icon} ${name}`;
}
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
});
const activeLocaleName = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
)?.name;
});
const categoryMenuItems = computed(() => {
const defaultMenuItem = {
label: t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL'),
value: CATEGORY_ALL,
action: 'filter',
};
const categoryItems = props.categories.map(category => ({
label: category.name,
value: category.slug,
action: 'filter',
emoji: category.icon,
}));
const hasCategorySlug = !!route.params.categorySlug;
return hasCategorySlug ? [defaultMenuItem, ...categoryItems] : categoryItems;
});
const hasCategoryMenuItems = computed(() => {
return categoryMenuItems.value?.length > 0;
});
const localeMenuItems = computed(() => {
return props.allowedLocales.map(locale => ({
label: locale.name,
value: locale.code,
action: 'filter',
}));
});
const hasMoreThanOneLocaleMenuItems = computed(() => {
return localeMenuItems.value?.length > 1;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
};
const handleCategoryAction = ({ value }) => {
emit('categoryChange', value);
isCategoryMenuOpen.value = false;
};
const handleNewArticle = () => {
emit('newArticle');
};
const handleTabChange = value => {
emit('tabChange', value);
};
</script>
<template>
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChange"
/>
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<div v-if="hasMoreThanOneLocaleMenuItems" class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
size="sm"
icon="i-lucide-chevron-down"
color="slate"
trailing-icon
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
</div>
<div v-if="hasCategoryMenuItems" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
</OnClickOutside>
</div>
</div>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="handleNewArticle"
/>
</div>
</div>
</template>
@@ -0,0 +1,190 @@
<script setup>
import { ref, computed, watch } from 'vue';
import Draggable from 'vuedraggable';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
const props = defineProps({
articles: {
type: Array,
required: true,
},
isCategoryArticles: {
type: Boolean,
default: false,
},
});
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
});
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
const { tab, categorySlug, locale } = route.params;
if (props.isCategoryArticles) {
router.push({
name: 'portals_categories_articles_edit',
params: { articleSlug: id },
});
} else {
router.push({
name: 'portals_articles_edit',
params: {
articleSlug: id,
tab,
categorySlug,
locale,
},
});
}
};
const onReorder = reorderedGroup => {
store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
};
const onDragEnd = () => {
// Reuse existing positions to maintain order within the current group
const sortedArticlePositions = localArticles.value
.map(article => article.position)
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
const orderedArticles = localArticles.value.map(article => article.id);
// Create a map of article IDs to their new positions
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
obj[key] = sortedArticlePositions[index];
return obj;
}, {});
onReorder(reorderedGroup);
};
const getCategory = categoryId => {
return getCategoryById.value(categoryId) || { name: '', icon: '' };
};
const getStatusMessage = (status, isSuccess) => {
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
const statusMap = {
[ARTICLE_STATUS_TYPES.PUBLISH]: 'PUBLISH_ARTICLE',
[ARTICLE_STATUS_TYPES.ARCHIVE]: 'ARCHIVE_ARTICLE',
[ARTICLE_STATUS_TYPES.DRAFT]: 'DRAFT_ARTICLE',
};
return statusMap[status]
? t(`HELP_CENTER.${statusMap[status]}.API.${messageType}`)
: '';
};
const updateMeta = () => {
const { portalSlug, locale } = route.params;
return store.dispatch('portals/show', { portalSlug, locale });
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
if (action === 'delete') {
await store.dispatch('articles/delete', {
portalSlug,
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else {
await store.dispatch('articles/update', {
portalSlug,
articleId: id,
status,
});
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateMeta();
} catch (error) {
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
}
};
const updateArticle = ({ action, value, id }) => {
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
// Watch for changes in the articles prop and update the localArticles ref
watch(
() => props.articles,
newArticles => {
localArticles.value = newArticles;
},
{ deep: true }
);
</script>
<template>
<Draggable
v-model="localArticles"
:disabled="!dragEnabled"
item-key="id"
tag="ul"
ghost-class="article-ghost-class"
class="w-full h-full space-y-4"
@end="onDragEnd"
>
<template #item="{ element }">
<li class="list-none rounded-2xl">
<ArticleCard
:id="element.id"
:key="element.id"
:title="element.title"
:status="element.status"
:author="element.author"
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
/>
</li>
</template>
</Draggable>
</template>
<style lang="scss" scoped>
.article-ghost-class {
@apply opacity-50 bg-n-solid-1;
}
</style>
@@ -0,0 +1,72 @@
<script setup>
import ArticlesPage from './ArticlesPage.vue';
const articles = [
{
title: "How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
{
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
];
</script>
<template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<ArticlesPage :articles="articles" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,190 @@
<script setup>
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleHeaderControls.vue';
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
const props = defineProps({
articles: {
type: Array,
required: true,
},
categories: {
type: Array,
required: true,
},
allowedLocales: {
type: Array,
required: true,
},
portalName: {
type: String,
required: true,
},
meta: {
type: Object,
required: true,
},
isCategoryArticles: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
);
const isLoading = computed(() => isFetching.value || isSwitchingPortal.value);
const totalArticlesCount = computed(() => props.meta.allArticlesCount);
const hasNoArticlesInPortal = computed(
() => totalArticlesCount.value === 0 && !props.isCategoryArticles
);
const shouldShowPaginationFooter = computed(() => {
return !(isFetching.value || isSwitchingPortal.value || hasNoArticles.value);
});
const updateRoute = newParams => {
const { portalSlug, locale, tab, categorySlug } = route.params;
router.push({
name: 'portals_articles_index',
params: {
portalSlug,
locale: newParams.locale ?? locale,
tab: newParams.tab ?? tab,
categorySlug: newParams.categorySlug ?? categorySlug,
...newParams,
},
});
};
const articlesCount = computed(() => {
const { tab } = route.params;
const { meta } = props;
const countMap = {
'': meta.articlesCount,
mine: meta.mineArticlesCount,
draft: meta.draftArticlesCount,
archived: meta.archivedArticlesCount,
};
return Number(countMap[tab] || countMap['']);
});
const showArticleHeaderControls = computed(
() =>
!hasNoArticlesInPortal.value &&
!props.isCategoryArticles &&
!isSwitchingPortal.value
);
const showCategoryHeaderControls = computed(
() => props.isCategoryArticles && !isSwitchingPortal.value
);
const getEmptyStateText = type => {
if (props.isCategoryArticles) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
}
const tabName = route.params.tab?.toUpperCase() || 'ALL';
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.${tabName}.${type}`);
};
const getEmptyStateTitle = computed(() => getEmptyStateText('TITLE'));
const getEmptyStateSubtitle = computed(() => getEmptyStateText('SUBTITLE'));
const handleTabChange = tab =>
updateRoute({ tab: tab.value === ARTICLE_TABS.ALL ? '' : tab.value });
const handleCategoryAction = value =>
updateRoute({ categorySlug: value === CATEGORY_ALL ? '' : value });
const handleLocaleAction = value => {
updateRoute({ locale: value, categorySlug: '' });
emit('fetchPortal', value);
};
const handlePageChange = page => emit('pageChange', page);
const navigateToNewArticlePage = () => {
const { categorySlug, locale } = route.params;
router.push({
name: 'portals_articles_new',
params: { categorySlug, locale },
});
};
</script>
<template>
<HelpCenterLayout
:current-page="Number(meta.currentPage)"
:total-items="articlesCount"
:items-per-page="25"
:header="portalName"
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
<template #header-actions>
<div class="flex items-end justify-between">
<ArticleHeaderControls
v-if="showArticleHeaderControls"
:categories="categories"
:allowed-locales="allowedLocales"
:meta="meta"
@tab-change="handleTabChange"
@locale-change="handleLocaleAction"
@category-change="handleCategoryAction"
@new-article="navigateToNewArticlePage"
/>
<CategoryHeaderControls
v-else-if="showCategoryHeaderControls"
:categories="categories"
:allowed-locales="allowedLocales"
:has-selected-category="isCategoryArticles"
/>
</div>
</template>
<template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<ArticleEmptyState
v-else
class="pt-14"
:title="getEmptyStateTitle"
:subtitle="getEmptyStateSubtitle"
:show-button="hasNoArticlesInPortal"
:button-label="
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
"
@click="navigateToNewArticlePage"
/>
</template>
</HelpCenterLayout>
</template>
@@ -0,0 +1,209 @@
<script setup>
import CategoriesPage from './CategoriesPage.vue';
const categories = [
{
id: 'getting-started',
title: '🚀 Getting started',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
},
{
id: 'marketing',
title: 'Marketing',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
articlesCount: '4',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Published article',
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
],
},
{
id: 'development',
title: 'Development',
description: '',
articlesCount: '5',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Archived article',
title: 'Best practices for organizing your Help Center content',
status: 'archived',
updatedAt: '3 days ago',
author: 'Fernando',
category: '💰 Finance',
views: 400,
},
{
variant: 'Published article',
title: 'Customizing the appearance of your Help Center',
status: '',
updatedAt: '5 days ago',
author: 'Jane',
category: '💰 Finance',
views: 400,
},
],
},
{
id: 'roadmap',
title: '🛣️ Roadmap',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '3',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
},
{
id: 'finance',
title: '💰 Finance',
description:
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
articlesCount: '2',
articles: [
{
variant: 'Draft article',
title:
"How to get an SSL certificate for your Help Center's custom domain",
status: 'draft',
updatedAt: '2 days ago',
author: 'Michael',
category: '⚡️ Marketing',
views: 3400,
},
{
variant: 'Published article',
title: 'Setting up your first Help Center portal',
status: '',
updatedAt: '1 week ago',
author: 'John',
category: '🛠️ Development',
views: 400,
},
],
},
];
</script>
<template>
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
<Variant title="All Categories">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<CategoriesPage :categories="categories" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import CategoryEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue';
import EditCategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
categories: {
type: Array,
required: true,
},
isFetching: {
type: Boolean,
required: false,
},
allowedLocales: {
type: Array,
required: true,
},
});
const emit = defineEmits(['fetchCategories']);
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const hasCategories = computed(() => props.categories?.length > 0);
const updateRoute = (newParams, routeName) => {
const { accountId, portalSlug, locale } = route.params;
const baseParams = { accountId, portalSlug, locale };
router.push({
name: routeName,
params: {
...baseParams,
...newParams,
categorySlug: newParams.categorySlug,
},
});
};
const openCategoryArticles = slug => {
updateRoute({ categorySlug: slug }, 'portals_categories_articles_index');
};
const handleLocaleChange = value => {
updateRoute({ locale: value }, 'portals_categories_index');
emit('fetchCategories', value);
};
async function deleteCategory(category) {
try {
await store.dispatch('categories/delete', {
portalSlug: route.params.portalSlug,
categoryId: category.id,
});
useTrack(PORTALS_EVENTS.DELETE_CATEGORY, {
hasArticles: category?.meta?.articles_count > 0,
});
useAlert(
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.DELETE.API.ERROR_MESSAGE')
);
}
}
const handleAction = ({ action, id, category: categoryData }) => {
if (action === 'edit') {
selectedCategory.value = props.categories.find(
category => category.id === id
);
editCategoryDialog.value.dialogRef.open();
}
if (action === 'delete') {
deleteCategory(categoryData);
}
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
:categories="categories"
:is-category-articles="false"
:allowed-locales="allowedLocales"
@locale-change="handleLocaleChange"
/>
</template>
<template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CategoryList
v-else-if="hasCategories"
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
/>
<CategoryEmptyState
v-else
class="pt-14"
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_EMPTY_STATE.SUBTITLE')"
/>
</template>
<EditCategoryDialog
ref="editCategoryDialog"
:allowed-locales="allowedLocales"
:selected-category="selectedCategory"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue';
const props = defineProps({
mode: {
type: String,
default: 'edit',
validator: value => ['edit', 'create'].includes(value),
},
selectedCategory: {
type: Object,
default: () => ({}),
},
portalName: {
type: String,
default: '',
},
activeLocaleName: {
type: String,
default: '',
},
activeLocaleCode: {
type: String,
default: '',
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const handleCategory = async formData => {
const { id, name, slug, icon, description, locale } = formData;
const categoryData = { name, icon, slug, description };
if (props.mode === 'create') {
categoryData.locale = locale;
} else {
categoryData.id = id;
}
try {
const action = props.mode === 'edit' ? 'update' : 'create';
const payload = {
portalSlug: route.params.portalSlug,
categoryObj: categoryData,
};
if (action === 'update') {
payload.categoryId = id;
}
await store.dispatch(`categories/${action}`, payload);
const successMessage = t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.SUCCESS_MESSAGE`
);
useAlert(successMessage);
const trackEvent =
props.mode === 'edit'
? PORTALS_EVENTS.EDIT_CATEGORY
: PORTALS_EVENTS.CREATE_CATEGORY;
useTrack(
trackEvent,
props.mode === 'create'
? { hasDescription: Boolean(description) }
: undefined
);
emit('close');
} catch (error) {
const errorMessage =
error?.message ||
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.${props.mode.toUpperCase()}.API.ERROR_MESSAGE`
);
useAlert(errorMessage);
}
};
</script>
<template>
<div
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}`
)
}}
</h3>
<CategoryForm
:mode="mode"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="portalName"
:active-locale-name="activeLocaleName"
@submit="handleCategory"
@cancel="emit('close')"
/>
</div>
</template>
@@ -0,0 +1,271 @@
<script setup>
import {
reactive,
ref,
watch,
computed,
defineAsyncComponent,
onMounted,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
selectedCategory: {
type: Object,
default: () => ({}),
},
activeLocaleCode: {
type: String,
default: '',
},
showActionButtons: {
type: Boolean,
default: true,
},
portalName: {
type: String,
default: '',
},
activeLocaleName: {
type: String,
default: '',
},
});
const emit = defineEmits(['submit', 'cancel']);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const { t } = useI18n();
const route = useRoute();
const getters = useStoreGetters();
const isCreating = useMapGetter('categories/isCreating');
const isUpdatingCategory = computed(() => {
const id = props.selectedCategory?.id;
if (id) return getters['categories/uiFlags'].value(id)?.isUpdating;
return false;
});
const isEmojiPickerOpen = ref(false);
const state = reactive({
id: '',
name: '',
icon: '',
slug: '',
description: '',
locale: '',
});
const isEditMode = computed(() => props.mode === 'edit');
const rules = {
name: { required, minLength: minLength(1) },
slug: { required },
};
const v$ = useVuelidate(rules, state);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const nameError = computed(() =>
v$.value.name.$error
? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.ERROR')
: ''
);
const slugError = computed(() =>
v$.value.slug.$error
? t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.ERROR')
: ''
);
const slugHelpText = computed(() => {
const { portalSlug, locale } = route.params;
return t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.HELP_TEXT', {
portalSlug,
localeCode: locale,
categorySlug: state.slug,
});
});
const onClickInsertEmoji = emoji => {
state.icon = emoji;
isEmojiPickerOpen.value = false;
};
const handleSubmit = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('submit', { ...state });
};
const handleCancel = () => {
emit('cancel');
};
watch(
() => state.name,
() => {
if (!isEditMode.value) {
state.slug = convertToCategorySlug(state.name);
}
}
);
watch(
() => props.selectedCategory,
newCategory => {
if (props.mode === 'edit' && newCategory) {
const { id, name, icon, slug, description } = newCategory;
Object.assign(state, { id, name, icon, slug, description });
}
},
{ immediate: true }
);
onMounted(() => {
if (props.mode === 'create') {
state.locale = props.activeLocaleCode;
}
});
defineExpose({ state, isSubmitDisabled });
</script>
<template>
<div class="flex flex-col gap-4">
<div
class="flex items-center justify-start gap-8 px-4 py-2 border rounded-lg border-n-strong"
>
<div class="flex flex-col items-start w-full gap-2 py-2">
<span class="text-sm font-medium text-n-slate-11">
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
</span>
<span class="text-sm text-n-slate-12">
{{ portalName }}
</span>
</div>
<div class="justify-start w-px h-10 bg-n-strong" />
<div class="flex flex-col w-full gap-2 py-2">
<span class="text-sm font-medium text-n-slate-11">
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
</span>
<span
:title="`${activeLocaleName} (${activeLocaleCode})`"
class="text-sm line-clamp-1 text-n-slate-12"
>
{{ `${activeLocaleName} (${activeLocaleCode})` }}
</span>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="relative">
<Input
v-model="state.name"
:label="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.LABEL')
"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.NAME.PLACEHOLDER')
"
:message="nameError"
:message-type="nameError ? 'error' : 'info'"
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12"
>
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
:label="state.icon"
color="slate"
size="sm"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-16"
show-remove-button
:on-click="onClickInsertEmoji"
/>
</OnClickOutside>
</template>
</Input>
</div>
<Input
v-model="state.slug"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.LABEL')"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.SLUG.PLACEHOLDER')
"
:disabled="isEditMode"
:message="slugError ? slugError : slugHelpText"
:message-type="slugError ? 'error' : 'info'"
custom-input-class="!h-10"
/>
<TextArea
v-model="state.description"
:label="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t(
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.FORM.DESCRIPTION.PLACEHOLDER'
)
"
show-character-count
/>
<div
v-if="showActionButtons"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.${mode.toUpperCase()}`
)
"
class="w-full"
:disabled="isSubmitDisabled || isCreating || isUpdatingCategory"
:is-loading="isCreating || isUpdatingCategory"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>
@@ -0,0 +1,202 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
const props = defineProps({
categories: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
hasSelectedCategory: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['localeChange']);
const route = useRoute();
const router = useRouter();
const getters = useStoreGetters();
const { t } = useI18n();
const isLocaleMenuOpen = ref(false);
const isCreateCategoryDialogOpen = ref(false);
const isEditCategoryDialogOpen = ref(false);
const currentPortalSlug = computed(() => {
return route.params.portalSlug;
});
const currentPortal = computed(() => {
const slug = currentPortalSlug.value;
if (slug) return getters['portals/portalBySlug'].value(slug);
return getters['portals/allPortals'].value[0];
});
const currentPortalName = computed(() => {
return currentPortal.value?.name;
});
const activeLocale = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
);
});
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const localeMenuItems = computed(() => {
return props.allowedLocales.map(locale => ({
label: locale.name,
value: locale.code,
action: 'filter',
}));
});
const selectedCategory = computed(() =>
props.categories.find(category => category.slug === route.params.categorySlug)
);
const selectedCategoryName = computed(() => {
return selectedCategory.value?.name;
});
const selectedCategoryCount = computed(
() => selectedCategory.value?.meta?.articles_count || 0
);
const selectedCategoryEmoji = computed(() => {
return selectedCategory.value?.icon;
});
const categoriesCount = computed(() => props.categories?.length);
const breadcrumbItems = computed(() => {
const items = [
{
label: t(
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.CATEGORY_LOCALE',
{ localeCode: activeLocaleCode.value }
),
link: '#',
},
];
if (selectedCategory.value) {
items.push({
label: t(
'HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.BREADCRUMB.ACTIVE_CATEGORY',
{
categoryName: selectedCategoryName.value,
categoryCount: selectedCategoryCount.value,
}
),
emoji: selectedCategoryEmoji.value,
});
}
return items;
});
const handleLocaleAction = ({ value }) => {
emit('localeChange', value);
isLocaleMenuOpen.value = false;
};
const handleBreadcrumbClick = () => {
const { categorySlug, ...otherParams } = route.params;
router.push({
name: 'portals_categories_index',
params: otherParams,
});
};
</script>
<template>
<div class="flex items-center justify-between w-full">
<div v-if="!hasSelectedCategory" class="flex items-center gap-4">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
:label="activeLocaleName"
size="sm"
trailing-icon
icon="i-lucide-chevron-down"
color="slate"
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
<DropdownMenu
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" />
<span
class="min-w-0 text-sm font-medium truncate text-slate-800 dark:text-slate-100"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
n: categoriesCount,
})
}}
</span>
</div>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-else class="relative">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
color="slate"
size="sm"
@click="isEditCategoryDialogOpen = !isEditCategoryDialogOpen"
/>
<CategoryDialog
v-if="isEditCategoryDialogOpen"
:selected-category="selectedCategory"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isEditCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['click', 'action']);
const handleClick = slug => {
emit('click', slug);
};
const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category });
};
</script>
<template>
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
<CategoryCard
v-for="category in categories"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
@click="handleClick(category.slug)"
@action="handleAction($event, category)"
/>
</ul>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import CategoryForm from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue';
const props = defineProps({
selectedCategory: {
type: Object,
default: () => ({}),
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const getters = useStoreGetters();
const dialogRef = ref(null);
const categoryFormRef = ref(null);
const isUpdatingCategory = computed(() => {
const id = props.selectedCategory?.id;
if (id) return getters['categories/uiFlags'].value(id)?.isUpdating;
return false;
});
const isInvalidForm = computed(() => {
if (!categoryFormRef.value) return false;
const { isSubmitDisabled } = categoryFormRef.value;
return isSubmitDisabled;
});
const activeLocale = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
);
});
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const onUpdateCategory = async () => {
if (!categoryFormRef.value) return;
const { state } = categoryFormRef.value;
const { id, name, slug, icon, description } = state;
const categoryData = { name, icon, slug, description };
categoryData.id = id;
try {
const payload = {
portalSlug: route.params.portalSlug,
categoryObj: categoryData,
categoryId: id,
};
await store.dispatch(`categories/update`, payload);
const successMessage = t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.SUCCESS_MESSAGE`
);
useAlert(successMessage);
dialogRef.value.close();
const trackEvent = PORTALS_EVENTS.EDIT_CATEGORY;
useTrack(trackEvent, { hasDescription: Boolean(description) });
} catch (error) {
const errorMessage =
error?.message ||
t(`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.EDIT.API.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
// Expose the dialogRef to the parent component
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.EDIT')"
:description="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.DESCRIPTION')
"
:is-loading="isUpdatingCategory"
:disable-confirm-button="isUpdatingCategory || isInvalidForm"
@confirm="onUpdateCategory"
>
<template #form>
<CategoryForm
ref="categoryFormRef"
mode="edit"
:selected-category="selectedCategory"
:active-locale-code="activeLocaleCode"
:portal-name="route.params.portalSlug"
:active-locale-name="activeLocaleName"
:show-action-buttons="false"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,101 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import allLocales from 'shared/constants/locales.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const isUpdating = ref(false);
const selectedLocale = ref('');
const addedLocales = computed(() => {
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
return allowedLocales.map(locale => locale.code);
});
const locales = computed(() => {
return Object.keys(allLocales)
.map(key => {
return {
value: key,
label: `${allLocales[key]} (${key})`,
};
})
.filter(locale => !addedLocales.value.includes(locale.value));
});
const onCreate = async () => {
if (!selectedLocale.value) return;
isUpdating.value = true;
const updatedLocales = [...addedLocales.value, selectedLocale.value];
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: { allowed_locales: updatedLocales },
});
useTrack(PORTALS_EVENTS.CREATE_LOCALE, {
localeAdded: selectedLocale.value,
totalLocales: updatedLocales.length,
from: route.name,
});
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.ERROR_MESSAGE')
);
} finally {
isUpdating.value = false;
}
};
// Expose the dialogRef to the parent component
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@confirm="onCreate"
>
<template #form>
<div class="flex flex-col gap-6">
<ComboBox
v-model="selectedLocale"
:options="locales"
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,107 @@
<script setup>
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const props = defineProps({
locales: {
type: Array,
required: true,
},
portal: {
type: Object,
required: true,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
const updatePortalLocales = async ({
newAllowedLocales,
defaultLocale,
messageKey,
}) => {
let alertMessage = '';
try {
await store.dispatch('portals/update', {
portalSlug: props.portal.slug,
config: {
default_locale: defaultLocale,
allowed_locales: newAllowedLocales,
},
});
alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`);
} catch (error) {
alertMessage =
error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`);
} finally {
useAlert(alertMessage);
}
};
const changeDefaultLocale = ({ localeCode }) => {
const newAllowedLocales = props.locales.map(locale => locale.code);
updatePortalLocales({
newAllowedLocales,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
useTrack(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
};
const deletePortalLocale = ({ localeCode }) => {
const updatedLocales = props.locales
.filter(locale => locale.code !== localeCode)
.map(locale => locale.code);
const defaultLocale = props.portal.meta.default_locale;
updatePortalLocales({
newAllowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
useTrack(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
});
};
const handleAction = ({ action }, localeCode) => {
if (action === 'change-default') {
changeDefaultLocale({ localeCode: localeCode });
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
};
</script>
<template>
<ul role="list" class="w-full h-full space-y-4">
<LocaleCard
v-for="(locale, index) in locales"
:key="index"
:locale="locale.name"
:is-default="isLocaleDefault(locale.code)"
:locale-code="locale.code"
:article-count="locale.articlesCount || 0"
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
</ul>
</template>
@@ -0,0 +1,52 @@
<script setup>
import LocalesPage from './LocalesPage.vue';
const locales = [
{
name: 'English (en-US)',
isDefault: true,
articleCount: 5,
categoryCount: 5,
},
{
name: 'Spanish (es-ES)',
isDefault: false,
articleCount: 20,
categoryCount: 10,
},
{
name: 'English (en-UK)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malay (ms-MY)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
{
name: 'Malayalam (ml-IN)',
isDefault: false,
articleCount: 10,
categoryCount: 5,
},
{
name: 'Hindi (hi-IN)',
isDefault: false,
articleCount: 15,
categoryCount: 7,
},
];
</script>
<template>
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
<Variant title="All Locales">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<LocalesPage :locales="locales" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
const props = defineProps({
locales: {
type: Array,
required: true,
},
portal: {
type: Object,
default: () => ({}),
},
});
const addLocaleDialogRef = ref(null);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const openAddLocaleDialog = () => {
addLocaleDialogRef.value.dialogRef.open();
};
const localeCount = computed(() => props.locales?.length);
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</template>
<template #content>
<div
v-if="isSwitchingPortal"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<LocaleList v-else :locales="locales" :portal="portal" />
</template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout>
</template>
@@ -0,0 +1,74 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
mode: {
type: String,
default: 'add',
},
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['addCustomDomain']);
const { t } = useI18n();
const dialogRef = ref(null);
const formState = reactive({
customDomain: props.customDomain,
});
watch(
() => props.customDomain,
newVal => {
formState.customDomain = newVal;
}
);
const handleDialogConfirm = () => {
emit('addCustomDomain', formState.customDomain);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_HEADER`
)
"
:confirm-button-label="
t(
`HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.${props.mode.toUpperCase()}_CONFIRM_BUTTON_LABEL`
)
"
@confirm="handleDialogConfirm"
>
<template #form>
<Input
v-model="formState.customDomain"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
)
"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,51 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
defineProps({
activePortalName: {
type: String,
required: true,
},
});
const emit = defineEmits(['deletePortal']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('deletePortal');
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.HEADER',
{
portalName: activePortalName,
}
)
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.DESCRIPTION'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DIALOG.CONFIRM_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,79 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
customDomain: {
type: String,
default: '',
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
});
const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<template #form>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,327 @@
<script setup>
import { reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { useAlert } from 'dashboard/composables';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { shouldBeUrl } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['updatePortal']);
const { t } = useI18n();
const store = useStore();
const getters = useStoreGetters();
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const state = reactive({
name: '',
headerText: '',
pageTitle: '',
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
return inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
});
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
homePageLink: { shouldBeUrl },
};
const v$ = useVuelidate(rules, state);
const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const homePageLinkError = computed(() =>
v$.value.homePageLink.$error
? t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.ERROR')
: ''
);
const isUpdatingPortal = computed(() => {
const slug = props.activePortal?.slug;
if (slug) return getters['portals/uiFlagsIn'].value(slug)?.isUpdating;
return false;
});
watch(
() => props.activePortal,
newVal => {
if (newVal && !props.isFetching) {
Object.assign(state, {
name: newVal.name,
headerText: newVal.header_text,
pageTitle: newVal.page_title,
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id,
});
if (newVal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = newVal;
state.logoUrl = logoURL;
state.avatarBlobId = blobId;
} else {
state.logoUrl = '';
state.avatarBlobId = '';
}
Object.assign(originalState, state);
}
},
{ immediate: true, deep: true }
);
const hasChanges = computed(() => {
return JSON.stringify(state) !== JSON.stringify(originalState);
});
const handleUpdatePortal = () => {
const portal = {
id: props.activePortal?.id,
slug: state.slug,
name: state.name,
color: state.widgetColor,
page_title: state.pageTitle,
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
emit('updatePortal', portal);
};
async function uploadLogoToStorage({ file }) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
state.logoUrl = fileUrl;
state.avatarBlobId = blobId;
}
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SUCCESS'));
} catch (error) {
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_ERROR'));
}
}
async function deleteLogo() {
try {
const portalSlug = props.activePortal?.slug;
await store.dispatch('portals/deleteLogo', {
portalSlug,
});
useAlert(t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_DELETE_ERROR')
);
}
}
const handleAvatarUpload = file => {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadLogoToStorage(file);
} else {
const errorKey =
'HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.IMAGE_UPLOAD_SIZE_ERROR';
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
}
};
const handleAvatarDelete = () => {
state.logoUrl = '';
state.avatarBlobId = '';
deleteLogo();
};
</script>
<template>
<div class="flex flex-col w-full gap-4">
<div class="flex flex-col w-full gap-2">
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<EditableAvatar
label="Avatar"
:src="state.logoUrl"
:name="state.name"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col w-full gap-4">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
</label>
<Input
v-model="state.name"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
:message-type="nameError ? 'error' : 'info'"
:message="nameError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.name.$touch()"
@blur="v$.name.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
</label>
<Input
v-model="state.headerText"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER')
"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
</label>
<Input
v-model="state.pageTitle"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER')
"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
</label>
<Input
v-model="state.homePageLink"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER')
"
:message-type="homePageLinkError ? 'error' : 'info'"
:message="homePageLinkError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.homePageLink.$touch()"
@blur="v$.homePageLink.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
</label>
<Input
v-model="state.slug"
:placeholder="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.PLACEHOLDER')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
custom-input-class="!bg-transparent dark:!bg-transparent"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
<ComboBox
v-model="state.liveChatWidgetInboxId"
:options="liveChatWidgets"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
"
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button]:!outline-n-weak"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
</label>
<div class="w-[432px] justify-start">
<ColorPicker v-model="state.widgetColor" />
</div>
</div>
<div class="flex justify-end w-full gap-2">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.FORM.SAVE_CHANGES')"
:disabled="!hasChanges || isUpdatingPortal || v$.$invalid"
:is-loading="isUpdatingPortal"
@click="handleUpdatePortal"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,118 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activePortal: {
type: Object,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
custom_domain: customDomain,
};
emit('updatePortalConfiguration', portal);
addCustomDomainDialogRef.value.dialogRef.close();
if (customDomain) {
updatedDomainAddress.value = customDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
}
};
const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DESCRIPTION'
)
}}
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<Button
v-else
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
)
"
color="slate"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
</div>
</div>
</div>
<AddCustomDomainDialog
ref="addCustomDomainDialogRef"
:mode="customDomainAddress ? 'edit' : 'add'"
:custom-domain="customDomainAddress"
@add-custom-domain="updatePortalConfiguration"
/>
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
/>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import PortalSettings from './PortalSettings.vue';
</script>
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
<PortalSettings />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,130 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
portals: {
type: Array,
required: true,
},
isFetching: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
]);
const { t } = useI18n();
const route = useRoute();
const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
});
const activePortalName = computed(() => activePortal.value?.name || '');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const handleUpdatePortal = portal => {
emit('updatePortal', portal);
};
const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
};
</script>
<template>
<HelpCenterLayout :show-pagination-footer="false">
<template #content>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 pt-2 pb-8 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
)
}}
</span>
</div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{
portalName: activePortalName,
}
)
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
/>
</div>
</div>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { ref, reactive, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const isCreatingPortal = useMapGetter('portals/isCreatingPortal');
const state = reactive({
name: '',
slug: '',
domain: '',
logoUrl: '',
avatarBlobId: '',
});
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
};
const v$ = useVuelidate(rules, state);
const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const isSubmitDisabled = computed(() => v$.value.$invalid);
watch(
() => state.name,
() => {
state.slug = convertToCategorySlug(state.name);
}
);
const redirectToPortal = portal => {
emit('create', { slug: portal.slug, locale: 'en' });
};
const resetForm = () => {
Object.keys(state).forEach(key => {
state[key] = '';
});
v$.value.$reset();
};
const createPortal = async portal => {
try {
await store.dispatch('portals/create', portal);
dialogRef.value.close();
const analyticsPayload = {
has_custom_domain: Boolean(portal.custom_domain),
};
useTrack(PORTALS_EVENTS.ONBOARD_BASIC_INFORMATION, analyticsPayload);
useTrack(PORTALS_EVENTS.CREATE_PORTAL, analyticsPayload);
useAlert(
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.SUCCESS_MESSAGE')
);
resetForm();
redirectToPortal(portal);
} catch (error) {
dialogRef.value.close();
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.API.CREATE_PORTAL.ERROR_MESSAGE')
);
}
};
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
const portal = {
name: state.name,
slug: state.slug,
custom_domain: state.domain,
blob_id: state.avatarBlobId || null,
color: '#2781F6', // The default color is set to Chatwoot brand color
};
await createPortal(portal);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.CREATE_PORTAL_DIALOG.TITLE')"
:confirm-button-label="
t('HELP_CENTER.CREATE_PORTAL_DIALOG.CONFIRM_BUTTON_LABEL')
"
:description="t('HELP_CENTER.CREATE_PORTAL_DIALOG.DESCRIPTION')"
:disable-confirm-button="isSubmitDisabled || isCreatingPortal"
:is-loading="isCreatingPortal"
@confirm="handleDialogConfirm"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
v-model="state.name"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
:message-type="nameError ? 'error' : 'info'"
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
/>
<Input
id="portal-slug"
v-model="state.slug"
type="text"
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,44 @@
<script setup>
import PortalSwitcher from './PortalSwitcher.vue';
const portals = [
{
id: 1,
name: 'Chatwoot Help Center',
articles: 67,
domain: 'chatwoot.help',
slug: 'help-center',
},
{
id: 2,
name: 'Chatwoot Handbook',
articles: 42,
domain: 'chatwoot.help',
slug: 'handbook',
},
{
id: 3,
name: 'Developer Documentation',
articles: 89,
domain: 'dev.chatwoot.com',
slug: 'docs',
},
];
</script>
<template>
<Story
title="Components/HelpCenter/PortalSwitcher"
:layout="{ type: 'grid', width: '510px' }"
>
<Variant title="Portal Switcher">
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
<PortalSwitcher
:portals="portals"
header="Choose a Portal"
description="Select from available help center portals"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,163 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
const emit = defineEmits(['close', 'createPortal']);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const DEFAULT_ROUTE = 'portals_articles_index';
const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_edit',
];
const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const portalLink = computed(() => {
return buildPortalURL(currentPortalSlug.value);
});
const isPortalActive = portal => {
return portal.slug === currentPortalSlug.value;
};
const getPortalThumbnailSrc = portal => {
return portal?.logo?.file_url || '';
};
const fetchPortalAndItsCategories = async (slug, locale) => {
await store.dispatch('portals/switchPortal', true);
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: slug,
locale,
};
await store.dispatch('portals/show', selectedPortalParam);
await store.dispatch('categories/index', selectedPortalParam);
await store.dispatch('agents/get');
await store.dispatch('portals/switchPortal', false);
};
const handlePortalChange = async portal => {
if (isPortalActive(portal)) return;
const {
slug,
meta: { default_locale: defaultLocale },
} = portal;
emit('close');
await fetchPortalAndItsCategories(slug, defaultLocale);
const targetRouteName = CATEGORY_SUB_ROUTES.includes(route.name)
? CATEGORY_ROUTE
: route.name || DEFAULT_ROUTE;
router.push({
name: targetRouteName,
params: {
portalSlug: slug,
locale: defaultLocale,
},
});
};
const openCreatePortalDialog = () => {
emit('createPortal');
emit('close');
};
const onClickPreviewPortal = () => {
window.open(portalLink.value, '_blank');
};
const redirectToPortalHomePage = () => {
router.push({
name: 'portals_index',
params: {
navigationPath: DEFAULT_ROUTE,
},
});
};
</script>
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
@click="redirectToPortalHomePage"
>
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
</h2>
<Button
icon="i-lucide-arrow-up-right"
variant="ghost"
icon-lib="lucide"
size="sm"
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
@click="onClickPreviewPortal"
/>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300">
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
</p>
</div>
<Button
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreatePortalDialog"
/>
</div>
<div v-if="portals.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="(portal, index) in portals"
:key="index"
:label="portal.name"
variant="ghost"
trailing-icon
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
size="sm"
@click="handlePortalChange(portal)"
>
<div v-if="portal.custom_domain" class="flex items-center gap-1">
<span class="i-lucide-link size-3" />
<span class="text-sm truncate text-n-slate-11">
{{ portal.custom_domain || '' }}
</span>
</div>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Thumbnail
v-if="portal"
:author="portal"
:name="portal.name"
:size="20"
:src="getPortalThumbnailSrc(portal)"
:show-author-name="false"
icon-name="i-lucide-building-2"
/>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import Avatar from './Avatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Default with upload">
<div class="p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
allow-upload
/>
</div>
</Variant>
<Variant title="Invalid or empty SRC">
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
<Avatar name="Bruce Wayne" allow-upload />
</div>
</Variant>
<Variant title="Rounded Full">
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
allow-upload
rounded-full
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
allow-upload
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
allow-upload
/>
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
allow-upload
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,122 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { computed, ref, watch } from 'vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
src: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
size: {
type: Number,
default: 32,
},
allowUpload: {
type: Boolean,
default: false,
},
roundedFull: {
type: Boolean,
default: false,
},
status: {
type: String,
default: null,
validator: value => {
if (!value) return true;
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
},
},
});
const emit = defineEmits(['upload']);
const isImageValid = ref(true);
function invalidateCurrentImage() {
isImageValid.value = false;
}
const initials = computed(() => {
const splitNames = props.name.split(' ');
if (splitNames.length > 1) {
const firstName = splitNames[0];
const lastName = splitNames[splitNames.length - 1];
return firstName[0] + lastName[0];
}
const firstName = splitNames[0];
return firstName[0];
});
watch(
() => props.src,
() => {
isImageValid.value = true;
}
);
</script>
<template>
<span
class="relative inline"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<slot name="badge" :size>
<div
class="rounded-full w-2.5 h-2.5 absolute z-20"
:style="{
top: `${size - 10}px`,
left: `${size - 10}px`,
}"
:class="{
'bg-n-teal-10': status === 'online',
'bg-n-amber-10': status === 'busy',
'bg-n-slate-10': status === 'offline',
}"
/>
</slot>
<span
role="img"
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
:class="{
'rounded-full': roundedFull,
'rounded-xl': !roundedFull,
}"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<img
v-if="src && isImageValid"
:src="src"
:alt="name"
@error="invalidateCurrentImage"
/>
<span v-else>
{{ initials }}
</span>
<div
v-if="allowUpload"
role="button"
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="emit('upload')"
>
<Icon
icon="i-lucide-upload"
class="text-white dark:text-white size-4"
/>
</div>
</span>
</span>
</template>
@@ -0,0 +1,36 @@
<script setup>
import EditableAvatar from './EditableAvatar.vue';
</script>
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
class="bg-ruby-300 dark:bg-ruby-900"
/>
</div>
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
class="bg-green-300 dark:bg-green-900"
/>
<EditableAvatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
class="bg-indigo-300 dark:bg-indigo-900"
/>
<EditableAvatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,105 @@
<script setup>
import { computed, ref } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
default: '',
},
size: {
type: Number,
default: 72,
},
name: {
type: String,
default: '',
},
});
const emit = defineEmits(['upload', 'delete']);
const avatarSize = computed(() => `${props.size}px`);
const iconSize = computed(() => `${props.size / 2}px`);
const fileInput = ref(null);
const imgError = ref(false);
const shouldShowImage = computed(() => props.src && !imgError.value);
const handleUploadAvatar = () => {
fileInput.value.click();
};
const handleImageUpload = event => {
const [file] = event.target.files;
if (file) {
emit('upload', {
file,
url: file ? URL.createObjectURL(file) : null,
});
}
};
const handleDeleteAvatar = () => {
if (fileInput.value) {
fileInput.value.value = null;
}
emit('delete');
};
const handleDismiss = event => {
event.stopPropagation();
handleDeleteAvatar();
};
</script>
<template>
<div
class="relative flex flex-col items-center gap-2 select-none rounded-xl outline outline-1 outline-n-container group/avatar"
:style="{ width: avatarSize, height: avatarSize }"
>
<img
v-if="shouldShowImage"
:src="src"
:alt="name || 'avatar'"
class="object-cover w-full h-full shadow-sm rounded-xl"
@error="imgError = true"
/>
<div
v-else
class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2"
>
<Icon
icon="i-lucide-building-2"
class="text-n-brand/50"
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
/>
</div>
<div
v-if="src"
class="absolute z-20 outline outline-1 outline-n-container flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
</div>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { ref } from 'vue';
import Breadcrumb from './Breadcrumb.vue';
const singleItem = ref([{ label: 'Home', link: '#' }]);
const twoItems = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
]);
const threeItems = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#' },
{ label: 'Marketing', count: 6, emoji: '📊' },
]);
const longBreadcrumb = ref([
{ label: 'Home', link: '#' },
{ label: 'Categories', link: '#', emoji: '📁' },
{ label: 'Marketing', link: '#' },
{ label: 'Digital', link: '#', emoji: '💻' },
{ label: 'Social Media', count: 12, emoji: '📱' },
]);
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Breadcrumb"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Single Item">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="singleItem" />
</div>
</Variant>
<Variant title="Two Items">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="twoItems" />
</div>
</Variant>
<Variant title="Three Items with Count">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</Variant>
<Variant title="Long Breadcrumb">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
</div>
</Variant>
<Variant title="RTL Support">
<div dir="rtl">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { defineProps } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
items: {
type: Array,
required: true,
validator: value => {
return value.every(
item =>
typeof item.label === 'string' &&
(item.link === undefined || typeof item.link === 'string') &&
(item.count === undefined || typeof item.count === 'number')
);
},
},
});
const emit = defineEmits(['click']);
const { t } = useI18n();
const onClick = event => {
emit('click', event);
};
</script>
<template>
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<button
v-if="index === 0"
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
@click="onClick"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
</button>
<template v-else>
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<span
class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56"
>
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</template>
</li>
</ol>
</nav>
</template>
@@ -0,0 +1,124 @@
<script setup>
import Button from './Button.vue';
// Constants for documentation
const VARIANTS = ['solid', 'outline', 'faded', 'link', 'ghost'];
const COLORS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
const SIZES = ['default', 'sm', 'lg'];
</script>
<template>
<Story title="Components/Button" :layout="{ type: 'grid', width: '800px' }">
<!-- Basic Variants -->
<Variant title="Basic Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button
v-for="variant in VARIANTS"
:key="variant"
:label="variant"
:variant="variant"
/>
</div>
</Variant>
<!-- Colors -->
<Variant title="Color Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button
v-for="color in COLORS"
:key="color"
:label="color"
:color="color"
/>
</div>
</Variant>
<!-- Sizes -->
<Variant title="Size Variants">
<div
class="flex flex-wrap items-center gap-2 p-4 bg-white dark:bg-slate-900"
>
<Button v-for="size in SIZES" :key="size" :label="size" :size="size" />
</div>
</Variant>
<!-- Icons -->
<Variant title="Icons">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Leading Icon" icon="i-lucide-plus" />
<Button label="Trailing Icon" icon="i-lucide-plus" trailing-icon />
<Button icon="i-lucide-plus" />
</div>
</Variant>
<!-- Loading State -->
<Variant title="Loading State">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Loading" is-loading />
<Button label="Loading" variant="outline" is-loading />
<Button is-loading icon="i-lucide-plus" />
</div>
</Variant>
<!-- Disabled State -->
<Variant title="Disabled State">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Disabled" disabled />
<Button label="Disabled Outline" variant="outline" disabled />
<Button label="Disabled Icon" icon="delete" disabled />
<Button
label="Disabled Destructive"
color="ruby"
disabled
icon="delete"
size="sm"
/>
</div>
</Variant>
<!-- Color Combinations -->
<Variant title="Color & Variant Combinations">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<template v-for="color in COLORS" :key="color">
<Button
v-for="variant in VARIANTS"
:key="`${color}-${variant}`"
:label="`${color} ${variant}`"
:color="color"
:variant="variant"
/>
</template>
</div>
</Variant>
<!-- Icon Positions -->
<Variant title="Icon Positions & Sizes">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<template v-for="size in SIZES" :key="size">
<Button
:label="`${size} Leading`"
icon="i-lucide-plus"
:size="size"
/>
<Button
:label="`${size} Trailing`"
icon="i-lucide-plus"
trailing-icon
:size="size"
/>
<Button icon="i-lucide-plus" :size="size" />
</template>
</div>
</Variant>
<!-- Ghost & Link Variants -->
<Variant title="Ghost & Link Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<Button label="Ghost Button" variant="ghost" />
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
<Button label="Link Button" variant="link" />
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,167 @@
<script setup>
import { computed, useSlots } from 'vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
default: '',
},
variant: {
type: String,
default: 'solid',
validator: value =>
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
},
color: {
type: String,
default: 'blue',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
},
size: {
type: String,
default: 'md',
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
},
icon: {
type: String,
default: '',
},
trailingIcon: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
});
const slots = useSlots();
const STYLE_CONFIG = {
colors: {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-brand hover:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
link: 'text-n-ruby-9 hover:underline outline-transparent',
},
amber: {
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
faded:
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
},
slate: {
solid:
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
},
},
sizes: {
regular: {
xs: 'h-6 px-2',
sm: 'h-8 px-3',
md: 'h-10 px-4',
lg: 'h-12 px-5',
},
iconOnly: {
xs: 'h-6 w-6 p-0',
sm: 'h-8 w-8 p-0',
md: 'h-10 w-10 p-0',
lg: 'h-12 w-12 p-0',
},
link: {
xs: 'p-0',
sm: 'p-0',
md: 'p-0',
lg: 'p-0',
},
},
fontSize: {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-sm font-medium',
lg: 'text-base',
},
base: 'inline-flex items-center justify-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
};
const variantClasses = computed(() => {
const variantMap = {
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[props.color].outline,
faded: STYLE_CONFIG.colors[props.color].faded,
solid: STYLE_CONFIG.colors[props.color].solid,
};
return variantMap[props.variant];
});
const isIconOnly = computed(() => !props.label && !slots.default);
const isLink = computed(() => props.variant === 'link');
const buttonClasses = computed(() => {
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
const classes = [
variantClasses.value,
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
].filter(Boolean);
return classes.join(' ');
});
const linkButtonClasses = computed(() => {
const classes = [
variantClasses.value,
STYLE_CONFIG.sizes.link[props.size],
].filter(Boolean);
return classes.join(' ');
});
</script>
<template>
<button
:class="{
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[size]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
>
<slot v-if="(icon || $slots.icon) && !isLoading" name="icon">
<Icon :icon="icon" class="flex-shrink-0" />
</slot>
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
<slot v-if="label || $slots.default" name="default">
<span class="min-w-0 truncate">{{ label }}</span>
</slot>
</button>
</template>
@@ -0,0 +1,101 @@
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
import { Chrome } from '@lk77/vue3-color';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const isPickerOpen = ref(false);
const toggleColorPicker = () => {
isPickerOpen.value = !isPickerOpen.value;
};
const closeTogglePicker = () => {
if (isPickerOpen.value) {
toggleColorPicker();
}
};
const updateColor = e => {
emit('update:modelValue', e.hex);
};
const pickerRef = ref(null);
</script>
<template>
<div ref="pickerRef" class="relative w-fit">
<OnClickOutside @trigger="closeTogglePicker">
<Button
color="slate"
icon="i-lucide-pipette"
trailing-icon
class="!px-3 !py-3 [&>svg]:w-4 [&>svg]:h-4"
@click="toggleColorPicker"
>
<div class="flex items-center flex-grow gap-2">
<span
class="rounded-md size-4"
:style="{ backgroundColor: modelValue }"
/>
<span class="min-w-0 truncate">{{ modelValue }}</span>
</div>
</Button>
<Chrome
v-if="isPickerOpen"
disable-alpha
:model-value="modelValue"
class="colorpicker--chrome"
@update:model-value="updateColor"
/>
</OnClickOutside>
</div>
</template>
<style scoped lang="scss">
.colorpicker--chrome.vc-chrome {
@apply shadow-lg absolute bg-n-background z-[9999] border border-n-weak dark:border-n-weak rounded-[8px];
:deep() {
.vc-chrome-saturation-wrap {
@apply rounded-t-[7px];
.vc-saturation {
@apply rounded-t-[8px];
}
}
.vc-chrome-body {
@apply rounded-b-[7px] bg-n-alpha-3;
.vc-chrome-toggle-btn {
.vc-chrome-toggle-icon svg {
@apply [&>path]:fill-n-slate-10 dark:[&>path]:fill-n-slate-10 left-3 relative;
}
.vc-chrome-toggle-icon-highlight {
@apply bg-n-background;
}
}
}
input,
.vc-input__input {
@apply bg-n-background text-slate-900 dark:text-slate-50 rounded-md shadow-none;
}
.vc-input__label {
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
</style>
@@ -0,0 +1,30 @@
<script setup>
import { ref } from 'vue';
import ComboBox from './ComboBox.vue';
const options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
{ value: 5, label: 'Option 5' },
];
const selectedValue = ref('');
</script>
<template>
<Story title="Components/ComboBox" :layout="{ type: 'grid', width: '300px' }">
<Variant title="Default">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<ComboBox v-model="selectedValue" :options="options" />
<p class="mt-2">Selected value: {{ selectedValue }}</p>
</div>
</Variant>
<Variant title="Disabled">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<ComboBox :options="options" disabled />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,143 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
const props = defineProps({
options: {
type: Array,
required: true,
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: [String, Number],
default: '',
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValue = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label.toLowerCase().includes(searchTerm)
);
});
const selectPlaceholder = computed(() => {
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
});
const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? selectPlaceholder.value;
});
const selectOption = option => {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
open.value = false;
search.value = '';
};
const toggleDropdown = () => {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => dropdownRef.value?.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValue.value = newValue;
}
);
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full min-w-0"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
@click.prevent
>
<OnClickOutside @trigger="open = false">
<Button
variant="outline"
:color="hasError && !open ? 'ruby' : open ? 'blue' : 'slate'"
:label="selectedLabel"
trailing-icon
:disabled="disabled"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
:class="{ focused: open }"
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="toggleDropdown"
/>
<ComboBoxDropdown
ref="dropdownRef"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:selected-values="selectedValue"
@update:search-value="search = $event"
@select="selectOption"
/>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="{
'text-n-ruby-9': hasError,
'text-n-slate-11': !hasError,
}"
>
{{ message }}
</p>
</OnClickOutside>
</div>
</template>
@@ -0,0 +1,107 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
open: {
type: Boolean,
required: true,
},
options: {
type: Array,
required: true,
},
searchValue: {
type: String,
required: true,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
multiple: {
type: Boolean,
default: false,
},
selectedValues: {
type: [String, Number, Array],
default: () => [],
},
});
const emit = defineEmits(['update:searchValue', 'select']);
const { t } = useI18n();
const searchInput = ref(null);
const isSelected = option => {
if (Array.isArray(props.selectedValues)) {
return props.selectedValues.includes(option.value);
}
return option.value === props.selectedValues;
};
defineExpose({
focus: () => searchInput.value?.focus(),
});
</script>
<template>
<div
v-show="open"
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<input
ref="searchInput"
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
@input="emit('update:searchValue', $event.target.value)"
/>
</div>
<ul
class="py-1 mb-0 overflow-auto max-h-60"
role="listbox"
:aria-multiselectable="multiple"
>
<li
v-for="option in options"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': isSelected(option),
}"
role="option"
:aria-selected="isSelected(option)"
@click="emit('select', option)"
>
<span
:class="{
'font-medium': isSelected(option),
}"
class="text-n-slate-12"
>
{{ option.label }}
</span>
<span
v-if="isSelected(option)"
class="flex-shrink-0 i-lucide-check size-4 text-n-slate-11"
/>
</li>
<li
v-if="options.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
</template>
@@ -0,0 +1,183 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
const props = defineProps({
options: {
type: Array,
required: true,
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValues = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label?.toLowerCase().includes(searchTerm)
);
});
const selectPlaceholder = computed(() => {
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
});
const selectedTags = computed(() => {
return selectedValues.value.map(value => {
const option = props.options.find(opt => opt.value === value);
return option || { value, label: value };
});
});
const toggleOption = option => {
const index = selectedValues.value.indexOf(option.value);
if (index === -1) {
selectedValues.value.push(option.value);
} else {
selectedValues.value.splice(index, 1);
}
emit('update:modelValue', selectedValues.value);
};
const removeTag = value => {
const index = selectedValues.value.indexOf(value);
if (index !== -1) {
selectedValues.value.splice(index, 1);
emit('update:modelValue', selectedValues.value);
}
};
const toggleDropdown = () => {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => dropdownRef.value?.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValues.value = newValue;
}
);
defineExpose({
toggleDropdown,
open,
disabled: props.disabled,
});
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full min-w-0"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
@click.prevent
>
<OnClickOutside @trigger="open = false">
<div
class="flex flex-wrap w-full gap-2 px-3 py-2.5 border rounded-lg cursor-pointer bg-n-alpha-black2 min-h-[42px] transition-all duration-500 ease-in-out"
:class="{
'border-n-ruby-8': hasError,
'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6':
!hasError && !open,
'border-n-brand': open,
'cursor-not-allowed pointer-events-none opacity-50': disabled,
}"
@click="toggleDropdown"
>
<div
v-for="tag in selectedTags"
:key="tag.value"
class="flex items-center justify-center max-w-full gap-1 px-2 py-0.5 rounded-lg bg-n-alpha-black1"
@click.stop
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag.label }}
</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3 text-n-slate-11"
@click="removeTag(tag.value)"
/>
</div>
<span
v-if="selectedTags.length === 0"
class="flex items-center text-sm text-n-slate-11"
>
{{ selectPlaceholder }}
</span>
</div>
<ComboBoxDropdown
ref="dropdownRef"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
multiple
:selected-values="selectedValues"
@update:search-value="search = $event"
@select="toggleOption"
/>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="{
'text-n-ruby-9': hasError,
'text-n-slate-11': !hasError,
}"
>
{{ message }}
</p>
</OnClickOutside>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue';
import TagMultiSelectComboBox from './TagMultiSelectComboBox.vue';
const options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
{ value: 5, label: 'Option 5' },
];
const selectedValues = ref([]);
const preselectedValues = ref([1, 2]);
</script>
<template>
<Story
title="Components/TagMultiSelectComboBox"
:layout="{ type: 'grid', width: '300px' }"
>
<Variant title="Default">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox v-model="selectedValues" :options="options" />
<p class="mt-2">Selected values: {{ selectedValues }}</p>
</div>
</Variant>
<Variant title="With Preselected Values">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox
v-model="preselectedValues"
:options="options"
placeholder="Select multiple options"
/>
</div>
</Variant>
<Variant title="Disabled">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox :options="options" disabled />
</div>
</Variant>
<Variant title="With Error">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox
:options="options"
has-error
message="This field is required"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { ref } from 'vue';
import Dialog from './Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const alertDialog = ref(null);
const editDialog = ref(null);
const confirmDialog = ref(null);
const openAlertDialog = () => {
alertDialog.value.open();
};
const openEditDialog = () => {
editDialog.value.open();
};
const openConfirmDialog = () => {
confirmDialog.value.open();
};
// eslint-disable-next-line no-unused-vars
const onConfirm = dialog => {};
</script>
<template>
<Story title="Components/Dialog" :layout="{ type: 'grid', width: '100%' }">
<Variant title="Alert Dialog">
<Button label="Open Alert Dialog" @click="openAlertDialog" />
<Dialog
ref="alertDialog"
type="alert"
title="Alert"
description="This is an alert message."
/>
</Variant>
<Variant title="Edit Dialog">
<Button label="Open Edit Dialog" @click="openEditDialog" />
<Dialog
ref="editDialog"
type="edit"
description="You can create a new portal here, by providing a name and a slug."
title="Create Portal"
confirm-button-label="Save"
@confirm="onConfirm()"
>
<template #form>
<div class="flex flex-col gap-6">
<Input
id="portal-name"
type="text"
placeholder="User Guide | Chatwoot"
label="Name"
message="This will be the name of your public facing portal"
/>
<Input
id="portal-slug"
type="text"
placeholder="user-guide"
label="Slug"
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
/>
</div>
</template>
</Dialog>
</Variant>
<Variant title="Confirm Dialog">
<Button label="Open Confirm Dialog" @click="openConfirmDialog" />
<Dialog
ref="confirmDialog"
type="confirm"
title="Confirm Action"
description="Are you sure you want to perform this action?"
confirm-button-label="Yes, I'm sure"
cancel-button-label="No, cancel"
@confirm="onConfirm()"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,133 @@
<script setup>
import { ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
type: {
type: String,
default: 'edit',
validator: value => ['alert', 'edit'].includes(value),
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
cancelButtonLabel: {
type: String,
default: '',
},
confirmButtonLabel: {
type: String,
default: '',
},
disableConfirmButton: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
showCancelButton: {
type: Boolean,
default: true,
},
showConfirmButton: {
type: Boolean,
default: true,
},
overflowYAuto: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'close']);
const { t } = useI18n();
const isRTL = useMapGetter('accounts/isRTL');
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const open = () => {
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
};
const confirm = () => {
emit('confirm');
};
defineExpose({ open, close });
</script>
<template>
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close"
>
<OnClickOutside @trigger="close">
<div
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@click.stop
>
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ title }}
</h3>
<slot name="description">
<p v-if="description" class="mb-0 text-sm text-n-slate-11">
{{ description }}
</p>
</slot>
</div>
<slot name="form">
<!-- Form content will be injected here -->
</slot>
<div class="flex items-center justify-between w-full gap-3">
<Button
v-if="showCancelButton"
variant="faded"
color="slate"
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
class="w-full"
@click="close"
/>
<Button
v-if="showConfirmButton"
:color="type === 'edit' ? 'blue' : 'ruby'"
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isLoading"
:disabled="disableConfirmButton || isLoading"
@click="confirm"
/>
</div>
</div>
</OnClickOutside>
</dialog>
</Teleport>
</template>
<style scoped>
dialog::backdrop {
@apply dark:bg-n-alpha-white bg-n-alpha-black2;
}
</style>
@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue';
import DropdownMenu from './DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const menuItems = [
{ label: 'Edit', action: 'edit', icon: 'edit' },
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
{ label: 'Archive', action: 'archive', icon: 'archive' },
{ label: 'Delete', action: 'delete', icon: 'delete' },
];
const isOpen = ref(false);
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const handleAction = () => {
isOpen.value = false;
};
</script>
<template>
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
<Variant title="Default">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<div class="relative">
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
<DropdownMenu
v-if="isOpen"
:menu-items="menuItems"
class="absolute left-0 top-10"
@action="handleAction"
/>
</div>
</div>
</Variant>
<Variant title="Always Open">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
</div>
</Variant>
<Variant title="Custom Items">
<div class="p-4 bg-white h-72 dark:bg-slate-900">
<DropdownMenu
:menu-items="[
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
{ label: 'Danger', action: 'delete', icon: 'delete' },
]"
@action="handleAction"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,58 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
defineProps({
menuItems: {
type: Array,
required: true,
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
thumbnailSize: {
type: Number,
default: 20,
},
});
const emit = defineEmits(['action']);
const handleAction = (action, value) => {
emit('action', { action, value });
};
</script>
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
>
<button
v-for="item in menuItems"
:key="item.action"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item.action, item.value)"
>
<Thumbnail
v-if="item.thumbnail"
:author="item.thumbnail"
:name="item.thumbnail.name"
:size="thumbnailSize"
:src="item.thumbnail.src"
/>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
}}</span>
</button>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import { h, isVNode } from 'vue';
const props = defineProps({
icon: { type: [String, Object, Function], required: true },
});
const renderIcon = () => {
if (!props.icon) return null;
if (typeof props.icon === 'function' || isVNode(props.icon)) {
return props.icon;
}
return h('span', { class: props.icon });
};
</script>
<template>
<component :is="renderIcon" />
</template>
@@ -0,0 +1,28 @@
<template>
<svg
v-once
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#woot-logo-clip-2342424e23u32098)">
<path
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
fill="#2781F6"
/>
<path
d="M11.4172 11.4172H7.70831C5.66383 11.4172 4 9.75328 4 7.70828C4 5.66394 5.66383 4 7.70835 4C9.75339 4 11.4172 5.66394 11.4172 7.70828V11.4172Z"
fill="white"
stroke="white"
stroke-width="0.1875"
/>
</g>
<defs>
<clipPath id="woot-logo-clip-2342424e23u32098">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>
@@ -0,0 +1,82 @@
<script setup>
import InlineInput from './InlineInput.vue';
</script>
<template>
<Story
title="Components/InlineInput"
:layout="{ type: 'grid', width: '400' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput id="inline-input-1" placeholder="Default InlineInput" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-2"
label="Username"
placeholder="Enter your username"
/>
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-3"
label="Disabled InlineInput"
placeholder="Can't type here"
disabled
/>
</div>
</Variant>
<Variant title="With Custom Classes">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-4"
label="Custom Input Class"
placeholder="Custom input style"
custom-input-class="placeholder:text-green-200 dark:placeholder:text-green-800"
/>
<InlineInput
id="inline-input-5"
label="Custom Label Class"
placeholder="Custom label style"
custom-label-class="text-green-600 dark:text-green-400"
/>
</div>
</Variant>
<Variant title="Different Types">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<InlineInput
id="inline-input-6"
label="Text"
placeholder="Text input"
/>
<InlineInput
id="inline-input-7"
label="Number"
placeholder="Number input"
type="number"
/>
<InlineInput
id="inline-input-8"
label="Password"
placeholder="Password input"
type="password"
/>
<InlineInput
id="inline-input-9"
label="Email"
placeholder="Email input"
type="email"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,70 @@
<script setup>
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
customLabelClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'enterPress']);
const onEnterPress = () => {
emit('enterPress');
};
</script>
<template>
<div
class="relative flex items-center justify-between w-full gap-3 whitespace-nowrap"
>
<label
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding custom labels to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
@keydown.enter.prevent="onEnterPress"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Input from './Input.vue';
</script>
<template>
<Story title="Components/Input" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-800">
<Input placeholder="Default Input" />
</div>
</Variant>
<Variant title="With Label">
<div class="p-4 bg-white dark:bg-slate-800">
<Input label="Username" placeholder="Enter your username" />
</div>
</Variant>
<Variant title="Disabled">
<div class="p-4 bg-white dark:bg-slate-800">
<Input label="Disabled Input" placeholder="Can't type here" disabled />
</div>
</Variant>
<Variant title="With Message">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<Input
label="Email"
placeholder="Enter your email"
message="We'll never share your email."
/>
<Input
label="Password"
type="password"
placeholder="Enter your password"
message="Password is incorrect"
message-type="error"
/>
<Input
label="Verification Code"
placeholder="Enter the code"
message="Code verified successfully"
message-type="success"
/>
</div>
</Variant>
<Variant title="Different Types">
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
<Input label="Text" type="text" placeholder="Text input" />
<Input label="Number" type="number" placeholder="Number input" />
<Input label="Password" type="password" placeholder="Password input" />
<Input label="Email" type="email" placeholder="Email input" />
<Input label="Date" type="date" />
</div>
</Variant>
<Variant title="Custom Input Class">
<div class="p-4 bg-white dark:bg-slate-800">
<Input
label="Custom Style"
placeholder="Custom input class"
custom-input-class="border-yellow-500 dark:border-yellow-700"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,106 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
const messageClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'text-n-ruby-9 dark:text-n-ruby-9';
case 'success':
return 'text-green-500 dark:text-green-400';
default:
return 'text-n-slate-11 dark:text-n-slate-11';
}
});
const inputBorderClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
default:
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
}
});
const handleInput = event => {
emit('update:modelValue', event.target.value);
emit('input', event);
};
</script>
<template>
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
:class="[customInputClass, inputBorderClass]"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="emit('blur')"
/>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup>
import { ref } from 'vue';
import PaginationFooter from './PaginationFooter.vue';
const createPaginationState = (initialPage, totalItems, itemsPerPage) => {
const currentPage = ref(initialPage);
const handlePageChange = newPage => {
currentPage.value = newPage;
};
return { currentPage, totalItems, itemsPerPage, handlePageChange };
};
const defaultState = createPaginationState(1, 100, 16);
const middlePageState = createPaginationState(3, 100, 16);
const lastPageState = createPaginationState(7, 100, 16);
const customItemsState = createPaginationState(2, 100, 10);
const singlePageState = createPaginationState(1, 10, 16);
</script>
<template>
<Story
title="Components/PaginationFooter"
:layout="{ type: 'grid', width: '957' }"
>
<Variant title="Default">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="defaultState.currentPage.value"
:total-items="defaultState.totalItems"
:items-per-page="defaultState.itemsPerPage"
@update:current-page="defaultState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Middle Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="middlePageState.currentPage.value"
:total-items="middlePageState.totalItems"
:items-per-page="middlePageState.itemsPerPage"
@update:current-page="middlePageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Last Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="lastPageState.currentPage.value"
:total-items="lastPageState.totalItems"
:items-per-page="lastPageState.itemsPerPage"
@update:current-page="lastPageState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Custom Items Per Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="customItemsState.currentPage.value"
:total-items="customItemsState.totalItems"
:items-per-page="customItemsState.itemsPerPage"
@update:current-page="customItemsState.handlePageChange"
/>
</div>
</Variant>
<Variant title="Single Page">
<div class="p-4 bg-white dark:bg-slate-900">
<PaginationFooter
:current-page="singlePageState.currentPage.value"
:total-items="singlePageState.totalItems"
:items-per-page="singlePageState.itemsPerPage"
@update:current-page="singlePageState.handlePageChange"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,107 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
currentPage: {
type: Number,
required: true,
},
totalItems: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
default: 16,
},
});
const emit = defineEmits(['update:currentPage']);
const { t } = useI18n();
const totalPages = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
);
const startItem = computed(
() => (props.currentPage - 1) * props.itemsPerPage + 1
);
const endItem = computed(() =>
Math.min(startItem.value + props.itemsPerPage - 1, props.totalItems)
);
const isFirstPage = computed(() => props.currentPage === 1);
const isLastPage = computed(() => props.currentPage === totalPages.value);
const changePage = newPage => {
if (newPage >= 1 && newPage <= totalPages.value) {
emit('update:currentPage', newPage);
}
};
const currentPageInformation = computed(() => {
return t('PAGINATION_FOOTER.SHOWING', {
startItem: startItem.value,
endItem: endItem.value,
totalItems: props.totalItems,
});
});
const pageInfo = computed(() => {
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
currentPage: '',
totalPages: totalPages.value,
});
});
</script>
<template>
<div
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
{{ currentPageInformation }}
</span>
</div>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-chevrons-left"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(1)"
/>
<Button
icon="i-lucide-chevron-left"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isFirstPage"
@click="changePage(currentPage - 1)"
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ currentPage }}
</span>
<span>{{ pageInfo }}</span>
</div>
<Button
icon="i-lucide-chevron-right"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@click="changePage(currentPage + 1)"
/>
<Button
icon="i-lucide-chevrons-right"
variant="ghost"
size="sm"
class="!w-8 !h-6"
:disabled="isLastPage"
@click="changePage(totalPages)"
/>
</div>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
inbox: {
type: Object,
required: true,
},
});
const channelTypeIconMap = {
'Channel::Api': 'i-ri-cloudy-fill',
'Channel::Email': 'i-ri-mail-fill',
'Channel::FacebookPage': 'i-ri-messenger-fill',
'Channel::Line': 'i-ri-line-fill',
'Channel::Sms': 'i-ri-chat-1-fill',
'Channel::Telegram': 'i-ri-telegram-fill',
'Channel::TwilioSms': 'i-ri-chat-1-fill',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
};
const providerIconMap = {
microsoft: 'i-ri-microsoft-fill',
google: 'i-ri-google-fill',
};
const channelIcon = computed(() => {
const type = props.inbox.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && props.inbox.provider) {
if (Object.keys(providerIconMap).includes(props.inbox.provider)) {
icon = providerIconMap[props.inbox.provider];
}
}
return icon ?? 'i-ri-global-fill';
});
const reauthorizationRequired = computed(() => {
return props.inbox.reauthorization_required;
});
</script>
<template>
<span
class="size-4 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<Icon :icon="channelIcon" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="grid place-content-center size-5 bg-n-ruby-5/60 rounded-full"
>
<Icon icon="i-woot-alert" class="size-3 text-n-ruby-9" />
</div>
</template>
@@ -0,0 +1,503 @@
<script setup>
import { h, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarNotificationBell from './SidebarNotificationBell.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
const emit = defineEmits([
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
]);
const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
} else {
emit('closeKeyShortcutModal');
}
};
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
localStorage
);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
};
provideSidebarContext({
expandedItem,
setExpandedItem,
});
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
onMounted(() => {
store.dispatch('labels/get');
store.dispatch('inboxes/get');
store.dispatch('notifications/unReadCount');
store.dispatch('teams/get');
store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact');
});
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const menuItems = computed(() => {
return [
{
name: 'Inbox',
label: t('SIDEBAR.INBOX'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('inbox_view'),
},
{
name: 'Conversation',
label: t('SIDEBAR.CONVERSATIONS'),
icon: 'i-lucide-message-circle',
children: [
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
},
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
to: accountScopedRoute('conversation_unattended'),
},
{
name: 'Folders',
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
children: conversationCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
{
name: 'Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
{
name: 'Channels',
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps => h(ChannelLeaf, { ...leafProps, inbox }),
})),
},
{
name: 'Labels',
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
label: label.title,
}),
})),
},
],
},
{
name: 'Captain',
icon: 'i-lucide-bot',
label: t('SIDEBAR.CAPTAIN'),
to: accountScopedRoute('captain'),
},
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
icon: 'i-lucide-contact',
children: [
{
name: 'All Contacts',
label: t('SIDEBAR.ALL_CONTACTS'),
to: accountScopedRoute('contacts_dashboard'),
},
{
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('contacts_segments_dashboard', {
id: view.id,
}),
})),
},
{
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('contacts_labels_dashboard', {
label: label.title,
}),
})),
},
],
},
{
name: 'Reports',
label: t('SIDEBAR.REPORTS'),
icon: 'i-lucide-chart-spline',
children: [
{
name: 'Report Overview',
label: t('SIDEBAR.REPORTS_OVERVIEW'),
to: accountScopedRoute('account_overview_reports'),
},
{
name: 'Report Conversation',
label: t('SIDEBAR.REPORTS_CONVERSATION'),
to: accountScopedRoute('conversation_reports'),
},
{
name: 'Reports CSAT',
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
to: accountScopedRoute('agent_reports'),
},
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
},
{
name: 'Reports Inbox',
label: t('SIDEBAR.REPORTS_INBOX'),
to: accountScopedRoute('inbox_reports'),
},
{
name: 'Reports Team',
label: t('SIDEBAR.REPORTS_TEAM'),
to: accountScopedRoute('team_reports'),
},
{
name: 'Reports SLA',
label: t('SIDEBAR.REPORTS_SLA'),
to: accountScopedRoute('sla_reports'),
},
],
},
{
name: 'Campaigns',
label: t('SIDEBAR.CAMPAIGNS'),
icon: 'i-lucide-megaphone',
children: [
{
name: 'Live chat',
label: t('SIDEBAR.LIVE_CHAT'),
to: accountScopedRoute('campaigns_livechat_index'),
},
{
name: 'SMS',
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
],
},
{
name: 'Portals',
label: t('SIDEBAR.HELP_CENTER.TITLE'),
icon: 'i-lucide-library-big',
children: [
{
name: 'Articles',
label: t('SIDEBAR.HELP_CENTER.ARTICLES'),
activeOn: [
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_articles_index',
}),
},
{
name: 'Categories',
label: t('SIDEBAR.HELP_CENTER.CATEGORIES'),
activeOn: [
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_categories_index',
}),
},
{
name: 'Locales',
label: t('SIDEBAR.HELP_CENTER.LOCALES'),
activeOn: ['portals_locales_index'],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_locales_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.HELP_CENTER.SETTINGS'),
activeOn: ['portals_settings_index'],
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_settings_index',
}),
},
],
activeOn: [
'portals_new',
'portals_index',
'portals_articles_index',
'portals_articles_new',
'portals_articles_edit',
'portals_categories_index',
'portals_categories_articles_index',
'portals_categories_articles_edit',
'portals_locales_index',
'portals_settings_index',
],
},
{
name: 'Settings',
label: t('SIDEBAR.SETTINGS'),
icon: 'i-lucide-bolt',
children: [
{
name: 'Settings Account Settings',
label: t('SIDEBAR.ACCOUNT_SETTINGS'),
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
icon: 'i-lucide-square-user',
to: accountScopedRoute('agent_list'),
},
{
name: 'Settings Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
icon: 'i-lucide-inbox',
to: accountScopedRoute('settings_inbox_list'),
},
{
name: 'Settings Labels',
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tags',
to: accountScopedRoute('labels_list'),
},
{
name: 'Settings Custom Attributes',
label: t('SIDEBAR.CUSTOM_ATTRIBUTES'),
icon: 'i-lucide-code',
to: accountScopedRoute('attributes_list'),
},
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('automation_list'),
},
{
name: 'Settings Agent Bots',
label: t('SIDEBAR.AGENT_BOTS'),
icon: 'i-lucide-bot',
to: accountScopedRoute('agent_bots'),
},
{
name: 'Settings Macros',
label: t('SIDEBAR.MACROS'),
icon: 'i-lucide-toy-brick',
to: accountScopedRoute('macros_wrapper'),
},
{
name: 'Settings Canned Responses',
label: t('SIDEBAR.CANNED_RESPONSES'),
icon: 'i-lucide-message-square-quote',
to: accountScopedRoute('canned_list'),
},
{
name: 'Settings Integrations',
label: t('SIDEBAR.INTEGRATIONS'),
icon: 'i-lucide-blocks',
to: accountScopedRoute('settings_applications'),
},
{
name: 'Settings Audit Logs',
label: t('SIDEBAR.AUDIT_LOGS'),
icon: 'i-lucide-briefcase',
to: accountScopedRoute('auditlogs_list'),
},
{
name: 'Settings Custom Roles',
label: t('SIDEBAR.CUSTOM_ROLES'),
icon: 'i-lucide-shield-plus',
to: accountScopedRoute('custom_roles_list'),
},
{
name: 'Settings Sla',
label: t('SIDEBAR.SLA'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Settings Billing',
label: t('SIDEBAR.BILLING'),
icon: 'i-lucide-credit-card',
to: accountScopedRoute('billing_settings_index'),
},
],
},
];
});
</script>
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center">
<Logo />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow min-w-0 -mx-1"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</div>
<div class="flex gap-2 px-2">
<RouterLink
:to="{ name: 'search' }"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
class="hidden tracking-wide pointer-events-none select-none text-n-slate-10"
>
{{ searchShortcut }}
</span>
</RouterLink>
<button
v-if="enableNewConversation"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
>
<span
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
/>
</button>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
v-bind="item"
/>
</ul>
</nav>
<section
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<div v-if="false" class="flex items-center">
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')"
/>
</div>
</section>
</aside>
</template>

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