Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub 1652b477c8 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-27 22:29:11 +05:30
Pranav 39a6532874 Remove show.llm 2024-11-25 18:45:44 -08:00
Pranav b0504c5c04 Merge branch 'develop' into feat/contacts-redesign 2024-11-25 18:45:10 -08:00
c23cd094f9 feat(v4): Add filter input components (#10493)
This PR adds three components along with stories

1. MultiSelect - This is used for filter values, allowing multiple values and folding of values where there are too many items
2. SingleSelect - This is used for filter values, allows selecting and toggling a single item
3. FilterSelect - This is used for operators and others, it allows icons and labels as well as toggling them using props. The v-model for this binds just the final value unlike the previous two components with bind the entire object.

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-25 11:52:28 -08:00
Pranav 8efb760f97 Merge branch 'develop' into feat/contacts-redesign 2024-11-24 19:48:09 -08:00
Sivin VargheseandGitHub e9ba4200b2 feat: Add custom attributes components (#10467) 2024-11-24 16:46:00 -08:00
Sivin VargheseandGitHub ba6d291e3b Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 10:30:45 +05:30
iamsivin 7eac4f241b chore: Fix build 2024-11-22 09:37:23 +05:30
Sivin VargheseandGitHub 137f5616bd Merge branch 'develop' into feat/contacts-redesign 2024-11-22 09:28:44 +05:30
iamsivin f375d962ab chore: Minor fix 2024-11-22 09:28:15 +05:30
iamsivin 7c94da3bc3 Revert "feat: Add Label Input components (#10480)" This reverts commit 2dae4b22a2. 2024-11-22 09:26:32 +05:30
Sivin VargheseandGitHub 6b5b53a722 chore: Fix code climate 2024-11-22 09:09:53 +05:30
0f659224a7 feat: async update of article [CW-3721] (#10435)
### The problem

Writing in the text editor can be very frustrating, the reason is that
the editor had a debounced save method which would push the article to
the backend and update the current state. This however is a bad idea,
since the can take anywhere between 100-300ms depending on network
conditions.

While this would be in progress, the article is still being edited by
the user. So at the end of the network request, the state returned from
the backend and the current state in the editor is diverged. But since
the update happens anyway, the editor would prepend older context.

```
Time   --> 

User Action:      [Edit 1] ---> [Edit 2] ---> [Edit 3]
Backend Save:           Save Req (Edit 1) ----> Response (Edit 1)
Resulting Editor State: [Edit 3] + [Edit 1] (Outdated state prepended)
```

### The solution

The solution is to unbind the article from the backend state, ensuring
that the article editor is the source of truth and ignoring the
responses. This pull request does this by adding an asynchronous save
functionality. The changes include adding a new `saveArticleAsync` event
and ensuring that the local state is not updated unnecessarily during
asynchronous saves.

```
Time   --> 

User Action:      [Edit 1] ---> [Edit 2] ---> [Edit 3]
Backend Save:           Save Req (Edit 1) ----> Response (ignored)
Resulting Editor State: [Edit 3] (Consistent and up-to-date)
```

Added the following two debounced methods

These complementary debounce methods prevent unnecessary re-renders
while ensuring backend is in sync. `saveArticleAsync` preserves the
editor as the source of truth, while `saveArticle` manages periodic
state updates from the backend with a delay large enough to safely
assume that the user has stopped typing
Method | Delay | Behavior
-- | -- | -- 
`saveArticleAsync` | 400ms | Sends data to backend and ignores the
response
`saveArticle` | 2.5s | Sends data and updates local state with the
backend response

### How to test

1. Remove the following line
https://github.com/chatwoot/chatwoot/blob/dc042f6ddcb4e108795ff83bb57f8d92c6a2b90a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue#L64
1. Update the latency here to 400 (P.S. the diff shows the latency to be
600, but that was added as a stop-gap solution)

https://github.com/chatwoot/chatwoot/blob/dc042f6ddcb4e108795ff83bb57f8d92c6a2b90a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue#L51
1. Set the browser network latency to Slow 3G or 3G
1. Start writing on the editor, try fixing typos with backspace or
moving around with the cursor

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-21 19:38:08 -08:00
Sivin VargheseandGitHub 4156881dec Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 09:04:27 +05:30
iamsivin 93d1e2a032 chore: Update contact card with company name 2024-11-22 09:03:46 +05:30
iamsivin ca05be1c1a chore: Decamelize keys on contact update 2024-11-22 09:03:07 +05:30
Sivin VargheseandGitHub 7c09c42bde Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-22 08:13:07 +05:30
Sivin VargheseandGitHub ceaeba84ee Merge branch 'develop' into feat/contacts-redesign 2024-11-22 08:09:28 +05:30
Sivin VargheseandGitHub 2dae4b22a2 feat: Add Label Input components (#10480) 2024-11-21 13:57:43 -08:00
cf6ef11b9f feat: Add contact merge form component (#10478)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-21 13:52:25 -08:00
497bc055a2 feat: Attributify button component (#10473)
This PR allows attributify for `variant`, `size` and `color` props. This allows using shorthands, instant of writing full props.

We also added a small computed method to ensure these does not show up
in the DOM and pollute it

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-21 13:48:25 -08:00
Sivin VargheseandGitHub 79daf56c31 feat: Add contact note item component (#10479) 2024-11-21 13:47:57 -08:00
Sivin VargheseandGitHub 67fba33005 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 22:55:50 +05:30
Sivin VargheseandGitHub 92190ec81b Merge branch 'develop' into feat/contacts-redesign 2024-11-21 22:55:36 +05:30
Sivin VargheseandGitHub 7b6195f28b fix: Component <woot-tabs /> reactivity issue. (#10476)
# Pull Request Template

## Description

This PR will fix reactivity issue with `<woot-tabs />` component.

**Cause of issue**
The `<woot-tabs />` component used an internal ref,
`internalActiveIndex` to track the `active` tab. However, it didn’t sync
with the `index` prop when updated by the parent, causing mismatched tab
selections.

**Solution**
The component now directly uses `props.index` to ensure it always
reflects the latest value from the parent. The unnecessary
`internalActiveIndex` ref has been removed. Changes to the active tab
emit a `change` event to update the parent.


## 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/76eb32f1e7f7422f84055a102bf80951?sid=bc28c6ff-9640-4d3b-956c-99c1ec164971

**After**

https://www.loom.com/share/6bd8125ede5d43dc8fe115c3f1fb159b?sid=c376617a-94fb-4f71-8664-e0bd9e7af0b4

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-11-21 16:25:13 +05:30
iamsivin 2d0c813300 chore: Minor fix 2024-11-21 15:54:01 +05:30
iamsivin 7083137c5a chore: Label components 2024-11-21 15:48:43 +05:30
iamsivin b9b3c43d63 feat: Contact note item component 2024-11-21 15:25:06 +05:30
iamsivin 2a724cad7c chore: Create contact merge component 2024-11-21 14:52:19 +05:30
iamsivin ef51083b3c chore: Minor fix 2024-11-21 13:34:42 +05:30
iamsivin edfb1029a7 chore: Minor fix 2024-11-21 12:43:54 +05:30
Sivin VargheseandGitHub 75cf5322c9 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 12:12:03 +05:30
Sivin VargheseandGitHub 634a31a7ab Merge branch 'develop' into feat/contacts-redesign 2024-11-21 12:11:35 +05:30
iamsivin ee55e8e8ff chore: Minor fix 2024-11-21 10:58:09 +05:30
2309424cb1 feat: Add Contact card and form component (#10466)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-20 21:18:25 -08:00
iamsivin 1a4f44555f chore: Minor fix 2024-11-21 10:37:20 +05:30
Shivam MishraandGitHub 3a334be582 feat: add channel icon component (#10471)
This pull request introduces a new `ChannelIcon` component and refactors the existing code to use this component, which simplifies the icon management for different channel types and providers.
2024-11-20 20:23:12 -08:00
Sojan JoseandGitHub 515778eabb chore: Disable throwing error for malformed to address (#10464)
We don't need to raise error on sentry for malformed to address as it is already logged.

Fixes: https://linear.app/chatwoot/issue/CW-3151/standarderror-invalid-email-to-address-header-standarderror
2024-11-20 18:58:54 -08:00
iamsivin b919afa182 chore: Minor fix 2024-11-21 00:53:45 +05:30
Sivin VargheseandGitHub 189aa06fa3 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-21 00:50:28 +05:30
iamsivin 257a5ccf83 chore: Minor fix 2024-11-21 00:42:01 +05:30
iamsivin e3f50da277 chore: Minor fix 2024-11-21 00:37:12 +05:30
iamsivin edcc15fab2 chore: Custom attributes story 2024-11-21 00:24:08 +05:30
iamsivin 525a979283 chore: Minor fix 2024-11-21 00:08:03 +05:30
iamsivin 7021a85da5 chore: Minor fix 2024-11-20 23:33:17 +05:30
Sivin VargheseandGitHub 93cd88253d Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-20 22:36:58 +05:30
iamsivin 2852f01026 chore: Minor fix 2024-11-20 22:34:49 +05:30
Sivin VargheseandGitHub 2dcb6ec997 Merge branch 'develop' into feat/contacts-redesign 2024-11-20 22:33:04 +05:30
Sojan JoseandGitHub 93b7ce6eba feat: Display Account Custom Attributes in Super Admin (#10459)
Viewing the account custom attributes is quite handy when debugging on Chatwoot cloud. Hence adding this option to Super Admin.
2024-11-20 09:02:33 -08:00
Sivin VargheseandGitHub 2538e161a2 Merge branch 'develop' into feat/contacts-redesign 2024-11-20 22:32:01 +05:30
Sivin VargheseandGitHub c3604bfcbf feat: New phone number input component (#10446) 2024-11-20 22:31:05 +05:30
iamsivin 6f4b37a6cd chore: Minor fix 2024-11-20 20:31:07 +05:30
Sivin VargheseandGitHub b9cb4cc65e Merge branch 'develop' into feat/contacts-redesign 2024-11-20 20:26:27 +05:30
Sivin VargheseandGitHub b0d6089bb6 feat: Updates on new components (#10444) 2024-11-20 20:21:35 +05:30
76a4140224 fix: Feature flags are not be stored on account creation (#10387)
when creating an account via the platform API the feature flags do not get stored

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-11-20 21:39:17 +08:00
iamsivin e46d704573 chore: Minor fix 2024-11-20 17:03:50 +05:30
Sivin VargheseandGitHub 8f7589f173 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-20 08:15:51 +05:30
Sivin VargheseandGitHub f5a72c58db Merge branch 'develop' into feat/contacts-redesign 2024-11-20 08:15:33 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
93ebfccac2 chore(deps): bump cross-spawn from 7.0.3 to 7.0.6 (#10447)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from
7.0.3 to 7.0.6.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md">cross-spawn's
changelog</a>.</em></p>
<blockquote>
<h3><a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.5...v7.0.6">7.0.6</a>
(2024-11-18)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>update cross-spawn version to 7.0.5 in package-lock.json (<a
href="https://github.com/moxystudio/node-cross-spawn/commit/f700743918d901eff92960e15a8dd68f87bd4176">f700743</a>)</li>
</ul>
<h3><a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.4...v7.0.5">7.0.5</a>
(2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>fix escaping bug introduced by backtracking (<a
href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f">640d391</a>)</li>
</ul>
<h3><a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.4">7.0.4</a>
(2024-11-07)</h3>
<h3>Bug Fixes</h3>
<ul>
<li>disable regexp backtracking (<a
href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>)
(<a
href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff">5ff3a07</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/77cd97f3ca7b62c904a63a698fc4a79bf41977d0"><code>77cd97f</code></a>
chore(release): 7.0.6</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/6717de49ff1e5de49622488dcb9c33fb25370c85"><code>6717de4</code></a>
chore: upgrade standard-version</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/f700743918d901eff92960e15a8dd68f87bd4176"><code>f700743</code></a>
fix: update cross-spawn version to 7.0.5 in package-lock.json</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/9a7e3b2165917367f74b8365faad9873b30d7263"><code>9a7e3b2</code></a>
chore: fix build status badge</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/085268352dcbcad8064c64c5efb25268b4023184"><code>0852683</code></a>
chore(release): 7.0.5</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/640d391fde65388548601d95abedccc12943374f"><code>640d391</code></a>
fix: fix escaping bug introduced by backtracking</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/bff0c87c8b627c4e6d04ec2449e733048bebb464"><code>bff0c87</code></a>
chore: remove codecov</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/a7c6abc6fee79641d45b452fe6217deaa1bd0973"><code>a7c6abc</code></a>
chore: replace travis with github workflows</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/9b9246e0969e86656d7ccd527716bc3c18842a19"><code>9b9246e</code></a>
chore(release): 7.0.4</li>
<li><a
href="https://github.com/moxystudio/node-cross-spawn/commit/5ff3a07d9add449021d806e45c4168203aa833ff"><code>5ff3a07</code></a>
fix: disable regexp backtracking (<a
href="https://redirect.github.com/moxystudio/node-cross-spawn/issues/160">#160</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cross-spawn&package-manager=npm_and_yarn&previous-version=7.0.3&new-version=7.0.6)](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-11-19 18:45:24 -08:00
Sivin VargheseandGitHub 62f357fc7f Merge branch 'develop' into feat/contacts-redesign 2024-11-20 07:07:10 +05:30
759615d041 fix: Update the dropdown bg to match the design system (#10438)
This PR updates the background used in dropdown to match our design system. Previous PR failed to add this correctly.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-19 14:29:45 -08:00
Sivin VargheseandGitHub a7e3d443c9 feat: Add the new select menu component (#10445) 2024-11-19 14:25:45 -08:00
Sojan JoseandGitHub 7a3303e841 fix: Undefined method `encode' for nil for avatar from url job (#10450)
Invalid urls supplied to the job was causing sentry issues. The issue primarily occurs when the download file.original_filename comes out as empty

fixes: https://github.com/chatwoot/chatwoot/issues/10449
2024-11-19 14:24:01 -08:00
Sivin VargheseandGitHub 4fda057afb Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 23:01:53 +05:30
Sivin VargheseandGitHub fccfa5adba Merge branch 'develop' into feat/contacts-redesign 2024-11-19 22:47:59 +05:30
iamsivin 60fdab4bbe chore: More story case 2024-11-19 22:36:03 +05:30
iamsivin 93eb295152 chore: Adds story 2024-11-19 22:31:51 +05:30
iamsivin 1982cb159c chore: Clean up 2024-11-19 22:03:54 +05:30
Sojan 9cc6b1a4ba Merge branch 'release/3.15.0' into develop 2024-11-19 18:02:13 +08:00
Sojan 7d9800d2f8 Bump version to 3.15.0 2024-11-19 18:01:33 +08:00
Sivin VargheseandGitHub b680fa43ec fix: Country code missing when typing in phone input. (#10439) 2024-11-19 14:38:00 +05:30
Sivin VargheseandGitHub ca46ed9bca Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 08:56:25 +05:30
Sivin VargheseandGitHub e591f14729 Merge branch 'develop' into feat/contacts-redesign 2024-11-19 08:56:02 +05:30
Shivam MishraandGitHub c2e2954dfa fix: Remove the warnings generated from the Sidebar component (#10437)
The following warning occurred since the `ChannelLeaf` component was passed extra props. This PR fixes it by passing only the required props
2024-11-18 19:09:09 -08:00
Sivin VargheseandGitHub 7d0bfabb49 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-19 08:28:34 +05:30
Sivin VargheseandGitHub aef2ffc3a9 Merge branch 'develop' into feat/contacts-redesign 2024-11-19 08:28:24 +05:30
aaa328be87 feat: Add dropdown component (#10358)
This PR adds dropdown primitives to help compose custom dropdowns across the app. The following the sample usage

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2024-11-18 17:29:27 -08:00
iamsivin c3cf73de85 chore: Minor fix 2024-11-19 00:06:23 +05:30
iamsivin 610b05ed5a feat: Attachment preview 2024-11-18 23:47:58 +05:30
iamsivin 2e30d4b825 chore: Minor fix 2024-11-18 22:10:23 +05:30
Sivin VargheseandGitHub 99c762404e Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-18 21:04:19 +05:30
Sivin VargheseandGitHub b6ad0211e3 Merge branch 'develop' into feat/contacts-redesign 2024-11-18 21:03:53 +05:30
iamsivin 15470506bf chore: Minor fixes 2024-11-18 17:18:58 +05:30
Vishnu NarayananandGitHub 54afed9fb4 feat: add judoscaler gem for heroku autoscaling (#10419)
- add judoscaler gem to allow judoscale use in heroku environments
- This will allow auto scaling for both web and worker dynos across both
standard-1x/2x and performance dynos
- This will scaling in response to queue time rather than response
time(heroku default)
- This also allows you to scale multiple dynos in and out at once,
rather than scaling them one at a time, as is the default.

Ref
----
1. https://judoscale.com/
2. https://devcenter.heroku.com/articles/judoscale
2024-11-18 12:36:27 +05:30
Sivin VargheseandGitHub f15c7a8df1 Merge branch 'feat/contacts-redesign' into feat/compose-new-conversation 2024-11-15 23:00:49 +05:30
Sivin VargheseandGitHub dceb616cc4 Merge branch 'develop' into feat/contacts-redesign 2024-11-15 23:00:34 +05:30
iamsivin 14e7950186 feat: Inbox empty state warning 2024-11-15 22:56:35 +05:30
iamsivin e3602acf68 chore: Created file upload composable and use it in compose view 2024-11-15 22:12:16 +05:30
casparandGitHub 8773929c0e fix: Fix line sticker URL to prevent certain images from failing to d… (#10416)
This commit fixes the issue with Line stickers URLs to prevent certain
images from failing to display. The problem was due to the use of
incorrect URLs. The original URLs pointed to the `iphone` variant, which
failed to load properly in some cases. The fix updates the URLs to use
the `android` variant, ensuring all images are displayed correctly.

### Example:  
- Original (failing URL):  

`https://stickershop.line-scdn.net/stickershop/v1/sticker/17/iphone/sticker.png`
- Fixed (working URL):  

`https://stickershop.line-scdn.net/stickershop/v1/sticker/17/android/sticker.png`


## How Has This Been Tested?

1. Verified the updated URLs by loading multiple Line sticker images to
ensure they display correctly.
2. Tested in both local and production-like environments to confirm the
fix resolves the issue.
3. Reviewed logs to ensure no additional errors are generated related to
Line sticker URLs.
2024-11-15 17:27:58 +04:00
iamsivin b97ee9675e chore: Fix build 2024-11-15 18:27:24 +05:30
iamsivin 1b7317b71c chore: Adds translations 2024-11-15 18:21:39 +05:30
iamsivin 7cc0d6d457 chore: Clean up 2024-11-15 17:22:48 +05:30
iamsivin 28481c45cb feat: Code clean up 2024-11-15 17:12:39 +05:30
iamsivin 7790b88725 chore: Fix whats app template 2024-11-15 14:30:46 +05:30
933ae8aa49 fix: Email attachments created with empty filename (#10420)
- We observed in prod for certain emails active storage blob objects
were getting created with empty file name. The conversations further
causes conversation and filter pages to break. This change will fix the
mentioned issue.

fixes:
https://linear.app/chatwoot/issue/CW-3331/missing-file-name-for-some-of-the-uploads-for-emails

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-15 09:07:24 +04:00
iamsivin 9ea992fe45 feat(v4): Compose new conversation 2024-11-15 02:06:20 +05:30
Sivin VargheseandGitHub bf59285041 Merge branch 'develop' into feat/contacts-redesign 2024-11-14 18:41:22 +05:30
iamsivin 90162ad39b feat: Conversation card click 2024-11-14 18:40:47 +05:30
Sivin VargheseandGitHub 4cfa7e4c97 feat: Remove incoming messages metrics from the agent reports (#10415) 2024-11-14 17:55:24 +05:30
Sivin VargheseandGitHub bd691d2e2b Merge branch 'develop' into feat/contacts-redesign 2024-11-13 16:57:50 +05:30
iamsivin 94142acb93 chore: Minor fix 2024-11-13 15:55:16 +05:30
Shivam MishraandGitHub ac729cf0cf feat: update debounce duration for article editor (#10410)
Quick fix for CW-3721 until we work on a better solution
2024-11-13 12:29:50 +05:30
iamsivin 78e6f995eb chore: Filter enhancements 2024-11-13 10:05:06 +05:30
Sivin VargheseandGitHub 4cb9abaf8c Merge branch 'develop' into feat/contacts-redesign 2024-11-13 08:19:24 +05:30
PranavandGitHub 7dc0eba48a fix: Remove duplicate / character on the proxy route (#10404)
The proxy method adds an extra slash even if the route param has the
character. It’s challenging to check the expected format on each route.
The simplest solution is to check if the route param begins with a
slash, and if not, append one.

NB: The existing tests are sufficient to cover this case. There’s no
need for an additional test to specifically test this.
2024-11-12 22:38:29 +04:00
iamsivin b75afc2b29 chore: Minor fix 2024-11-12 22:38:49 +05:30
iamsivin 3f608ece5a fix: Phone number input 2024-11-12 22:18:55 +05:30
iamsivin 72496ac4a0 chore: Minor fix 2024-11-12 22:06:08 +05:30
iamsivin 909755c340 chore: Clean up 2024-11-12 22:02:26 +05:30
iamsivin 42a9e8dc82 chore: Minor fix 2024-11-12 21:26:33 +05:30
iamsivin f0c5d214e7 chore: Minor fix 2024-11-12 21:11:37 +05:30
iamsivin cc6ac082b1 feat: Phone input 2024-11-12 21:03:56 +05:30
Sivin VargheseandGitHub d8bc4ae40f Merge branch 'develop' into feat/contacts-redesign 2024-11-12 11:40:26 +05:30
iamsivin 7aa22eabf8 chore: Fix sidebar activeOn issue 2024-11-12 11:39:59 +05:30
iamsivin 1dd690e8f9 chore: Minor fix 2024-11-12 09:14:27 +05:30
iamsivin 6caf7ded2d chore: Minor fix 2024-11-12 09:08:21 +05:30
Shivam MishraandGitHub 97d7b9d754 feat(ee): Setup @chatwoot/captain NPM library (#10389)
--- 
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-11-11 17:39:09 -08:00
Vishnu NarayananandGitHub 7a45144526 chore: Set rack-timeout to log at ERROR level (#10400)
40 % of Chatwoot's current log volume is from state transition logs generated by `rack-timeout`, which are logged at the `INFO` level. This PR reduce the noise in logs and set RACK::TIMEOUT to log at `error` level
2024-11-11 12:35:05 -08:00
iamsivin b39e25f9e2 chore: Fix empty state 2024-11-11 23:31:32 +05:30
iamsivin bda9f0208a chore: Fix build 2024-11-11 23:11:37 +05:30
iamsivin 74a90c0173 chore: Fix sidebar 2024-11-11 23:09:30 +05:30
iamsivin 9210d2cecd chore: Minor fix 2024-11-11 22:49:16 +05:30
iamsivin fd4b1f2d21 feat: Contact Empty state 2024-11-11 22:38:08 +05:30
iamsivin ae804baf9b chore: More changes 2024-11-11 21:51:32 +05:30
iamsivin e2c1e6ff00 chore: Fix warning 2024-11-11 21:26:21 +05:30
iamsivin 2dbe5bd1c0 chore: Fix validations for custom attributes 2024-11-11 21:24:34 +05:30
iamsivin 0fe6d404b9 feat: Contact attributes 2024-11-11 21:07:12 +05:30
iamsivin d6cc5422d0 feat: Add avatar upload and merge EditableAvatar.vue with Avatar.vue 2024-11-09 15:30:52 +05:30
iamsivin 271b322761 feat: Contact exporting 2024-11-09 14:06:33 +05:30
iamsivin 1b013544a8 chore: Fix sidebar filter issue and sort reactivity issue 2024-11-09 13:39:22 +05:30
iamsivin d53c60363b feat: Contact filtering by labels and segments 2024-11-08 15:44:10 +05:30
iamsivin 3d6d6e2afb feat: Import contact 2024-11-08 10:30:14 +05:30
Sivin VargheseandGitHub 430c6d2526 Merge branch 'develop' into feat/contacts-redesign 2024-11-08 10:16:07 +05:30
Sivin VargheseandGitHub db327378fa feat(v4): Add new conversation card component (#10392) 2024-11-07 20:30:56 -08:00
iamsivin 8320ecd92b feat: Create contact 2024-11-08 02:22:22 +05:30
iamsivin ecd8b265bc feat: Contact sort 2024-11-08 01:27:51 +05:30
iamsivin c5974f97cd chore: Update the usage with camel case 2024-11-07 20:46:29 +05:30
iamsivin 314a33e053 chore: Use camel case 2024-11-07 10:32:38 +05:30
iamsivin 4b85a9045a chore: Minor fix 2024-11-06 23:22:52 +05:30
iamsivin d41857abc4 feat: Contact search 2024-11-06 23:20:00 +05:30
iamsivin 816c65df2d chore: Sort labels 2024-11-06 23:04:53 +05:30
iamsivin 91ee5fc2f0 chore: Form validations 2024-11-06 21:35:05 +05:30
iamsivin 28cbdb6133 feat: Contact merge 2024-11-06 21:08:14 +05:30
iamsivin 703b36e38c chore: Add or remove labels 2024-11-06 18:42:35 +05:30
iamsivin 51cf4df3e3 chore: Code clean up 2024-11-06 18:08:14 +05:30
Sivin VargheseandGitHub caaf8d55c6 Merge branch 'develop' into feat/contacts-redesign 2024-11-06 17:52:52 +05:30
iamsivin d5a1c7f49b chore: Fix build 2024-11-06 17:51:42 +05:30
iamsivin db6f08c12b feat: Contact history 2024-11-06 17:51:03 +05:30
iamsivin 7021e96ad4 chore: More changes 2024-11-06 17:34:04 +05:30
iamsivin c254c44994 chore: Minor fix 2024-11-06 00:06:02 +05:30
iamsivin 05b76f7182 chore: Fix build 2024-11-06 00:03:16 +05:30
iamsivin ae1c6e3f98 chore: Replace thumbnail component with avatar 2024-11-05 23:56:11 +05:30
iamsivin 3c58d6af59 chore: Minor fix 2024-11-05 21:35:19 +05:30
iamsivin fa8a7db612 feat: COntact sidebar 2024-11-05 21:34:05 +05:30
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
iamsivin 759ce1229d chore: Minor color fix 2024-11-04 23:37:13 +05:30
iamsivin b92805933f chore: fix build 2024-11-04 23:26:04 +05:30
iamsivin b71a05f790 feat: Contact details screen 2024-11-04 19:46:02 +05:30
iamsivin dd9e9286b3 feat: Contact card with expand view 2024-11-04 15:15:17 +05:30
iamsivin f341d159c2 feat: Route view 2024-11-04 15:14:43 +05:30
iamsivin a7bf762bcd feat: Contacts layout 2024-11-04 15:14:01 +05:30
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
261 changed files with 15374 additions and 3520 deletions
+3
View File
@@ -181,6 +181,9 @@ gem 'reverse_markdown'
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end
group :development do
+9
View File
@@ -397,6 +397,13 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
judoscale-ruby (1.8.2)
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1)
base64
kaminari (1.2.2)
@@ -891,6 +898,8 @@ DEPENDENCIES
jbuilder
json_refs
json_schemer
judoscale-rails
judoscale-sidekiq
jwt
kaminari
koala
@@ -17,7 +17,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index
@contacts_count = Current.account.contactable_contacts_count
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts)
end
@@ -1,22 +1,53 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook
before_action :hook
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
end
def request_url
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def permitted_params
params.permit(:method, :route, body: {})
end
end
@@ -4,6 +4,7 @@ class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.create!(account_params)
update_resource_features
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
+3 -2
View File
@@ -26,7 +26,8 @@ class AccountDashboard < Administrate::BaseDashboard
conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany
account_users: Field::HasMany,
custom_attributes: Field::String
}.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES
@@ -45,7 +46,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
SHOW_PAGE_ATTRIBUTES = (%i[
id
name
+2 -2
View File
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
}
@@ -112,8 +112,8 @@
--teal-12: 13 61 56;
--background-color: 253 253 253;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 1;
--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;
@@ -122,14 +122,13 @@
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--text-blue: 8, 109, 224, 1;
/* alpha is added by default */
--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;
}
@@ -188,8 +187,6 @@
--teal-12: 173 240 221;
--background-color: 18 18 19;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
@@ -198,14 +195,15 @@
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--text-blue: 126, 182, 255, 1;
--text-blue: 126 182 255;
/* alpha is added by default */
--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 */
@@ -31,7 +31,13 @@ hr {
ul,
ol,
dl {
@apply mb-2 list-disc list-outside leading-[1.65];
@apply list-disc list-outside leading-[1.65];
}
ul:not(.reset-base),
ol:not(.reset-base),
dl:not(.reset-base) {
@apply mb-0;
}
// Form elements
@@ -96,7 +96,7 @@ button {
}
// @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content {
.icon + .button__content {
@apply w-auto;
}
@@ -0,0 +1,137 @@
<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 layout="row">
<div class="flex flex-col items-start justify-between flex-1 min-w-0 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>
<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>
</CardLayout>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
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">
<Avatar
:name="senderName"
:src="senderThumbnailSrc"
:size="16"
rounded-full
/>
<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? I’m 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! 👋 I’m here for any questions you may have. Let’s 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! We’re 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,38 @@
<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"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
is-live-chat-type
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,37 @@
<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"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,38 @@
<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"
: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,74 @@
<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"
>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</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>
@@ -1,5 +1,13 @@
<script setup>
defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
@@ -7,10 +15,18 @@ const handleClick = () => {
<template>
<div
class="relative flex flex-col w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
@click="handleClick"
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<slot name="header" />
<slot name="footer" />
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
@click="handleClick"
>
<slot />
</div>
<slot name="after" />
</div>
</template>
@@ -0,0 +1,46 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import ContactSortMenu from './ContactSortMenu.vue';
import ContactMoreActions from './ContactMoreActions.vue';
defineProps({
isDetailView: {
type: Boolean,
default: false,
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isEmptyState: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['filter', 'update:sort']);
</script>
<template>
<div class="flex items-center gap-2">
<Button
v-if="!isDetailView && !isEmptyState"
icon="i-lucide-list-filter"
color="slate"
size="sm"
variant="ghost"
@click="emit('filter')"
/>
<ContactSortMenu
v-if="!isDetailView && !isEmptyState"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions v-if="!isDetailView" />
</div>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
const uiFlags = useMapGetter('contacts/getUIFlags');
const isExportingContact = computed(() => uiFlags.value.isExporting);
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegment = computed(() =>
activeSegmentId.value
? segments.value.find(view => view.id === Number(activeSegmentId.value))
: undefined
);
const exportContacts = async () => {
let query = { payload: [] };
if (activeSegmentId.value && activeSegment.value) {
query = activeSegment.value.query;
} else if (Object.keys(appliedFilters.value).length > 0) {
query = filterQueryGenerator(appliedFilters.value);
}
try {
store.dispatch('contacts/export', {
...query,
label: route.params.label || '',
});
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
);
}
};
const handleDialogConfirm = async () => {
await exportContacts();
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.TITLE')"
:description="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.DESCRIPTION')
"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.CONFIRM')
"
:is-loading="isExportingContact"
:disable-confirm-button="isExportingContact"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isImportingContact = computed(() => uiFlags.value.isImporting);
const dialogRef = ref(null);
const fileInput = ref(null);
const hasSelectedFile = ref(null);
const selectedFileName = ref('');
const csvUrl = () => '/downloads/import-contacts-sample.csv';
const handleFileClick = () => fileInput.value?.click();
const processFileName = fileName => {
const lastDotIndex = fileName.lastIndexOf('.');
const extension = fileName.slice(lastDotIndex);
const baseName = fileName.slice(0, lastDotIndex);
return baseName.length > 20
? `${baseName.slice(0, 20)}...${extension}`
: fileName;
};
const handleFileChange = () => {
const file = fileInput.value?.files[0];
hasSelectedFile.value = file;
selectedFileName.value = file ? processFileName(file.name) : '';
};
const handleRemoveFile = () => {
hasSelectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = null;
}
selectedFileName.value = '';
};
const uploadFile = async () => {
if (!hasSelectedFile.value) return;
try {
await store.dispatch('contacts/import', hasSelectedFile.value);
dialogRef.value?.close();
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.TITLE')"
:confirm-button-label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.IMPORT')
"
:is-loading="isImportingContact"
:disable-confirm-button="isImportingContact"
@confirm="uploadFile"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DESCRIPTION') }}
<a
:href="csvUrl"
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
}}
</a>
</p>
</template>
<template #form>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm text-n-slate-12 whitespace-nowrap">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
</label>
<div class="flex items-center justify-between w-full gap-2">
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
{{ selectedFileName }}
</span>
<Button
v-if="!hasSelectedFile"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
"
icon="i-lucide-download"
color="slate"
variant="ghost"
size="sm"
class="!w-fit"
@click="handleFileClick"
/>
<div v-else class="flex items-center gap-1">
<Button
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')
"
color="slate"
variant="ghost"
size="sm"
@click="handleFileClick"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
color="slate"
variant="ghost"
size="sm"
@click="handleRemoveFile"
/>
</div>
</div>
</div>
</div>
<input
ref="fileInput"
type="file"
accept="text/csv"
class="hidden"
@change="handleFileChange"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,70 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactHeader/ContactImportDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactHeader/ContactExportDialog.vue';
const { t } = useI18n();
const contactMenuItems = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const createNewContactDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const handleContactAction = ({ action }) => {
if (action === 'add') {
createNewContactDialogRef.value?.dialogRef.open();
} else if (action === 'import') {
contactImportDialogRef.value?.dialogRef.open();
} else if (action === 'export') {
contactExportDialogRef.value?.dialogRef.open();
}
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
<ContactImportDialog ref="contactImportDialogRef" />
<ContactExportDialog ref="contactExportDialogRef" />
</template>
@@ -0,0 +1,134 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
value: 'country',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
value: 'city',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
value: 'last_activity_at',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return (
selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
);
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({
contactId: {
type: [String, Number],
default: null,
},
});
const store = useStore();
const route = useRoute();
const showDropdown = ref(false);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
const savedLabels = computed(() => {
const availableContactLabels = contactLabels.value(props.contactId);
return allLabels.value.filter(({ title }) =>
availableContactLabels.includes(title)
);
});
const labelMenuItems = computed(() => {
return allLabels.value
?.map(label => ({
label: label.title,
value: label.id,
thumbnail: { name: label.title, color: label.color },
isSelected: savedLabels.value.some(
savedLabel => savedLabel.id === label.id
),
action: 'addLabel',
}))
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
const fetchLabels = async contactId => {
if (!contactId) {
return;
}
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ action, value }) => {
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
// Find the label title for the ID (value)
const selectedLabel = allLabels.value.find(label => label.id === value);
if (!selectedLabel) return;
let updatedLabels;
if (action === 'addLabel') {
// If label is already selected, remove it (toggle behavior)
if (currentLabels.includes(selectedLabel.title)) {
updatedLabels = currentLabels.filter(
labelTitle => labelTitle !== selectedLabel.title
);
} else {
// Add the new label
updatedLabels = [...currentLabels, selectedLabel.title];
}
}
await store.dispatch('contactLabels/update', {
contactId: props.contactId,
labels: updatedLabels,
});
showDropdown.value = false;
} catch (error) {
// error
}
};
watch(
() => props.contactId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchLabels(newVal);
}
}
);
onMounted(() => {
if (route.params.contactId) {
fetchLabels(route.params.contactId);
}
});
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<LabelItem v-for="label in savedLabels" :key="label.id" :label="label" />
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="handleLabelAction"
/>
</div>
</template>
@@ -0,0 +1,104 @@
<script setup>
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
isExpanded: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const { t } = useI18n();
const handleFormUpdate = updatedData => {
emit('updateContact', { id: props.id, updatedData });
};
const onClickViewDetails = async () => {
emit('showContact', props.id);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-between gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span
v-if="additionalAttributes?.companyName"
class="i-lucide-briefcase-business size-4 text-n-slate-11"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</div>
<div class="flex items-center gap-3">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="emit('toggle')"
/>
<template #after>
<transition
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[360px]"
leave-from-class="opacity-100 max-h-[360px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
>
<div v-show="isExpanded" class="w-full">
<div class="p-6 border-t border-n-strong">
<ContactsForm
:contact-data="{
id,
name,
email,
phoneNumber,
additionalAttributes,
}"
@update="handleFormUpdate"
/>
</div>
</div>
</transition>
</template>
</CardLayout>
</template>
@@ -0,0 +1,67 @@
<script setup>
import { ref } from 'vue';
import ContactsCard from '../ContactsCard.vue';
import contacts from './fixtures';
const expandedCardId = ref(null);
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<Story
title="Components/Contacts/ContactsCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default with expandable function">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="contacts[0]"
:is-expanded="expandedCardId === contacts[0].id"
@toggle="toggleExpanded(contacts[0].id)"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="With Company Name and without phone number">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="{ ...contacts[1], phoneNumber: '' }"
:is-expanded="false"
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="Expanded State">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="contacts[2]"
is-expanded
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="Without Email and Phone">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="{ ...contacts[3], email: '', phoneNumber: '' }"
:is-expanded="false"
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,149 @@
export default [
{
additionalAttributes: {
socialProfiles: {},
},
availabilityStatus: null,
email: 'johndoe@chatwoot.com',
id: 370,
name: 'John Doe',
phoneNumber: '+918634322418',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Felix',
customAttributes: {},
lastActivityAt: 1731608270,
createdAt: 1731586271,
},
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: null,
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Upload',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
{
additionalAttributes: {
city: 'Kerala',
country: 'India',
description:
"I'm Candice developer focusing on building things for the web 🌍. Currently, I’m working as a Product Developer here at @chatwootapp ⚡️🔥",
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
availabilityStatus: null,
email: 'cmathersonj@va.test',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+917474774742',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Emery',
customAttributes: {
dateContact: '2024-11-12T03:23:06.963Z',
linkContact: 'https://sd.sd',
textContact: 'hey',
numberContact: '12',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
availabilityStatus: null,
email: 'ofolkardi@taobao.test',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBPZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--08dcac8eb72ef12b2cad92d58dddd04cd8a5f513/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/32.jpg',
customAttributes: {},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
socialProfiles: {},
},
availabilityStatus: null,
email: 'wcasteloth@exblog.jp',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+919384',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Jade',
customAttributes: {},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
availabilityStatus: null,
email: 'ederingtong@printfriendly.test',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Jade',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const dialogRef = ref(null);
const deleteContact = async id => {
if (!id) return;
try {
await store.dispatch('contacts/delete', id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
emit('goToContactsList');
await deleteContact(route.params.contactId || props.selectedContact.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -0,0 +1,112 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
type: Object,
required: true,
},
primaryContactId: {
type: [Number, null],
default: null,
},
primaryContactList: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:primaryContactId', 'search']);
const { t } = useI18n();
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
</div>
<ComboBox
id="inbox"
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
isSearching
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
"
:search-placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
:has-error="hasError"
:message="errorMessage"
class="[&>div>button]:bg-n-alpha-black2"
@update:model-value="value => emit('update:primaryContactId', value)"
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
/>
<div
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="selectedContact.name || ''"
:src="selectedContact.thumbnail || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.email }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,297 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
const props = defineProps({
contactData: {
type: Object,
default: null,
},
isDetailsView: {
type: Boolean,
default: false,
},
isNewContact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
LAST_NAME: { field: 'lastName' },
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
TWITTER: 'i-ri-twitter-x-fill',
};
const defaultState = {
id: 0,
name: '',
email: '',
firstName: '',
lastName: '',
phoneNumber: '',
additionalAttributes: {
description: '',
companyName: '',
countryCode: '',
country: '',
city: '',
socialProfiles: {
facebook: '',
github: '',
instagram: '',
linkedin: '',
twitter: '',
},
},
};
const state = reactive({ ...defaultState });
const validationRules = {
firstName: { required, minLength: minLength(2) },
email: { email },
};
const v$ = useVuelidate(validationRules, state);
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
return; // Added to prevent state update for new contact form
}
const {
id,
name = '',
email: emailAddress,
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const [firstName = '', lastName = ''] = name.split(' ');
const {
description,
companyName,
countryCode,
country,
city,
socialProfiles = {},
} = additionalAttributes || {};
Object.assign(state, {
id,
name,
firstName,
lastName,
email: emailAddress,
phoneNumber,
additionalAttributes: {
description,
companyName,
countryCode,
country,
city,
socialProfiles,
},
});
};
const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name }))
);
const editDetailsForm = computed(() =>
Object.keys(FORM_CONFIG).map(key => ({
key,
placeholder: t(
`CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.${key}.PLACEHOLDER`
),
}))
);
const socialProfilesForm = computed(() =>
Object.entries(SOCIAL_CONFIG).map(([key, icon]) => ({
key,
placeholder: t(`CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.FORM.${key}.PLACEHOLDER`),
icon,
}))
);
const isValidationField = key => {
const field = FORM_CONFIG[key]?.field;
return ['firstName', 'email'].includes(field);
};
const getValidationKey = key => {
return FORM_CONFIG[key]?.field;
};
// Creates a computed property for two-way form field binding
const getFormBinding = key => {
const field = FORM_CONFIG[key]?.field;
if (!field) return null;
return computed({
get: () => {
// Handle firstName/lastName fields
if (field === 'firstName' || field === 'lastName') {
return state[field]?.toString() || '';
}
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
// Example: 'email' → state.email
// Example: 'additionalAttributes.city' → state.additionalAttributes.city
return (nested ? state[base][nested] : state[base])?.toString() || '';
},
set: async value => {
// Handle name fields specially to maintain the combined 'name' field
if (field === 'firstName' || field === 'lastName') {
state[field] = value;
// Example: firstName="John", lastName="Doe" → name="John Doe"
state.name = `${state.firstName} ${state.lastName}`.trim();
} else {
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
if (nested) {
// Example: additionalAttributes.city = "New York"
state[base][nested] = value;
} else {
// Example: email = "test@example.com"
state[base] = value;
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
const getMessageType = key => {
return isValidationField(key) && v$.value[getValidationKey(key)]?.$error
? 'error'
: 'info';
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
// Expose state to parent component for avatar upload
defineExpose({
state,
});
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-2 gap-4">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
}"
@update:model-value="emit('update', state)"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
}`"
class="w-full"
@input="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
@blur="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
/>
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="item in socialProfilesForm"
:key="item.key"
class="flex items-center h-8 gap-2 px-2 rounded-lg"
:class="{
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
}"
>
<Icon
:icon="item.icon"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<input
v-model="
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
"
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
:placeholder="item.placeholder"
:size="item.placeholder.length"
@input="emit('update', state)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const contact = ref(null);
const uiFlags = useMapGetter('contacts/getUIFlags');
const isCreatingContact = computed(() => uiFlags.value.isCreating);
const createNewContact = contactItem => {
contact.value = contactItem;
};
const handleDialogConfirm = async () => {
if (!contact.value) return;
await store.dispatch('contacts/create', contact.value);
dialogRef.value.close();
};
const closeDialog = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm is-new-contact @update="createNewContact" />
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:is-loading="isCreatingContact"
@click="handleDialogConfirm"
/>
</div>
</template>
</Dialog>
</template>
@@ -0,0 +1,73 @@
<script setup>
import ContactMergeForm from '../ContactMergeForm.vue';
import { contactData, primaryContactList } from './fixtures';
const handleSearch = query => {
console.log('Searching for:', query);
};
const handleUpdate = value => {
console.log('Primary contact updated:', value);
};
</script>
<template>
<Story
title="Components/Contacts/ContactMergeForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="With Selected Primary Contact">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="1"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Error State">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
has-error
error-message="Please select a primary contact"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Empty Primary Contact List">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="[]"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,80 @@
<script setup>
import ContactsForm from '../ContactsForm.vue';
import { contactData } from './fixtures';
const handleUpdate = updatedData => {
console.log('Form updated:', updatedData);
};
</script>
<template>
<Story
title="Components/Contacts/ContactsForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default without border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm :contact-data="contactData" @update="handleUpdate" />
</div>
</Variant>
<Variant title="Details View with border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="contactData"
is-details-view
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="Minimal Data">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
id: 21,
name: 'Ophelia Folkard',
email: 'ofolkardi@taobao.test',
phoneNumber: '',
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="With All Social Profiles">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
...contactData,
additionalAttributes: {
...contactData.additionalAttributes,
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,47 @@
export const contactData = {
id: 370,
name: 'John Doe',
email: 'johndoe@chatwoot.com',
phoneNumber: '+918634322418',
additionalAttributes: {
city: 'Kerala',
country: 'India',
description: 'Curious about the web.',
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'johndoe',
twitter: 'johndoe',
facebook: 'johndoe',
linkedin: 'johndoe',
instagram: 'johndoe',
},
},
};
export const primaryContactList = [
{
id: 1,
name: 'Jane Smith',
email: 'jane@chatwoot.com',
thumbnail: '',
label: '(ID: 1) Jane Smith',
value: 1,
},
{
id: 2,
name: 'Mike Johnson',
email: 'mike@chatwoot.com',
thumbnail: '',
label: '(ID: 2) Mike Johnson',
value: 2,
},
{
id: 3,
name: 'Sarah Wilson',
email: 'sarah@chatwoot.com',
thumbnail: '',
label: '(ID: 3) Sarah Wilson',
value: 3,
},
];
@@ -0,0 +1,209 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ContactActions from 'dashboard/components-next/Contacts/ContactHeader/ContactActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
showPaginationFooter: {
type: Boolean,
default: true,
},
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 15,
},
isDetailView: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: () => ({}),
},
activeSort: {
type: String,
default: '',
},
activeOrdering: {
type: String,
default: '',
},
isEmptyState: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'filter',
'more',
'update:currentPage',
'goToContactsList',
'search',
'sort',
]);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const selectedContactName = computed(() => {
return props.selectedContact?.name;
});
const breadcrumbItems = computed(() => {
const items = [
{
label: t('CONTACTS_LAYOUT.HEADER.BREADCRUMB.CONTACTS'),
link: '#',
},
];
if (props.selectedContact) {
items.push({
label: selectedContactName.value,
});
}
return items;
});
const hasSidebar = computed(() => {
return slots.sidebar && props.isDetailView;
});
const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300"
:class="hasSidebar && `ltr:2xl:ml-56 rtl:2xl:mr-56`"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div
class="w-full mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<div class="flex items-center justify-between w-full h-20 gap-2">
<span
v-if="!isDetailView"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div class="flex items-center gap-4">
<div
v-if="!isDetailView && !isEmptyState && isNotSegmentView"
class="flex items-center gap-2"
>
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<ContactActions
:is-empty-state="isEmptyState"
:is-detail-view="isDetailView"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@filter="emit('filter')"
@update:sort="emit('sort', $event)"
@more="emit('more')"
/>
<div v-if="!isDetailView" class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div
class="w-full py-4 mx-auto"
:class="hasSidebar ? `max-w-[650px]` : `max-w-[900px]`"
>
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
</section>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const handleDelete = async () => {
try {
await store.dispatch('contacts/deleteCustomAttributes', {
id: route.params.contactId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await store.dispatch('contacts/update', {
id: route.params.contactId,
custom_attributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(() => {
return (
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
});
</script>
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-1"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const searchQuery = ref('');
const attributes = useMapGetter('attributes/getAttributesByModelType');
const contactAttributes = computed(() => attributes.value('contact_attribute'));
const hasContactAttributes = computed(
() => contactAttributes.value?.length > 0
);
// Convert attribute key value from snake_case to camelCase
const toCamelCase = str =>
str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const usedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
return props.selectedContact.customAttributes[camelKey] !== undefined;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
});
const unusedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
const attributeValue = props.selectedContact.customAttributes[camelKey];
// For checkbox type, consider both true/false as valid values
if (attribute.attributeDisplayType === 'checkbox') {
return typeof attributeValue !== 'boolean';
}
// For other types, check if value doesn't exist or is null/undefined
return attributeValue === undefined || attributeValue === null;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
});
const filteredUnusedAttributes = computed(() => {
return unusedAttributes.value?.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
);
});
const unusedAttributesCount = computed(() => unusedAttributes.value?.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
:key="attribute.id"
is-editing-view
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="attribute.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>
@@ -0,0 +1,57 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
const { t } = useI18n();
const route = useRoute();
const conversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const isFetching = computed(() => uiFlags.value.isFetching);
const contactConversations = computed(() =>
conversations.value(route.params.contactId)
);
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="contactConversations.length > 0" class="flex flex-col py-6">
<div
v-for="conversation in contactConversations"
:key="conversation.id"
class="border-b border-n-strong"
>
<ConversationCard
v-if="conversation"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="px-6 !rounded-none dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
/>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
</p>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { useRoute } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import ContactAPI from 'dashboard/api/contacts';
import { debounce } from '@chatwoot/utils';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
const props = defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const state = reactive({
primaryContactId: null,
});
const uiFlags = useMapGetter('contacts/getUIFlags');
const searchResults = ref([]);
const isSearching = ref(false);
const validationRules = {
primaryContactId: { required },
};
const v$ = useVuelidate(validationRules, state);
const isMergingContact = computed(() => uiFlags.value.isMerging);
const primaryContactList = computed(
() =>
searchResults.value?.map(item => ({
value: item.id,
label: `(ID: ${item.id}) ${item.name}`,
})) ?? []
);
const onContactSearch = debounce(
async query => {
isSearching.value = true;
searchResults.value = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
searchResults.value = payload.filter(
contact => contact.id !== props.selectedContact.id
);
isSearching.value = false;
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const resetState = () => {
state.primaryContactId = null;
searchResults.value = [];
isSearching.value = false;
};
const onMergeContacts = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
try {
await store.dispatch('contacts/merge', {
childId: props.selectedContact.id || route.params.contactId,
parentId: state.primaryContactId,
});
emit('goToContactsList');
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SUCCESS_MESSAGE'));
resetState();
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.MERGE.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
<ContactMergeForm
v-model:primary-contact-id="state.primaryContactId"
:selected-contact="selectedContact"
:primary-contact-list="primaryContactList"
:is-searching="isSearching"
:has-error="!!v$.primaryContactId.$error"
:error-message="
v$.primaryContactId.$error
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
: ''
"
@search="onContactSearch"
/>
<div class="flex items-center justify-between gap-3">
<Button
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
:is-loading="isMergingContact"
:disabled="isMergingContact"
@click="onMergeContacts"
/>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from './components/ContactNoteItem.vue';
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const state = reactive({
message: '',
});
const currentUser = useMapGetter('getCurrentUser');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const notes = computed(() => notesByContact.value(route.params.contactId));
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user.name;
};
const onAdd = content => {
if (!content) return;
const { contactId } = route.params;
store.dispatch('contactNotes/create', { content, contactId });
state.message = '';
};
const onDelete = noteId => {
if (!noteId) return;
const { contactId } = route.params;
store.dispatch('contactNotes/delete', { noteId, contactId });
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(state.message),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
<div class="flex items-center gap-3">
<Button
variant="link"
color="blue"
size="sm"
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
class="hover:no-underline"
:is-loading="isCreatingNote"
:disabled="!state.message || isCreatingNote"
@click="onAdd(state.message)"
/>
</div>
</template>
</Editor>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="notes.length > 0">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:written-by="getWrittenBy(note)"
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
</p>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
note: {
type: Object,
required: true,
},
writtenBy: {
type: String,
required: true,
},
});
const emit = defineEmits(['delete']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const handleDelete = () => {
emit('delete', props.note.id);
};
</script>
<template>
<div
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
</span>
</div>
</div>
<Button
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup>
import ContactNoteItem from '../ContactNoteItem.vue';
import notes from './fixtures';
const controls = {
writtenBy: {
type: 'text',
default: 'You',
},
};
// Example delete handler
const onDelete = noteId => {
console.log('Note deleted:', noteId);
};
</script>
<template>
<Story
title="Components/Contacts/ContactNoteItem"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Multiple Notes">
<div class="flex flex-col border rounded-lg border-n-strong">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:written-by="
note.id === notes[1].id
? controls.writtenBy.default
: note.user.name
"
@delete="onDelete"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,69 @@
export default [
{
id: 12,
content:
'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
accountId: null,
contactId: null,
user: {
id: 30,
account_id: 2,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'bruce@paperlayer.test',
available_name: 'Bruce',
name: 'Bruce',
role: 'administrator',
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
custom_role_id: null,
},
createdAt: 1730786556,
updatedAt: 1730786556,
},
{
id: 10,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'hillary@chatwoot.com',
available_name: 'Hillary',
name: 'Hillary',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782566,
updatedAt: 1730782566,
},
{
id: 9,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* They’ll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'john@chatwoot.com',
available_name: 'John',
name: 'John',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782564,
updatedAt: 1730782564,
},
];
@@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
showButton: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '',
},
});
const createNewContactDialogRef = ref(null);
const onClick = () => {
createNewContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ContactsCard
v-for="contact in contactContent.slice(0, 5)"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="0 === contact.id"
@toggle="toggleExpanded(contact.id)"
/>
</div>
</template>
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
<CreateNewContactDialog ref="createNewContactDialogRef" />
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,228 @@
export default [
{
additionalAttributes: {
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
twitter: 'candice_w_dev',
facebook: 'candice.dev',
linkedin: 'candice-matherson',
instagram: 'candice.codes',
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-11-11T11:53:09.299Z',
linkContact: 'https://example.com',
listContact: 'Follow-Up',
textContact: 'Hi there!',
numberContact: '42',
checkboxContact: false,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'San Francisco',
country: 'United States',
description: 'Passionate about design and user experience.',
companyName: 'Designify',
countryCode: 'US',
socialProfiles: {
github: 'ophelia-folkard',
twitter: 'oph_designs',
facebook: 'ophelia.folkard',
linkedin: 'ophelia-folkard',
instagram: 'ophelia.design',
},
},
availabilityStatus: 'offline',
email: 'ophelia.folkard@designify.com',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '+14155552672',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-10-05T10:12:34.567Z',
linkContact: 'https://designify.com',
listContact: 'Prospects',
textContact: 'Looking forward to connecting!',
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Austin',
country: 'United States',
description: 'Avid coder and tech enthusiast.',
companyName: 'CodeHub',
countryCode: 'US',
socialProfiles: {
github: 'willy_castelot',
twitter: 'willy_code',
facebook: 'willy.castelot',
linkedin: 'willy-castelot',
instagram: 'willy.coder',
},
},
availabilityStatus: 'offline',
email: 'willy.castelot@codehub.io',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+14155552673',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Let’s collaborate!',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Seattle',
country: 'United States',
description: 'Product manager with a love for innovation.',
companyName: 'InnovaTech',
countryCode: 'US',
socialProfiles: {
github: 'elisabeth-d',
twitter: 'elisabeth_innova',
facebook: 'elisabeth.derington',
linkedin: 'elisabeth-derington',
instagram: 'elisabeth.innovates',
},
},
availabilityStatus: 'offline',
email: 'elisabeth.derington@innova.com',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '+14155552674',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Let’s schedule a call.',
},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Chicago',
country: 'United States',
description: 'Marketing specialist and content creator.',
companyName: 'Contently',
countryCode: 'US',
socialProfiles: {
github: 'olia-olenchenko',
twitter: 'olia_content',
facebook: 'olia.olenchenko',
linkedin: 'olia-olenchenko',
instagram: 'olia.creates',
},
},
availabilityStatus: 'offline',
email: 'olia.olenchenko@contently.com',
id: 18,
name: 'Olia Olenchenko',
phoneNumber: '+14155552675',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Boston',
country: 'United States',
description: 'SEO expert and analytics enthusiast.',
companyName: 'OptiSearch',
countryCode: 'US',
socialProfiles: {
github: 'nate-vannuchi',
twitter: 'nate_seo',
facebook: 'nathaniel.vannuchi',
linkedin: 'nathaniel-vannuchi',
instagram: 'nate.optimizes',
},
},
availabilityStatus: 'offline',
email: 'nathaniel.vannuchi@optisearch.com',
id: 17,
name: 'Nathaniel Vannuchi',
phoneNumber: '+14155552676',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Denver',
country: 'United States',
description: 'UI/UX designer with a flair for minimalist designs.',
companyName: 'Minimal Designs',
countryCode: 'US',
socialProfiles: {
github: 'merrile-petruk',
twitter: 'merrile_ux',
facebook: 'merrile.petruk',
linkedin: 'merrile-petruk',
instagram: 'merrile.designs',
},
},
availabilityStatus: 'offline',
email: 'merrile.petruk@minimal.com',
id: 16,
name: 'Merrile Petruk',
phoneNumber: '+14155552677',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Miami',
country: 'United States',
description: 'Entrepreneur with a background in e-commerce.',
companyName: 'Ecom Solutions',
countryCode: 'US',
socialProfiles: {
github: 'cordell-d',
twitter: 'cordell_entrepreneur',
facebook: 'cordell.dalinder',
linkedin: 'cordell-dalinder',
instagram: 'cordell.ecom',
},
},
availabilityStatus: 'offline',
email: 'cordell.dalinder@ecomsolutions.com',
id: 15,
name: 'Cordell Dalinder',
phoneNumber: '+14155552678',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -0,0 +1,164 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
const props = defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const confirmDeleteContactDialogRef = ref(null);
const avatarFile = ref(null);
const avatarUrl = ref('');
const contactsFormRef = ref(null);
const selectedContactData = computed(() => {
if (!props.selectedContact) return {};
return props.selectedContact;
});
const createdAt = computed(() => {
return props.selectedContact?.createdAt
? dynamicTime(props.selectedContact.createdAt)
: '';
});
const lastActivityAt = computed(() => {
return props.selectedContact?.lastActivityAt
? dynamicTime(props.selectedContact.lastActivityAt)
: '';
});
const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : props.selectedContact?.thumbnail;
});
const updateContact = async updatedData => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact?.id
);
};
const handleFormUpdate = debounce(
updatedData => updateContact(updatedData),
600,
false
);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
const handleAvatarUpload = async ({ file, url }) => {
avatarFile.value = file;
avatarUrl.value = url;
try {
await updateContact({
...contactsFormRef.value?.state,
avatar: file,
isFormData: true,
});
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.SUCCESS_MESSAGE'));
} catch {
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.UPLOAD.ERROR_MESSAGE'));
}
};
const handleAvatarDelete = async () => {
try {
if (props.selectedContact && props.selectedContact.id) {
await store.dispatch('contacts/deleteAvatar', props.selectedContact.id);
useAlert(t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.SUCCESS_MESSAGE'));
}
avatarFile.value = null;
avatarUrl.value = '';
} catch (error) {
useAlert(
error.message
? error.message
: t('CONTACTS_LAYOUT.DETAILS.AVATAR.DELETE.ERROR_MESSAGE')
);
}
};
</script>
<template>
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact.name || ''"
:size="72"
allow-upload
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact.name }}
</h3>
<span class="text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
•
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
<ContactLabels :contact-id="selectedContact.id" />
</div>
<ContactsForm
ref="contactsFormRef"
:contact-data="selectedContactData"
is-details-view
@update="handleFormUpdate"
/>
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
import { useRouter, useRoute } from 'vue-router';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({
contacts: {
type: Array,
required: true,
},
});
const store = useStore();
const router = useRouter();
const route = useRoute();
// Manage expanded state here
const expandedCardId = ref(null);
const updateContact = async ({ id, updatedData }) => {
await store.dispatch('contacts/update', updatedData);
await store.dispatch('contacts/fetchContactableInbox', id);
};
const handleFormUpdate = debounce(
({ id, updatedData }) => updateContact({ id, updatedData }),
600,
false
);
const ROUTE_MAPPINGS = {
contacts_dashboard_labels_index: 'contacts_dashboard_labels_edit_index',
contacts_dashboard_segments_index: 'contacts_dashboard_segments_edit_index',
};
const onClickViewDetails = async id => {
const dynamicRouteName =
ROUTE_MAPPINGS[route.name] || 'contacts_dashboard_edit_index';
const params = { contactId: id };
if (route.name.includes('segments')) {
params.segmentId = route.params.segmentId;
} else if (route.name.includes('labels')) {
params.label = route.params.label;
}
await router.push({ name: dynamicRouteName, params, query: route.query });
};
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<div class="flex flex-col gap-4">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="expandedCardId === contact.id"
@toggle="toggleExpanded(contact.id)"
@update-contact="handleFormUpdate"
@show-contact="onClickViewDetails"
/>
</div>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
conversationLabels: {
type: Array,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const WIDTH_CONFIG = Object.freeze({
DEFAULT_WIDTH: 80,
CHAR_WIDTH: {
SHORT: 8, // For labels <= 5 chars
LONG: 6, // For labels > 5 chars
},
BASE_WIDTH: 12, // dot + gap
THRESHOLD: 5, // character length threshold
});
const containerRef = ref(null);
const maxLabels = ref(1);
const activeLabels = computed(() => {
const labelSet = new Set(props.conversationLabels);
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
});
const calculateLabelWidth = ({ title = '' }) => {
const charWidth =
title.length > WIDTH_CONFIG.THRESHOLD
? WIDTH_CONFIG.CHAR_WIDTH.LONG
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
};
const getAverageWidth = labels => {
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
const totalWidth = labels.reduce(
(sum, label) => sum + calculateLabelWidth(label),
0
);
return totalWidth / labels.length;
};
const visibleLabels = computed(() =>
activeLabels.value?.slice(0, maxLabels.value)
);
const updateVisibleLabels = () => {
if (!containerRef.value) return;
const containerWidth = containerRef.value.offsetWidth;
const avgWidth = getAverageWidth(activeLabels.value);
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
};
</script>
<template>
<div
ref="containerRef"
v-resize="updateVisibleLabels"
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
>
<template v-for="(label, index) in visibleLabels" :key="label.id">
<div
class="flex items-center gap-1.5 min-w-0"
:class="[
index !== visibleLabels.length - 1
? 'flex-shrink-0 text-ellipsis'
: 'flex-shrink',
]"
>
<div
:style="{ backgroundColor: label.color }"
class="size-1.5 rounded-full flex-shrink-0"
/>
<span
class="text-sm text-n-slate-10 whitespace-nowrap"
:class="{ truncate: index === visibleLabels.length - 1 }"
>
{{ label.title }}
</span>
</div>
</template>
</div>
</template>
@@ -0,0 +1,59 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
</script>
<template>
<div class="flex items-end w-full gap-2 pb-1">
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
{{ lastNonActivityMessageContent }}
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const { t } = useI18n();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {} } = props.conversation;
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
</script>
<template>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
{{ lastNonActivityMessageContent }}
</p>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
<div
class="grid items-center gap-2.5 h-7"
:class="
hasSlaThreshold
? 'grid-cols-[auto_auto_1fr_20px]'
: 'grid-cols-[1fr_20px]'
"
>
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
<div class="overflow-hidden">
<CardLabels
:conversation-labels="conversation.labels"
:account-labels="accountLabels"
/>
</div>
<Avatar
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
</div>
</div>
</template>
@@ -0,0 +1,207 @@
<script setup>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
priority: {
type: String,
default: '',
},
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template>
@@ -0,0 +1,477 @@
<script setup>
import { computed } from 'vue';
import ConversationCard from './ConversationCard.vue';
// Base conversation object
const conversationWithoutMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phone_number: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
last_activity_at: 1712127410,
created_at: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 38,
messages: [
{
id: 3597,
content: 'Sivin set the priority to low',
accountId: 2,
inboxId: 7,
conversationId: 38,
messageType: 2,
createdAt: 1730885168,
updatedAt: '2024-11-06T09:26:08.565Z',
private: false,
status: 'sent',
source_id: null,
contentType: 'text',
contentAttributes: {},
senderType: null,
senderId: null,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent: 'Sivin set the priority to low',
sentiment: {},
conversation: {
assigneeId: 1,
unreadCount: 0,
lastActivityAt: 1730885168,
contactInbox: {
sourceId: 'candice@chatwoot.com',
},
},
},
],
accountId: 2,
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
additionalAttributes: {
mail_subject: 'Test email',
},
agentLastSeenAt: 0,
assigneeLastSeenAt: 0,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [],
status: 'open',
createdAt: 1730836533,
timestamp: 1730885168,
firstReplyCreatedAt: 1730836533,
unreadCount: 0,
lastNonActivityMessage: {
id: 3591,
content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
account_id: 2,
inbox_id: 7,
conversation_id: 38,
message_type: 1,
created_at: 1730836533,
updated_at: '2024-11-05T19:55:37.158Z',
private: false,
status: 'sent',
source_id:
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: ['test@gmail.com'],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885168,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885168,
priority: 'low',
waitingSince: 0,
slaPolicyId: null,
slaEvents: [],
};
const conversationWithMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 37,
messages: [
{
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
accountId: 2,
inboxId: 7,
conversationId: 37,
messageType: 1,
createdAt: 1730885428,
updatedAt: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
sourceId:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
contentType: 'text',
contentAttributes: {
ccEmails: [],
bccEmails: [],
toEmails: [],
},
sender_type: 'User',
senderId: 1,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
availableName: 'Sivin',
avatarUrl: '',
type: 'user',
availabilityStatus: 'online',
thumbnail: '',
},
},
],
accountId: 2,
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
additionalAttributes: {
mail_subject: 'we',
},
agentLastSeenAt: 1730885428,
assigneeLastSeenAt: 1730885428,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [
'billing',
'delivery',
'lead',
'premium-customer',
'software',
'ops-handover',
],
muted: false,
snoozedUntil: null,
status: 'open',
createdAt: 1722487645,
timestamp: 1730885428,
firstReplyCreatedAt: 1722487645,
unreadCount: 0,
lastNonActivityMessage: {
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
account_id: 2,
inbox_id: 7,
conversation_id: 37,
message_type: 1,
created_at: 1730885428,
updated_at: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
source_id:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 2,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'willy@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885428,
priority: 'urgent',
waitingSince: 1730885428,
slaPolicyId: 3,
appliedSla: {
id: 4,
sla_id: 3,
sla_status: 'active_with_misses',
created_at: 1712127410,
updated_at: 1712127545,
sla_description:
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
sla_name: 'Premium SLA',
sla_first_response_time_threshold: 120,
sla_next_response_time_threshold: 180,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 360,
},
slaEvents: [
{
id: 8,
event_type: 'frt',
meta: {},
updated_at: 1712127545,
created_at: 1712127545,
},
{
id: 9,
event_type: 'rt',
meta: {},
updated_at: 1712127790,
created_at: 1712127790,
},
],
};
const contactForConversationWithoutMeta = computed(() => ({
availabilityStatus: null,
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
customAttributes: {},
last_activity_at: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const contactForConversationWithMeta = computed(() => ({
availabilityStatus: null,
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
customAttributes: {},
lastActivityAt: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const webWidgetInbox = computed(() => ({
phone_number: '+918585858585',
channel_type: 'Channel::WebWidget',
}));
const accountLabels = computed(() => [
{
id: 1,
title: 'billing',
description: 'Label is used for tagging billing related conversations',
color: '#28AD21',
show_on_sidebar: true,
},
{
id: 3,
title: 'delivery',
description: null,
color: '#A2FDD5',
show_on_sidebar: true,
},
{
id: 6,
title: 'lead',
description: null,
color: '#F161C8',
show_on_sidebar: true,
},
{
id: 4,
title: 'ops-handover',
description: null,
color: '#A53326',
show_on_sidebar: true,
},
{
id: 5,
title: 'premium-customer',
description: null,
color: '#6FD4EF',
show_on_sidebar: true,
},
{
id: 2,
title: 'software',
description: null,
color: '#8F6EF2',
show_on_sidebar: true,
},
]);
</script>
<template>
<Story
title="Components/ConversationCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Conversation without meta">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="conversationWithoutMeta"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
priority: 'medium',
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation without meta (Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="{
...conversationWithoutMeta,
unreadCount: 2,
priority: 'high',
}"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
unreadCount: 2,
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,127 @@
<script setup>
import { computed } from 'vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardMessagePreview from './CardMessagePreview.vue';
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
contact: {
type: Object,
required: true,
},
stateInbox: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const router = useRouter();
const route = useRoute();
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
const currentContactStatus = computed(
() => currentContact.value?.availabilityStatus
);
const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
});
const lastActivityAt = computed(() => {
const timestamp = props.conversation?.timestamp;
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const showMessagePreviewWithoutMeta = computed(() => {
const { slaPolicyId, labels = [] } = props.conversation;
return !slaPolicyId && labels.length === 0;
});
const onCardClick = e => {
const path = frontendURL(
conversationUrl({
accountId: route.params.accountId,
id: props.conversation.id,
})
);
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
router.push({ path });
};
</script>
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out cursor-pointer rounded-xl"
@click="onCardClick"
>
<Avatar
:name="currentContactName"
:src="currentContactThumbnail"
:size="24"
:status="currentContactStatus"
rounded-full
/>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between h-6 gap-2">
<h4 class="text-base font-medium truncate text-n-slate-12">
{{ currentContactName }}
</h4>
<div class="flex items-center gap-2">
<CardPriorityIcon :priority="conversation.priority || null" />
<div
v-tooltip.left="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-3"
/>
</div>
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
</div>
<CardMessagePreview
v-if="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-else
:conversation="conversation"
:account-labels="accountLabels"
/>
</div>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
chat: props.conversation,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.conversation, updateSlaStatus);
</script>
<template>
<div class="flex items-center min-w-fit gap-0.5 h-6">
<div class="flex items-center justify-center size-4">
<svg
width="10"
height="13"
viewBox="0 0 10 13"
fill="none"
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
/>
</svg>
</div>
<span
class="text-sm truncate"
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
</span>
</div>
</template>
@@ -0,0 +1,46 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const handleChange = value => {
emit('update', value);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { ref, computed } from 'vue';
import { parseISO } from 'date-fns';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const rules = {
editedValue: {
required,
isDate: value => new Date(value).toISOString(),
},
};
const v$ = useVuelidate(rules, { editedValue });
const formattedDate = computed(() => {
return props.attribute.value
? new Date(props.attribute.value).toLocaleDateString()
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
});
const hasError = computed(() => v$.value.$errors.length > 0);
const defaultDateValue = computed({
get() {
const existingDate = editedValue.value ?? props.attribute.value;
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
return isEditingValue.value && !hasError.value
? new Date().toISOString().slice(0, 10)
: '';
},
set(value) {
editedValue.value = value ? new Date(value).toISOString() : value;
},
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value && !editedValue.value) {
v$.value.$reset();
editedValue.value = new Date().toISOString();
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', parseISO(editedValue.value));
isEditingValue.value = false;
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
{{ formattedDate }}
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
: ''
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,100 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
const attributeListMenuItems = computed(() => {
return (
props.attribute.attributeValues?.map(value => ({
label: value,
value,
action: 'select',
isSelected: value === props.attribute.value,
})) || []
);
});
const handleAttributeAction = async action => {
emit('update', action.value);
toggleAttributeListDropdown(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
>
<span
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<!-- Attribute type "Text, URL, Number" -->
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { isValidURL } from 'dashboard/helper/URLHelper.js';
import { getRegexp } from 'shared/helpers/Validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const isAttributeTypeLink = computed(
() => props.attribute.attributeDisplayType === 'link'
);
const isAttributeTypeText = computed(
() => props.attribute.attributeDisplayType === 'text'
);
const isAttributeTypeNumber = computed(
() => props.attribute.attributeDisplayType === 'number'
);
const rules = computed(() => ({
editedValue: {
required,
...(isAttributeTypeLink.value && {
url: value => !value || isValidURL(value),
}),
...(isAttributeTypeText.value &&
props.attribute.regexPattern && {
regexValidation: value => {
if (!value) return true;
return getRegexp(props.attribute.regexPattern).test(value);
},
}),
},
}));
const v$ = useVuelidate(rules, { editedValue });
const hasError = computed(() => v$.value.$error);
const attributeErrorMessage = computed(() => {
if (!hasError.value) return '';
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (
isAttributeTypeText.value &&
props.attribute.regexPattern &&
v$.value.editedValue.regexValidation?.$invalid
) {
return (
props.attribute.regexCue ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
}
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
});
const getInputType = computed(() => {
switch (props.attribute.attributeDisplayType) {
case 'link':
return 'url';
case 'number':
return 'number';
default:
return 'text';
}
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value) {
v$.value.$reset();
editedValue.value = props.attribute.value || '';
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', editedValue.value);
toggleEditValue(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
:href="attribute.value"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
@click.stop
>
{{ attribute.value }}
</a>
<template v-else>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
}}
</template>
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup>
import Attributes from './fixtures';
import OtherAttribute from '../OtherAttribute.vue';
import ListAttribute from '../ListAttribute.vue';
import DateAttribute from '../DateAttribute.vue';
import CheckboxAttribute from '../CheckboxAttribute.vue';
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const getCurrentComponent = type => {
return componentMap[type] || componentMap.default;
};
const handleUpdate = (type, value) => {
console.log(`${type} updated:`, value);
};
const handleDelete = type => {
console.log(`${type} deleted`);
};
</script>
<template>
<Story
title="Components/CustomAttributes"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Create View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
<Variant title="Saved View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
is-editing-view
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,39 @@
export default [
{
attributeKey: 'textContact',
attributeDisplayName: 'Text Input',
attributeDisplayType: 'text',
value: 'Sample text value',
},
{
attributeKey: 'linkContact',
attributeDisplayName: 'URL Input',
attributeDisplayType: 'link',
value: 'https://www.chatwoot.com',
},
{
attributeKey: 'numberContact',
attributeDisplayName: 'Number Input',
attributeDisplayType: 'number',
value: '42',
},
{
attributeKey: 'listContact',
attributeDisplayName: 'List Input',
attributeDisplayType: 'list',
value: 'Option 2',
attributeValues: ['Option 1', 'Option 2', 'Option 3'],
},
{
attributeKey: 'dateContact',
attributeDisplayName: 'Date Input',
attributeDisplayType: 'date',
value: '2024-03-25T00:00:00.000Z',
},
{
attributeKey: 'checkboxContact',
attributeDisplayName: 'Checkbox Input',
attributeDisplayType: 'checkbox',
value: true,
},
];
@@ -0,0 +1,179 @@
<script setup>
import { computed, ref, watch, useSlots } 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 slots = useSlots();
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 && !slots.actions) {
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 || slots.actions"
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
>
<span
v-if="showCharacterCount && !slots.actions"
class="text-xs tabular-nums text-n-slate-10"
>
{{ characterCount }} / {{ maxLength }}
</span>
<slot v-else name="actions" />
</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 !top-0 !relative !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-10 dark:text-n-slate-10;
}
}
}
}
}
}
</style>
@@ -34,7 +34,7 @@ defineProps({
{{ title }}
</h2>
<p
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -1,6 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import {
@@ -9,11 +9,11 @@ import {
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';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: {
@@ -50,7 +50,7 @@ const emit = defineEmits(['openArticle', 'articleAction']);
const { t } = useI18n();
const isOpen = ref(false);
const [showActionsDropdown, toggleDropdown] = useToggle();
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
@@ -72,11 +72,11 @@ const articleMenuItems = computed(() => {
const statusTextColor = computed(() => {
switch (props.status) {
case 'archived':
return '!text-n-slate-12';
return 'text-n-slate-12';
case 'draft':
return '!text-n-amber-11';
return 'text-n-amber-11';
default:
return '!text-n-teal-11';
return 'text-n-teal-11';
}
});
@@ -101,7 +101,7 @@ const categoryName = computed(() => {
});
const authorName = computed(() => {
return props.author?.name || props.author?.availableName || '-';
return props.author?.name || props.author?.availableName || '';
});
const authorThumbnailSrc = computed(() => {
@@ -113,7 +113,7 @@ const lastUpdatedAt = computed(() => {
});
const handleArticleAction = ({ action, value }) => {
isOpen.value = false;
toggleDropdown(false);
emit('articleAction', { action, value, id: props.id });
};
@@ -124,67 +124,72 @@ const handleClick = id => {
<template>
<CardLayout>
<template #header>
<div class="flex justify-between gap-1">
<div class="flex justify-between w-full 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-base cursor-pointer hover:underline text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ title }}
{{ statusText }}
</span>
<div class="relative group" @click.stop>
<OnClickOutside @trigger="isOpen = false">
<Button
variant="ghost"
size="sm"
class="text-xs font-medium bg-n-alpha-2 hover:bg-n-alpha-1 !h-6 rounded-md border-0 !px-2 !py-0.5"
:label="statusText"
:class="statusTextColor"
@click="isOpen = !isOpen"
/>
<DropdownMenu
v-if="isOpen"
: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)"
/>
</OnClickOutside>
<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>
</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 }}
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="16"
rounded-full
/>
<span class="text-sm truncate text-n-slate-11">
{{ authorName || '-' }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<FluentIcon icon="eye-show" size="18" />
<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 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>
</template>
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
</CardLayout>
</template>
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
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';
@@ -38,20 +38,20 @@ const emit = defineEmits(['click', 'action']);
const { t } = useI18n();
const isOpen = ref(false);
const [showActionsDropdown, toggleDropdown] = useToggle();
const categoryMenuItems = [
{
label: 'Edit',
action: 'edit',
value: 'edit',
icon: 'edit',
icon: 'i-lucide-pencil',
},
{
label: 'Delete',
action: 'delete',
value: 'delete',
icon: 'delete',
icon: 'i-lucide-trash',
},
];
@@ -73,63 +73,61 @@ const handleClick = slug => {
const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
isOpen.value = false;
toggleDropdown(false);
};
</script>
<template>
<CardLayout>
<template #header>
<div class="flex gap-2">
<div class="flex justify-between w-full gap-1">
<div class="flex items-center justify-start gap-2">
<span
class="text-base cursor-pointer hover:underline text-slate-900 dark:text-slate-50 line-clamp-1"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center truncate border rounded-lg min-w-fit text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<div class="relative group" @click.stop>
<OnClickOutside @trigger="isOpen = false">
<Button
variant="ghost"
size="sm"
icon="more-vertical"
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="isOpen = !isOpen"
/>
<DropdownMenu
v-if="isOpen"
: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"
/>
</OnClickOutside>
</div>
<div class="flex w-full 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>
</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>
</div>
<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>
</CardLayout>
</template>
@@ -33,7 +33,7 @@ const onClick = () => {
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 overflow-hidden">
<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"
@@ -49,12 +49,7 @@ const onClick = () => {
</template>
<template #actions>
<div v-if="showButton">
<Button
variant="default"
:label="buttonLabel"
icon="add"
@click="onClick"
/>
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
</template>
</EmptyStateLayout>
@@ -18,7 +18,7 @@ defineProps({
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 p-px">
<div class="space-y-4">
<CategoryCard
v-for="category in categoryContent"
@@ -28,7 +28,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 p-px">
<div class="space-y-4">
<ArticleCard
v-for="(article, index) in articleContent"
@@ -59,9 +59,8 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
</template>
<template #actions>
<Button
variant="default"
:label="$t('HELP_CENTER.NEW_PAGE.CREATE_PORTAL_BUTTON')"
icon="add"
icon="i-lucide-plus"
@click="openDialog"
/>
<CreatePortalDialog
@@ -67,17 +67,17 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="text-xl font-medium text-slate-900 dark:text-white"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="chevron-lucide-down"
icon="i-lucide-chevron-down"
variant="ghost"
icon-lib="lucide"
class="!w-6 !h-6 group-hover:bg-n-solid-2 !p-0.5 rounded-md"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
/>
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
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';
@@ -35,7 +35,7 @@ const emit = defineEmits(['action']);
const { t } = useI18n();
const showDropdownMenu = ref(false);
const [showDropdownMenu, toggleDropdown] = useToggle();
const localeMenuItems = computed(() =>
LOCALE_MENU_ITEMS.map(item => ({
@@ -47,71 +47,70 @@ const localeMenuItems = computed(() =>
const handleAction = ({ action, value }) => {
emit('action', { action, value });
showDropdownMenu.value = false;
toggleDropdown(false);
};
</script>
<template>
<CardLayout class="ltr:pr-2 rtl:pl-2">
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<CardLayout>
<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 font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ locale }} ({{ localeCode }})
{{
$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
v-if="isDefault"
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
categoryCount
)
}}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<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 class="relative group">
<OnClickOutside @trigger="showDropdownMenu = false">
<Button
variant="ghost"
size="sm"
icon="more-vertical"
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
@click="showDropdownMenu = !showDropdownMenu"
/>
<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"
/>
</OnClickOutside>
</div>
<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>
</template>
</div>
</CardLayout>
</template>
@@ -27,6 +27,7 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
@@ -35,19 +36,37 @@ const emit = defineEmits([
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const saveAndSync = value => {
emit('saveArticle', value);
};
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
const articleTitle = computed({
get: () => props.article.title,
set: value => {
saveArticle({ title: value });
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
saveArticle({ content });
quickSave({ content });
saveAndSyncDebounced({ content });
},
});
@@ -93,7 +112,7 @@ const previewArticle = () => {
/>
<ArticleEditorControls
:article="article"
@save-article="saveArticle"
@save-article="saveAndSync"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -6,7 +6,7 @@ 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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
@@ -50,7 +50,7 @@ const author = computed(() => {
});
const authorName = computed(
() => author.value?.name || author.value?.available_name || '-'
() => author.value?.name || author.value?.available_name || ''
);
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
@@ -61,9 +61,9 @@ const agentList = computed(() => {
label: name,
value: id,
thumbnail: { name, src: thumbnail },
isSelected:
id === props.article?.author?.id ||
id === (selectedAuthorId.value || currentUserId.value),
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)
@@ -181,20 +181,20 @@ onMounted(() => {
<div class="relative flex items-center gap-2">
<OnClickOutside @trigger="openAgentsList = false">
<Button
:label="authorName"
variant="ghost"
class="!px-0 font-normal"
class="!px-0 font-normal hover:!bg-transparent"
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<template #leftPrefix>
<Thumbnail
:author="author"
:name="authorName"
:size="20"
:src="authorThumbnailSrc"
/>
</template>
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
{{ authorName || '-' }}
</span>
</Button>
<DropdownMenu
v-if="openAgentsList && hasAgentList"
@@ -212,13 +212,20 @@ onMounted(() => {
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:emoji="selectedCategory?.icon || ''"
:icon="!selectedCategory?.icon ? 'play-shape' : ''"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
class="!px-2 font-normal"
text-variant="info"
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"
@@ -235,11 +242,10 @@ onMounted(() => {
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.MORE_PROPERTIES')
"
icon="add"
icon="i-lucide-plus"
variant="ghost"
:disabled="isNewArticle"
text-variant="info"
class="!px-2 font-normal"
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
@click="openProperties = !openProperties"
/>
<ArticleEditorProperties
@@ -118,11 +118,11 @@ const updateArticleStatus = async ({ value }) => {
<div class="flex items-center justify-between h-20">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.BACK_TO_ARTICLES')"
icon="chevron-lucide-left"
icon-lib="lucide"
icon="i-lucide-chevron-left"
variant="link"
text-variant="info"
color="slate"
size="sm"
class="ltr:pl-3 rtl:pr-3"
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
@@ -135,7 +135,7 @@ const updateArticleStatus = async ({ value }) => {
<div class="flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
variant="secondary"
color="slate"
size="sm"
:disabled="!articleId"
@click="previewArticle"
@@ -156,8 +156,7 @@ const updateArticleStatus = async ({ value }) => {
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
<Button
icon="chevron-lucide-down"
icon-lib="lucide"
icon="i-lucide-chevron-down"
size="sm"
:disabled="!articleId"
class="ltr:rounded-l-none rtl:rounded-r-none"
@@ -52,7 +52,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
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>
@@ -63,10 +63,10 @@ onMounted(() => {
}}
</h3>
<Button
icon="dismiss"
icon="i-lucide-x"
size="sm"
variant="ghost"
class="w-8 hover:text-n-slate-11"
class="hover:text-n-slate-11"
@click="emit('close')"
/>
</div>
@@ -89,7 +89,7 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[224px]"
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
@@ -110,7 +110,7 @@ onMounted(() => {
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
@@ -148,10 +148,9 @@ const handleTabChange = value => {
<Button
:label="activeLocaleName"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
icon="i-lucide-chevron-down"
color="slate"
trailing-icon
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
@@ -167,11 +166,10 @@ const handleTabChange = value => {
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
@@ -187,7 +185,7 @@ const handleTabChange = value => {
</div>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="add"
icon="i-lucide-plus"
size="sm"
@click="handleNewArticle"
/>
@@ -157,24 +157,24 @@ defineExpose({ state, isSubmitDisabled });
<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-slate-50 dark:border-slate-700/50"
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-slate-700 dark:text-slate-300">
<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-slate-800 dark:text-slate-100">
<span class="text-sm text-n-slate-12">
{{ portalName }}
</span>
</div>
<div class="justify-start w-px h-10 bg-slate-50 dark:bg-slate-700/50" />
<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-slate-700 dark:text-slate-300">
<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-slate-800 dark:text-slate-100"
class="text-sm line-clamp-1 text-n-slate-12"
>
{{ `${activeLocaleName} (${activeLocaleCode})` }}
</span>
@@ -192,16 +192,16 @@ defineExpose({ state, isSubmitDisabled });
"
:message="nameError"
:message-type="nameError ? 'error' : 'info'"
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12 !bg-slate-25 dark:!bg-slate-900"
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12"
>
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
:label="state.icon"
variant="secondary"
color="slate"
size="sm"
:icon="!state.icon ? 'emoji-add' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
: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
@@ -223,7 +223,7 @@ defineExpose({ state, isSubmitDisabled });
:disabled="isEditMode"
:message="slugError ? slugError : slugHelpText"
:message-type="slugError ? 'error' : 'info'"
custom-input-class="!h-10 !bg-slate-25 dark:!bg-slate-900 "
custom-input-class="!h-10"
/>
<TextArea
v-model="state.description"
@@ -236,17 +236,16 @@ defineExpose({ state, isSubmitDisabled });
)
"
show-character-count
custom-text-area-wrapper-class="!bg-slate-25 dark:!bg-slate-900"
/>
<div
v-if="showActionButtons"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="ghost"
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
text-variant="default"
class="w-full bg-n-alpha-2 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -133,10 +133,9 @@ const handleBreadcrumbClick = () => {
<Button
:label="activeLocaleName"
size="sm"
icon-position="right"
icon="chevron-lucide-down"
icon-lib="lucide"
variant="secondary"
trailing-icon
icon="i-lucide-chevron-down"
color="slate"
@click="isLocaleMenuOpen = !isLocaleMenuOpen"
/>
<DropdownMenu
@@ -148,7 +147,9 @@ const handleBreadcrumbClick = () => {
</OnClickOutside>
</div>
<div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" />
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
<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,
@@ -165,7 +166,7 @@ const handleBreadcrumbClick = () => {
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="add"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
@@ -183,7 +184,7 @@ const handleBreadcrumbClick = () => {
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
variant="secondary"
color="slate"
size="sm"
@click="isEditCategoryDialogOpen = !isEditCategoryDialogOpen"
/>
@@ -97,16 +97,14 @@ defineExpose({ dialogRef });
: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>
<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"
/>
</Dialog>
</template>
@@ -85,17 +85,15 @@ defineExpose({ dialogRef });
: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>
<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>
</Dialog>
</template>
@@ -41,7 +41,7 @@ const localeCount = computed(() => props.locales?.length);
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="add"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
@@ -55,20 +55,18 @@ defineExpose({ dialogRef });
"
@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>
<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'
)
"
/>
</Dialog>
</template>
@@ -59,21 +59,20 @@ defineExpose({ dialogRef });
}}
</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>
<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>
</Dialog>
</template>
@@ -12,7 +12,7 @@ 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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
@@ -187,25 +187,28 @@ const handleAvatarDelete = () => {
<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"
<Avatar
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
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')"
class="w-[432px]"
:message-type="nameError ? 'error' : 'info'"
:message="nameError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@@ -213,9 +216,11 @@ const handleAvatarDelete = () => {
@blur="v$.name.$touch()"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
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>
@@ -224,13 +229,14 @@ const handleAvatarDelete = () => {
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.PLACEHOLDER')
"
class="w-[432px]"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50"
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>
@@ -239,13 +245,14 @@ const handleAvatarDelete = () => {
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.PLACEHOLDER')
"
class="w-[432px]"
custom-input-class="!bg-transparent dark:!bg-transparent"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 py-2.5 dark:text-slate-50"
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>
@@ -254,7 +261,6 @@ const handleAvatarDelete = () => {
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.PLACEHOLDER')
"
class="w-[432px]"
:message-type="homePageLinkError ? 'error' : 'info'"
:message="homePageLinkError"
custom-input-class="!bg-transparent dark:!bg-transparent"
@@ -262,16 +268,17 @@ const handleAvatarDelete = () => {
@blur="v$.homePageLink.$touch()"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
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')"
class="w-[432px]"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
custom-input-class="!bg-transparent dark:!bg-transparent"
@@ -279,9 +286,11 @@ const handleAvatarDelete = () => {
@blur="v$.slug.$touch()"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
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>
@@ -294,12 +303,12 @@ const handleAvatarDelete = () => {
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>button]:w-[432px] !w-[432px]"
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 min-w-[100px] py-2.5 text-slate-900 dark:text-slate-50"
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>
@@ -82,12 +82,12 @@ const closeDNSConfigurationDialog = () => {
<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'
)
"
variant="secondary"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<Button
@@ -97,7 +97,7 @@ const closeDNSConfigurationDialog = () => {
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.ADD_BUTTON'
)
"
variant="secondary"
color="slate"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
</div>
@@ -114,8 +114,8 @@ const handleDeletePortal = () => {
}
)
"
variant="destructive"
class="w-56"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
/>
</div>
@@ -120,29 +120,27 @@ defineExpose({ dialogRef });
: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>
<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>
</Dialog>
</template>
@@ -6,7 +6,7 @@ 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';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createPortal']);
@@ -106,7 +106,7 @@ const redirectToPortalHomePage = () => {
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
</h2>
<Button
icon="arrow-up-right-lucide"
icon="i-lucide-arrow-up-right"
variant="ghost"
icon-lib="lucide"
size="sm"
@@ -120,8 +120,8 @@ const redirectToPortalHomePage = () => {
</div>
<Button
:label="t('HELP_CENTER.PORTAL_SWITCHER.NEW_PORTAL')"
variant="secondary"
icon="add"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreatePortalDialog"
@@ -133,29 +133,29 @@ const redirectToPortalHomePage = () => {
:key="index"
:label="portal.name"
variant="ghost"
:icon="isPortalActive(portal) ? 'checkmark-lucide' : ''"
icon-lib="lucide"
icon-position="right"
class="!justify-start !px-2 !py-2 hover:!bg-n-alpha-2 [&>svg]:text-n-teal-10 [&>svg]:w-5 [&>svg]:h-5 h-9"
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)"
>
<template #leftPrefix>
<Thumbnail
v-if="portal"
:author="portal"
:name="portal.name"
:size="20"
:src="getPortalThumbnailSrc(portal)"
:show-author-name="false"
icon-name="building-lucide"
/>
</template>
<template #rightPrefix>
<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>
</template>
</div>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Avatar
v-if="portal"
:name="portal.name"
:src="getPortalThumbnailSrc(portal)"
:size="20"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>
</div>
@@ -0,0 +1,49 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
defineProps({
labelMenuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['updateLabel']);
const { t } = useI18n();
const showDropdown = ref(false);
</script>
<template>
<div class="relative">
<button
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="showDropdown = !showDropdown"
>
<span class="i-lucide-plus" />
<span class="text-sm text-n-slate-11">
{{ t('LABEL.TAG_BUTTON') }}
</span>
</button>
<DropdownMenu
v-if="showDropdown"
v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="emit('updateLabel', $event)"
>
<template #thumbnail="{ item }">
<div
class="rounded-sm size-2"
:style="{ backgroundColor: item.thumbnail.color }"
/>
</template>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
defineProps({
label: {
type: Object,
default: null,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
{{ label.title }}
</span>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup>
import AddLabel from '../AddLabel.vue';
import { labelMenuItems } from './fixtures';
function onUpdateLabel(label) {
console.log('Label updated:', label);
}
</script>
<template>
<Story title="Components/Label/Add Label">
<Variant title="Default (button with label menu items with active state)">
<div class="h-[300px] p-4">
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="onUpdateLabel"
/>
</div>
</Variant>
<Variant title="Empty List (button with empty label menu)">
<div class="h-[300px] p-4">
<AddLabel :label-menu-items="[]" @update-label="onUpdateLabel" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import Label from '../LabelItem.vue';
import { label } from './fixtures';
</script>
<template>
<Story title="Components/Label/Label item">
<Variant title="Default">
<Label :label="label" />
</Variant>
<Variant title="Custom Label">
<Label
:label="{
title: 'Custom Label',
color: '#FF5733',
}"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,62 @@
export const label = {
id: 1,
title: 'delivery',
color: '#A2FDD5',
};
export const labelMenuItems = [
{
label: 'delivery',
value: 3,
thumbnail: {
color: '#A2FDD5',
},
isSelected: true,
action: 'addLabel',
},
{
label: 'lead',
value: 6,
thumbnail: {
color: '#F161C8',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'ops-handover',
value: 4,
thumbnail: {
color: '#A53326',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'billing',
value: 1,
thumbnail: {
color: '#28AD21',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'premium-customer',
value: 5,
thumbnail: {
color: '#6FD4EF',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'software',
value: 2,
thumbnail: {
color: '#8F6EF2',
},
isSelected: false,
action: 'addLabel',
},
];
@@ -0,0 +1,194 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import {
searchContacts,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactId = computed(() => route.params.contactId || null);
const activeContact = computed(() => contactById.value(contactId.value));
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
}
}
};
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
onContactSearch('');
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
};
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
params: payload,
isFromWhatsApp,
});
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
useAlert(
error instanceof ExceptionWithMessage
? error.data
: t('COMPOSE_NEW_CONVERSATION.FORM.ERROR_MESSAGE')
);
return false; // Return failure
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
watch(
activeContact,
() => {
if (activeContact.value && contactId.value) {
// Add null check for contactInboxes
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
};
}
},
{ immediate: true, deep: true }
);
onMounted(() => {
onContactSearch('');
});
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="relative">
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
@search-contacts="onContactSearch"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</template>
@@ -0,0 +1,251 @@
<script setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
attachedFiles: {
type: Array,
default: () => [],
},
isWhatsappInbox: {
type: Boolean,
default: false,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
default: false,
},
isTwilioSmsInbox: {
type: Boolean,
default: false,
},
messageTemplates: {
type: Array,
default: () => [],
},
channelType: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
disableSendButton: {
type: Boolean,
default: false,
},
hasNoInbox: {
type: Boolean,
default: false,
},
isDropdownActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'insertEmoji',
'addSignature',
'removeSignature',
'attachFile',
]);
const { t } = useI18n();
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const messageSignature = useMapGetter('getMessageSignature');
const signatureToApply = computed(() => messageSignature.value);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
isEditorHotKeyEnabled,
} = useUISettings();
const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const isSignatureEnabledForInbox = computed(() => {
return props.isEmailOrWebWidgetInbox && sendWithSignature.value;
});
const setSignature = () => {
if (signatureToApply.value) {
if (isSignatureEnabledForInbox.value) {
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', signatureToApply.value);
}
}
};
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
const newFile = {
resource: blob || file,
isPrivate: false,
thumb: reader.result,
blobSignedId: blob?.signed_id,
};
emit('attachFile', [...props.attachedFiles, newFile]);
};
},
});
const sendButtonLabel = computed(() => {
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode,
});
});
const keyboardEvents = {
Enter: {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
'$mod+Enter': {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<WhatsAppOptions
v-if="isWhatsappInbox"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-on-clickaway="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
icon="i-lucide-smile-plus"
color="slate"
size="sm"
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
v-if="isEmailOrWebWidgetInbox"
ref="uploadAttachment"
input-id="composeNewConversationAttachment"
:size="4096 * 4096"
:accept="ALLOWED_FILE_TYPES"
multiple
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
class="p-px"
@input-file="onFileUpload"
>
<Button
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10 relative"
/>
</FileUpload>
<Button
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
class="!w-10"
@click="toggleMessageSignature"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
:disabled="isLoading || disableSendButton"
:is-loading="isLoading"
@click="emit('sendMessage')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>

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