Compare commits

..
Author SHA1 Message Date
Pranav ff42272f3a Move update message status above low 2025-07-16 08:11:37 -07:00
Pranav 51eeebd599 Move to deferred 2025-07-16 07:33:38 -07:00
Pranav 4da02be347 Update the queue name 2025-07-16 07:24:24 -07:00
Pranav 52cb81fa90 Move avatar job to deferred, update message status to low 2025-07-16 07:13:48 -07:00
Sojan Jose 2fec2e5993 Merge branch 'release/4.4.0' into develop 2025-07-16 00:10:53 -07:00
Sojan Jose 090316a078 Bump version to 4.4.0 2025-07-16 00:09:26 -07:00
Shivam MishraandGitHub fcd604dcde chore: Make captain_integration_v2 an internal feature (#11953) 2025-07-16 10:09:46 +05:30
13b4fdb34c chore: Add submenu for super admin settings (#11860)
- Improve how settings are rendered in Chatwoot Super admin panel
- Add google settings support 
- show setting for community edition

##  Settings page - community edition
<img width="1702" alt="Screenshot 2025-07-08 at 9 08 03 PM"
src="https://github.com/user-attachments/assets/0434f56f-ea74-44a8-a7b0-8e26fab88093"
/>

## Expanded settings
<img width="1675" alt="Screenshot 2025-07-03 at 2 17 16 AM"
src="https://github.com/user-attachments/assets/3aa1f888-c54a-4b58-896a-0d3e828fa176"
/>

---------

Co-authored-by: Sojan Jose <sojan@Sojans-MacBook-Pro.local>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-15 21:28:39 -07:00
45d4d3660c feat: Add private note action to automations (#11926)
## Summary
- allow AutomationRule to accept `add_private_note` action
- support `add_private_note` in automation action service
- expose private note action in frontend constants and i18n
- test new automation rule action

## Testing
- `pnpm eslint
app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`
- `bundle exec rubocop app/services/automation_rules/action_service.rb
app/models/automation_rule.rb
spec/services/automation_rules/action_service_spec.rb`
- `bundle exec rspec
spec/services/automation_rules/action_service_spec.rb`


------
https://chatgpt.com/codex/tasks/task_e_6870c5f7b8b88326a9bd60b2ba710ccd

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-16 09:57:35 +05:30
0ea616a6ea feat: WhatsApp campaigns (#11910)
# Pull Request Template

## Description

This PR adds support for WhatsApp campaigns to Chatwoot, allowing
businesses to reach their customers through WhatsApp. The implementation
includes backend support for WhatsApp template messages, frontend UI
components, and integration with the existing campaign system.

Fixes #8465

Fixes https://linear.app/chatwoot/issue/CW-3390/whatsapp-campaigns

## Type of change

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

## How Has This Been Tested?

- Tested WhatsApp campaign creation UI flow
- Verified backend API endpoints for campaign creation
- Tested campaign service integration with WhatsApp templates
- Validated proper filtering of WhatsApp campaigns in the store

## Checklist:

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

## What we have changed:

We have added support for WhatsApp campaigns as requested in the
discussion.
Ref: https://github.com/orgs/chatwoot/discussions/8465

**Note:** This implementation doesn't exactly match the maintainer's
specification and variable support is missing. This is an initial
implementation that provides the core WhatsApp campaign functionality.

### Changes included:

**Backend:**
- Added `template_params` column to campaigns table (migration + schema)
- Created `Whatsapp::OneoffCampaignService` for WhatsApp campaign
execution
- Updated campaign model to support WhatsApp inbox types
- Added template_params support to campaign controller and API

**Frontend:**
- Added WhatsApp campaign page, dialog, and form components
- Updated campaign store to filter WhatsApp campaigns separately
- Added WhatsApp-specific routes and empty state
- Updated i18n translations for WhatsApp campaigns
- Modified sidebar to include WhatsApp campaigns navigation

This provides a foundation for WhatsApp campaigns that can be extended
with variable support and other enhancements in future iterations.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-16 09:04:02 +05:30
6b8dd3c86a chore: move UpdateMessageStatus to deferred queue (#11943)
-  move `UpdateMessageStatus` to `deferred` queue below `scheduled_jobs`

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-16 08:19:00 +05:30
Tanmay Deep SharmaGitHubMuhsin KelothCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>iamsivinSivin VargheseSojan Jose
61d10044a0 feat: Whatsapp embedded signup (#11612)
## Description

This PR introduces WhatsApp Embedded Signup functionality, enabling
users to connect their WhatsApp Business accounts through Meta's
streamlined OAuth flow without manual webhook configuration. This
significantly improves the user experience by automating the entire
setup process.

**Key Features:**

- Embedded signup flow using Facebook SDK and Meta's OAuth 2.0
- Automatic webhook registration and phone number configuration
- Enhanced provider selection UI with card-based design
- Real-time progress tracking during signup process
- Comprehensive error handling and user feedback


## Required Configuration

The following environment variables must be configured by administrators
before this feature can be used:
Super Admin Configuration (via
super_admin/app_config?config=whatsapp_embedded)

- `WHATSAPP_APP_ID`: The Facebook App ID for WhatsApp Business API
integration
- `WHATSAPP_CONFIGURATION_ID`: The Configuration ID for WhatsApp
Embedded Signup flow (obtained from Meta Developer Portal)
- `WHATSAPP_APP_SECRET`: The App Secret for WhatsApp Embedded Signup
flow (required for token exchange)
![Screenshot 2025-06-09 at 11 21
08 AM](https://github.com/user-attachments/assets/1615fb0d-27fc-4d9e-b193-9be7894ea93a)


## How Has This Been Tested?

#### Backend Tests (RSpec):

- Authentication validation for embedded signup endpoints
- Authorization code validation and error handling
- Missing business parameter validation
- Proper response format for configuration endpoint
- Unauthorized access prevention

#### Manual Test Cases:

- Complete embedded signup flow (happy path)
- Provider selection UI navigation
- Facebook authentication popup handling
- Error scenarios (cancelled auth, invalid business data, API failures)
- Configuration presence/absence behavior

## Related Screenshots:

![Screenshot 2025-06-09 at 7 48
18 PM](https://github.com/user-attachments/assets/34001425-df11-4d78-9424-334461e3178f)
![Screenshot 2025-06-09 at 7 48
22 PM](https://github.com/user-attachments/assets/c09f4964-3aba-4c39-9285-d1e8e37d0e33)
![Screenshot 2025-06-09 at 7 48
32 PM](https://github.com/user-attachments/assets/a34d5382-7a91-4e1c-906e-dc2d570c864a)
![Screenshot 2025-06-09 at 10 43
05 AM](https://github.com/user-attachments/assets/a15840d8-8223-4513-82e4-b08f23c95927)
![Screenshot 2025-06-09 at 10 42
56 AM](https://github.com/user-attachments/assets/8c345022-38b5-44c4-aba2-0cda81389c69)


Fixes
https://linear.app/chatwoot/issue/CW-2131/spec-for-whatsapp-cloud-channels-sign-in-with-facebook

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-07-14 21:37:06 -07:00
4378506a35 feat: add response guidelines and guardrails field (#11911)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-15 08:56:03 +05:30
Shivam MishraandGitHub 1132556512 feat: setup qlty [CW-4614] (#11918) 2025-07-14 17:12:28 +05:30
93f18315cc feat: add Captain::Scenario Model and API [CW-4597] (#11907)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-14 16:12:38 +05:30
d89fa09359 chore: Update translations (#11903)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-14 14:52:57 +05:30
Sivin VargheseandGitHub 07dd6448f4 fix: fast scrolling in canned responses list on mouse hover (#11933)
# Pull Request Template

## Description

This PR fixes a fast scrolling issue in the canned responses list that
occurred when hovering near the top or bottom edge.

Fixes https://github.com/chatwoot/chatwoot/issues/11923,
[CW-4624](https://linear.app/chatwoot/issue/CW-4624/canned-responses-menu-scrolls-too-fast-when-mouse-near-top-or-bottom)

## Type of change

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

## How Has This Been Tested?

### Screencast

**Before**


https://github.com/user-attachments/assets/1c39ad33-c5c9-49ce-a252-542428b7f7e3

**After**


https://github.com/user-attachments/assets/19c73713-0ffe-461a-9c3d-486e53e21abf




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-07-14 11:02:51 +05:30
Sivin VargheseandGitHub 661326ae51 fix: Contact name editing did not allow spaces (#11931)
# Pull Request Template

## Description

This PR fixes an issue where editing a contact name from the “Contacts”
tab would not allow spaces in the First or Last Name fields — the space
would disappear immediately after typing.
This issue did not occur in the sidebar editor or when adding a new
contact.



**Cause:** The form had a deep watcher on `contactData`, which triggered
`prepareStateBasedOnProps()` on every nested change. This caused the
form state to reset.

**Solution:** Replaced the deep watcher with a shallow watcher that only
watches `contactData.id`.
This fires once on mount and whenever a new contact is selected,
avoiding unnecessary re-hydration while the user is typing.


Fixes https://github.com/chatwoot/chatwoot/issues/11922 ,
CW-[4623](https://linear.app/chatwoot/issue/CW-4623/inconsistent-name-field-validation-when-editing-contacts-from-contacts)

## Type of change

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

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/e088f627-d7b1-4d67-85eb-58911ac0c012



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-07-14 10:27:50 +05:30
18eb53acf4 feat: New Assistants Edit Page (#11920)
# Pull Request Template

## Description

Fixes https://linear.app/chatwoot/issue/CW-4602/assistants-edit-page

### Screenshots
<img width="1650" height="890" alt="image"
src="https://github.com/user-attachments/assets/f9834cd3-9cf3-4173-8d33-1aad04d8991e"
/>
<img width="1650" height="890" alt="image"
src="https://github.com/user-attachments/assets/a628328a-fdfd-4ee7-86b5-2a1945e0c114"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-12 17:02:10 +05:30
1b02ebec68 fix: Approved FAQ not disappearing from pending list after filtering (#11909)
# Pull Request Template

## Description

This PR fixes an issue where approved FAQs were not removed from the
list when filtered by `pending` status.

**Fix:** Implemented real-time client-side filtering using a
`filteredResponses` computed property in `Index.vue`, updated all list
and selection logic to use it, and also hide the card toggle dropdown
button when the bulk action checkbox is selected.


## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/216221a4910c44ebb1d49ab9e34c820c?sid=98c9c239-54eb-4bd9-b04d-aeccc55bfb3a

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-12 10:06:30 +05:30
Tanmay Deep SharmaandGitHub 5b9f997fa0 feat: Add the support for images in Captain (#11850) 2025-07-11 23:28:46 +07:00
Sivin VargheseandGitHub 802f0694ed chore: Alphabetically sort inbox list on settings page (#11921)
# Pull Request Template

## Description

This PR updates the inbox list on the settings page to be sorted
alphabetically.

## Type of change

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

## How Has This Been Tested?

### Screenshot
<img width="222" height="463" alt="image"
src="https://github.com/user-attachments/assets/0caabeb1-b2bd-4072-a44f-e0ac5a52404d"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-07-11 11:46:15 +05:30
Muhsin KelothandGitHub 17500cc62d chore: Auto assign PR to author when PR opened (#11890)
- gh action to auto-assign PR to author when PR opened
2025-07-10 11:36:37 +05:30
Sivin VargheseandGitHub 5140deb6f6 feat: Captain settings header component (#11912) 2025-07-09 22:21:25 +05:30
Sivin VargheseandGitHub 3e5ca9bca9 fix: Widget message input resize issue (#11896) 2025-07-09 09:23:20 +05:30
5ebe8c71ec feat: Support customizable welcome text, availability messages, and UI toggles (#11891)
# Pull Request Template

## Description

This PR allows users to dynamically pass custom welcome and availability
messages, along with UI feature toggles, via `window.chatwootSettings`.
If any of the following settings are provided, the widget will use them;
otherwise, it falls back to default behavior.

**New options:**
```
window.chatwootSettings = {
  welcomeTitle: 'Need help?',                        // Custom widget title
  welcomeDescription: 'We’re here to support you.',        // Subtitle in the header
  availableMessage: 'We’re online and ready to chat!', // Shown when team is online
  unavailableMessage: 'We’re currently offline.',      // Shown when team is unavailable

  enableFileUpload: true,          // Enable file attachments
  enableEmojiPicker: true,         // Enable emoji picker in chat input
  enableEndConversation: true     // Allow users to end the conversation
}
```


Fixes
https://linear.app/chatwoot/issue/CW-4589/add-options-to-windowchatwootsettings

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/413fc4aa59384366b071450bd19d1bf8?sid=ff30fb4c-267c-4beb-80ab-d6f583aa960d

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-08 14:26:00 -07:00
Sivin VargheseandGitHub 8c78573d9d chore: Remove defer attribute from widget-loader script (#11887) 2025-07-08 15:31:33 +05:30
c553997af8 fix: Translate Priority and Messages types in Automations and Macros (#11741)
# Pull Request Template

## Description

With these fixes, I could improve some translations in portuguese, and
also I added some improvements to make some drowpdown values, that were
not translatable into translatable strings, into the Automations, Macros
and Custom Attributes page. I also fixed some typos.

Here are the main improvements.

- ~Fixed typo in portuguese into `Reports > Agents` page:~
~Before:

![image](https://github.com/user-attachments/assets/5a911108-76a6-4e54-82f1-1185c3e1981b)
After:

![image](https://github.com/user-attachments/assets/2a4fc5bf-3239-47b5-9113-ba66e0f44b9c)~

- Added the possibility to make the `Priority` and `Message types`
translatables in other languages, into Macros and Automations page. Also
added the same feature for Custom attributes page at `applies to` and
`type` fields:
Before:

![image](https://github.com/user-attachments/assets/d53b3e6d-be3e-4e02-9478-7d3121cc11cb)

![image](https://github.com/user-attachments/assets/28a7bffe-fe8b-43f6-8e30-9d62ab8adf14)

![image](https://github.com/user-attachments/assets/18a983c1-2db8-445d-a4cc-982beb88015a)

![image](https://github.com/user-attachments/assets/2be8b0f2-ed9e-4bac-aeb0-596533200da4)
After:

![image](https://github.com/user-attachments/assets/0fa904ae-7b48-4ce4-afb8-e0586c5624b7)

![image](https://github.com/user-attachments/assets/a524f119-9222-4e98-9cd7-2fca3303e8d5)

![image](https://github.com/user-attachments/assets/7062f277-e9c9-4473-980b-6ca2d6bdcefc)

![image](https://github.com/user-attachments/assets/0bb66f07-ee10-4833-b950-b9aa9441a312)

- ~Improve Bots page. In the Brazilian portuguese is very common and
widely used bots to refer to chatbots, using `robô` as a direct
translations sounds weird, `robô` is used more often when we are talking
about robots, not chatbots.
Before:

![image](https://github.com/user-attachments/assets/3966cc11-51f4-4e1a-bc81-ada2795408e8)
After:

![image](https://github.com/user-attachments/assets/c9ec0ad5-974e-40d9-9542-031db99839e2)~

- Added multiselect both `no options` and `Select` placeholder
translatable strings:
Before:

![image](https://github.com/user-attachments/assets/545641d4-87ae-4305-8adc-3b73bbaf2ab1)

![image](https://github.com/user-attachments/assets/8800c001-abe7-41bb-bd68-feb5db13b8f0)
After:

![image](https://github.com/user-attachments/assets/46748652-28f2-4ae3-9d20-55db1015aaae)

- Added `.pnpm-store` to `.gitignore`, when I'm using docker, the pnpm
always creates this folder into my root directory, so I imagine the same
could happens with others, so I fixed it.


## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

I've added some translations for my language (brazilian portuguese), so
i just switched the languages between the original in EN to PT_BR.

## Checklist:

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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-08 12:40:40 +05:30
Shivam MishraandGitHub 30a3a35281 feat: remove colon and semicolons when sanitizing inbox name (#11889) 2025-07-08 09:41:40 +05:30
Chatwoot BotandGitHub e4ba80e2f0 chore: Update translations (#11893) 2025-07-07 14:34:25 -07:00
b72848513f fix: Show billing upgrade page if there is a mismatch in the user count (#11886)
Disable features/show billing upgrade for accounts with more users than
the one in the license.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-06 19:30:27 -07:00
Muhsin KelothandGitHub b8814a8bd5 fix: Escape closing bracket in mention regex (#11877)
**Summary**
- Fixed Ruby warning about unescaped closing bracket in character class
within `MENTION_REGEX`
- Properly escaped the `]` character in the regex pattern to follow Ruby
regex syntax standards

**Changes**
- Updated `MENTION_REGEX` in `lib/regex_helper.rb` to escape the closing
bracket in character class `[^]]+` → `[^\\]]+`
2025-07-04 10:35:11 +05:30
PranavandGitHub 3e993ead0f feat: OG Image in Chatwoot Help Center (#11826)
_Note: This is only available for Cloud version of Chatwoot._



![image](https://github.com/user-attachments/assets/b7d61035-8682-4dbd-9460-4f8546ca6a98)
2025-07-03 12:41:12 -07:00
Sivin VargheseandGitHub 7343e53659 fix: Variable search item not showing after braces/commas (#11864)
# Pull Request Template

## Description

This PR fixes an issue where typing variables, like `{{contact.name}}`,
caused the variable list to miss showing `contact.name`. The search key
in this case became `contact.name}},` which didn't match any available
options. The logic in `VariableList.vue` only checked the part after the
last comma and didn’t fully sanitize the input.

**Solution**
Updated `searchKey` to remove all {} and commas for accurate matching.

Fixes
[CW-4574](https://linear.app/chatwoot/issue/CW-4574/i-dont-see-an-option-for-contactname-it-shows-initially-but-it-doesnt)

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/fc86e53853ad49e6acf6de57ebbd8fcb?sid=6702f896-d1a3-4c5a-9eb7-b96b5ed91531


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-07-03 19:39:36 +05:30
Sivin VargheseandGitHub cb01a2bc66 fix: Incorrect account ID in conversation header back button URL (#11866) 2025-07-03 13:22:44 +05:30
Muhsin KelothandGitHub 4e4aa7f580 fix: Handle emoji and special characters in mention notifications (#11857)
Fixes notification display issues when user or team names contain emojis
and special characters. Previously, mention notifications would show
URL-encoded characters instead of properly formatted names with emojis.

  **Before:**
Notification: "John Doe: Hey @%F0%9F%91%8D%20customer%20support please
check this"

  **After:**
  Notification: "John Doe: Hey @👍 customer support please check this"
2025-07-03 11:32:13 +05:30
a6cc3617c0 feat: Add the ability to mention team in private message (#11758)
This PR allows agents to mention entire teams in private messages using
`@team_name` syntax. When a team is mentioned, all team members with
inbox access are automatically notified. The scheme changes can be found
[here](https://github.com/chatwoot/prosemirror-schema/pull/34).

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-07-02 19:57:59 +05:30
687 changed files with 14272 additions and 1135 deletions
-62
View File
@@ -1,62 +0,0 @@
version: '2'
plugins:
rubocop:
enabled: false
channel: rubocop-0-73
eslint:
enabled: false
csslint:
enabled: true
scss-lint:
enabled: true
brakeman:
enabled: false
checks:
similar-code:
enabled: false
method-count:
enabled: true
config:
threshold: 32
file-lines:
enabled: true
config:
threshold: 300
method-lines:
config:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/**/**'
- '**/spec/**/**'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
- 'config/**/*'
- 'public/**/*'
- 'vendor/**/*'
- 'node_modules/**/*'
- 'lib/tasks/auto_annotate_models.rake'
- 'app/test-matchers.js'
- 'docs/*'
- '**/*.md'
- '**/*.yml'
- 'app/javascript/dashboard/i18n/locale'
- '**/*.stories.js'
- 'stories/'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
- 'app/javascript/shared/constants/countries.js'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+28
View File
@@ -0,0 +1,28 @@
name: Auto-assign PR to Author
on:
pull_request:
types: [opened]
jobs:
auto-assign:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Auto-assign PR to author
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
await github.rest.issues.addAssignees({
owner,
repo,
issue_number: pull_number,
assignees: [author]
});
console.log(`Assigned PR #${pull_number} to ${author}`);
+7
View File
@@ -0,0 +1,7 @@
*
!configs
!configs/**
!hooks
!hooks/**
!qlty.toml
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
ignored:
- DL3008
+1
View File
@@ -0,0 +1 @@
source-path=SCRIPTDIR
+8
View File
@@ -0,0 +1,8 @@
rules:
document-start: disable
quoted-strings:
required: only-when-needed
extra-allowed: ["{|}"]
key-duplicates: {}
octal-values:
forbid-implicit-octal: true
+84
View File
@@ -0,0 +1,84 @@
# This file was automatically generated by `qlty init`.
# You can modify it to suit your needs.
# We recommend you to commit this file to your repository.
#
# This configuration is used by both Qlty CLI and Qlty Cloud.
#
# Qlty CLI -- Code quality toolkit for developers
# Qlty Cloud -- Fully automated Code Health Platform
#
# Try Qlty Cloud: https://qlty.sh
#
# For a guide to configuration, visit https://qlty.sh/d/config
# Or for a full reference, visit https://qlty.sh/d/qlty-toml
config_version = "0"
exclude_patterns = [
"*_min.*",
"*-min.*",
"*.min.*",
"**/.yarn/**",
"**/*.d.ts",
"**/assets/**",
"**/bower_components/**",
"**/build/**",
"**/cache/**",
"**/config/**",
"**/db/**",
"**/deps/**",
"**/dist/**",
"**/extern/**",
"**/external/**",
"**/generated/**",
"**/Godeps/**",
"**/gradlew/**",
"**/mvnw/**",
"**/node_modules/**",
"**/protos/**",
"**/seed/**",
"**/target/**",
"**/templates/**",
"**/testdata/**",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
]
test_patterns = [
"**/test/**",
"**/spec/**",
"**/*.test.*",
"**/*.spec.*",
"**/*_test.*",
"**/*_spec.*",
"**/test_*.*",
"**/spec_*.*",
]
[smells]
mode = "comment"
[smells.boolean_logic]
threshold = 4
[smells.file_complexity]
threshold = 66
enabled = true
[smells.return_statements]
threshold = 4
[smells.nested_control_flow]
threshold = 4
[smells.function_parameters]
threshold = 4
[smells.function_complexity]
threshold = 5
[smells.duplication]
enabled = true
threshold = 20
[[source]]
name = "default"
default = true
+1 -1
View File
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Enabled: true
Style/SafeNavigationChainLength:
Enabled: false
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
end
end
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
end
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
render json: { success: false }, status: :unprocessable_entity
end
end
end
end
@@ -0,0 +1,64 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles the embedded signup callback data from the Facebook SDK
def create
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
render_error_response(e)
end
private
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
)
service.perform
end
def render_success_response(inbox)
render json: {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
end
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
success: false,
error: error.message
}, status: :unprocessable_entity
end
def validate_feature_enabled!
return if Current.account.feature_whatsapp_embedded_signup?
render json: {
success: false,
error: 'WhatsApp embedded signup is not enabled for this account'
}, status: :forbidden
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
missing_params << 'business_id' if params[:business_id].blank?
missing_params << 'waba_id' if params[:waba_id].blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end
+2
View File
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
end
@@ -17,7 +17,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
limit_results
end
def show; end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@@ -8,7 +8,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
@categories = @portal.categories.order(position: :asc)
end
def show; end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
end
private
@@ -4,7 +4,9 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show; end
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
@@ -39,8 +39,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -7,8 +7,9 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper
include ActionView::Context
include SuperAdmin::NavigationHelper
helper_method :render_vue_component
helper_method :render_vue_component, :settings_open?, :settings_pages
# authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin!
+7 -3
View File
@@ -1,9 +1,13 @@
module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
# attachment message without content, message_content is nil
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
return '' unless message_content.presence
# Use CommonMarker to convert markdown to plain text for notifications
# This handles all markdown formatting (links, bold, italic, etc.) not just mentions
# Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support)
# To: @👍 customer support
CommonMarker.render_doc(message_content).to_plaintext.strip
end
def render_message_content(message_content)
+17
View File
@@ -1,4 +1,21 @@
module PortalHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF']
uri = URI.parse(cdn_url)
uri.path = '/og'
uri.query = URI.encode_www_form(
clientRef: client_ref,
title: title,
portalName: portal_name
)
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
@@ -1,5 +1,7 @@
# TODO: Move this values to features.yml itself
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
@@ -32,6 +34,15 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
# ------- Product Features ------- #
help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
# ------- Communication Channels ------- #
live_chat:
name: 'Live Chat'
description: 'Improve your customer experience using a live chat on your website.'
@@ -42,6 +53,12 @@ email:
description: 'Manage your email customer interactions from Chatwoot.'
enabled: true
icon: 'icon-mail-send-fill'
config_key: 'email'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'icon-message-line'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
@@ -69,22 +86,22 @@ line:
description: 'Manage your Line customer interactions from Chatwoot.'
enabled: true
icon: 'icon-line-line'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
# ------- OAuth & Authentication ------- #
google:
name: 'Google'
description: 'Configuration for setting up Google OAuth Integration'
enabled: true
icon: 'icon-message-line'
help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
icon: 'icon-google'
config_key: 'google'
microsoft:
name: 'Microsoft'
description: 'Configuration for setting up Microsoft Email'
enabled: true
icon: 'icon-microsoft'
config_key: 'microsoft'
# ------- Third-party Integrations ------- #
linear:
name: 'Linear'
description: 'Configuration for setting up Linear Integration'
@@ -103,6 +120,12 @@ slack:
enabled: true
icon: 'icon-slack'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'icon-whatsapp-line'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
@@ -1,6 +1,6 @@
module SuperAdmin::FeaturesHelper
def self.available_features
YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access
YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access
end
def self.plan_details
@@ -0,0 +1,16 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def settings_pages
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
general_feature + features.to_a
end
end
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class WhatsappChannel extends ApiClient {
constructor() {
super('whatsapp', { accountScoped: true });
}
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
}
export default new WhatsappChannel();
@@ -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,48 @@
<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 WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -0,0 +1,357 @@
<script setup>
import { reactive, computed, watch, ref } 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 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/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { 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 templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
// Extract template content - this should be the template message body
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
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();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? '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.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.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="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Preview -->
<div
v-if="selectedTemplate"
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</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.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.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.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -218,10 +218,13 @@ const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
watch(
() => props.contactData?.id,
id => {
if (id) prepareStateBasedOnProps();
},
{ immediate: true }
);
// Expose state to parent component for avatar upload
defineExpose({
@@ -15,8 +15,8 @@ const emit = defineEmits(['click']);
const { t } = useI18n();
const onClick = event => {
emit('click', event);
const onClick = (item, index) => {
emit('click', item, index);
};
</script>
@@ -24,22 +24,25 @@ const onClick = event => {
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<!-- Render as button for all except the last item -->
<button
v-if="index === 0"
v-if="index !== items.length - 1"
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
@click="onClick"
@click="onClick(item, index)"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
</button>
<template v-else>
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<span class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</template>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
</ol>
</nav>
@@ -0,0 +1,91 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="my-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -55,6 +55,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -130,7 +134,7 @@ const handleDocumentableClick = () => {
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
<div v-if="!compact" class="flex items-center gap-2">
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
@@ -0,0 +1,151 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const initialState = {
name: '',
description: '',
productName: '',
features: {
conversationFaqs: false,
memories: false,
},
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) updateStateFromAssistant(newAssistant);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
</label>
<label class="flex items-center gap-2">
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
</div>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleBasicInfoUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
controlItem: {
type: Object,
default: () => ({}),
},
});
const router = useRouter();
const onClick = name => {
router.push({ name });
};
</script>
<template>
<div
:key="controlItem.name"
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"
@click="onClick(controlItem.routeName)"
>
<div class="flex items-center justify-between w-full gap-1 h-8">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ controlItem.name }}
</span>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-chevron-right"
slate
ghost
xs
@click="onClick(controlItem.routeName)"
/>
</div>
</div>
<span class="text-n-slate-11 text-sm leading-[21px] line-clamp-5">
{{ controlItem.description }}
</span>
</div>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const initialState = {
handoffMessage: '',
resolutionMessage: '',
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) updateStateFromAssistant(newAssistant);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import SettingsHeader from './SettingsHeader.vue';
</script>
<template>
<Story
title="Captain/PageComponents/SettingsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<SettingsHeader
heading="General Settings"
description="Configure general preferences for your workspace."
/>
</div>
</Variant>
<Variant title="Long Description">
<div class="px-6 py-4 bg-n-background">
<SettingsHeader
heading="Integrations"
description="Manage and configure third-party integrations such as Slack, Zapier, and Webhooks to enhance your workflow."
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,19 @@
<script setup>
defineProps({
heading: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<header class="flex flex-col items-start gap-2">
<h2 class="text-n-slate-12 text-base font-medium">{{ heading }}</h2>
<p class="text-n-slate-11 text-sm">{{ description }}</p>
</header>
</template>
@@ -331,6 +331,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
{
name: 'WhatsApp',
label: t('SIDEBAR.WHATSAPP'),
to: accountScopedRoute('campaigns_whatsapp_index'),
},
],
},
{
@@ -133,7 +133,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'multi_select'"
@@ -152,7 +156,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<input
v-else-if="inputType === 'email'"
@@ -203,7 +203,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
multiple
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -211,7 +211,11 @@ export default {
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div
v-else-if="inputType === 'search_select'"
@@ -221,7 +225,7 @@ export default {
v-model="values"
track-by="id"
label="name"
placeholder="Select"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
@@ -229,7 +233,11 @@ export default {
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
>
<template #noOptions>
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
</template>
</multiselect>
</div>
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
<input
@@ -39,15 +39,21 @@ const chatMetadata = computed(() => props.chat.meta);
const backButtonUrl = computed(() => {
const {
params: { inbox_id: inboxId, label, teamId },
params: { inbox_id: inboxId, label, teamId, id: customViewId },
name,
} = route;
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({
accountId,
accountId: accountId.value,
inboxId,
label,
teamId,
conversationType: name === 'conversation_mentions' ? 'mention' : '',
conversationType: conversationTypeMap[name],
customViewId,
});
});
@@ -1,8 +1,9 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import { ref, computed, watch, nextTick } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useI18n } from 'vue-i18n';
const props = defineProps({
searchKey: {
@@ -13,41 +14,89 @@ const props = defineProps({
const emit = defineEmits(['selectAgent']);
const { t } = useI18n();
const getters = useStoreGetters();
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const teams = useMapGetter('teams/getTeams');
const tagAgentsRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
if (!props.searchKey) {
return agents.value;
}
return agents.value.filter(agent =>
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
const search = props.searchKey?.trim().toLowerCase() || '';
const buildItems = (list, type, infoKey) =>
list
.map(item => ({
...item,
type,
displayName: item.name,
displayInfo: item[infoKey],
}))
.filter(item =>
search ? item.displayName.toLowerCase().includes(search) : true
);
const categories = [
{
title: t('CONVERSATION.MENTION.AGENTS'),
data: buildItems(agents.value, 'user', 'email'),
},
{
title: t('CONVERSATION.MENTION.TEAMS'),
data: buildItems(teams.value, 'team', 'description'),
},
];
return categories.flatMap(({ title, data }) =>
data.length
? [
{ type: 'header', title, id: `${title.toLowerCase()}-header` },
...data,
]
: []
);
});
const selectableItems = computed(() => {
return items.value.filter(item => item.type !== 'header');
});
const getSelectableIndex = item => {
return selectableItems.value.findIndex(
selectableItem =>
selectableItem.type === item.type && selectableItem.id === item.id
);
};
const adjustScroll = () => {
nextTick(() => {
if (tagAgentsRef.value) {
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
const selectedElement = tagAgentsRef.value.querySelector(
`#mention-item-${selectedIndex.value}`
);
if (selectedElement) {
selectedElement.scrollIntoView({
block: 'nearest',
behavior: 'auto',
});
}
}
});
};
const onSelect = () => {
emit('selectAgent', items.value[selectedIndex.value]);
emit('selectAgent', selectableItems.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items,
items: selectableItems,
onSelect,
adjustScroll,
selectedIndex,
});
watch(items, newListOfAgents => {
watch(selectableItems, newListOfAgents => {
if (newListOfAgents.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
@@ -69,40 +118,61 @@ const onAgentSelect = index => {
v-if="items.length"
ref="tagAgentsRef"
class="vertical dropdown menu mention--box bg-n-solid-1 p-1 rounded-xl text-sm overflow-auto absolute w-full z-20 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border border-solid border-n-strong"
role="listbox"
>
<li
v-for="(agent, index) in items"
:id="`mention-item-${index}`"
:key="agent.id"
:class="{
'bg-n-alpha-black2': index === selectedIndex,
'last:mb-0': items.length <= 4,
}"
class="flex items-center px-2 py-1 rounded-md"
@click="onAgentSelect(index)"
@mouseover="onHover(index)"
v-for="item in items"
:id="
item.type === 'header'
? undefined
: `mention-item-${getSelectableIndex(item)}`
"
:key="`${item.type}-${item.id}`"
>
<div class="ltr:mr-2 rtl:ml-2">
<Avatar :src="agent.thumbnail" :name="agent.name" rounded-full />
</div>
<!-- Section Header -->
<div
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
v-if="item.type === 'header'"
class="px-2 py-2 text-xs font-medium tracking-wide capitalize text-n-slate-11"
>
<h5
class="mb-0 overflow-hidden text-sm text-n-slate-11 whitespace-nowrap text-ellipsis"
:class="{
'text-n-slate-12': index === selectedIndex,
}"
>
{{ agent.name }}
</h5>
{{ item.title }}
</div>
<!-- Selectable Item -->
<div
v-else
:class="{
'bg-n-alpha-black2': getSelectableIndex(item) === selectedIndex,
}"
class="flex items-center px-2 py-1 rounded-md cursor-pointer"
role="option"
@click="onAgentSelect(getSelectableIndex(item))"
@mouseover="onHover(getSelectableIndex(item))"
>
<div class="ltr:mr-2 rtl:ml-2">
<Avatar
:src="item.thumbnail"
:name="item.displayName"
rounded-full
/>
</div>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
:class="{
'text-n-slate-11': index === selectedIndex,
}"
class="overflow-hidden flex-1 max-w-full whitespace-nowrap text-ellipsis"
>
{{ agent.email }}
<h5
class="overflow-hidden mb-0 text-sm capitalize whitespace-nowrap text-n-slate-11 text-ellipsis"
:class="{
'text-n-slate-12': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayName }}
</h5>
<div
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-n-slate-10"
:class="{
'text-n-slate-11': getSelectableIndex(item) === selectedIndex,
}"
>
{{ item.displayInfo }}
</div>
</div>
</div>
</li>
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { MESSAGE_VARIABLES } from 'shared/constants/messages';
import { sanitizeVariableSearchKey } from 'dashboard/helper/commons';
import MentionBox from '../mentions/MentionBox.vue';
export default {
@@ -16,6 +17,9 @@ export default {
...mapGetters({
customAttributes: 'attributes/getAttributes',
}),
sanitizedSearchKey() {
return sanitizeVariableSearchKey(this.searchKey);
},
items() {
return [
...this.standardAttributeVariables,
@@ -25,8 +29,8 @@ export default {
standardAttributeVariables() {
return MESSAGE_VARIABLES.filter(variable => {
return (
variable.label.includes(this.searchKey) ||
variable.key.includes(this.searchKey)
variable.label.includes(this.sanitizedSearchKey) ||
variable.key.includes(this.sanitizedSearchKey)
);
}).map(variable => ({
label: variable.key,
@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
const props = defineProps({
@@ -19,19 +19,16 @@ const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
const adjustScroll = () => {
const container = mentionsListContainerRef.value;
const item = container.querySelector(`#mention-item-${selectedIndex.value}`);
if (item) {
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.offsetHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom + 34 > containerBottom) {
container.scrollTop = itemBottom - container.offsetHeight + 34;
nextTick(() => {
const container = mentionsListContainerRef.value;
if (!container) return;
const selectedElement = container.querySelector(
`#mention-item-${selectedIndex.value}`
);
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
}
});
};
const onSelect = () => {
@@ -9,6 +9,8 @@ import {
teams,
labels,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
campaigns,
contacts,
inboxes,
@@ -16,7 +18,6 @@ import {
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
@@ -71,7 +72,9 @@ describe('useAutomation', () => {
case 'country_code':
return countries;
case 'message_type':
return MESSAGE_CONDITION_VALUES;
return messageTypeOptions;
case 'priority':
return priorityOptions;
default:
return [];
}
@@ -93,6 +96,8 @@ describe('useAutomation', () => {
return [];
case 'add_sla':
return slaPolicies;
case 'change_priority':
return priorityOptions;
default:
return [];
}
@@ -218,8 +223,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
MESSAGE_CONDITION_VALUES
messageTypeOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
it('gets action dropdown values correctly', () => {
@@ -231,6 +237,7 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
});
it('handles event change correctly', () => {
@@ -5,6 +5,9 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
describe('useMacros', () => {
const mockLabels = [
@@ -148,9 +151,11 @@ describe('useMacros', () => {
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('change_priority')).toEqual(
PRIORITY_CONDITION_VALUES
);
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
}));
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
});
it('returns an empty array for unknown types', () => {
@@ -8,6 +8,10 @@ import {
getActionOptions,
getConditionOptions,
} from 'dashboard/helper/automationHelper';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
/**
* This is a shared composables that holds utilites used to build dropdown and file options
@@ -60,6 +64,20 @@ export default function useAutomationValues() {
];
});
const messageTypeOptions = computed(() =>
MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
}))
);
const priorityOptions = computed(() =>
PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
}))
);
/**
* Adds a translated "None" option to the beginning of a list
* @param {Array} list - The list to add "None" to
@@ -87,6 +105,8 @@ export default function useAutomationValues() {
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
priorityOptions: priorityOptions.value,
messageTypeOptions: messageTypeOptions.value,
teams: teams.value,
languages,
countries,
@@ -108,6 +128,7 @@ export default function useAutomationValues() {
languages,
type,
addNoneToListFn: addNoneToList,
priorityOptions: priorityOptions.value,
});
};
@@ -115,6 +136,8 @@ export default function useAutomationValues() {
booleanFilterOptions,
statusFilterItems,
statusFilterOptions,
priorityOptions,
messageTypeOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
@@ -7,6 +8,7 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const { t } = useI18n();
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
@@ -32,7 +34,10 @@ export const useMacros = () => {
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES;
return PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
}));
default:
return [];
}
@@ -38,11 +38,13 @@ export const DEFAULT_ACTIONS = [
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
name: 'Incoming',
i18nKey: 'INCOMING',
},
{
id: 'outgoing',
name: 'Outgoing Message',
name: 'Outgoing',
i18nKey: 'OUTGOING',
},
];
@@ -50,21 +52,26 @@ export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
i18nKey: 'NONE',
},
{
id: 'low',
name: 'Low',
i18nKey: 'LOW',
},
{
id: 'medium',
name: 'Medium',
i18nKey: 'MEDIUM',
},
{
id: 'high',
name: 'High',
i18nKey: 'HIGH',
},
{
id: 'urgent',
name: 'Urgent',
i18nKey: 'URGENT',
},
];
+4
View File
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
@@ -36,6 +37,8 @@ export const FEATURE_FLAGS = {
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
};
export const PREMIUM_FEATURES = [
@@ -44,4 +47,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
];
@@ -8,8 +8,6 @@ import {
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
@@ -103,6 +101,7 @@ export const getActionOptions = ({
slaPolicies,
type,
addNoneToListFn,
priorityOptions,
}) => {
const actionsMap = {
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
@@ -110,7 +109,7 @@ export const getActionOptions = ({
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
change_priority: PRIORITY_CONDITION_VALUES,
change_priority: priorityOptions,
add_sla: slaPolicies,
};
return actionsMap[type];
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
statusFilterOptions,
teams,
type,
priorityOptions,
messageTypeOptions,
}) => {
if (isCustomAttributeCheckbox(customAttributes, type)) {
return booleanFilterOptions;
@@ -147,8 +148,8 @@ export const getConditionOptions = ({
browser_language: languages,
conversation_language: languages,
country_code: countries,
message_type: MESSAGE_CONDITION_VALUES,
priority: PRIORITY_CONDITION_VALUES,
message_type: messageTypeOptions,
priority: priorityOptions,
};
return conditionFilterMaps[type];
@@ -83,3 +83,16 @@ export const convertToPortalSlug = text => {
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
/**
* Strip curly braces, commas and leading/trailing whitespace from a search key.
* Eg. "{{contact.name}}," => "contact.name"
* @param {string} searchKey
* @returns {string}
*/
export const sanitizeVariableSearchKey = (searchKey = '') => {
return searchKey
.replace(/[{}]/g, '') // remove all curly braces
.replace(/,/g, '') // remove commas
.trim();
};
@@ -301,11 +301,18 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention':
return state.schema.nodes.mention.create({
case 'mention': {
const mentionType = content.type || 'user';
const displayName = content.displayName || content.name;
const mentionNode = state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
userFullName: displayName,
mentionType,
});
return mentionNode;
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
@@ -4,6 +4,7 @@ import {
convertToAttributeSlug,
convertToCategorySlug,
convertToPortalSlug,
sanitizeVariableSearchKey,
} from '../commons';
describe('#getTypingUsersText', () => {
@@ -107,3 +108,37 @@ describe('convertToPortalSlug', () => {
expect(convertToPortalSlug('Room rental')).toBe('room-rental');
});
});
describe('sanitizeVariableSearchKey', () => {
it('removes braces', () => {
expect(sanitizeVariableSearchKey('{{contact.name}}')).toBe('contact.name');
});
it('removes right braces', () => {
expect(sanitizeVariableSearchKey('contact.name}}')).toBe('contact.name');
});
it('removes braces, comma and whitespace', () => {
expect(sanitizeVariableSearchKey(' {{contact.name }},')).toBe(
'contact.name'
);
});
it('trims whitespace', () => {
expect(sanitizeVariableSearchKey(' contact.name ')).toBe('contact.name');
});
it('handles multiple commas', () => {
expect(sanitizeVariableSearchKey('{{contact.name}},,')).toBe(
'contact.name'
);
});
it('returns empty string when only braces/commas/whitespace', () => {
expect(sanitizeVariableSearchKey(' { }, , ')).toBe('');
});
it('returns empty string for undefined input', () => {
expect(sanitizeVariableSearchKey()).toBe('');
});
});
@@ -48,6 +48,7 @@ describe('getContentNode', () => {
{
userId: content.id,
userFullName: content.name,
mentionType: 'user',
}
);
});
@@ -8,6 +8,7 @@ import {
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
} from '../editorHelper';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
@@ -18,12 +19,28 @@ const schema = new Schema({
nodes: {
doc: { content: 'paragraph+' },
paragraph: {
content: 'text*',
content: 'inline*',
group: 'block',
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
},
text: {
group: 'inline',
toDOM: node => node.text, // Represents text as its actual string value.
},
mention: {
attrs: {
userId: { default: '' },
userFullName: { default: '' },
mentionType: { default: 'user' },
},
inline: true,
group: 'inline',
toDOM: node => [
'span',
{ class: 'mention' },
`@${node.attrs.userFullName}`,
],
},
},
});
@@ -439,3 +456,173 @@ describe('setURLWithQueryAndSize', () => {
expect(editorView.dispatch).not.toHaveBeenCalled();
});
});
describe('getContentNode', () => {
let mockEditorView;
beforeEach(() => {
mockEditorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(attrs => ({
type: { name: 'mention' },
attrs,
})),
},
},
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
},
},
};
});
describe('mention node creation', () => {
it('creates a user mention node with correct attributes', () => {
const userContent = {
id: '123',
name: 'John Doe',
type: 'user',
};
const result = getContentNode(mockEditorView, 'mention', userContent, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '123',
userFullName: 'John Doe',
mentionType: 'user',
});
expect(result).toEqual({
node: {
type: { name: 'mention' },
attrs: {
userId: '123',
userFullName: 'John Doe',
mentionType: 'user',
},
},
from: 0,
to: 5,
});
});
it('creates a team mention node with correct attributes', () => {
const teamContent = {
id: '456',
name: 'Support Team',
type: 'team',
};
const result = getContentNode(mockEditorView, 'mention', teamContent, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '456',
userFullName: 'Support Team',
mentionType: 'team',
});
expect(result).toEqual({
node: {
type: { name: 'mention' },
attrs: {
userId: '456',
userFullName: 'Support Team',
mentionType: 'team',
},
},
from: 0,
to: 5,
});
});
it('defaults to user mention type when type is not specified', () => {
const contentWithoutType = {
id: '789',
name: 'Jane Smith',
};
getContentNode(mockEditorView, 'mention', contentWithoutType, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '789',
userFullName: 'Jane Smith',
mentionType: 'user',
});
});
it('uses displayName over name when both are provided', () => {
const contentWithDisplayName = {
id: '101',
name: 'john_doe',
displayName: 'John Doe (Admin)',
type: 'user',
};
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '101',
userFullName: 'John Doe (Admin)',
mentionType: 'user',
});
});
it('handles missing displayName by falling back to name', () => {
const contentWithoutDisplayName = {
id: '102',
name: 'jane_smith',
type: 'user',
};
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
from: 0,
to: 5,
});
expect(
mockEditorView.state.schema.nodes.mention.create
).toHaveBeenCalledWith({
userId: '102',
userFullName: 'jane_smith',
mentionType: 'user',
});
});
});
describe('unsupported node types', () => {
it('returns null node for unsupported type', () => {
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
from: 0,
to: 5,
});
expect(result).toEqual({
node: null,
from: 0,
to: 5,
});
});
});
});
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
import allCountries from 'shared/constants/countries.js';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
export const customAttributes = [
{
id: 1,
@@ -468,7 +473,7 @@ export const inboxes = [
welcome_title: '',
welcome_tagline: '',
web_widget_script:
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
reply_time: 'in_a_few_minutes',
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
];
export const languages = allLanguages;
export const countries = allCountries;
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
}));
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
}));
export const automationToSubmit = {
name: 'Fayaz',
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -147,7 +147,19 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
"CAPTAIN": {
@@ -473,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -539,6 +578,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "جلب الصفات المخصصة",
"DESCRIPTION": "سمة مخصصة تتتبع تفاصيل إضافية حول جهات الاتصال أو المحادثات الخاصة بك - مثل خطة الاشتراك أو تاريخ الشراء الأول. يمكنك إضافة أنواع مختلفة من السمات المخصصة، مثل النص أو القوائم أو الأرقام، لالتقاط المعلومات المحددة التي تحتاجها.",
"LEARN_MORE": "تعرف على المزيد حول السمات المخصصة",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال"
},
"ATTRIBUTE_TYPES": {
"TEXT": "النص",
"NUMBER": "العدد",
"LINK": "الرابط",
"DATE": "Date",
"LIST": "القائمة",
"CHECKBOX": "خانة الاختيار"
},
"ADD": {
"TITLE": "إضافة صفة خاصة",
"SUBMIT": "إنشاء",
@@ -147,7 +147,19 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "تغيير الأولوية",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "فتح المحادثة"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "لا شيء",
"LOW": "منخفضة",
"MEDIUM": "متوسطة",
"HIGH": "عالية",
"URGENT": "عاجل"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "الأسبوع القادم"
}
},
"MENTION": {
"AGENTS": "الوكلاء",
"TEAMS": "الفرق"
},
"CUSTOM_SNOOZE": {
"TITLE": "تأجيل حتى",
"APPLY": "تأجيل",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "إعدادات الحساب",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "اضغط على زر الإدخال للاختيار",
"ENTER_TO_REMOVE": "اضغط على زر الإدخال للحذف",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "اختر واحدا",
"SELECT": "اختر"
}
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "رقم الهاتف",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "معرف حساب Twilio (يعرف أيضاً بـ Account SID)",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "رمز المصادقة Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "مفتاح API SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "مفتاح سر API",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "قناة API",
"DESC": "اربط مع قناة API وابدأ في دعم عملائك.",
@@ -789,7 +829,8 @@
"TELEGRAM": "تيليجرام",
"LINE": "Line",
"API": "قناة API",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "نعم، احذف",
"CANCEL": "إلغاء"
}
}
},
"CAPTAIN": {
@@ -473,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -539,6 +578,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "حذف",
"BULK_APPROVE": {
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "تغيير الأولوية",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "لا شيء",
"LOW": "منخفضة",
"MEDIUM": "متوسطة",
"HIGH": "عالية",
"URGENT": "عاجل"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -147,7 +147,19 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
"CAPTAIN": {
@@ -473,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -539,6 +578,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Разговор",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Създаване",
@@ -147,7 +147,19 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Next week"
}
},
"MENTION": {
"AGENTS": "Агенти",
"TEAMS": "Teams"
},
"CUSTOM_SNOOZE": {
"TITLE": "Snooze until",
"APPLY": "Snooze",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Account settings",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Yes, delete",
"CANCEL": "Отмени"
}
}
},
"CAPTAIN": {
@@ -473,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -539,6 +578,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Изтрий",
"BULK_APPROVE": {
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "S'estan recollint atributs personalitzats",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversa",
"CONTACT": "Contacte"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Llista",
"NUMBER": "Número",
"LINK": "Enllaç",
"DATE": "Date",
"LIST": "Llista",
"CHECKBOX": "Casella de selecció"
},
"ADD": {
"TITLE": "Afegir atribut personalitzat",
"SUBMIT": "Crear",
@@ -147,7 +147,19 @@
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"CHANGE_PRIORITY": "Canvia la prioritat",
"ADD_SLA": "Afegeix SLA"
"ADD_SLA": "Afegeix SLA",
"OPEN_CONVERSATION": "Obrir conversa"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Ningú",
"LOW": "Baixa",
"MEDIUM": "Mitjana",
"HIGH": "Alta",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}
@@ -96,6 +96,10 @@
"NEXT_WEEK": "Pròxima setmana"
}
},
"MENTION": {
"AGENTS": "Agents",
"TEAMS": "Equips"
},
"CUSTOM_SNOOZE": {
"TITLE": "Posposat fins a",
"APPLY": "Posposat",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "You have exceeded the conversation limit. Hacker plan allows only 500 conversations.",
"INBOXES": "You have exceeded the inbox limit. Hacker plan only supports website live-chat. Additional inboxes like email, WhatsApp etc. require a paid plan.",
"AGENTS": "You have exceeded the agent limit. Hacker plan allows only 2 agents.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"NON_ADMIN": "Please contact your administrator to upgrade the plan and continue using all features."
},
"TITLE": "Configuració del compte",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Presiona retorn (tecla enter) per seleccionar",
"ENTER_TO_REMOVE": "Presiona retorn (tecla enter) per eliminar",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Selecciona un",
"SELECT": "Selecciona"
}
@@ -268,6 +268,46 @@
"ERROR_MESSAGE": "No hem pogut desar el canal WhatsApp"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"PHONE_NUMBER": {
"LABEL": "Número de telèfon",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Compte SID",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
},
"AUTH_TOKEN": {
"LABEL": "Token d'autenticació",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
},
"API_KEY_SID": {
"LABEL": "API Key SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
}
},
"API_CHANNEL": {
"TITLE": "Canal de l'API",
"DESC": "Integra’t amb el canal API i comença a donar suport als teus clients.",
@@ -789,7 +829,8 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "Canal de l'API",
"INSTAGRAM": "Instagram"
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
}
}
}
@@ -331,6 +331,14 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"NOTION": {
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
"CONFIRM": "Sí, esborra",
"CANCEL": "Cancel·la"
}
}
},
"CAPTAIN": {
@@ -473,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -539,6 +578,8 @@
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Esborrar",
"BULK_APPROVE": {
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Canvia la prioritat",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "Ningú",
"LOW": "Baixa",
"MEDIUM": "Mitjana",
"HIGH": "Alta",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -147,7 +147,19 @@
"SEND_ATTACHMENT": "Odeslat přílohu",
"SEND_MESSAGE": "Odeslat zprávu",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA"
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Nic",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Typ zprávy",
@@ -49,5 +49,8 @@
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
}
}

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