Compare commits

..
Author SHA1 Message Date
Paul Atreides 676493eff2 chore: more changes 2025-08-24 10:04:13 +05:30
Paul Atreides b0d8eb3a2b feat: Roll out WhatsApp Embedded Signup for all users 2025-08-24 09:59:22 +05:30
Sivin VargheseandGitHub 655db56be9 chore: Increase Category index per-page limit to 1000 (#12282)
# Pull Request Template

## Description

This PR increases the per-page limit for the Category index endpoint
from 25 to 1000.

Fixes
[CW-5486](https://linear.app/chatwoot/issue/CW-5486/you-cannot-work-with-more-than-25-help-center-categories)
https://github.com/chatwoot/chatwoot/issues/12274

## Type of change

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/431fb48491a44578851621fc4607c4dd?sid=81198d89-e8c0-4fb9-b93f-8b4dbf524a46


## 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-08-22 12:41:38 -07:00
PranavandGitHub 7f56cd92f8 chore: Update the prompts to include language of the account for FAQs (#12280)
There were customer reported issues with FAQs which were generated in a
different langauge than what they were expecting. The reason behind this
was that the language of the account was not considered in the prompt
provided. If the language of the content was say Spanish, and the
account locale was english. The output was not predicable. The output
depends on the model and the execution time.

This PR would update the prompt to behave consistently with the account
locale. Even though the content provided is in a different language, it
would generate FAQs in the account locale.

Changes:
- Updated the prompt to include a detailed expectation of the FAQs
quality along with the language
- Added specs for the services where the prompt generator is called.

Tested the prompt using Phoenix playground across GPT 5, GPT 4.1, GPT
4.0. The reasoning setting for GPT 5 needs to be low so that it doesn't
generate random questions like "What was this updated?"
2025-08-22 10:03:52 -07:00
Vishnu NarayananandGitHub c07529c2b0 fix: prevent filter text from being cutoff in superadmin console (#12238) 2025-08-22 20:39:11 +05:30
Sojan JoseandGitHub d48503bdcf feat(voice): Improved voice call creation flow [EE] (#12268)
This PR improves the voice call creation flow by simplifying
configuration and automating setup with Twilio APIs.

references: #11602 , #11481 

## Key changes

- Removed the requirement for twiml_app_sid – provisioning is now
automated through APIs.
- Auto-configured webhook URLs for:
  - Voice number callbacks
  - Status callbacks
  -  twiML callbacks
- Disabled business hours, help center, and related options until voice
inbox is fully supported.
- Added a configuration tab in the voice inbox to display the required
Twilio URLs (to make verification easier in Twilio console).


## Test Cases
- Provisioning
- Create a new voice inbox → verify that Twilio app provisioning happens
automatically.
  - Verify twiML callback 
- Webhook configuration
- Check that both voice number callback and status callback URLs are
auto-populated in Twilio.
- Disabled features
- Confirm that business hours and help center options are
hidden/disabled for voice inbox.
- Configuration tab
- Open the voice inbox configuration tab → verify that the displayed
Twilio URLs match what’s set in Twilio.
2025-08-22 13:38:23 +02:00
d35b0a9465 feat: Convert availability slots to local timezone (#12273)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-08-22 16:12:24 +05:30
83873d49ae fix(meta): use dynamic installation name in vueapp.html.erb (#11799)
This PR updates the meta description in `vueapp.html.erb` to use the
dynamic installation name, replacing the previously hardcoded "Chatwoot"
brand.
**Motivation:** This change allows self-hosted instances to display
their own branding in meta descriptions, improving customization and SEO
for custom installations.


Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-08-22 12:07:36 +02:00
6ca38e10e9 feat: Migrate availability mixins to composable and helper (#11596)
# Pull Request Template

## Description

**This PR includes:**

* Refactored two legacy mixins (`availability.js`,
`nextAvailability.js`) into a Vue 3 composable (`useAvailability`),
helper module and component based rendering logic.
* Fixed an issue where the widget wouldn't load if business hours were
enabled but all days were unchecked.
* Fixed translation issue
[[#11280](https://github.com/chatwoot/chatwoot/issues/11280)](https://github.com/chatwoot/chatwoot/issues/11280).
* Reduced code complexity and size.
* Added test coverage for both the composable and helper functions.

## 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/2bc3ed694b4349419505e275d14d0b98?sid=22d585e4-0dc7-4242-bcb6-e3edc16e3aee

### Story
<img width="995" height="442" alt="image"
src="https://github.com/user-attachments/assets/d6340738-07db-41d5-86fa-a8ecf734cc70"
/>



## Checklist:

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


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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-08-22 00:43:34 +05:30
Vishnu NarayananandGitHub 1a1dfd09cb chore: add tidewave gem for development (#12236)
-  add tidewave gem for development

ref: https://github.com/tidewave-ai/tidewave_rails
2025-08-21 15:55:27 +02:00
Muhsin KelothandGitHub 35d0a7f1a7 feat: Add liquid template support for WhatsApp template parameters (#12227)
Extends liquid template processing to WhatsApp `template_params`,
allowing dynamic variable substitution in template parameter values.

Users can now use liquid variables in WhatsApp template parameters:

```
{
  "template_params": {
    "name": "greet",
    "category": "MARKETING",
    "language": "en",
    "processed_params": {
      "body": {
        "customer_name": "{{contact.name}}",
        "customer_email": "{{contact.email | default: 'no-email@example.com'}}"
      }
    }
  }
}

```

When the message is saved, {{contact.name}} gets replaced with the
actual contact name.

Supported Variables

- {{contact.name}}, {{contact.email}}, {{contact.phone_number}}
- {{agent.name}}, {{agent.first_name}}
- {{account.name}}, {{inbox.name}}
- {{conversation.display_id}}
- Custom attributes: {{contact.custom_attribute.key_name}}
- Liquid filters: {{ contact.email | default: "fallback@example.com" }}
2025-08-21 16:44:51 +05:30
47867c0b8a fix: cwctl version to handle the upgrade loop (#12232)
- fix: cwctl version to handle the upgrade loop


Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2025-08-21 14:56:29 +05:30
530125d4c5 chore(deps): upgrade twilio-ruby to 7.6.0 for upcoming features (#12243)
### Summary

- Update Twilio gem to support latest features and API changes.
- No app code changes; Gemfile and Gemfile.lock only.

references: #11602 , #11481 

### Testing

- Existing Twilio SMS: send/receive still works; delivery status
updates.
- Existing Twilio WhatsApp: send/receive still works; templates (if
used) unaffected.
- Create new Twilio SMS/WhatsApp inboxes: can be created and can
send/receive messages.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-08-21 11:07:43 +02:00
Sojan Jose 7679004bb4 Merge branch 'release/4.5.2' into develop 2025-08-20 21:45:41 +02:00
Sojan Jose ae3ac33049 Bump version to 4.5.2 2025-08-20 21:44:30 +02:00
Sojan JoseandGitHub 714f24de11 revert: "fix(sdk): Ignore messages from a different origin and sanitizee URLs (#8879)" (#12248)
This reverts commit a42b99ada0.

fixes: #12247
2025-08-20 21:39:50 +02:00
Sojan JoseandGitHub 3038e672f8 chore(annotations): sync model annotations with current schema (#12245)
- Update Schema Information headers for AssignmentPolicy, Campaign,
Notification
- Reflect schema change for Campaign.template_params (not null with
default)
- Keep annotations consistent to avoid drift
2025-08-20 20:23:42 +02:00
Sojan Jose 7aad3c9c6b Merge branch 'release/4.5.1' into develop 2025-08-20 16:25:33 +02:00
Sojan Jose c6113852d7 Bump version to 4.5.1 2025-08-20 16:23:38 +02:00
Sojan JoseandGitHub 94e930a141 fix(migrations): skip AddFeatureCitationToAssistantConfig on OSS (#12244) 2025-08-20 19:15:19 +05:30
7fe94dc1a2 feat(voice): Call button on Contacts with inbox picker (#12218)
## Description
This introduces a reusable Call button used in the Contacts details
header (between Block contact and Send message) and
in the conversation contact panel. The button shows only when the
account has at least one Voice inbox and the contact
has a phone number. If multiple Voice inboxes are present, clicking
opens a picker modal; otherwise, a “Calling is under
development” toast is shown

> Actual wiring to functionality will available in follow up PRs

references: #11602 , #11481 

## Screens 

<img width="250" alt="Screenshot 2025-08-18 at 8 33 02 PM"
src="https://github.com/user-attachments/assets/d7a09a9d-8eff-4461-a75f-27854540c2a0"
/>
<img width="250" alt="Screenshot 2025-08-18 at 8 32 31 PM"
src="https://github.com/user-attachments/assets/682ae66e-dd5f-4750-9c30-0a210e120250"
/>
<img width="250" alt="Screenshot 2025-08-18 at 8 32 40 PM"
src="https://github.com/user-attachments/assets/7de0d753-eefc-4b7f-942b-ecca1964fcd7"
/>


## Test Cases

- Enable voice feature and create inboxes and test the cases for both
contact details view and contact card view in conversation
  - Details: 0 Voice inboxes → no Call button.
- Details: 1+ Voice inboxes, no phone number for contact → no Call
button.
- Details: 1 Voice inbox + phone number for contact → button visible;
click → toast.
- Details: >1 Voice inboxes + phone number for contact → click → modal →
choose → toast.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-08-19 15:42:01 +05:30
Chatwoot BotandGitHub cae097c5fa chore: Update translations (#12208) 2025-08-19 12:27:13 +05:30
faf35738b3 fix: Prevent reopening a resolved conversation (#11168)
# Pull Request Template

## Description

This PR addresses the issue where navigating back and starting a new
conversation incorrectly shows the previous messages or message screen.

Fixes
https://linear.app/chatwoot/issue/CW-4169/prevent-continue-conversation-in-previously-resolved-conversation

## Type of change

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

## How Has This Been Tested?

### Loom video
**Before**

https://www.loom.com/share/18172a3b26ff4e8faf8e1c3c1a0ba279?sid=ffbda52a-93e1-400f-bedc-17b925bae4d3

**After**

https://www.loom.com/share/584177d411424ad38c6812be868eb060?sid=fe5e771a-3faa-4c14-a5fe-918a3ccdb408

## 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: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-08-19 12:06:22 +05:30
341487b93e feat: Add assignment policies controllers with jbuilder views (#12199)
## Linear reference:
https://linear.app/chatwoot/issue/CW-4649/re-imagine-assignments

## Description
This PR introduces the foundation for Assignment V2 system by
implementing assignment policies and their association with inboxes.
Assignment policies allow configuring how conversations are distributed
among agents, with support for different assignment orders (round_robin
in community, balanced in enterprise) and conversation prioritization
strategies

Fixes # (issue)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] 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?

Test Coverage:
-  Controller specs for assignment policies CRUD operations
-  Enterprise-specific specs for balanced assignment order
-  Model specs for community/enterprise separation

Manual Testing:
1. Create assignment policy: POST
/api/v1/accounts/{id}/assignment_policies
2. List policies: GET /api/v1/accounts/{id}/assignment_policies
3. Assign policy to inbox: POST
/api/v1/accounts/{id}/assignment_policies/{id}/inboxes
4. View inbox policy: GET
/api/v1/accounts/{id}/inboxes/{id}/assignment_policy
5. Verify community edition ignores "balanced" assignment order
6. Verify enterprise edition supports both "round_robin" and "balanced"

- testing the flows after enterprise folder deletion

## Checklist:

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

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-08-18 19:15:21 -07:00
Sojan Jose f569ed93ad Merge branch 'release/4.5.0' into develop 2025-08-18 18:40:19 +02:00
Sojan Jose eac292b11b Bump version to 4.5.0 2025-08-18 18:39:28 +02:00
Vishnu NarayananandGitHub 75119d4a08 fix: switch to datadog v2 gem (#12214)
# Pull Request Template

## Description
- The `0.48` version of the `ddtrace` gem was out of date, which was
causing the application to crash if `DD_AGENT_URL` was configured
- Switch to `datadog` gem, which is the currently maintained gem from DD


Ref: https://github.com/DataDog/dd-trace-rb/releases/tag/v2.0.0
2025-08-18 21:54:27 +05:30
Muhsin KelothandGitHub 67d7ee0185 feat: Add full change log to profile (#12215)
**Preview**
<img width="400" height="600" alt="CleanShot 2025-08-18 at 19 48 37@2x"
src="https://github.com/user-attachments/assets/28983793-adf4-4145-bb80-573e21cf5a0d"
/>
2025-08-18 21:40:13 +05:30
Sojan JoseandGitHub 7e33e033cd docs: update AGENTS.md to include enterprise guidelines (#12213)
- Update agent.md to include enterprise guidelines
2025-08-18 16:50:34 +05:30
Sivin VargheseandGitHub 02fa76f3df chore: Update contact empty state data (#12207)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5432/use-a-different-company-nameemail-for-the-empty-state-in-contacts


## How Has This Been Tested?

### Screenshot
<img width="1044" height="555" alt="image"
src="https://github.com/user-attachments/assets/a414f88f-13ca-4c1e-bb76-cd5b9217d21f"
/>


## 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-08-18 12:06:03 +05:30
91d80004a6 chore: Improvements in scenarios (#12098)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-14 19:07:28 +05:30
b809cd2f15 chore: Optimize contact page for smaller displays (#12183)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-08-14 19:07:20 +05:30
135 changed files with 3902 additions and 3420 deletions
+18 -1
View File
@@ -55,4 +55,21 @@
## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles
- Use compact `module/class` definitions; avoid nested styles
## Enterprise Edition Notes
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
- Follow the Enterprise development practices documented here:
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
Practical checklist for any change impacting core logic or public APIs
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
- If adding new endpoints, services, or models, consider whether Enterprise needs:
- An override (e.g., `enterprise/app/...`), or
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
+4 -2
View File
@@ -89,7 +89,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.66'
gem 'twilio-ruby'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
@@ -108,7 +108,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
# ref application.rb
gem 'ddtrace', require: false
gem 'datadog', '~> 2.0', require: false
gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
@@ -212,6 +212,8 @@ group :development do
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
gem 'tidewave'
end
group :test do
+78 -21
View File
@@ -194,10 +194,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.4.1)
ddtrace (0.48.0)
ffi (~> 1.0)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -226,6 +230,35 @@ GEM
addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.1.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.2.0)
dry-initializer (3.2.0)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-schema (1.14.1)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.1)
dry-initializer (~> 3.2)
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.6.2)
@@ -248,8 +281,10 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
@@ -257,13 +292,20 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.1.0)
net-http
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
fast-mcp (1.5.0)
addressable (~> 2.8)
base64
dry-schema (~> 1.14)
json (~> 2.0)
mime-types (~> 3.4)
rack (~> 3.1)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
@@ -417,7 +459,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.8.1)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -444,6 +486,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -489,7 +541,7 @@ GEM
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
@@ -577,7 +629,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.15)
rack (3.2.0)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -586,19 +638,20 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (3.2.0)
rack-protection (4.1.1)
base64 (>= 0.1.0)
rack (~> 2.2, >= 2.2.4)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (1.0.2)
rack (< 3)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rackup (1.0.1)
rack (< 3)
webrick
rackup (2.2.1)
rack (>= 3)
rails (7.1.5.2)
actioncable (= 7.1.5.2)
actionmailbox (= 7.1.5.2)
@@ -829,13 +882,17 @@ GEM
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
tidewave (0.2.0)
fast-mcp (~> 1.5.0)
rack (>= 2.0)
rails (>= 7.1.0)
tilt (2.3.0)
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0)
@@ -882,7 +939,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
@@ -928,7 +984,7 @@ DEPENDENCIES
commonmarker
csv-safe
database_cleaner
ddtrace
datadog (~> 2.0)
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
@@ -1026,8 +1082,9 @@ DEPENDENCIES
stripe
telephone_number
test-prof
tidewave
time_diff
twilio-ruby (~> 5.66)
twilio-ruby
twitty (~> 0.1.5)
tzinfo-data
uglifier
+1 -1
View File
@@ -1 +1 @@
3.4.2
3.4.3
@@ -0,0 +1,20 @@
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy
before_action -> { check_authorization(AssignmentPolicy) }
def index
@inboxes = @assignment_policy.inboxes
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(
params[:assignment_policy_id]
)
end
def permitted_params
params.permit(:assignment_policy_id)
end
end
@@ -1,42 +1,24 @@
# frozen_string_literal: true
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
@assignment_policies = Current.account.assignment_policies
end
def show
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
end
def show; end
def create
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
if @assignment_policy.save
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
end
def update
if @assignment_policy.update(assignment_policy_params)
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
@assignment_policy.update!(assignment_policy_params)
end
def destroy
if @assignment_policy.destroy
head :ok
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
@assignment_policy.destroy!
head :ok
end
private
@@ -51,29 +33,4 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
def serialize_assignment_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
assignment_order: policy.assignment_order,
conversation_priority: policy.conversation_priority,
fair_distribution_limit: policy.fair_distribution_limit,
fair_distribution_window: policy.fair_distribution_window,
enabled: policy.enabled,
inbox_count: policy.inboxes.count,
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_assignment_policies(policies)
policies.map { |policy| serialize_assignment_policy(policy) }
end
def check_authorization
authorize(AssignmentPolicy)
end
end
@@ -1,81 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :check_authorization
def show
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}
else
render json: {
inbox_assignment_policy: nil,
message: 'No assignment policy assigned to this inbox'
}
end
end
def create
# Remove existing assignment if any
@inbox.inbox_assignment_policy&.destroy
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
if @inbox_assignment_policy.save
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}, status: :created
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
if @inbox_assignment_policy.destroy
head :ok
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def check_authorization
authorize(@inbox, :update?)
end
def serialize_inbox_assignment_policy(inbox_assignment_policy)
{
id: inbox_assignment_policy.id,
inbox_id: inbox_assignment_policy.inbox_id,
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
assignment_policy: {
id: inbox_assignment_policy.assignment_policy.id,
name: inbox_assignment_policy.assignment_policy.name,
description: inbox_assignment_policy.assignment_policy.description,
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
enabled: inbox_assignment_policy.assignment_policy.enabled
},
created_at: inbox_assignment_policy.created_at,
updated_at: inbox_assignment_policy.updated_at
}
end
end
@@ -0,0 +1,46 @@
class Api::V1::Accounts::Inboxes::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_assignment_policy, only: [:create]
before_action -> { check_authorization(AssignmentPolicy) }
before_action :validate_assignment_policy, only: [:show, :destroy]
def show
@assignment_policy = @inbox.assignment_policy
end
def create
# There should be only one assignment policy for an inbox.
# If there is a new request to add an assignment policy, we will
# delete the old one and attach the new policy
remove_inbox_assignment_policy
@inbox_assignment_policy = @inbox.create_inbox_assignment_policy!(assignment_policy: @assignment_policy)
@assignment_policy = @inbox.assignment_policy
end
def destroy
remove_inbox_assignment_policy
head :ok
end
private
def remove_inbox_assignment_policy
@inbox.inbox_assignment_policy&.destroy
end
def fetch_inbox
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
end
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(permitted_params[:assignment_policy_id])
end
def permitted_params
params.permit(:assignment_policy_id, :inbox_id)
end
def validate_assignment_policy
return render_not_found_error(I18n.t('errors.assignment_policy.not_found')) unless @inbox.assignment_policy
end
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -65,15 +64,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, 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?
@@ -1,11 +1,13 @@
<script setup>
import { computed, useSlots } from 'vue';
import { computed, useSlots, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
const props = defineProps({
selectedContact: {
@@ -24,6 +26,8 @@ const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const isContactSidebarOpen = ref(false);
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
@@ -56,6 +60,15 @@ const handleBreadcrumbClick = () => {
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
const handleConversationSidebarToggle = () => {
isContactSidebarOpen.value = !isContactSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isContactSidebarOpen.value) return;
isContactSidebarOpen.value = false;
};
</script>
<template>
@@ -67,7 +80,9 @@ const toggleBlock = () => {
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
@@ -85,6 +100,11 @@ const toggleBlock = () => {
:disabled="isUpdating"
@click="toggleBlock"
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
@@ -105,11 +125,65 @@ const toggleBlock = () => {
</main>
</div>
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
<!-- Mobile sidebar container -->
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
>
<!-- Toggle button -->
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#contact-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isContactSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
:icon="
isContactSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-contact-sidebar-toggle
@click="handleConversationSidebarToggle"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<slot name="sidebar" />
</div>
</Transition>
</div>
</section>
</template>
@@ -34,13 +34,13 @@ const emit = defineEmits([
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2">
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
@@ -48,6 +48,7 @@ const emit = defineEmits([
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
@@ -58,64 +59,66 @@ const emit = defineEmits([
</template>
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<div class="flex items-center flex-shrink-0 gap-4">
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
</div>
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
</div>
<Button
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
variant="ghost"
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -291,17 +291,20 @@ defineExpose({
@delete-segment="openDeleteSegmentDialog"
>
<template #filter>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
<div
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
</div>
</template>
</ContactsHeader>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -96,10 +96,7 @@ const openFilter = () => {
<slot name="default" />
</div>
</main>
<footer
v-if="showPaginationFooter"
class="sticky bottom-0 z-10 px-4 pb-4"
>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -4,8 +4,8 @@ export default [
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
"I'm Candice, a developer focusing on building web solutions. Currently, I’m working as a Product Developer at Lumora.",
companyName: 'Lumora',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
@@ -16,7 +16,7 @@ export default [
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
email: 'candice.matherson@lumora.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
@@ -0,0 +1,91 @@
<script setup>
import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
tooltipLabel: { type: String, default: '' },
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const { t } = useI18n();
const inboxesList = useMapGetter('inboxes/getInboxes');
const voiceInboxes = computed(() =>
(inboxesList.value || []).filter(
inbox => inbox.channel_type === INBOX_TYPES.VOICE
)
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const dialogRef = ref(null);
const onClick = () => {
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
}
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
};
const onPickInbox = () => {
// Placeholder until actual call wiring happens
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
dialogRef.value?.close();
};
</script>
<template>
<span class="contents">
<Button
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:label="label"
:icon="icon"
:size="size"
@click="onClick"
/>
<Dialog
v-if="shouldRender && voiceInboxes.length > 1"
ref="dialogRef"
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
show-cancel-button
:show-confirm-button="false"
width="md"
>
<div class="flex flex-col gap-2">
<button
v-for="inbox in voiceInboxes"
:key="inbox.id"
type="button"
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
@click="onPickInbox(inbox)"
>
<div class="flex items-center gap-2">
<span class="i-ri-phone-fill text-n-slate-10" />
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
</div>
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
{{ inbox.phone_number }}
</span>
</button>
</div>
</Dialog>
</span>
</template>
@@ -47,6 +47,7 @@ const unreadMessagesCount = computed(() => {
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
@@ -96,6 +96,7 @@ defineExpose({
/>
</div>
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
@@ -21,9 +21,17 @@ const onClick = (item, index) => {
</script>
<template>
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<nav
:aria-label="t('BREADCRUMB.ARIA_LABEL')"
class="flex items-center h-8 min-w-0"
>
<ol class="flex items-center mb-0 min-w-0">
<li
v-for="(item, index) in items"
:key="index"
class="flex items-center"
:class="{ 'min-w-0 flex-1': index === items.length - 1 }"
>
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
@@ -40,7 +48,7 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
@@ -52,10 +52,10 @@ const handleBreadcrumbClick = item => {
<template>
<section
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
class="px-6 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 mb-4">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
@@ -1,7 +1,7 @@
<script setup>
import { computed, h, reactive } from 'vue';
import { computed, h, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useToggle, useElementSize } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
@@ -11,6 +11,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -31,7 +32,7 @@ const props = defineProps({
},
tools: {
type: Array,
required: true,
default: () => [],
},
selectable: {
type: Boolean,
@@ -60,7 +61,13 @@ const state = reactive({
instruction: '',
});
const instructionContentRef = ref();
const [isEditing, toggleEditing] = useToggle();
const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
const { height: contentHeight } = useElementSize(instructionContentRef);
const needsOverlay = computed(() => contentHeight.value > 160);
const startEdit = () => {
Object.assign(state, {
@@ -111,7 +118,7 @@ const LINK_INSTRUCTION_CLASS =
const renderInstruction = instruction => () =>
h('p', {
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
class: `text-sm text-n-slate-12 py-4 mb-0 prose prose-sm min-w-0 break-words max-w-none ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
</script>
@@ -157,8 +164,38 @@ const renderInstruction = instruction => () =>
/>
</div>
</div>
<component :is="renderInstruction(formatMessage(instruction, false))" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
<div
class="relative overflow-hidden transition-all duration-300 ease-in-out group/expandable"
:class="{ 'cursor-pointer': needsOverlay }"
:style="{
maxHeight: isInstructionExpanded ? `${contentHeight}px` : '10rem',
}"
@click="needsOverlay ? toggleInstructionExpanded() : null"
>
<div ref="instructionContentRef">
<component
:is="renderInstruction(formatMessage(instruction, false))"
/>
</div>
<div
class="absolute bottom-0 w-full flex items-end justify-center text-xs text-n-slate-11 bg-gradient-to-t h-40 from-n-solid-2 via-n-solid-2 via-10% to-transparent transition-all duration-500 ease-in-out px-2 py-1 rounded pointer-events-none"
:class="{
'visible opacity-100': !isInstructionExpanded,
'invisible opacity-0': isInstructionExpanded || !needsOverlay,
}"
>
<Icon
icon="i-lucide-chevron-down"
class="text-n-slate-7 mb-4 size-4 group-hover/expandable:text-n-slate-11 transition-colors duration-200"
/>
</div>
</div>
<span
v-if="tools?.length"
class="text-sm text-n-slate-11 font-medium mb-1"
>
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ tools?.map(tool => `@${tool}`).join(', ') }}
</span>
@@ -192,6 +192,7 @@ defineExpose({ validate });
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click.stop="emit('remove')"
/>
</div>
@@ -104,7 +104,7 @@ const outsideClickHandler = [
<template>
<div
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
class="z-40 max-w-3xl min-w-96 lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
@@ -146,10 +146,10 @@ const outsideClickHandler = [
</template>
</ul>
<div class="flex justify-between gap-2">
<Button sm ghost blue @click="addFilter">
<Button sm ghost blue class="flex-shrink-0" @click="addFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
</Button>
<div class="flex gap-2">
<div class="flex gap-2 flex-shrink-0">
<Button sm faded slate @click="resetFilter">
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
</Button>
@@ -86,6 +86,15 @@ const menuItems = computed(() => {
nativeLink: true,
target: '_blank',
},
{
show: true,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CHANGELOG'),
icon: 'i-lucide-scroll-text',
link: 'https://www.chatwoot.com/changelog/',
nativeLink: true,
target: '_blank',
},
{
show: currentUser.value.type === 'SuperAdmin',
showOnCustomBrandedInstance: true,
@@ -114,7 +123,7 @@ const allowedMenuItems = computed(() => {
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
<template #trigger="{ toggle, isOpen }">
<button
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
:class="{ 'bg-n-alpha-1': isOpen }"
@click="toggle"
>
@@ -127,16 +136,16 @@ const allowedMenuItems = computed(() => {
rounded-full
/>
<div class="min-w-0">
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
{{ currentUser.available_name }}
</div>
<div class="text-n-slate-11 text-xs truncate">
<div class="text-xs truncate text-n-slate-11">
{{ currentUser.email }}
</div>
</div>
</button>
</template>
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<DropdownBody class="bottom-12 z-50 mb-2 w-80 ltr:left-0 rtl:right-0">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
-1
View File
@@ -37,7 +37,6 @@ 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',
};
@@ -17,6 +17,11 @@
"IP_ADDRESS": "IP Address",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
"TITLE": "Previous Conversations"
@@ -272,8 +272,8 @@
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -296,7 +296,10 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already linked to an Embedded Signup, or if you're a tech provider onboarding your own number, please use the",
"MANUAL_SETUP_LINK": "manual setup flow",
"MANUAL_FALLBACK_INSTEAD": "instead"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -330,13 +333,14 @@
"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"
}
},
"CONFIGURATION": {
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
@@ -226,6 +226,7 @@
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
"DOCS": "Read documentation",
"CHANGELOG": "Changelog",
"LOGOUT": "Log out"
},
"APP_GLOBAL": {
@@ -13,7 +13,7 @@
"TEXT": "Texto",
"NUMBER": "Número",
"LINK": "Link",
"DATE": "Date",
"DATE": "Data",
"LIST": "Lista",
"CHECKBOX": "Checkbox"
},
@@ -131,7 +131,7 @@
"CONVERSATION_CREATED": "Conversa Criada",
"CONVERSATION_UPDATED": "Conversa Atualizada",
"MESSAGE_CREATED": "Mensagem Criada",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_RESOLVED": "Conversa resolvida",
"CONVERSATION_OPENED": "Conversa Aberta"
},
"ACTIONS": {
@@ -153,8 +153,8 @@
"OPEN_CONVERSATION": "Abrir conversa"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
"INCOMING": "Mensagem Recebida",
"OUTGOING": "Mensagem de Saída"
},
"PRIORITY_TYPES": {
"NONE": "Nenhuma",
@@ -138,11 +138,11 @@
}
},
"WHATSAPP": {
"HEADER_TITLE": "WhatsApp campaigns",
"HEADER_TITLE": "Campanhas do WhatsApp",
"NEW_CAMPAIGN": "Criar campanha",
"EMPTY_STATE": {
"TITLE": "No WhatsApp campaigns are available",
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
"TITLE": "Nenhuma campanha do WhatsApp está disponível",
"SUBTITLE": "Inicie uma campanha do WhatsApp para atingir seus clientes diretamente. Envie ofertas ou faça anúncios facilmente. Clique em \"Criar campanha\" para começar."
},
"CARD": {
"STATUS": {
@@ -155,7 +155,7 @@
}
},
"CREATE": {
"TITLE": "Create WhatsApp campaign",
"TITLE": "Criar campanha do WhatsApp",
"CANCEL_BUTTON_TEXT": "Cancelar",
"CREATE_BUTTON_TEXT": "Criar",
"FORM": {
@@ -170,15 +170,15 @@
"ERROR": "Caixa de entrada obrigatória"
},
"TEMPLATE": {
"LABEL": "WhatsApp Template",
"PLACEHOLDER": "Select a template",
"INFO": "Select a template to use for this campaign.",
"ERROR": "Template is required",
"LABEL": "Modelo do WhatsApp",
"PLACEHOLDER": "Selecione um modelo",
"INFO": "Selecione um modelo para usar para esta campanha.",
"ERROR": "Modelo é obrigatório",
"PREVIEW_TITLE": "Processar {templateName}",
"LANGUAGE": "Idioma",
"CATEGORY": "Categoria",
"VARIABLES_LABEL": "Variáveis",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
"VARIABLE_PLACEHOLDER": "Digite um valor para {variable}"
},
"AUDIENCE": {
"LABEL": "Público",
@@ -195,7 +195,7 @@
"CANCEL": "Cancelar"
},
"API": {
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
"SUCCESS_MESSAGE": "Campanha do WhatsApp criada com sucesso",
"ERROR_MESSAGE": "Houve um erro. Por favor, tente novamente."
}
}
@@ -51,6 +51,6 @@
"PLACEHOLDER": "Insira a duração"
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
"COMING_SOON": "Em breve!"
}
}
@@ -144,9 +144,9 @@
"AGENTS_LOADING": "Carregando agentes...",
"ASSIGN_TEAM": "Atribuir time",
"DELETE": "Excluir conversa",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"OPEN_IN_NEW_TAB": "Abrir em nova aba",
"COPY_LINK": "Copiar link da conversa",
"COPY_LINK_SUCCESS": "Link da conversa copiado",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "ID da conversa {conversationId} atribuído para \"{agentName}\"",
@@ -3,7 +3,7 @@
"LIMIT_MESSAGES": {
"CONVERSATION": "Você excedeu o limite de conversas. O plano Hacker permite apenas 500 conversas.",
"INBOXES": "Você excedeu o limite da caixa de entrada. O plano Hacker só suporta chat ao vivo do site. Caixas adicionais como e-mail, WhatsApp etc. requerem um plano pago.",
"AGENTS": "You have exceeded the agent limit. Your plan only allows {allowedAgents} agents.",
"AGENTS": "Você excedeu o limite do agente. Seu plano permite apenas {allowedAgents} agentes.",
"NON_ADMIN": "Entre em contato com o administrador para atualizar o plano e continuar usando todos os recursos."
},
"TITLE": "Conta",
@@ -134,7 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Digite enter para selecionar",
"ENTER_TO_REMOVE": "Digite enter para remover",
"NO_OPTIONS": "List is empty",
"NO_OPTIONS": "Lista vazia",
"SELECT_ONE": "Selecione um",
"SELECT": "Selecionar"
}
@@ -160,8 +160,8 @@
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
"SUCCESS_MESSAGE": "Instruções do CNAME enviadas com sucesso",
"ERROR_MESSAGE": "Erro ao enviar as instruções CNAME"
}
}
},
@@ -732,7 +732,7 @@
"HOME_PAGE_LINK": {
"LABEL": "Link da Página Inicial",
"PLACEHOLDER": "Link da página inicial do portal",
"ERROR": "Enter a valid URL. The Home page link must start with 'http://' or 'https://'."
"ERROR": "Digite uma URL válida. O link da página inicial deve começar com 'http://' ou 'https://'."
},
"SLUG": {
"LABEL": "Slug",
@@ -753,14 +753,14 @@
"HEADER": "Domínio personalizado",
"LABEL": "Domínio personalizado:",
"DESCRIPTION": "Você pode hospedar seu portal em um domínio personalizado. Por exemplo, se seu site for meudominio.com e você quer o seu portal disponível em docs.meudominio.com, basta digitar isso neste campo.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"STATUS_DESCRIPTION": "Seu portal personalizado começará a funcionar assim que for verificado.",
"PLACEHOLDER": "Domínio personalizado do portal",
"EDIT_BUTTON": "Alterar",
"ADD_BUTTON": "Adicionar domínio personalizado",
"STATUS": {
"LIVE": "Em tempo real",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
"PENDING": "Aguardando verificação",
"ERROR": "Verificação falhou"
},
"DIALOG": {
"ADD_HEADER": "Adicionar domínio personalizado",
@@ -770,17 +770,17 @@
"LABEL": "Domínio personalizado",
"PLACEHOLDER": "Domínio personalizado do portal",
"ERROR": "Domínio personalizado é obrigatório",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"FORMAT_ERROR": "Por favor, insira um domínio de URL válido, ex.: docs.seudominio.com"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "Configuração de DNS",
"DESCRIPTION": "Faça o login na conta que você tem com seu provedor DNS e adicione um registro CNAME para subdomínio apontando para chatwoot.help",
"COPY": "Successfully copied CNAME",
"COPY": "CNAME copiado com sucesso",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"HEADER": "Enviar instruções",
"DESCRIPTION": "Se você preferir ter alguém da sua equipe de desenvolvimento para lidar com essa etapa, você pode digitar o endereço de e-mail abaixo e nós enviaremos as instruções necessárias.",
"PLACEHOLDER": "Insira o e-mail dele",
"ERROR": "Insira um endereço de e-mail válido",
"SEND_BUTTON": "Enviar"
}
}
@@ -74,21 +74,21 @@
"DELETE_ALL_READ": "Todas as notificações lidas foram excluídas"
},
"REAUTHORIZE": {
"TITLE": "Reauthorization Required",
"DESCRIPTION": "Your WhatsApp connection has expired. Please reconnect to continue receiving and sending messages.",
"BUTTON_TEXT": "Reconnect WhatsApp",
"LOADING_FACEBOOK": "Loading Facebook SDK...",
"SUCCESS": "WhatsApp reconnected successfully",
"ERROR": "Failed to reconnect WhatsApp. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"CONFIGURATION_ERROR": "Configuration error occurred during reauthorization.",
"FACEBOOK_LOAD_ERROR": "Failed to load Facebook SDK. Please try again.",
"TITLE": "Reautenticação necessária",
"DESCRIPTION": "Sua conexão com o WhatsApp expirou. Por favor, reconecte para continuar recebendo e enviando mensagens.",
"BUTTON_TEXT": "Reconectar WhatsApp",
"LOADING_FACEBOOK": "Carregando SDK do Facebook...",
"SUCCESS": "WhatsApp reconectado com sucesso",
"ERROR": "Falha ao reconectar o WhatsApp. Por favor, tente novamente.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID não está configurado. Por favor, contate seu administrador.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID não está configurado. Por favor, contate seu administrador.",
"CONFIGURATION_ERROR": "Ocorreu um erro de configuração ao reautenticar.",
"FACEBOOK_LOAD_ERROR": "Falha para carregar o SDK do Facebook. Por favor, tente novamente.",
"TROUBLESHOOTING": {
"TITLE": "Troubleshooting",
"POPUP_BLOCKED": "Ensure pop-ups are allowed for this site",
"COOKIES": "Third-party cookies must be enabled",
"ADMIN_ACCESS": "You need admin access to the WhatsApp Business Account"
"TITLE": "Solucionar problemas",
"POPUP_BLOCKED": "Certifique-se de que os pop-ups são permitidos para este site",
"COOKIES": "_Cookies_ de terceiros devem estar habilitados",
"ADMIN_ACCESS": "Você precisa de acesso de administrador na conta do WhatsApp Business"
}
}
}
@@ -225,13 +225,13 @@
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "Cloud do WhatsApp",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
"TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
"TITLE": "Selecione seu provedor de API",
"DESCRIPTION": "Escolha seu provedor do WhatsApp. Você pode se conectar diretamente através de metade, que não requer nenhuma configuração ou se conectar pelo Twilio usando as credenciais da sua conta."
},
"INBOX_NAME": {
"LABEL": "Nome da Caixa de Entrada",
@@ -272,74 +272,74 @@
},
"SUBMIT_BUTTON": "Criar canal do WhatsApp",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Configuração rápida com Meta",
"DESC": "Você será redirecionado para a Meta para entrar na sua conta do WhatsApp Business. Ter acesso administrativo ajudará a facilitar a instalação.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
"TITLE": "Benefícios da inscrição incorporada:",
"EASY_SETUP": "Nenhuma configuração manual é necessária",
"SECURE_AUTH": "Autenticação segura baseada em OAuth",
"AUTO_CONFIG": "Configuração automática de webhook e número de telefone"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"TEXT": "Para saber mais sobre inscrições integradas, preços e limitações visite",
"LINK_TEXT": "este link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUBMIT_BUTTON": "Conecte-se com WhatsApp Business",
"AUTH_PROCESSING": "Autenticando com Meta",
"WAITING_FOR_BUSINESS_INFO": "Por favor, complete a configuração do negócio na janela da Meta...",
"PROCESSING": "Configurando sua conta do WhatsApp Business",
"LOADING_SDK": "Carregando SDK do Facebook...",
"CANCELLED": "A inscrição no WhatsApp foi cancelada",
"SUCCESS_TITLE": "Conta do WhatsApp Business conectada!",
"WAITING_FOR_AUTH": "Aguardando autenticação...",
"INVALID_BUSINESS_DATA": "Dados de negócio inválidos recebidos do Facebook. Por favor, tente novamente.",
"SIGNUP_ERROR": "Ocorreu um erro no cadastro",
"AUTH_NOT_COMPLETED": "Autenticação não concluída. Por favor, reinicie o processo.",
"SUCCESS_FALLBACK": "A conta do WhatsApp Business foi configurada com sucesso"
},
"API": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
"TITLE": "Canal de Voz",
"DESC": "Integre o Twilio Voice e comece a oferecer suporte a seus clientes através de chamadas telefônicas.",
"PHONE_NUMBER": {
"LABEL": "Número de Telefone",
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
"PLACEHOLDER": "Digite seu número de telefone (por exemplo, +551234567890)",
"ERROR": "Por favor, forneça um número de telefone válido no formato E.164 (por exemplo, +551234567890)"
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "SID da Conta",
"PLACEHOLDER": "Enter your Twilio Account SID",
"REQUIRED": "Account SID is required"
"PLACEHOLDER": "Insira o SID da sua Conta Twilio",
"REQUIRED": "O SID da conta é necessário"
},
"AUTH_TOKEN": {
"LABEL": "Token de autenticação",
"PLACEHOLDER": "Enter your Twilio Auth Token",
"REQUIRED": "Auth Token is required"
"PLACEHOLDER": "Por favor, digite seu Token de Autenticação do Twilio",
"REQUIRED": "Um Token de autenticação é necessário"
},
"API_KEY_SID": {
"LABEL": "Chave da API SID",
"PLACEHOLDER": "Enter your Twilio API Key SID",
"REQUIRED": "API Key SID is required"
"PLACEHOLDER": "Insira sua chave de API do Twilio SID",
"REQUIRED": "API Key SID é obrigatório"
},
"API_KEY_SECRET": {
"LABEL": "Segredo da Chave API",
"PLACEHOLDER": "Enter your Twilio API Key Secret",
"REQUIRED": "API Key Secret is required"
"PLACEHOLDER": "Digite o segredo da sua chave de API do Twilio",
"REQUIRED": "Segredo da chave da API é obrigatório"
},
"TWIML_APP_SID": {
"LABEL": "TwiML App SID",
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
"REQUIRED": "TwiML App SID is required"
"PLACEHOLDER": "Insira seu Twilio TwiML App SID (começa com AP)",
"REQUIRED": "TwiML App SID é obrigatório"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"SUBMIT_BUTTON": "Criar Canal de Voz",
"API": {
"ERROR_MESSAGE": "We were not able to create the voice channel"
"ERROR_MESSAGE": "Não conseguimos criar o canal de voz"
}
},
"API_CHANNEL": {
@@ -603,27 +603,27 @@
"WHATSAPP_SECTION_UPDATE_TITLE": "Atualizar Chave de API",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Digite a nova chave de API aqui",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Atualizar",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "WhatsApp Embedded Signup",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
"WHATSAPP_EMBEDDED_SIGNUP_TITLE": "Inscrição incorporada do WhatsApp",
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "Esta caixa de entrada está conectada através da inscrição incorporada do WhatsApp.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "Você pode reconfigurar esta caixa de entrada para atualizar suas configurações do WhatsApp Business.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigurar",
"WHATSAPP_CONNECT_TITLE": "Conectar ao WhatsApp Business",
"WHATSAPP_CONNECT_SUBHEADER": ".",
"WHATSAPP_CONNECT_DESCRIPTION": "Conecte esta caixa de entrada ao WhatsApp Business para ter recursos aprimorados e um gerenciamento mais fácil.",
"WHATSAPP_CONNECT_BUTTON": "Conectar",
"WHATSAPP_CONNECT_SUCCESS": "Successfully connected to WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Failed to connect to WhatsApp Business. Please try again.",
"WHATSAPP_RECONFIGURE_SUCCESS": "Successfully reconfigured WhatsApp Business!",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to reconfigure WhatsApp Business. Please try again.",
"WHATSAPP_APP_ID_MISSING": "WhatsApp App ID is not configured. Please contact your administrator.",
"WHATSAPP_CONFIG_ID_MISSING": "WhatsApp Configuration ID is not configured. Please contact your administrator.",
"WHATSAPP_LOGIN_CANCELLED": "WhatsApp login was cancelled. Please try again.",
"WHATSAPP_CONNECT_SUCCESS": "Conectado com sucesso ao WhatsApp Business!",
"WHATSAPP_CONNECT_ERROR": "Não foi possível reconfigurar o WhatsApp Business. Tente novamente.",
"WHATSAPP_RECONFIGURE_SUCCESS": "WhatsApp Business reconfigurado com sucesso!",
"WHATSAPP_RECONFIGURE_ERROR": "Não foi possível reconfigurar o WhatsApp Business. Tente novamente.",
"WHATSAPP_APP_ID_MISSING": "O ID do WhatsApp não está configurado. Por favor, contate o administrador.",
"WHATSAPP_CONFIG_ID_MISSING": "O ID de Configuração do WhatsApp não está configurado. Por favor, contate o administrador.",
"WHATSAPP_LOGIN_CANCELLED": "O login do WhatsApp foi cancelado. Por favor, tente novamente.",
"WHATSAPP_WEBHOOK_TITLE": "Token de verificação Webhook",
"WHATSAPP_WEBHOOK_SUBHEADER": "Este token é usado para verificar a autenticidade do webhook endpoint.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_TEMPLATES_SYNC_TITLE": "Sincronizar Modelos",
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Sincronize manualmente os modelos de mensagens do WhatsApp para atualizar seus modelos disponíveis.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sincronizar Modelos",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Sincronização de modelos iniciada com sucesso. Pode demorar alguns minutos para atualizar.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat"
},
"HELP_CENTER": {
@@ -883,7 +883,7 @@
"LINE": "Line",
"API": "Canal da API",
"INSTAGRAM": "Instagram",
"VOICE": "Voice"
"VOICE": "Voz"
}
}
}
@@ -334,8 +334,8 @@
},
"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.",
"TITLE": "Você tem certeza que deseja excluir a integração com Notion?",
"MESSAGE": "Excluir essa integração removerá o acesso ao seu espaço de trabalho Notion e encerrará todas as funcionalidades relacionadas.",
"CONFIRM": "Sim, excluir",
"CANCEL": "Cancelar"
}
@@ -473,7 +473,7 @@
"TITLE": "Funcionalidades",
"ALLOW_CONVERSATION_FAQS": "Gerar perguntas frequentes a partir de conversas resolvidas",
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas"
}
},
"EDIT": {
@@ -487,28 +487,28 @@
"ASSISTANT": "Assistente"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
"TITLE": "Configurações básicas",
"DESCRIPTION": "Personalize o que o assistente diz quando termina uma conversa ou transfere para um humano."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
"TITLE": "Configurações do sistema",
"DESCRIPTION": "Personalize o que o assistente diz quando termina uma conversa ou transfere para um humano."
},
"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.",
"TITLE": "As Coisas Divertidas",
"DESCRIPTION": "Adicione mais controle ao assistente. (algo mais visual como uma história: Consulta → cenários → saída) Força o usuário para realmente utilizá-los.",
"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."
"TITLE": "Proteções",
"DESCRIPTION": "Mantém as coisas no caminho — apenas os tipos de perguntas que você quer que seu assistente responda, nada fora de limites ou fora do tópico."
},
"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.”"
"TITLE": "Cenários",
"DESCRIPTION": "Dê algum contexto ao seu assistente — como \"o que fazer quando um usuário estiver com problemas\", ou \"como agir durante uma solicitação de reembolso\"."
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
"TITLE": "Diretrizes de resposta",
"DESCRIPTION": "O jeito e a estrutura das respostas do seu assistente — tranquilo e amigável? Curto e ágil? Detalhado e formal?"
}
}
}
@@ -527,138 +527,138 @@
}
},
"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.",
"TITLE": "Proteções",
"DESCRIPTION": "Mantém as coisas no caminho — apenas os tipos de perguntas que você quer que seu assistente responda, nada fora de limites ou fora do tópico.",
"BREADCRUMB": {
"TITLE": "Guardrails"
"TITLE": "Proteções"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECTED": "{count} item selecionado | {count} itens selecionados",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"BULK_DELETE_BUTTON": "Excluir"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example guardrails",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another guardrail..."
"TITLE": "Exemplos de proteções",
"ADD": "Adicionar todos",
"ADD_SINGLE": "Adicionar este",
"SAVE": "Adicionar e salvar (↵)",
"PLACEHOLDER": "Escreva outra proteção"
},
"NEW": {
"TITLE": "Add a guardrail",
"TITLE": "Adicionar proteção",
"CREATE": "Criar",
"CANCEL": "Cancelar",
"PLACEHOLDER": "Type in another guardrail...",
"TEST_ALL": "Test all"
"PLACEHOLDER": "Escreva outra proteção",
"TEST_ALL": "Testar tudo"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Pesquisar..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"EMPTY_MESSAGE": "Nenhuma proteção encontrada. Crie uma ou adicione exemplos para começar.",
"SEARCH_EMPTY_MESSAGE": "Nenhuma proteção encontrada para essa pesquisa.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
"ERROR": "There was an error adding guardrails, please try again."
"SUCCESS": "Proteções adicionadas com sucesso",
"ERROR": "Ocorreu um erro ao adicionar as proteções. Por favor, tente novamente."
},
"UPDATE": {
"SUCCESS": "Guardrails updated successfully",
"ERROR": "There was an error updating guardrails, please try again."
"SUCCESS": "Proteções atualizados com sucesso",
"ERROR": "Ocorreu um erro ao atualizar as proteções. Por favor, tente novamente."
},
"DELETE": {
"SUCCESS": "Guardrails deleted successfully",
"ERROR": "There was an error deleting guardrails, please try again."
"SUCCESS": "Proteções removidas com sucesso",
"ERROR": "Ocorreu um erro ao excluir as proteções, por favor, tente novamente."
}
}
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response Guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
"TITLE": "Diretrizes de Resposta",
"DESCRIPTION": "O jeito e a estrutura das respostas do seu assistente — tranquilo e amigável? Curto e ágil? Detalhado e formal?",
"BREADCRUMB": {
"TITLE": "Response Guidelines"
"TITLE": "Diretrizes de Resposta"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECTED": "{count} item selecionado | {count} itens selecionados",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"BULK_DELETE_BUTTON": "Excluir"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example response guidelines",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another response guideline..."
"TITLE": "Exemplos de diretrizes de resposta",
"ADD": "Adicionar todos",
"ADD_SINGLE": "Adicionar este",
"SAVE": "Adicionar e salvar (↵)",
"PLACEHOLDER": "Escreva uma outra diretriz de resposta..."
},
"NEW": {
"TITLE": "Add a response guideline",
"TITLE": "Adicione uma diretriz de resposta",
"CREATE": "Criar",
"CANCEL": "Cancelar",
"PLACEHOLDER": "Type in another response guideline...",
"TEST_ALL": "Test all"
"PLACEHOLDER": "Escreva uma outra diretriz de resposta...",
"TEST_ALL": "Testar tudo"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Pesquisar..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"EMPTY_MESSAGE": "Nenhuma diretriz de resposta encontrada. Crie uma ou adicione exemplos para começar.",
"SEARCH_EMPTY_MESSAGE": "Nenhuma diretriz de resposta encotrada para essa pesquisa.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
"ERROR": "There was an error adding response guidelines, please try again."
"SUCCESS": "Diretrizes de resposta adicionadas com sucesso",
"ERROR": "Houve um erro ao adicionar diretrizes de resposta, por favor, tente novamente."
},
"UPDATE": {
"SUCCESS": "Response Guidelines updated successfully",
"ERROR": "There was an error updating response guidelines, please try again."
"SUCCESS": "Diretrizes de Resposta atualizadas com sucesso",
"ERROR": "Houve um erro ao atualizar as diretrizes de resposta, por favor, tente novamente."
},
"DELETE": {
"SUCCESS": "Response Guidelines deleted successfully",
"ERROR": "There was an error deleting response guidelines, please try again."
"SUCCESS": "Diretrizes de resposta removidas com sucesso",
"ERROR": "Houve um erro ao excluir as diretrizes de resposta, por favor, tente novamente."
}
}
},
"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.”",
"TITLE": "Cenários",
"DESCRIPTION": "Dê algum contexto ao seu assistente — como \"o que fazer quando um usuário estiver com problemas\", ou \"como agir durante uma solicitação de reembolso\".",
"BREADCRUMB": {
"TITLE": "Scenarios"
"TITLE": "Cenários"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECTED": "{count} item selecionado | {count} itens selecionados",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"BULK_DELETE_BUTTON": "Excluir"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
"TITLE": "Exemplos de cenários",
"ADD": "Adicionar todos",
"ADD_SINGLE": "Adicionar este",
"TOOLS_USED": "Ferramentas usadas :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"CREATE": "Adicionar um cenário",
"TITLE": "Criar um cenário",
"FORM": {
"TITLE": {
"LABEL": "Título",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
"PLACEHOLDER": "Digite um nome para o cenário",
"ERROR": "O nome do cenário é obrigatório"
},
"DESCRIPTION": {
"LABEL": "Descrição",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Descrição do cenário é obrigatória"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
"LABEL": "Como lidar",
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Conteúdo do cenário é obrigatório"
},
"CREATE": "Criar",
"CANCEL": "Cancelar"
@@ -667,25 +667,25 @@
},
"UPDATE": {
"CANCEL": "Cancelar",
"UPDATE": "Update changes"
"UPDATE": "Atualizar alterações"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Pesquisar..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"EMPTY_MESSAGE": "Nenhum cenário encontrado. Crie ou adicione exemplos para começar.",
"SEARCH_EMPTY_MESSAGE": "Nenhum cenário encontrado para esta pesquisa.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
"SUCCESS": "Cenários adicionados com sucesso",
"ERROR": "Ocorreu um erro ao adicionar cenários, por favor tente novamente."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
"SUCCESS": "Cenários atualizados com sucesso",
"ERROR": "Ocorreu um erro ao atualizar cenários, por favor tente novamente."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
"SUCCESS": "Cenários excluídos com sucesso",
"ERROR": "Ocorreu um erro ao excluir os cenários, por favor tente novamente."
}
}
}
@@ -3,22 +3,22 @@
"MODAL": {
"TITLE": "Templates do Whatsapp",
"SUBTITLE": "Selecione o template do whatsapp que você deseja enviar",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
"TEMPLATE_SELECTED_SUBTITLE": "Configurar modelo: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Pesquisar modelos",
"NO_TEMPLATES_FOUND": "Não há templates encontrados para",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"HEADER": "Cabeçalho",
"BODY": "Corpo",
"FOOTER": "Rodapé",
"BUTTONS": "Botões",
"CATEGORY": "Categoria",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"MEDIA_CONTENT": "Conteúdo de Mídia",
"MEDIA_CONTENT_FALLBACK": "conteúdo de mídia",
"NO_TEMPLATES_AVAILABLE": "Não há modelos disponíveis do WhatsApp. Clique em atualizar para sincronizar os modelos do WhatsApp.",
"REFRESH_BUTTON": "Atualizar modelos",
"REFRESH_SUCCESS": "Atualização de modelos iniciada. Pode levar alguns minutos para atualizar.",
"REFRESH_ERROR": "Falha ao atualizar os modelos. Por favor, tente novamente.",
"LABELS": {
"LANGUAGE": "Idioma",
"TEMPLATE_BODY": "Conteúdo do Template",
@@ -33,14 +33,14 @@
"GO_BACK_LABEL": "Voltar",
"SEND_MESSAGE_LABEL": "Enviar Mensagem",
"FORM_ERROR_MESSAGE": "Por favor, preencha todas as variáveis antes de enviar",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
"MEDIA_HEADER_LABEL": "Cabeçalho {type}",
"OTP_CODE": "Digite OTP de 4 a 8 dígitos",
"EXPIRY_MINUTES": "Digite os minutos de expiração",
"BUTTON_PARAMETERS": "Parâmetros do botão",
"BUTTON_LABEL": "Botão {index}",
"COUPON_CODE": "Digite o código do cupom (máx. 15 caracteres)",
"MEDIA_URL_LABEL": "Digite a URL {type}",
"BUTTON_PARAMETER": "Insira o parâmetro do botão"
}
}
}
@@ -6,6 +6,7 @@ import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -20,6 +21,7 @@ const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { formatMessage } = useMessageFormatter();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
@@ -42,35 +44,25 @@ const breadcrumbItems = computed(() => {
];
});
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('span', {
class: 'text-sm text-n-slate-12 py-4',
innerHTML: instruction.replace(
TOOL_LINK_REGEX,
(_, title) =>
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
),
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Refund Order',
description: 'User encountered a technical issue or error message.',
title: 'Prospective Buyer',
description:
'Handle customers who are showing interest in purchasing a license',
instruction:
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if it’s a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
tools: ['create_bug_report', 'known_issues'],
},
{
id: 2,
title: 'Product Recommendation',
description: 'User is unsure which product or service to choose.',
instruction:
'Ask 2–3 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 2–3 options with pros/cons. Link to compare page if available.',
tools: ['product_match[user_needs]'],
'If someone is interested in purchasing a license, ask them for following:\n\n1. How many licenses are they willing to purchase?\n2. Are they migrating from another platform?\n. Once these details are collected, do the following steps\n1. add a private note to with the information you collected using [Add Private Note](tool://add_private_note)\n2. Add label "sales" to the contact using [Add Label to Conversation](tool://add_label_to_conversation)\n3. Reply saying "one of us will reach out soon" and provide an estimated timeline for the response and [Handoff to Human](tool://handoff)',
tools: ['add_private_note', 'add_label_to_conversation', 'handoff'],
},
];
@@ -248,7 +240,9 @@ onMounted(() => {
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component :is="renderInstruction(item.instruction)" />
<component
:is="renderInstruction(formatMessage(item.instruction, false))"
/>
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
@@ -11,6 +11,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import {
isAConversationRoute,
@@ -28,6 +29,7 @@ export default {
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
},
props: {
contact: {
@@ -278,6 +280,14 @@ export default {
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -116,16 +116,22 @@ export default {
key: 'collaborators',
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
},
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
@@ -144,6 +150,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -676,7 +683,7 @@ export default {
}}
</p>
</label>
<div class="pb-4">
<div v-if="!isAVoiceChannel" class="pb-4">
<label>
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
</label>
@@ -22,7 +22,6 @@ const state = reactive({
authToken: '',
apiKeySid: '',
apiKeySecret: '',
twimlAppSid: '',
});
const uiFlags = useMapGetter('inboxes/getUIFlags');
@@ -33,7 +32,6 @@ const validationRules = {
authToken: { required },
apiKeySid: { required },
apiKeySecret: { required },
twimlAppSid: { required },
};
const v$ = useVuelidate(validationRules, state);
@@ -55,9 +53,6 @@ const formErrors = computed(() => ({
apiKeySecret: v$.value.apiKeySecret?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
: '',
twimlAppSid: v$.value.twimlAppSid?.$error
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
: '',
}));
function getProviderConfig() {
@@ -67,7 +62,6 @@ function getProviderConfig() {
api_key_sid: state.apiKeySid,
api_key_secret: state.apiKeySecret,
};
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
return config;
}
@@ -160,17 +154,6 @@ async function createChannel() {
@blur="v$.apiKeySecret?.$touch"
/>
<Input
v-model="state.twimlAppSid"
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
:placeholder="
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
"
:message="formErrors.twimlAppSid"
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
@blur="v$.twimlAppSid?.$touch"
/>
<div>
<NextButton
:is-loading="uiFlags.isCreating"
@@ -2,23 +2,22 @@
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Button from 'dashboard/components-next/button/Button.vue';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const store = useStore();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
WHATSAPP_MANUAL: 'whatsapp_manual',
THREE_SIXTY_DIALOG: '360dialog',
};
@@ -29,14 +28,6 @@ const hasWhatsappAppId = computed(() => {
);
});
const isWhatsappEmbeddedSignupEnabled = computed(() => {
const accountId = route.params.accountId;
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
@@ -66,28 +57,11 @@ const selectProvider = providerValue => {
});
};
const shouldShowEmbeddedSignup = provider => {
// Check if the feature is enabled for the account
if (!isWhatsappEmbeddedSignupEnabled.value) {
return false;
}
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
return false;
}
// Show cloud whatsapp when:
// 1. Provider is whatsapp AND
// 2. Either no app ID is configured OR embedded signup feature is disabled
return provider === PROVIDER_TYPES.WHATSAPP;
return (
provider === PROVIDER_TYPES.WHATSAPP_MANUAL ||
(provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value)
);
};
</script>
@@ -138,10 +112,45 @@ const shouldShowCloudWhatsapp = provider => {
<div v-else-if="showConfiguration">
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<!-- Show embedded signup if app ID is configured -->
<div
v-if="
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
<WhatsappEmbeddedSignup />
<!-- Manual setup fallback option -->
<div class="pt-6 mt-6 border-t border-n-weak">
<p class="mb-4 text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_FALLBACK')
}}
<Button
variant="link"
size="sm"
color="slate"
@click="selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL)"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_SETUP_LINK'
)
}}
</Button>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.MANUAL_FALLBACK_INSTEAD'
)
}}
</p>
</div>
</div>
<!-- Show manual setup -->
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<!-- Other providers -->
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
@@ -126,6 +126,25 @@ export default {
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsSection>
</div>
<div v-else-if="isALineChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
+23 -2
View File
@@ -1,6 +1,7 @@
import './design-system/histoire.scss';
import { defineSetupVue3 } from '@histoire/plugin-vue';
import i18nMessages from 'dashboard/i18n';
import dashboardI18n from 'dashboard/i18n';
import widgetI18n from 'widget/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
@@ -9,10 +10,30 @@ import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
import { directive as onClickaway } from 'vue3-click-away';
function mergeMessages(...sources) {
return sources.reduce((acc, src) => {
Object.keys(src).forEach(key => {
if (
acc[key] &&
typeof acc[key] === 'object' &&
typeof src[key] === 'object'
) {
acc[key] = mergeMessages(acc[key], src[key]);
} else {
acc[key] = src[key];
}
});
return acc;
}, {});
}
const i18n = createI18n({
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
locale: 'en',
messages: i18nMessages,
messages: mergeMessages(
structuredClone(dashboardI18n),
structuredClone(widgetI18n)
),
});
export const setupVue3 = defineSetupVue3(({ app }) => {
+2 -31
View File
@@ -50,35 +50,11 @@ const updateCampaignReadStatus = baseDomain => {
});
};
const sanitizeURL = url => {
if (url === '') return '';
try {
// any invalid url will not be accepted
// example - JaVaScRiP%0at:alert(document.domain)"
// this has an obfuscated javascript protocol
const parsedURL = new URL(url);
// filter out dangerous protocols like `javascript`, `data`, `vbscript`
if (!['https', 'http'].includes(parsedURL.protocol)) {
throw new Error('Invalid Protocol');
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('Invalid URL', e);
}
return 'about:blank'; // blank page URL
};
export const IFrameHelper = {
getUrl({ baseUrl, websiteToken }) {
baseUrl = sanitizeURL(baseUrl);
return `${baseUrl}/widget?website_token=${websiteToken}`;
},
createFrame: ({ baseUrl, websiteToken }) => {
baseUrl = sanitizeURL(baseUrl);
if (IFrameHelper.getAppFrame()) {
return;
}
@@ -126,12 +102,10 @@ export const IFrameHelper = {
window.onmessage = e => {
if (
typeof e.data !== 'string' ||
e.data.indexOf('chatwoot-widget:') !== 0 ||
e.origin !== window.location.origin
e.data.indexOf('chatwoot-widget:') !== 0
) {
return;
}
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
if (typeof IFrameHelper.events[message.event] === 'function') {
IFrameHelper.events[message.event](message);
@@ -166,9 +140,7 @@ export const IFrameHelper = {
},
setupAudioListeners: () => {
let { baseUrl = '' } = window.$chatwoot;
baseUrl = sanitizeURL(baseUrl);
const { baseUrl = '' } = window.$chatwoot;
getAlertAudio(baseUrl, { type: 'widget', alertTone: 'ding' }).then(() =>
initOnEvents.forEach(event => {
document.removeEventListener(
@@ -262,7 +234,6 @@ export const IFrameHelper = {
},
popoutChatWindow: ({ baseUrl, websiteToken, locale }) => {
baseUrl = sanitizeURL(baseUrl);
const cwCookie = Cookies.get('cw_conversation');
window.$chatwoot.toggle('close');
popoutChatWindow(baseUrl, websiteToken, locale, cwCookie);
+3 -3
View File
@@ -57,15 +57,15 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
},
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
+18 -13
View File
@@ -4,12 +4,10 @@ import { setHeader } from 'widget/helpers/axios';
import addHours from 'date-fns/addHours';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import configMixin from './mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
import { getLocale } from './helpers/urlParamsHelper';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import { isEmptyObject } from 'widget/helpers/utils';
import Spinner from 'shared/components/Spinner.vue';
import routerMixin from './mixins/routerMixin';
import {
getExtraSpaceToScroll,
loadedEventConfig,
@@ -20,6 +18,8 @@ import {
ON_UNREAD_MESSAGE_CLICK,
} from './constants/widgetBusEvents';
import { useDarkMode } from 'widget/composables/useDarkMode';
import { useRouter } from 'vue-router';
import { useAvailability } from 'widget/composables/useAvailability';
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -28,10 +28,13 @@ export default {
components: {
Spinner,
},
mixins: [availabilityMixin, configMixin, routerMixin],
mixins: [configMixin],
setup() {
const { prefersDarkMode } = useDarkMode();
return { prefersDarkMode };
const router = useRouter();
const { isInWorkingHours } = useAvailability();
return { prefersDarkMode, router, isInWorkingHours };
},
data() {
return {
@@ -157,15 +160,17 @@ export default {
this.setUnreadView();
});
emitter.on(ON_UNREAD_MESSAGE_CLICK, () => {
this.replaceRoute('messages').then(() => this.unsetUnreadView());
this.router
.replace({ name: 'messages' })
.then(() => this.unsetUnreadView());
});
},
registerCampaignEvents() {
emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, () => {
if (this.shouldShowPreChatForm) {
this.replaceRoute('prechat-form');
this.router.replace({ name: 'prechat-form' });
} else {
this.replaceRoute('messages');
this.router.replace({ name: 'messages' });
emitter.emit('execute-campaign', {
campaignId: this.activeCampaign.id,
});
@@ -176,7 +181,7 @@ export default {
const { customAttributes, campaignId } = campaignDetails;
const { websiteToken } = window.chatwootWebChannel;
this.executeCampaign({ campaignId, websiteToken, customAttributes });
this.replaceRoute('messages');
this.router.replace({ name: 'messages' });
});
emitter.on('snooze-campaigns', () => {
const expireBy = addHours(new Date(), 1);
@@ -192,7 +197,7 @@ export default {
!messageCount &&
!shouldSnoozeCampaign;
if (this.isIFrame && isCampaignReadyToExecute) {
this.replaceRoute('campaigns').then(() => {
this.router.replace({ name: 'campaigns' }).then(() => {
this.setIframeHeight(true);
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
});
@@ -207,7 +212,7 @@ export default {
unreadMessageCount > 0 &&
!this.isWidgetOpen
) {
this.replaceRoute('unread-messages').then(() => {
this.router.replace({ name: 'unread-messages' }).then(() => {
this.setIframeHeight(true);
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
});
@@ -263,7 +268,7 @@ export default {
this.initCampaigns({
currentURL: referrerURL,
websiteToken,
isInBusinessHours: this.isInBusinessHours,
isInBusinessHours: this.isInWorkingHours,
});
window.referrerURL = referrerURL;
this.setReferrerHost(referrerHost);
@@ -314,12 +319,12 @@ export default {
['unread-messages', 'campaigns'].includes(this.$route.name);
if (shouldShowMessageView) {
this.replaceRoute('messages');
this.router.replace({ name: 'messages' });
}
if (shouldShowHomeView) {
this.$store.dispatch('conversation/setUserLastSeen');
this.unsetUnreadView();
this.replaceRoute('home');
this.router.replace({ name: 'home' });
}
if (!message.isOpen) {
this.resetCampaign();
@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
import AvailabilityText from './AvailabilityText.vue';
import { useAvailability } from 'widget/composables/useAvailability';
const props = defineProps({
agents: {
type: Array,
default: () => [],
},
showHeader: {
type: Boolean,
default: true,
},
showAvatars: {
type: Boolean,
default: true,
},
textClasses: {
type: String,
default: '',
},
});
const { t } = useI18n();
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
const {
currentTime,
hasOnlineAgents,
isOnline,
inboxConfig,
isInWorkingHours,
} = useAvailability(props.agents);
const workingHours = computed(() => inboxConfig.value.workingHours || []);
const workingHoursEnabled = computed(
() => inboxConfig.value.workingHoursEnabled || false
);
const utcOffset = computed(
() => inboxConfig.value.utcOffset || inboxConfig.value.timezone || 'UTC'
);
const replyTime = computed(
() => inboxConfig.value.replyTime || 'in_a_few_minutes'
);
// If online or in working hours
const isAvailable = computed(
() => isOnline.value || (workingHoursEnabled.value && isInWorkingHours.value)
);
const headerText = computed(() =>
isAvailable.value
? availableMessage.value || t('TEAM_AVAILABILITY.ONLINE')
: unavailableMessage.value || t('TEAM_AVAILABILITY.OFFLINE')
);
</script>
<template>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div v-if="showHeader" class="font-medium text-n-slate-12">
{{ headerText }}
</div>
<AvailabilityText
:time="currentTime"
:utc-offset="utcOffset"
:working-hours="workingHours"
:working-hours-enabled="workingHoursEnabled"
:has-online-agents="hasOnlineAgents"
:reply-time="replyTime"
:is-online="isOnline"
:is-in-working-hours="isInWorkingHours"
:class="textClasses"
class="text-n-slate-11"
/>
</div>
<GroupedAvatars v-if="showAvatars && isOnline" :users="agents" />
</div>
</template>
@@ -0,0 +1,217 @@
<script setup>
import AvailabilityText from './AvailabilityText.vue';
// Base time for consistent testing: Monday, July 15, 2024, 10:00:00 UTC
const baseTime = new Date('2024-07-15T10:00:00.000Z');
const utcOffset = '+00:00'; // UTC
const defaultProps = {
time: baseTime,
utcOffset,
workingHours: [
{
dayOfWeek: 0,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Sunday
{
dayOfWeek: 1,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Monday (current day)
{
dayOfWeek: 2,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Tuesday
{
dayOfWeek: 3,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Wednesday
{
dayOfWeek: 4,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Thursday
{
dayOfWeek: 5,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: false,
}, // Friday
{
dayOfWeek: 6,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
closedAllDay: true,
}, // Saturday (closed)
],
workingHoursEnabled: true,
replyTime: 'in_a_few_minutes',
isOnline: true,
isInWorkingHours: true,
};
const createVariant = (
title,
propsOverride = {},
isOnlineOverride = null,
isInWorkingHoursOverride = null
) => {
const props = { ...defaultProps, ...propsOverride };
if (isOnlineOverride !== null) props.isOnline = isOnlineOverride;
if (isInWorkingHoursOverride !== null)
props.isInWorkingHours = isInWorkingHoursOverride;
// Adjust time for specific scenarios
if (title.includes('Back Tomorrow')) {
// Set time to just after closing on Monday to trigger 'Back Tomorrow' (Tuesday)
props.time = new Date('2024-07-15T17:01:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Multiple Days Away')) {
// Set time to Friday evening to trigger 'Back on Sunday' (as Saturday is closed)
props.time = new Date('2024-07-19T18:00:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Same Day - In Minutes')) {
// Monday 16:50, next slot is 17:00 (in 10 minutes)
// To make this specific, let's assume the next slot is within the hour
// For this, we need to be outside working hours but a slot is available soon.
// Let's say current time is 8:50 AM, office opens at 9:00 AM.
props.time = new Date('2024-07-15T08:50:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Same Day - In Hours')) {
// Monday 07:30 AM, office opens at 9:00 AM (in 1.5 hours, rounds to 2 hours)
props.time = new Date('2024-07-15T07:30:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('in 1 hour')) {
// Monday 08:00 AM, office opens at 9:00 AM (exactly in 1 hour)
// At exactly 1 hour difference, remainingMinutes = 0
props.time = new Date('2024-07-15T08:00:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
if (title.includes('Back Same Day - At Time')) {
// Monday 05:00 AM, office opens at 9:00 AM (at 9:00 AM)
props.time = new Date('2024-07-15T05:00:00.000Z');
props.isInWorkingHours = false;
props.isOnline = false;
}
return {
title,
props,
};
};
const variants = [
createVariant(
'Working Hours Disabled - Online',
{ workingHoursEnabled: false },
true,
true
),
createVariant(
'Working Hours Disabled - Offline',
{ workingHoursEnabled: false },
false,
false
),
createVariant(
'All Day Closed - Offline',
{
workingHours: defaultProps.workingHours.map(wh => ({
...wh,
closedAllDay: true,
})),
},
false,
false
),
createVariant('Online and In Working Hours', {}, true, true),
createVariant(
'No Next Slot Available (e.g., all future slots closed or empty workingHours)',
{ workingHours: [] }, // No working hours defined
false,
false
),
createVariant(
'Back Tomorrow',
{},
false,
false // Time will be adjusted by createVariant
),
createVariant('Back Multiple Days Away (e.g., on Sunday)', {}, false, false),
createVariant(
'Back Same Day - In Minutes (e.g., in 10 minutes)',
{},
false,
false
),
createVariant(
'Back Same Day - In Hours (e.g., in 2 hours)',
{},
false,
false
),
createVariant(
'Back Same Day - Exactly an Hour (e.g., in 1 hour)',
{},
false,
false
),
createVariant(
'Back Same Day - At Time (e.g., at 09:00 AM)',
{},
false,
false
),
];
</script>
<template>
<Story
title="Widget/Components/Availability/AvailabilityText"
:layout="{ type: 'grid', width: 300 }"
>
<Variant v-for="(variant, i) in variants" :key="i" :title="variant.title">
<AvailabilityText
:time="variant.props.time"
:utc-offset="variant.props.utcOffset"
:working-hours="variant.props.workingHours"
:working-hours-enabled="variant.props.workingHoursEnabled"
:reply-time="variant.props.replyTime"
:is-online="variant.props.isOnline"
:is-in-working-hours="variant.props.isInWorkingHours"
class="text-n-slate-11"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,178 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getTime } from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { findNextAvailableSlotDetails } from 'widget/helpers/availabilityHelpers';
const props = defineProps({
time: {
type: Date,
required: true,
},
utcOffset: {
type: String,
required: true,
},
workingHours: {
type: Array,
required: true,
},
workingHoursEnabled: {
type: Boolean,
required: true,
},
replyTime: {
type: String,
default: 'in_a_few_minutes',
},
isOnline: {
type: Boolean,
required: true,
},
isInWorkingHours: {
type: Boolean,
required: true,
},
});
const MINUTE_ROUNDING_INTERVAL = 5;
const HOUR_THRESHOLD_FOR_EXACT_TIME = 3;
const MINUTES_IN_HOUR = 60;
const { t } = useI18n();
const dayNames = computed(() => [
t('DAY_NAMES.SUNDAY'),
t('DAY_NAMES.MONDAY'),
t('DAY_NAMES.TUESDAY'),
t('DAY_NAMES.WEDNESDAY'),
t('DAY_NAMES.THURSDAY'),
t('DAY_NAMES.FRIDAY'),
t('DAY_NAMES.SATURDAY'),
]);
// Check if all days in working hours are closed
const allDayClosed = computed(() => {
if (!props.workingHours.length) return false;
return props.workingHours.every(slot => slot.closedAllDay);
});
const replyTimeMessage = computed(() => {
const replyTimeKey = `REPLY_TIME.${props.replyTime.toUpperCase()}`;
return t(replyTimeKey);
});
const nextSlot = computed(() => {
if (
!props.workingHoursEnabled ||
allDayClosed.value ||
(props.isInWorkingHours && props.isOnline)
) {
return null;
}
const slot = findNextAvailableSlotDetails(
props.time,
props.utcOffset,
props.workingHours
);
if (!slot) return null;
return {
...slot,
hoursUntilOpen: Math.floor(slot.minutesUntilOpen / MINUTES_IN_HOUR),
remainingMinutes: slot.minutesUntilOpen % MINUTES_IN_HOUR,
};
});
const roundedMinutesUntilOpen = computed(() => {
if (!nextSlot.value) return 0;
return (
Math.ceil(nextSlot.value.remainingMinutes / MINUTE_ROUNDING_INTERVAL) *
MINUTE_ROUNDING_INTERVAL
);
});
const adjustedHoursUntilOpen = computed(() => {
if (!nextSlot.value) return 0;
return nextSlot.value.remainingMinutes > 0
? nextSlot.value.hoursUntilOpen + 1
: nextSlot.value.hoursUntilOpen;
});
const formattedOpeningTime = computed(() => {
if (!nextSlot.value) return '';
return getTime(
nextSlot.value.config.openHour || 0,
nextSlot.value.config.openMinutes || 0
);
});
</script>
<template>
<span>
<!-- 1. If currently in working hours, show reply time -->
<template v-if="isInWorkingHours">
{{ replyTimeMessage }}
</template>
<!-- 2. Else, if working hours are disabled, show based on online status -->
<template v-else-if="!workingHoursEnabled">
{{
isOnline
? replyTimeMessage
: t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE')
}}
</template>
<!-- 3. Else (not in working hours, but working hours ARE enabled) -->
<!-- Check if all configured slots are 'closedAllDay' -->
<template v-else-if="allDayClosed">
{{ t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') }}
</template>
<!-- 4. Else (not in WH, WH enabled, not allDayClosed), calculate next slot -->
<template v-else-if="!nextSlot">
{{ t('REPLY_TIME.BACK_IN_SOME_TIME') }}
</template>
<!-- Tomorrow -->
<template v-else-if="nextSlot.daysUntilOpen === 1">
{{ t('REPLY_TIME.BACK_TOMORROW') }}
</template>
<!-- Multiple days away (eg: on Monday) -->
<template v-else-if="nextSlot.daysUntilOpen > 1">
{{
t('REPLY_TIME.BACK_ON_DAY', {
day: dayNames[nextSlot.config.dayOfWeek],
})
}}
</template>
<!-- Same day - less than 1 hour (eg: in 5 minutes) -->
<template v-else-if="nextSlot.hoursUntilOpen === 0">
{{
t('REPLY_TIME.BACK_IN_MINUTES', {
time: `${roundedMinutesUntilOpen}`,
})
}}
</template>
<!-- Same day - less than 3 hours (eg: in 2 hours) -->
<template
v-else-if="nextSlot.hoursUntilOpen < HOUR_THRESHOLD_FOR_EXACT_TIME"
>
{{ t('REPLY_TIME.BACK_IN_HOURS', adjustedHoursUntilOpen) }}
</template>
<!-- Same day - 3+ hours away (eg: at 10:00 AM) -->
<template v-else>
{{
t('REPLY_TIME.BACK_AT_TIME', {
time: formattedOpeningTime,
})
}}
</template>
</span>
</template>
@@ -6,7 +6,7 @@ import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { sendEmailTranscript } from 'widget/api/conversation';
import routerMixin from 'widget/mixins/routerMixin';
import { useRouter } from 'vue-router';
import { IFrameHelper } from '../helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -17,7 +17,10 @@ export default {
CustomButton,
FooterReplyTo,
},
mixins: [routerMixin],
setup() {
const router = useRouter();
return { router };
},
data() {
return {
inReplyTo: null,
@@ -55,15 +58,8 @@ export default {
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
},
methods: {
...mapActions('conversation', [
'sendMessage',
'sendAttachment',
'clearConversations',
]),
...mapActions('conversationAttributes', [
'getAttributes',
'clearConversationAttributes',
]),
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
...mapActions('conversationAttributes', ['getAttributes']),
async handleSendMessage(content) {
await this.sendMessage({
content,
@@ -84,9 +80,7 @@ export default {
this.inReplyTo = null;
},
startNewConversation() {
this.clearConversations();
this.clearConversationAttributes();
this.replaceRoute('prechat-form');
this.router.replace({ name: 'prechat-form' });
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
+25 -51
View File
@@ -1,55 +1,26 @@
<script>
import availabilityMixin from 'widget/mixins/availability';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
<script setup>
import { toRef } from 'vue';
import { useRouter } from 'vue-router';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
import routerMixin from 'widget/mixins/routerMixin';
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
import { useAvailability } from 'widget/composables/useAvailability';
export default {
name: 'ChatHeader',
components: {
FluentIcon,
HeaderActions,
},
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin],
props: {
avatarUrl: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
showPopoutButton: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
},
availableAgents: {
type: Array,
default: () => {},
},
},
computed: {
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
const props = defineProps({
avatarUrl: { type: String, default: '' },
title: { type: String, default: '' },
showPopoutButton: { type: Boolean, default: false },
showBackButton: { type: Boolean, default: false },
availableAgents: { type: Array, default: () => [] },
});
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
onBackButtonClick() {
this.replaceRoute('home');
},
},
const availableAgents = toRef(props, 'availableAgents');
const router = useRouter();
const { isOnline } = useAvailability(availableAgents);
const onBackButtonClick = () => {
router.replace({ name: 'home' });
};
</script>
@@ -79,9 +50,12 @@ export default {
${isOnline ? 'bg-n-teal-10' : 'hidden'}`"
/>
</div>
<div class="text-xs leading-3 text-n-slate-11">
{{ replyWaitMessage }}
</div>
<AvailabilityContainer
:agents="availableAgents"
:show-header="false"
:show-avatars="false"
text-classes="text-xs leading-3"
/>
</div>
</div>
<HeaderActions :show-popout-button="showPopoutButton" />
@@ -6,7 +6,6 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin';
import { FormKit, createInput } from '@formkit/vue';
import PhoneInput from 'widget/components/Form/PhoneInput.vue';
@@ -17,7 +16,7 @@ export default {
Spinner,
FormKit,
},
mixins: [routerMixin, configMixin],
mixins: [configMixin],
props: {
options: {
type: Object,
@@ -1,74 +1,27 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import configMixin from 'widget/mixins/configMixin';
import availabilityMixin from 'widget/mixins/availability';
<script setup>
import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
export default {
name: 'TeamAvailability',
components: {
GroupedAvatars,
},
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
props: {
availableAgents: {
type: Array,
default: () => {},
},
hasConversation: {
type: Boolean,
default: false,
},
},
emits: ['startConversation'],
const props = defineProps({
availableAgents: { type: Array, default: () => [] },
hasConversation: { type: Boolean, default: false },
});
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
availableMessage: 'appConfig/getAvailableMessage',
unavailableMessage: 'appConfig/getUnavailableMessage',
}),
textColor() {
return getContrastingTextColor(this.widgetColor);
},
agentAvatars() {
return this.availableAgents.map(agent => ({
name: agent.name,
avatar: agent.avatar_url,
id: agent.id,
}));
},
headerMessage() {
return this.isOnline
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
},
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
const emit = defineEmits(['startConversation']);
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
startConversation() {
this.$emit('startConversation');
if (!this.hasConversation) {
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
data: { hasConversation: false },
});
}
},
},
const widgetColor = useMapGetter('appConfig/getWidgetColor');
const startConversation = () => {
emit('startConversation');
if (!props.hasConversation) {
IFrameHelper.sendMessage({
event: 'onEvent',
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
data: { hasConversation: false },
});
}
};
</script>
@@ -76,17 +29,8 @@ export default {
<div
class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="font-medium text-n-slate-12 line-clamp-2">
{{ headerMessage }}
</div>
<div class="text-n-slate-11">
{{ replyWaitMessage }}
</div>
</div>
<GroupedAvatars v-if="isOnline" :users="availableAgents" />
</div>
<AvailabilityContainer :agents="availableAgents" show-header show-avatars />
<button
class="inline-flex items-center gap-1 font-medium text-n-slate-12"
:style="{ color: widgetColor }"
@@ -0,0 +1,125 @@
import { ref } from 'vue';
import { useAvailability } from '../useAvailability';
const mockIsOnline = vi.fn();
const mockIsInWorkingHours = vi.fn();
const mockUseCamelCase = vi.fn(obj => obj);
vi.mock('widget/helpers/availabilityHelpers', () => ({
isOnline: (...args) => mockIsOnline(...args),
isInWorkingHours: (...args) => mockIsInWorkingHours(...args),
}));
vi.mock('dashboard/composables/useTransformKeys', () => ({
useCamelCase: obj => mockUseCamelCase(obj),
}));
describe('useAvailability', () => {
const originalWindow = window.chatwootWebChannel;
beforeEach(() => {
vi.clearAllMocks();
// Reset mocks to return true by default
mockIsOnline.mockReturnValue(true);
mockIsInWorkingHours.mockReturnValue(true);
mockUseCamelCase.mockImplementation(obj => obj);
window.chatwootWebChannel = {
workingHours: [],
workingHoursEnabled: false,
timezone: 'UTC',
utcOffset: 'UTC',
replyTime: 'in_a_few_minutes',
};
});
afterEach(() => {
window.chatwootWebChannel = originalWindow;
});
describe('initial state', () => {
it('should initialize with default values', () => {
const { availableAgents, hasOnlineAgents, isInWorkingHours, isOnline } =
useAvailability();
expect(availableAgents.value).toEqual([]);
expect(hasOnlineAgents.value).toBe(false);
expect(isInWorkingHours.value).toBe(true);
expect(isOnline.value).toBe(true);
});
});
describe('with agents', () => {
it('should handle agents array', () => {
const agents = [{ id: 1 }, { id: 2 }];
const { availableAgents, hasOnlineAgents } = useAvailability(agents);
expect(availableAgents.value).toEqual(agents);
expect(hasOnlineAgents.value).toBe(true);
});
it('should handle reactive agents', () => {
const agents = ref([{ id: 1 }]);
const { hasOnlineAgents } = useAvailability(agents);
expect(hasOnlineAgents.value).toBe(true);
agents.value = [];
expect(hasOnlineAgents.value).toBe(false);
});
});
describe('working hours', () => {
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
beforeEach(() => {
window.chatwootWebChannel = {
workingHours,
workingHoursEnabled: true,
utcOffset: '+05:30',
};
});
it('should check working hours', () => {
mockIsInWorkingHours.mockReturnValueOnce(true);
const { isInWorkingHours } = useAvailability();
const result = isInWorkingHours.value;
expect(result).toBe(true);
expect(mockIsInWorkingHours).toHaveBeenCalledWith(
expect.any(Date),
'+05:30',
workingHours
);
});
it('should determine online status based on working hours and agents', () => {
mockIsOnline.mockReturnValueOnce(true);
const { isOnline } = useAvailability([{ id: 1 }]);
const result = isOnline.value;
expect(result).toBe(true);
expect(mockIsOnline).toHaveBeenCalledWith(
true,
expect.any(Date),
'+05:30',
workingHours,
true
);
});
});
describe('config changes', () => {
it('should react to window.chatwootWebChannel changes', () => {
const { inboxConfig } = useAvailability();
window.chatwootWebChannel = {
...window.chatwootWebChannel,
replyTime: 'in_a_day',
};
expect(inboxConfig.value.replyTime).toBe('in_a_day');
});
});
});
@@ -0,0 +1,69 @@
import { computed, toRef } from 'vue';
import {
isOnline as checkIsOnline,
isInWorkingHours as checkInWorkingHours,
} from 'widget/helpers/availabilityHelpers';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
const DEFAULT_TIMEZONE = 'UTC';
const DEFAULT_REPLY_TIME = 'in_a_few_minutes';
/**
* Composable for availability-related logic
* @param {Ref|Array} agents - Available agents (can be ref or raw array)
* @returns {Object} Availability utilities and computed properties
*/
export function useAvailability(agents = []) {
const availableAgents = toRef(agents);
const channelConfig = computed(() => window.chatwootWebChannel || {});
const inboxConfig = computed(() => ({
workingHours: channelConfig.value.workingHours?.map(useCamelCase) || [],
workingHoursEnabled: channelConfig.value.workingHoursEnabled || false,
timezone: channelConfig.value.timezone || DEFAULT_TIMEZONE,
utcOffset:
channelConfig.value.utcOffset ||
channelConfig.value.timezone ||
DEFAULT_TIMEZONE,
replyTime: channelConfig.value.replyTime || DEFAULT_REPLY_TIME,
}));
const currentTime = computed(() => new Date());
const hasOnlineAgents = computed(() => {
const agentList = availableAgents.value || [];
return Array.isArray(agentList) ? agentList.length > 0 : false;
});
const isInWorkingHours = computed(() =>
checkInWorkingHours(
currentTime.value,
inboxConfig.value.utcOffset,
inboxConfig.value.workingHours
)
);
// Check if online (considering both working hours and agents)
const isOnline = computed(() =>
checkIsOnline(
inboxConfig.value.workingHoursEnabled,
currentTime.value,
inboxConfig.value.utcOffset,
inboxConfig.value.workingHours,
hasOnlineAgents.value
)
);
return {
channelConfig,
inboxConfig,
currentTime,
availableAgents,
hasOnlineAgents,
isOnline,
isInWorkingHours,
};
}
@@ -0,0 +1,340 @@
import { utcToZonedTime } from 'date-fns-tz';
// Constants
const DAYS_IN_WEEK = 7;
const MINUTES_IN_HOUR = 60;
const MINUTES_IN_DAY = 24 * 60;
// ---------------------------------------------------------------------------
// Internal helper utilities
// ---------------------------------------------------------------------------
/**
* Get date in timezone
* @private
* @param {Date|string} time
* @param {string} utcOffset
* @returns {Date}
*/
const getDateInTimezone = (time, utcOffset) => {
const dateString = time instanceof Date ? time.toISOString() : time;
try {
return utcToZonedTime(dateString, utcOffset);
} catch (error) {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// eslint-disable-next-line no-console
console.warn(
`Invalid timezone: ${utcOffset}, falling back to user timezone: ${userTimezone}`
);
return utcToZonedTime(dateString, userTimezone);
}
};
/**
* Convert time to minutes
* @private
* @param {number} hours
* @param {number} minutes
* @returns {number}
*/
const toMinutes = (hours = 0, minutes = 0) => hours * MINUTES_IN_HOUR + minutes;
/**
* Get today's config
* @private
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @returns {Object|null}
*/
const getTodayConfig = (time, utcOffset, workingHours) => {
const date = getDateInTimezone(time, utcOffset);
const dayOfWeek = date.getDay();
return workingHours.find(slot => slot.dayOfWeek === dayOfWeek) || null;
};
/**
* Check if current time is within working range, handling midnight crossing
* @private
* @param {number} currentMinutes
* @param {number} openMinutes
* @param {number} closeMinutes
* @returns {boolean}
*/
const isTimeWithinRange = (currentMinutes, openMinutes, closeMinutes) => {
const crossesMidnight = closeMinutes <= openMinutes;
return crossesMidnight
? currentMinutes >= openMinutes || currentMinutes < closeMinutes
: currentMinutes >= openMinutes && currentMinutes < closeMinutes;
};
/**
* Build a map keyed by `dayOfWeek` for all slots that are NOT closed all day.
* @private
*
* @param {Array<Object>} workingHours - Full array of working-hour slot configs.
* @returns {Map<number, Object>} Map where the key is the numeric day (0-6) and the value is the slot config.
*/
const getOpenDaysMap = workingHours =>
new Map(
(workingHours || [])
.filter(slot => !slot.closedAllDay)
.map(slot => [slot.dayOfWeek, slot])
);
/**
* Determine if today's slot is still upcoming.
* @private
* Returns an object with details if the slot is yet to open, otherwise `null`.
*
* @param {number} currentDay - `Date#getDay()` value (0-6) for current time.
* @param {number} currentMinutes - Minutes since midnight for current time.
* @param {Map<number, Object>} openDays - Map produced by `getOpenDaysMap`.
* @returns {Object|null} Slot details (config, minutesUntilOpen, etc.) or `null`.
*/
const checkTodayAvailability = (currentDay, currentMinutes, openDays) => {
const todayConfig = openDays.get(currentDay);
if (!todayConfig || todayConfig.openAllDay) return null;
const todayOpenMinutes = toMinutes(
todayConfig.openHour ?? 0,
todayConfig.openMinutes ?? 0
);
// Haven't opened yet today
if (currentMinutes < todayOpenMinutes) {
return {
config: todayConfig,
minutesUntilOpen: todayOpenMinutes - currentMinutes,
daysUntilOpen: 0,
dayOfWeek: currentDay,
};
}
return null;
};
/**
* Search the upcoming days (including tomorrow) for the next open slot.
* @private
*
* @param {number} currentDay - Day index (0-6) representing today.
* @param {number} currentMinutes - Minutes since midnight for current time.
* @param {Map<number, Object>} openDays - Map of open day configs.
* @returns {Object|null} Details of the next slot or `null` if none found.
*/
const findNextSlot = (currentDay, currentMinutes, openDays) =>
Array.from({ length: DAYS_IN_WEEK }, (_, i) => i + 1)
.map(daysAhead => {
const targetDay = (currentDay + daysAhead) % DAYS_IN_WEEK;
const config = openDays.get(targetDay);
if (!config) return null;
// Calculate minutes until this slot opens
const slotOpenMinutes = config.openAllDay
? 0
: toMinutes(config.openHour ?? 0, config.openMinutes ?? 0);
const minutesUntilOpen =
MINUTES_IN_DAY -
currentMinutes + // remaining mins today
(daysAhead - 1) * MINUTES_IN_DAY + // full days between
slotOpenMinutes; // opening on target day
return {
config,
minutesUntilOpen,
daysUntilOpen: daysAhead,
dayOfWeek: targetDay,
};
})
.find(Boolean) || null;
/**
* Convert slot details from inbox timezone to user timezone
* @private
* @param {Date} time - Current time
* @param {string} inboxTz - Inbox timezone
* @param {Object} slotDetails - Original slot details
* @returns {Object} Slot details with user timezone adjustments
*/
const convertSlotToUserTimezone = (time, inboxTz, slotDetails) => {
if (!slotDetails) return null;
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// If timezones match, no conversion needed
if (inboxTz === userTz) return slotDetails;
// Calculate when the slot opens (absolute time)
const now = time instanceof Date ? time : new Date(time);
const openingTime = new Date(
now.getTime() + slotDetails.minutesUntilOpen * 60000
);
// Convert to user timezone
const openingInUserTz = getDateInTimezone(openingTime, userTz);
const nowInUserTz = getDateInTimezone(now, userTz);
// Calculate days difference in user timezone
const openingDate = new Date(openingInUserTz);
openingDate.setHours(0, 0, 0, 0);
const todayDate = new Date(nowInUserTz);
todayDate.setHours(0, 0, 0, 0);
const daysUntilOpen = Math.round((openingDate - todayDate) / 86400000);
// Return with user timezone adjustments
return {
...slotDetails,
config: {
...slotDetails.config,
openHour: openingInUserTz.getHours(),
openMinutes: openingInUserTz.getMinutes(),
dayOfWeek: openingInUserTz.getDay(),
},
daysUntilOpen,
dayOfWeek: openingInUserTz.getDay(),
};
};
// ---------------------------------------------------------------------------
// Exported functions
// ---------------------------------------------------------------------------
/**
* Check if open all day
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @returns {boolean}
*/
export const isOpenAllDay = (time, utcOffset, workingHours = []) => {
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
return todayConfig?.openAllDay === true;
};
/**
* Check if closed all day
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @returns {boolean}
*/
export const isClosedAllDay = (time, utcOffset, workingHours = []) => {
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
return todayConfig?.closedAllDay === true;
};
/**
* Check if in working hours
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @returns {boolean}
*/
export const isInWorkingHours = (time, utcOffset, workingHours = []) => {
if (!workingHours.length) return false;
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
if (!todayConfig) return false;
// Handle all-day states
if (todayConfig.openAllDay) return true;
if (todayConfig.closedAllDay) return false;
// Check time-based availability
const date = getDateInTimezone(time, utcOffset);
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
const openMinutes = toMinutes(
todayConfig.openHour ?? 0,
todayConfig.openMinutes ?? 0
);
const closeMinutes = toMinutes(
todayConfig.closeHour ?? 0,
todayConfig.closeMinutes ?? 0
);
return isTimeWithinRange(currentMinutes, openMinutes, closeMinutes);
};
/**
* Find next available slot with detailed information
* Returns times adjusted to user's timezone for display
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @returns {Object|null}
*/
export const findNextAvailableSlotDetails = (
time,
utcOffset,
workingHours = []
) => {
const date = getDateInTimezone(time, utcOffset);
const currentDay = date.getDay();
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
const openDays = getOpenDaysMap(workingHours);
// No open days at all
if (openDays.size === 0) return null;
// Check today first
const todaySlot = checkTodayAvailability(
currentDay,
currentMinutes,
openDays
);
// Find the slot (today or next)
const slotDetails =
todaySlot || findNextSlot(currentDay, currentMinutes, openDays);
// Convert to user timezone for display
return convertSlotToUserTimezone(time, utcOffset, slotDetails);
};
/**
* Find minutes until next available slot
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @returns {number|null}
*/
export const findNextAvailableSlotDiff = (
time,
utcOffset,
workingHours = []
) => {
if (isInWorkingHours(time, utcOffset, workingHours)) {
return 0;
}
const nextSlot = findNextAvailableSlotDetails(time, utcOffset, workingHours);
return nextSlot ? nextSlot.minutesUntilOpen : null;
};
/**
* Check if online
* @param {boolean} workingHoursEnabled
* @param {Date|string} time
* @param {string} utcOffset
* @param {Array} workingHours
* @param {boolean} hasOnlineAgents
* @returns {boolean}
*/
export const isOnline = (
workingHoursEnabled,
time,
utcOffset,
workingHours,
hasOnlineAgents
) => {
if (!workingHoursEnabled) {
return hasOnlineAgents;
}
const inWorkingHours = isInWorkingHours(time, utcOffset, workingHours);
return inWorkingHours && hasOnlineAgents;
};
@@ -0,0 +1,580 @@
import { utcToZonedTime } from 'date-fns-tz';
import {
isOpenAllDay,
isClosedAllDay,
isInWorkingHours,
findNextAvailableSlotDetails,
findNextAvailableSlotDiff,
isOnline,
} from '../availabilityHelpers';
// Mock date-fns-tz
vi.mock('date-fns-tz', () => ({
utcToZonedTime: vi.fn(),
}));
describe('availabilityHelpers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('isOpenAllDay', () => {
it('should return true when slot is marked as open_all_day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z'); // Monday
mockDate.getDay = vi.fn().mockReturnValue(1);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(true);
});
it('should return false when slot is not open_all_day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, closeHour: 17, openAllDay: false },
];
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false);
});
it('should return false when no config exists for the day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Tuesday config
];
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false);
});
});
describe('isClosedAllDay', () => {
it('should return true when slot is marked as closed_all_day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, closedAllDay: true }];
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(true);
});
it('should return false when slot is not closed_all_day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, closeHour: 17, closedAllDay: false },
];
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false);
});
it('should return false when no config exists for the day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 2, openHour: 9, closeHour: 17 }];
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false);
});
});
describe('isInWorkingHours', () => {
it('should return false when no working hours are configured', () => {
expect(isInWorkingHours(new Date(), 'UTC', [])).toBe(false);
});
it('should return true when open_all_day is true', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
});
it('should return false when closed_all_day is true', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, closedAllDay: true }];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
});
it('should return true when current time is within working hours', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{
dayOfWeek: 1,
openHour: 9,
openMinutes: 0,
closeHour: 17,
closeMinutes: 0,
},
];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
});
it('should return false when current time is before opening', () => {
const mockDate = new Date('2024-01-15T08:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(8);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
});
it('should return false when current time is after closing', () => {
const mockDate = new Date('2024-01-15T18:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(18);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
});
it('should handle minutes in time comparison', () => {
const mockDate = new Date('2024-01-15T09:30:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(9);
mockDate.getMinutes = vi.fn().mockReturnValue(30);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{
dayOfWeek: 1,
openHour: 9,
openMinutes: 15,
closeHour: 17,
closeMinutes: 30,
},
];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
});
it('should return false when no config for current day', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Only Tuesday
];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
});
});
describe('findNextAvailableSlotDetails', () => {
it('should return null when no open days exist', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 0, closedAllDay: true },
{ dayOfWeek: 1, closedAllDay: true },
{ dayOfWeek: 2, closedAllDay: true },
{ dayOfWeek: 3, closedAllDay: true },
{ dayOfWeek: 4, closedAllDay: true },
{ dayOfWeek: 5, closedAllDay: true },
{ dayOfWeek: 6, closedAllDay: true },
];
expect(
findNextAvailableSlotDetails(new Date(), 'UTC', workingHours)
).toBe(null);
});
it('should return today slot when not opened yet', () => {
const mockDate = new Date('2024-01-15T08:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(8);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, openMinutes: 30, closeHour: 17 },
];
const result = findNextAvailableSlotDetails(
new Date(),
'UTC',
workingHours
);
expect(result).toEqual({
config: workingHours[0],
minutesUntilOpen: 90, // 1.5 hours = 90 minutes
daysUntilOpen: 0,
dayOfWeek: 1,
});
});
it('should return tomorrow slot when today is past closing', () => {
const mockDate = new Date('2024-01-15T18:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(18);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
{ dayOfWeek: 2, openHour: 9, closeHour: 17 },
];
const result = findNextAvailableSlotDetails(
new Date(),
'UTC',
workingHours
);
expect(result).toEqual({
config: workingHours[1],
minutesUntilOpen: 900, // 15 hours = 900 minutes
daysUntilOpen: 1,
dayOfWeek: 2,
});
});
it('should skip closed days and find next open day', () => {
const mockDate = new Date('2024-01-15T18:00:00.000Z'); // Monday evening
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(18);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
{ dayOfWeek: 2, closedAllDay: true },
{ dayOfWeek: 3, closedAllDay: true },
{ dayOfWeek: 4, openHour: 10, closeHour: 16 },
];
const result = findNextAvailableSlotDetails(
new Date(),
'UTC',
workingHours
);
// Monday 18:00 to Thursday 10:00
// Rest of Monday: 6 hours (18:00 to 24:00) = 360 minutes
// Tuesday: 24 hours = 1440 minutes
// Wednesday: 24 hours = 1440 minutes
// Thursday morning: 10 hours = 600 minutes
// Total: 360 + 1440 + 1440 + 600 = 3840 minutes
expect(result).toEqual({
config: workingHours[3],
minutesUntilOpen: 3840, // 64 hours = 3840 minutes
daysUntilOpen: 3,
dayOfWeek: 4,
});
});
it('should handle open_all_day slots', () => {
const mockDate = new Date('2024-01-15T18:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(18);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
{ dayOfWeek: 2, openAllDay: true },
];
const result = findNextAvailableSlotDetails(
new Date(),
'UTC',
workingHours
);
expect(result).toEqual({
config: workingHours[1],
minutesUntilOpen: 360, // 6 hours to midnight = 360 minutes
daysUntilOpen: 1,
dayOfWeek: 2,
});
});
it('should wrap around week correctly', () => {
const mockDate = new Date('2024-01-20T18:00:00.000Z'); // Saturday evening
mockDate.getDay = vi.fn().mockReturnValue(6);
mockDate.getHours = vi.fn().mockReturnValue(18);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openHour: 9, closeHour: 17 }, // Monday
];
const result = findNextAvailableSlotDetails(
new Date(),
'UTC',
workingHours
);
// Saturday 18:00 to Monday 9:00
// Rest of Saturday: 6 hours = 360 minutes
// Sunday: 24 hours = 1440 minutes
// Monday morning: 9 hours = 540 minutes
// Total: 360 + 1440 + 540 = 2340 minutes
expect(result).toEqual({
config: workingHours[0],
minutesUntilOpen: 2340, // 39 hours = 2340 minutes
daysUntilOpen: 2,
dayOfWeek: 1,
});
});
it('should handle today open_all_day correctly', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 1, openAllDay: true },
{ dayOfWeek: 2, openHour: 9, closeHour: 17 },
];
// Should skip today since it's open_all_day and look for next slot
const result = findNextAvailableSlotDetails(
new Date(),
'UTC',
workingHours
);
expect(result).toEqual({
config: workingHours[1],
minutesUntilOpen: 1380, // Rest of today + 9 hours tomorrow = 1380 minutes
daysUntilOpen: 1,
dayOfWeek: 2,
});
});
});
describe('findNextAvailableSlotDiff', () => {
it('should return 0 when currently in working hours', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
0
);
});
it('should return minutes until next slot when not in working hours', () => {
const mockDate = new Date('2024-01-15T08:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(8);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
60
);
});
it('should return null when no next slot available', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [
{ dayOfWeek: 0, closedAllDay: true },
{ dayOfWeek: 1, closedAllDay: true },
{ dayOfWeek: 2, closedAllDay: true },
{ dayOfWeek: 3, closedAllDay: true },
{ dayOfWeek: 4, closedAllDay: true },
{ dayOfWeek: 5, closedAllDay: true },
{ dayOfWeek: 6, closedAllDay: true },
];
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
null
);
});
});
describe('isOnline', () => {
it('should return agent status when working hours disabled', () => {
expect(isOnline(false, new Date(), 'UTC', [], true)).toBe(true);
expect(isOnline(false, new Date(), 'UTC', [], false)).toBe(false);
});
it('should check both working hours and agents when enabled', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
// In working hours + agents available = online
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true);
// In working hours but no agents = offline
expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe(
false
);
});
it('should return false when outside working hours even with agents', () => {
const mockDate = new Date('2024-01-15T08:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(8);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(false);
});
it('should handle open_all_day with agents', () => {
const mockDate = new Date('2024-01-15T02:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(2);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true);
expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe(
false
);
});
it('should handle string date input', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(
isOnline(true, '2024-01-15T10:00:00.000Z', 'UTC', workingHours, true)
).toBe(true);
});
});
describe('Timezone handling', () => {
it('should correctly handle different timezones', () => {
const mockDate = new Date('2024-01-15T15:30:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(15);
mockDate.getMinutes = vi.fn().mockReturnValue(30);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isInWorkingHours(new Date(), 'Asia/Kolkata', workingHours)).toBe(
true
);
expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith(
expect.any(String),
'Asia/Kolkata'
);
});
it('should handle UTC offset format', () => {
const mockDate = new Date('2024-01-15T10:00:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(10);
mockDate.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isInWorkingHours(new Date(), '+05:30', workingHours)).toBe(true);
expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith(
expect.any(String),
'+05:30'
);
});
});
describe('Edge cases', () => {
it('should handle working hours at exact boundaries', () => {
// Test at exact opening time
const mockDate1 = new Date('2024-01-15T09:00:00.000Z');
mockDate1.getDay = vi.fn().mockReturnValue(1);
mockDate1.getHours = vi.fn().mockReturnValue(9);
mockDate1.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate1);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
// Test at exact closing time
const mockDate2 = new Date('2024-01-15T17:00:00.000Z');
mockDate2.getDay = vi.fn().mockReturnValue(1);
mockDate2.getHours = vi.fn().mockReturnValue(17);
mockDate2.getMinutes = vi.fn().mockReturnValue(0);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate2);
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
});
it('should handle one minute before closing', () => {
const mockDate = new Date('2024-01-15T16:59:00.000Z');
mockDate.getDay = vi.fn().mockReturnValue(1);
mockDate.getHours = vi.fn().mockReturnValue(16);
mockDate.getMinutes = vi.fn().mockReturnValue(59);
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
});
});
});
+8 -2
View File
@@ -19,13 +19,19 @@
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment"
"OFFLINE": "We are away at the moment",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
"BACK_IN": "We will be back online"
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
+1 -1
View File
@@ -14,7 +14,7 @@
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Indisponível"
}
},
"TEAM_AVAILABILITY": {
@@ -1,100 +0,0 @@
import { utcToZonedTime } from 'date-fns-tz';
import { isTimeAfter } from 'shared/helpers/DateHelper';
export default {
computed: {
channelConfig() {
return window.chatwootWebChannel;
},
replyTime() {
return window.chatwootWebChannel.replyTime;
},
replyTimeStatus() {
switch (this.replyTime) {
case 'in_a_few_minutes':
return this.$t('REPLY_TIME.IN_A_FEW_MINUTES');
case 'in_a_few_hours':
return this.$t('REPLY_TIME.IN_A_FEW_HOURS');
case 'in_a_day':
return this.$t('REPLY_TIME.IN_A_DAY');
default:
return this.$t('REPLY_TIME.IN_A_FEW_HOURS');
}
},
replyWaitMessage() {
const { workingHoursEnabled } = this.channelConfig;
if (workingHoursEnabled) {
return this.isOnline
? this.replyTimeStatus
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
}
return this.isOnline
? this.replyTimeStatus
: this.$t('TEAM_AVAILABILITY.OFFLINE');
},
outOfOfficeMessage() {
return this.channelConfig.outOfOfficeMessage;
},
isInBetweenTheWorkingHours() {
const {
openHour,
openMinute,
closeHour,
closeMinute,
closedAllDay,
openAllDay,
} = this.currentDayAvailability;
if (openAllDay) {
return true;
}
if (closedAllDay) {
return false;
}
const { utcOffset } = this.channelConfig;
const today = this.getDateWithOffset(utcOffset);
const currentHours = today.getHours();
const currentMinutes = today.getMinutes();
const isAfterStartTime = isTimeAfter(
currentHours,
currentMinutes,
openHour,
openMinute
);
const isBeforeEndTime = isTimeAfter(
closeHour,
closeMinute,
currentHours,
currentMinutes
);
return isAfterStartTime && isBeforeEndTime;
},
currentDayAvailability() {
const { utcOffset } = this.channelConfig;
const dayOfTheWeek = this.getDateWithOffset(utcOffset).getDay();
const [workingHourConfig = {}] = this.channelConfig.workingHours.filter(
workingHour => workingHour.day_of_week === dayOfTheWeek
);
return {
closedAllDay: workingHourConfig.closed_all_day,
openHour: workingHourConfig.open_hour,
openMinute: workingHourConfig.open_minutes,
closeHour: workingHourConfig.close_hour,
closeMinute: workingHourConfig.close_minutes,
openAllDay: workingHourConfig.open_all_day,
};
},
isInBusinessHours() {
const { workingHoursEnabled } = this.channelConfig;
return workingHoursEnabled ? this.isInBetweenTheWorkingHours : true;
},
},
methods: {
getDateWithOffset(utcOffset) {
return utcToZonedTime(new Date().toISOString(), utcOffset);
},
},
};
@@ -1,250 +0,0 @@
import {
timeSlotParse,
defaultTimeSlot,
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { utcToZonedTime } from 'date-fns-tz';
import { generateRelativeTime } from 'shared/helpers/DateHelper';
const MINUTE_ROUNDING_FACTOR = 5;
export default {
data() {
return {
dayNames: [
this.$t('DAY_NAMES.SUNDAY'),
this.$t('DAY_NAMES.MONDAY'),
this.$t('DAY_NAMES.TUESDAY'),
this.$t('DAY_NAMES.WEDNESDAY'),
this.$t('DAY_NAMES.THURSDAY'),
this.$t('DAY_NAMES.FRIDAY'),
this.$t('DAY_NAMES.SATURDAY'),
],
timeSlots: [...defaultTimeSlot],
timeSlot: {},
};
},
computed: {
channelConfig() {
return window.chatwootWebChannel;
},
workingHours() {
return this.channelConfig.workingHours;
},
newDateWithTimeZone() {
return utcToZonedTime(new Date(), this.timeZoneValue);
},
presentHour() {
return this.newDateWithTimeZone.getHours();
},
presentMinute() {
return this.newDateWithTimeZone.getMinutes();
},
currentDay() {
const date = this.newDateWithTimeZone;
const day = date.getDay();
const currentDay = Object.keys(this.dayNames).find(
key => this.dayNames[key] === this.dayNames[day]
);
return Number(currentDay);
},
timeZoneValue() {
return this.channelConfig.timezone;
},
languageCode() {
return window.chatwootWebChannel.locale;
},
currentDayWorkingHours() {
return this.workingHours.find(
slot => slot.day_of_week === this.currentDay
);
},
nextDayWorkingHours() {
let nextDay = this.getNextDay(this.currentDay);
let nextWorkingHour = this.getNextWorkingHour(nextDay);
// It gets the next working hour for the next day. If there is no working hour for the next day,
// it keeps iterating through the days of the week until it finds the next working hour.
while (!nextWorkingHour) {
nextDay = this.getNextDay(nextDay);
nextWorkingHour = this.getNextWorkingHour(nextDay);
}
return nextWorkingHour;
},
currentDayTimings() {
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = this.currentDayWorkingHours ?? {};
return {
openHour,
openMinute,
closeHour,
};
},
nextDayTimings() {
const { open_hour: openHour, open_minutes: openMinute } =
this.nextDayWorkingHours ?? {};
return {
openHour,
openMinute,
};
},
dayDiff() {
// Here this is used to get the difference between current day and next working day
const nextDay = this.nextDayWorkingHours.day_of_week;
const totalDays = 6;
return nextDay > this.currentDay
? nextDay - this.currentDay - 1
: totalDays - this.currentDay + nextDay;
},
dayNameOfNextWorkingDay() {
return this.dayNames[this.nextDayWorkingHours.day_of_week];
},
hoursAndMinutesBackInOnline() {
if (this.presentHour >= this.currentDayTimings.closeHour) {
return this.getHoursAndMinutesUntilNextDayOpen(
this.nextDayWorkingHours.open_all_day
? 0
: this.nextDayTimings.openHour,
this.nextDayTimings.openMinute,
this.currentDayTimings.closeHour
);
}
return this.getHoursAndMinutesUntilNextDayOpen(
this.currentDayTimings.openHour,
this.currentDayTimings.openMinute,
this.currentDayTimings.closeHour
);
},
exactTimeInAmPm() {
return `${
this.timeSlot.day === this.currentDay ? `at ${this.timeSlot.from}` : ''
}`;
},
hoursAndMinutesLeft() {
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
const timeLeftChars = [];
if (hoursLeft > 0) {
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
const hourRelative = generateRelativeTime(
roundedUpHoursLeft,
'hour',
this.languageCode
);
timeLeftChars.push(`${hourRelative}`);
}
if (minutesLeft > 0 && hoursLeft === 0) {
const roundedUpMinLeft =
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
MINUTE_ROUNDING_FACTOR;
const minRelative = generateRelativeTime(
roundedUpMinLeft,
'minutes',
this.languageCode
);
timeLeftChars.push(`${minRelative}`);
}
return timeLeftChars.join(' ');
},
hoursAndMinutesToBack() {
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
if (hoursLeft >= 3) {
return this.exactTimeInAmPm;
}
if (hoursLeft > 0 || minutesLeft > 0) {
return this.hoursAndMinutesLeft;
}
return 'in some time';
},
timeLeftToBackInOnline() {
if (
this.hoursAndMinutesBackInOnline.hoursLeft >= 24 ||
(this.timeSlot.day !== this.currentDay && this.dayDiff === 0)
) {
const hourRelative = generateRelativeTime(
this.dayDiff + 1,
'days',
this.languageCode
);
return `${hourRelative}`;
}
if (
this.dayDiff >= 1 &&
this.presentHour >= this.currentDayTimings.closeHour
) {
return `on ${this.dayNameOfNextWorkingDay}`;
}
return this.hoursAndMinutesToBack;
},
},
mounted() {
this.setTimeSlot();
},
methods: {
getNextDay(day) {
// This code calculates the next day of the week based on the current day. If the current day is Saturday (6), then the next day will be Sunday (0).
return (day + 1) % 7;
},
getNextWorkingHour(day) {
const workingHour = this.workingHours.find(
slot => slot.day_of_week === day
);
if (workingHour && !workingHour.closed_all_day) {
return workingHour;
}
return null;
},
getHoursAndMinutesUntilNextDayOpen(
openHour, // If the present time is after the closing time of the current day, then the openHour will be the opening hour of the next day else it will be the opening hour of the current day.
openMinutes, // If the present time is after the closing time of the current day, then the openMinutes will be the opening minutes of the next day else it will be the opening minutes of the current day.
closeHour // The closeHour will be the closing hour of the current day. It will be used to calculate the time remaining until the next day's opening hours.
) {
// This code calculates the time remaining until the next day's opening hours,
// given the current time, the opening hours, and the closing hours of the current day.
if (closeHour < openHour) {
openHour += 24;
}
let diffMinutes =
openHour * 60 +
openMinutes -
(this.presentHour * 60 + this.presentMinute);
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
const [hoursLeft, minutesLeft] = [
Math.floor(diffMinutes / 60),
diffMinutes % 60,
];
// It returns the remaining time in hours and minutes as an object with keys hours and minutes.
return { hoursLeft, minutesLeft };
},
setTimeSlot() {
// It checks if the working hours feature is enabled for the store.
const timeSlots = this.workingHours;
// If the present hour is after the closing hour of the current day,
// then the next day's working hours will be used to calculate the time remaining until the next day's opening hours,
// else the current day's working hours will be used
const currentSlot =
this.presentHour >= this.currentDayTimings.closeHour
? this.nextDayWorkingHours
: this.currentDayWorkingHours;
// It parses the working hours to get the time slots in AM/PM format.
const slots = timeSlotParse(timeSlots).length
? timeSlotParse(timeSlots)
: defaultTimeSlot;
this.timeSlots = slots;
// It finds the time slot for the current slot.
this.timeSlot = this.timeSlots.find(
slot => slot.day === currentSlot.day_of_week
);
},
},
};
@@ -1,10 +0,0 @@
export default {
methods: {
async replaceRoute(name, params = {}) {
if (this.$route.name !== name) {
return this.$router.replace({ name, params });
}
return undefined;
},
},
};
@@ -1,87 +0,0 @@
import { mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import availabilityMixin from '../availability';
import { vi } from 'vitest';
global.chatwootWebChannel = {
workingHoursEnabled: true,
workingHours: [
{
day_of_week: 3,
closed_all_day: false,
open_hour: 8,
open_minutes: 30,
close_hour: 17,
close_minutes: 35,
open_all_day: false,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 8,
open_minutes: 30,
close_hour: 17,
close_minutes: 30,
open_all_day: false,
},
],
utcOffset: '-07:00',
};
let Component;
describe('availabilityMixin', () => {
beforeEach(() => {
vi.useRealTimers();
Component = defineComponent({
mixins: [availabilityMixin],
render() {
return h('div');
},
});
});
it('returns valid isInBetweenWorkingHours if in different timezone', () => {
vi.useFakeTimers().setSystemTime(
new Date('Thu Apr 14 2022 06:04:46 GMT+0530')
);
const wrapper = mount(Component);
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true);
});
it('returns valid isInBetweenWorkingHours if in same timezone', () => {
global.chatwootWebChannel.utcOffset = '+05:30';
vi.useFakeTimers().setSystemTime(
new Date('Thu Apr 14 2022 09:01:46 GMT+0530')
);
const wrapper = mount(Component);
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true);
});
it('returns false if closed all day', () => {
global.chatwootWebChannel.utcOffset = '-07:00';
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, closed_all_day: true },
];
vi.useFakeTimers().setSystemTime(
new Date('Thu Apr 14 2022 09:01:46 GMT+0530')
);
const wrapper = mount(Component);
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(false);
});
it('returns true if open all day', () => {
global.chatwootWebChannel.utcOffset = '-07:00';
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, open_all_day: true },
];
vi.useFakeTimers().setSystemTime(
new Date('Thu Apr 14 2022 09:01:46 GMT+0530')
);
const wrapper = mount(Component);
expect(wrapper.vm.isInBetweenTheWorkingHours).toBe(true);
});
});
@@ -1,402 +0,0 @@
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import nextAvailabilityTimeMixin from '../nextAvailabilityTime';
describe('nextAvailabilityTimeMixin', () => {
const chatwootWebChannel = {
workingHoursEnabled: true,
workingHours: [
{
day_of_week: 0,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 1,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 2,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 3,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 4,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 5,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 6,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
],
};
let Component;
beforeEach(() => {
Component = defineComponent({
mixins: [nextAvailabilityTimeMixin],
render() {
return h('div');
},
});
window.chatwootWebChannel = chatwootWebChannel;
});
afterEach(() => {
delete window.chatwootWebChannel;
});
beforeEach(() => {
vi.useRealTimers();
});
it('should return day names', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.dayNames).toEqual([
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]);
});
it('should return channelConfig', () => {
const wrapper = mount(Component);
expect(wrapper.vm.channelConfig).toEqual(chatwootWebChannel);
});
it('should return workingHours', () => {
const wrapper = mount(Component);
expect(wrapper.vm.workingHours).toEqual(chatwootWebChannel.workingHours);
});
it('should return currentDayWorkingHours', () => {
const currentDay = new Date().getDay();
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
slot => slot.day_of_week === currentDay
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.currentDayWorkingHours).toEqual(expectedWorkingHours);
});
it('should return nextDayWorkingHours', () => {
const currentDay = new Date().getDay();
const nextDay = currentDay === 6 ? 0 : currentDay + 1;
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
slot => slot.day_of_week === nextDay
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.nextDayWorkingHours).toEqual(expectedWorkingHours);
});
it('should return presentHour', () => {
const wrapper = mount(Component);
expect(wrapper.vm.presentHour).toBe(new Date().getHours());
});
it('should return presentMinute', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.presentMinute).toBe(new Date().getMinutes());
});
it('should return currentDay', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const date = new Date();
const day = date.getDay();
const currentDay = Object.keys(wrapper.vm.dayNames).find(
key => wrapper.vm.dayNames[key] === wrapper.vm.dayNames[day]
);
expect(wrapper.vm.currentDay).toBe(Number(currentDay));
});
it('should return currentDayTimings', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = wrapper.vm.currentDayWorkingHours;
expect(wrapper.vm.currentDayTimings).toEqual({
openHour,
openMinute,
closeHour,
});
});
it('should return nextDayTimings', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const { open_hour: openHour, open_minutes: openMinute } =
wrapper.vm.nextDayWorkingHours;
expect(wrapper.vm.nextDayTimings).toEqual({
openHour,
openMinute,
});
});
it('should return dayDiff', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const currentDay = wrapper.vm.currentDay;
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
const totalDays = 6;
const expectedDayDiff =
nextDay > currentDay
? nextDay - currentDay - 1
: totalDays - currentDay + nextDay;
expect(wrapper.vm.dayDiff).toEqual(expectedDayDiff);
});
it('should return dayNameOfNextWorkingDay', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
const expectedDayName = wrapper.vm.dayNames[nextDay];
expect(wrapper.vm.dayNameOfNextWorkingDay).toEqual(expectedDayName);
});
it('should return hoursAndMinutesBackInOnline', () => {
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const currentDayCloseHour =
chatwootWebChannel.workingHours[wrapper.vm.currentDay].close_hour;
const nextDayOpenHour =
chatwootWebChannel.workingHours[
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
].open_hour;
const nextDayOpenMinute =
chatwootWebChannel.workingHours[
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
].open_minutes;
const expectedHoursAndMinutes =
wrapper.vm.getHoursAndMinutesUntilNextDayOpen(
nextDayOpenHour,
nextDayOpenMinute,
currentDayCloseHour
);
expect(wrapper.vm.hoursAndMinutesBackInOnline).toEqual(
expectedHoursAndMinutes
);
});
it('should return getNextDay', () => {
const wrapper = mount(Component);
expect(wrapper.vm.getNextDay(6)).toBe(0);
});
it('should return in 30 minutes', () => {
vi.useFakeTimers('modern').setSystemTime(
new Date('Thu Apr 14 2022 14:04:46 GMT+0530')
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 18;
chatwootWebChannel.workingHours[4].open_minutes = 0;
chatwootWebChannel.workingHours[4].close_hour = 23;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 30 minutes');
});
it('should return in 2 hours', () => {
vi.useFakeTimers('modern').setSystemTime(
new Date('Thu Apr 14 2022 22:04:46 GMT+0530')
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 19;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 2 hours');
});
it('should return at 09:00 AM', () => {
vi.useFakeTimers('modern').setSystemTime(
new Date('Thu Apr 15 2022 22:04:46 GMT+0530')
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 10;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('at 09:00 AM');
});
it('should return tomorrow', () => {
vi.useFakeTimers('modern').setSystemTime(
new Date('Thu Apr 1 2022 23:04:46 GMT+0530')
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 9;
chatwootWebChannel.workingHours[4].close_hour = 16;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('tomorrow');
});
it.skip('should return on Saturday', () => {
vi.useFakeTimers('modern').setSystemTime(
new Date('Thu Apr 14 2022 23:04:46 GMT+0530')
);
const wrapper = mount(Component);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 9;
chatwootWebChannel.workingHours[4].close_hour = 16;
chatwootWebChannel.workingHours[5].closed_all_day = true;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('on Saturday');
});
});
+8 -4
View File
@@ -1,7 +1,7 @@
<script>
import TeamAvailability from 'widget/components/TeamAvailability.vue';
import { mapGetters } from 'vuex';
import routerMixin from 'widget/mixins/routerMixin';
import { useRouter } from 'vue-router';
import configMixin from 'widget/mixins/configMixin';
import ArticleContainer from '../components/pageComponents/Home/Article/ArticleContainer.vue';
export default {
@@ -10,7 +10,11 @@ export default {
ArticleContainer,
TeamAvailability,
},
mixins: [configMixin, routerMixin],
mixins: [configMixin],
setup() {
const router = useRouter();
return { router };
},
computed: {
...mapGetters({
availableAgents: 'agent/availableAgents',
@@ -21,9 +25,9 @@ export default {
methods: {
startConversation() {
if (this.preChatFormEnabled && !this.conversationSize) {
return this.replaceRoute('prechat-form');
return this.router.replace({ name: 'prechat-form' });
}
return this.replaceRoute('messages');
return this.router.replace({ name: 'messages' });
},
},
};
+12 -3
View File
@@ -1,7 +1,8 @@
<script>
import { mapActions } from 'vuex';
import { useRouter } from 'vue-router';
import PreChatForm from '../components/PreChat/Form.vue';
import configMixin from '../mixins/configMixin';
import routerMixin from '../mixins/routerMixin';
import { isEmptyObject } from 'widget/helpers/utils';
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -10,7 +11,11 @@ export default {
components: {
PreChatForm,
},
mixins: [configMixin, routerMixin],
mixins: [configMixin],
setup() {
const router = useRouter();
return { router };
},
mounted() {
// Register event listener for conversation creation
emitter.on(ON_CONVERSATION_CREATED, this.handleConversationCreated);
@@ -19,9 +24,11 @@ export default {
emitter.off(ON_CONVERSATION_CREATED, this.handleConversationCreated);
},
methods: {
...mapActions('conversation', ['clearConversations']),
...mapActions('conversationAttributes', ['clearConversationAttributes']),
handleConversationCreated() {
// Redirect to messages page after conversation is created
this.replaceRoute('messages');
this.router.replace({ name: 'messages' });
// Only after successful navigation, reset the isUpdatingRoute UIflag in app/javascript/widget/router.js
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
},
@@ -48,6 +55,8 @@ export default {
},
});
} else {
this.clearConversations();
this.clearConversationAttributes();
this.$store.dispatch('conversation/createConversation', {
fullName: fullName,
emailAddress: emailAddress,
-36
View File
@@ -1,36 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentJob < ApplicationJob
queue_as :low
def perform(inbox_id: nil, conversation_id: nil)
if conversation_id
assign_single_conversation(conversation_id)
elsif inbox_id
assign_inbox_conversations(inbox_id)
else
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
end
end
private
def assign_single_conversation(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
service.perform_for_conversation(conversation)
end
def assign_inbox_conversations(inbox_id)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
return unless inbox.assignment_v2_enabled?
service = AssignmentV2::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
end
end
+6 -28
View File
@@ -1,5 +1,3 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: assignment_policies
@@ -22,38 +20,18 @@
# index_assignment_policies_on_account_id_and_name (account_id,name) UNIQUE
# index_assignment_policies_on_enabled (enabled)
#
class AssignmentPolicy < ApplicationRecord
# Enums
enum assignment_order: { round_robin: 0 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
# Associations
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
# Validations
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1000 }
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
validates :assignment_order, inclusion: { in: assignment_orders.keys }
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
validates :fair_distribution_limit, numericality: { greater_than: 0 }
validates :fair_distribution_window, numericality: { greater_than: 0 }
def webhook_data
{
id: id,
name: name,
description: description,
assignment_order: assignment_order,
conversation_priority: conversation_priority,
fair_distribution_limit: fair_distribution_limit,
fair_distribution_window: fair_distribution_window,
enabled: enabled
}
end
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
enum assignment_order: { round_robin: 0 } unless ChatwootApp.enterprise?
end
AssignmentPolicy.prepend_mod_with('AssignmentPolicy')
AssignmentPolicy.include_mod_with('Concerns::AssignmentPolicy')
+1
View File
@@ -25,6 +25,7 @@
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
#
class Category < ApplicationRecord
paginates_per Limits::CATEGORIES_PER_PAGE
belongs_to :account
belongs_to :portal
has_many :folders, dependent: :destroy_async
@@ -14,18 +14,11 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
if inbox.assignment_v2_enabled?
# Use Assignment V2 system
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
else
# Use legacy assignment system
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
def should_run_auto_assignment?
# Check auto assignment is enabled (either legacy or v2)
return false unless inbox.auto_assignment_enabled?
return false unless inbox.enable_auto_assignment?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
@@ -1,57 +0,0 @@
# frozen_string_literal: true
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents(options = {})
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply rate limiting if assignment policy is enabled
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
scope
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
rate_limiter.within_limits?
end
end
end
+58
View File
@@ -3,6 +3,7 @@ module Liquidable
included do
before_create :process_liquid_in_content
before_create :process_liquid_in_template_params
end
private
@@ -35,4 +36,61 @@ module Liquidable
# We don't want to process liquid in code blocks
content.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}')
end
def process_liquid_in_template_params
return unless template_params_present? && liquid_processable_template_params?
processed_params = process_liquid_in_hash(template_params_data['processed_params'])
# Update the additional_attributes with processed template_params
self.additional_attributes = additional_attributes.merge(
'template_params' => template_params_data.merge('processed_params' => processed_params)
)
rescue Liquid::Error
# If there is an error in the liquid syntax, we don't want to process it
end
def template_params_present?
additional_attributes&.dig('template_params', 'processed_params').present?
end
def liquid_processable_template_params?
message_type == 'outgoing' || message_type == 'template'
end
def template_params_data
additional_attributes['template_params']
end
def process_liquid_in_hash(hash)
return hash unless hash.is_a?(Hash)
hash.transform_values { |value| process_liquid_value(value) }
end
def process_liquid_value(value)
case value
when String
process_liquid_string(value)
when Hash
process_liquid_in_hash(value)
when Array
process_liquid_array(value)
else
value
end
end
def process_liquid_array(array)
array.map { |item| process_liquid_value(item) }
end
def process_liquid_string(string)
return string if string.blank?
template = Liquid::Template.parse(string)
template.render(message_drops)
rescue Liquid::Error
string
end
end
+2 -18
View File
@@ -44,7 +44,6 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -68,15 +67,13 @@ class Inbox < ApplicationRecord
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
has_one :agent_bot_inbox, dependent: :destroy_async
has_one :agent_bot, through: :agent_bot_inbox
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
# Assignment V2 associations
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -189,19 +186,6 @@ class Inbox < ApplicationRecord
members.ids
end
# Assignment V2 methods
def assignment_v2_enabled?
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
end
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
end
end
private
def default_name_for_blank_name
-31
View File
@@ -1,5 +1,3 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inbox_assignment_policies
@@ -15,38 +13,9 @@
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
# Associations
belongs_to :inbox
belongs_to :assignment_policy
# Validations
validates :inbox_id, uniqueness: true
validate :inbox_belongs_to_same_account
# Delegations
delegate :account, to: :inbox
delegate :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled?,
to: :assignment_policy, prefix: :policy
def webhook_data
{
id: id,
inbox_id: inbox_id,
assignment_policy_id: assignment_policy_id,
policy: assignment_policy.webhook_data
}
end
private
def inbox_belongs_to_same_account
return unless inbox && assignment_policy
return if inbox.account_id == assignment_policy.account_id
errors.add(:inbox, 'must belong to the same account as the assignment policy')
end
end
+1
View File
@@ -19,6 +19,7 @@
#
# Indexes
#
# idx_notifications_performance (user_id,account_id,snoozed_until,read_at)
# index_notifications_on_account_id (account_id)
# index_notifications_on_last_activity_at (last_activity_at)
# index_notifications_on_user_id (user_id)
-2
View File
@@ -1,5 +1,3 @@
# frozen_string_literal: true
class AssignmentPolicyPolicy < ApplicationPolicy
def index?
@account_user.administrator?
@@ -1,102 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentService
pattr_initialize [:inbox!]
def perform_for_conversation(conversation)
return false unless can_assign?(conversation)
agent = find_agent_for_conversation(conversation)
return false unless agent
assign_conversation_to_agent(conversation, agent)
end
def perform_bulk_assignment(limit: 50)
return 0 unless assignment_enabled?
conversations = unassigned_conversations(limit)
assigned_count = 0
conversations.find_each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def policy
@policy ||= inbox.assignment_policy
end
def assignment_enabled?
policy&.enabled?
end
def can_assign?(conversation)
assignment_enabled? &&
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def find_agent_for_conversation(_conversation)
available_agents = inbox.available_agents(check_rate_limits: true)
if available_agents.empty?
log_no_agents_available
return nil
end
selector_service.select_agent(available_agents)
end
def selector_service
@selector_service ||= AssignmentV2::RoundRobinSelector.new(inbox: inbox)
end
def unassigned_conversations(limit)
scope = inbox.conversations
.unassigned
.open
# Apply conversation priority ordering
scope = case policy.conversation_priority
when 'longest_waiting'
scope.order(last_activity_at: :asc, created_at: :asc)
else
scope.order(created_at: :asc)
end
scope.limit(limit)
end
def assign_conversation_to_agent(conversation, agent)
conversation.update!(assignee: agent)
create_assignment_activity(conversation, agent)
true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AssignmentV2: Failed to assign conversation #{conversation.id}: #{e.message}"
false
end
def create_assignment_activity(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def enterprise_enabled?
@enterprise_enabled ||= defined?(Enterprise)
end
def log_no_agents_available
Rails.logger.warn("AssignmentV2: No agents available for inbox #{inbox.id}")
end
end
AssignmentV2::AssignmentService.prepend_mod_with('AssignmentV2::AssignmentService')
@@ -1,75 +0,0 @@
# frozen_string_literal: true
# Rate limiter for assignment operations
# Uses SQL to track assignment counts per agent per time window
# based on assignment policy's fair_distribution_limit and fair_distribution_window
class AssignmentV2::RateLimiter
pattr_initialize [:inbox!, :user!]
# Check if the user has exceeded rate limits
# @return [Boolean] true if within limits, false if exceeded
def within_limits?
return true unless policy_exists?
current_count < rate_limit
end
# Get current rate limit status for the user
# @return [Hash] Rate limit status information
def status
if policy_exists?
{
within_limits: within_limits?,
current_count: current_count,
limit: rate_limit,
reset_at: Time.zone.at(next_window_start)
}
else
{
within_limits: true,
current_count: 0,
limit: Float::INFINITY,
reset_at: nil
}
end
end
private
def policy
@policy ||= inbox.assignment_policy
end
def policy_exists?
policy.present? && policy.enabled?
end
def current_count
# Count conversations assigned to this user in the current time window
# from this inbox
window_start = Time.zone.at(current_window)
Conversation
.where(inbox_id: inbox.id)
.where(assignee_id: user.id)
.where('updated_at >= ?', window_start)
.where.not(assignee_id: nil)
.count
end
def rate_limit
policy&.fair_distribution_limit || 10
end
def time_window
policy&.fair_distribution_window || 3600
end
def current_window
(Time.current.to_i / time_window) * time_window
end
def next_window_start
current_window + time_window
end
end
@@ -1,37 +0,0 @@
# frozen_string_literal: true
class AssignmentV2::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
# Extract user IDs from inbox members
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
# Use Redis queue for round robin
selected_user_id = round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
return nil unless selected_user_id
# Return the user object
available_agents.find { |inbox_member| inbox_member.user_id.to_s == selected_user_id }&.user
end
def add_agent_to_queue(user_id)
round_robin_service.add_agent_to_queue(user_id)
end
def remove_agent_from_queue(user_id)
round_robin_service.remove_agent_from_queue(user_id)
end
def reset_queue
round_robin_service.reset_queue
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end
@@ -0,0 +1,10 @@
json.id assignment_policy.id
json.name assignment_policy.name
json.description assignment_policy.description
json.assignment_order assignment_policy.assignment_order
json.conversation_priority assignment_policy.conversation_priority
json.fair_distribution_limit assignment_policy.fair_distribution_limit
json.fair_distribution_window assignment_policy.fair_distribution_window
json.enabled assignment_policy.enabled
json.created_at assignment_policy.created_at.to_i
json.updated_at assignment_policy.updated_at.to_i
@@ -0,0 +1 @@
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
@@ -0,0 +1,5 @@
json.id @inbox_assignment_policy.id
json.inbox_id @inbox_assignment_policy.inbox_id
json.assignment_policy_id @inbox_assignment_policy.assignment_policy_id
json.created_at @inbox_assignment_policy.created_at.to_i
json.updated_at @inbox_assignment_policy.updated_at.to_i
@@ -0,0 +1,3 @@
json.inboxes @inboxes do |inbox|
json.partial! 'api/v1/models/inbox', formats: [:json], resource: inbox
end
@@ -0,0 +1,3 @@
json.array! @assignment_policies do |assignment_policy|
json.partial! 'assignment_policy', assignment_policy: assignment_policy
end
@@ -0,0 +1 @@
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
@@ -0,0 +1 @@
json.partial! 'assignment_policy', assignment_policy: @assignment_policy
@@ -0,0 +1 @@
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
@@ -0,0 +1 @@
json.partial! 'api/v1/accounts/assignment_policies/assignment_policy', formats: [:json], assignment_policy: @assignment_policy
@@ -118,3 +118,9 @@ if resource.whatsapp?
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="msapplication-TileColor" content="#1f93ff">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#1f93ff">
<meta name="description" content="Chatwoot is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers.Chatwoot also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
<meta name="description" content="<%= @global_config['INSTALLATION_NAME'] %> is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers. <%= @global_config['INSTALLATION_NAME'] %> also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
<% if ENV['IOS_APP_IDENTIFIER'].present? %>
<meta name="apple-itunes-app" content='app-id=<%= ENV['IOS_APP_IDENTIFIER'] %>'>
<% end %>
@@ -36,7 +36,7 @@ for a collection of resources.
</svg>
</div>
<div class="flex-1 h-full min-w-0 relative">
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
<select id="filter-select" class="appearance-none bg-transparent border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
<option value="">All records</option>
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.4.0'
version: '4.5.2'
development:
<<: *shared
+1 -1
View File
@@ -12,7 +12,7 @@ Bundler.require(*Rails.groups)
# We rely on DOTENV to load the environment variables
# We need these environment variables to load the specific APM agent
Dotenv::Rails.load
require 'ddtrace' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'datadog' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
+2
View File
@@ -184,6 +184,7 @@
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
deprecated: true
- name: whatsapp_campaign
display_name: WhatsApp Campaign
enabled: false
@@ -194,3 +195,4 @@
- name: assignment_v2
display_name: Assignment V2
enabled: false
chatwoot_internal: true
+2
View File
@@ -53,6 +53,8 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
assignment_policy:
not_found: Assignment policy not found
data_import:
data_type:
invalid: Invalid data type
+15 -15
View File
@@ -20,9 +20,9 @@ pt_BR:
hello: 'Olá, mundo'
inbox:
reauthorization:
success: 'Channel reauthorized successfully'
not_required: 'Reauthorization is not required for this inbox'
invalid_channel: 'Invalid channel type for reauthorization'
success: 'Canal reautenticado com sucesso'
not_required: 'Reautenticação não é necessária para esta caixa de entrada'
invalid_channel: 'Tipo de canal inválido para reautenticar'
messages:
reset_password_success: Legal! A solicitação de alteração de senha foi bem sucedida. Verifique seu e-mail para obter instruções.
reset_password_failure: Uh ho! Não conseguimos encontrar nenhum usuário com o e-mail especificado.
@@ -59,12 +59,12 @@ pt_BR:
slack:
invalid_channel_id: 'Canal de slack inválido. Por favor, tente novamente'
whatsapp:
token_exchange_failed: 'Failed to exchange code for access token. Please try again.'
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
token_exchange_failed: 'Falha ao trocar o código por um token de acesso. Por favor, tente novamente.'
invalid_token_permissions: 'O token de acesso não tem as permissões necessárias para o WhatsApp.'
phone_info_fetch_failed: 'Falha ao obter a informação do número de telefone. Por favor, tente novamente.'
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
generic: 'Falha ao reautenticar o WhatsApp. Por favor, tente novamente.'
not_supported: 'Reautenticação não é suportado por este tipo de canal WhatsApp.'
inboxes:
imap:
socket_error: Por favor, verifique a conexão de rede, endereço IMAP e tente novamente.
@@ -257,8 +257,8 @@ pt_BR:
description: 'Crie issues em Linear diretamente da sua janela de conversa. Alternativamente, vincule as issues lineares existentes para um processo de rastreamento de problemas mais simples e eficiente.'
notion:
name: 'Notion'
short_description: 'Integrate databases, documents and pages directly with Captain.'
description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
short_description: 'Integre banco de dados, documentos e páginas diretamente com o Capitão.'
description: 'Conecte o seu espaço de trabalho Notion para permitir que o Capitão acesse e gere respostas inteligentes usando o conteúdo de seus bancos de dados, documentos e páginas para fornecer suporte ao cliente mais contextual.'
shopify:
name: 'Shopify'
short_description: 'Acessar detalhes do pedido e dados de clientes da sua loja Shopify.'
@@ -359,9 +359,9 @@ pt_BR:
portals:
send_instructions:
email_required: 'E-mail é obrigatório'
invalid_email_format: 'Invalid email format'
custom_domain_not_configured: 'Custom domain is not configured'
instructions_sent_successfully: 'Instructions sent successfully'
subject: 'Finish setting up %{custom_domain}'
invalid_email_format: 'Formato inválido de e-mail'
custom_domain_not_configured: 'Domínio personalizado não está configurado'
instructions_sent_successfully: 'Instruções enviadas com sucesso'
subject: 'Termine de configurar %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
custom_domain_not_configured: 'Domínio personalizado não está configurado'
+4 -2
View File
@@ -218,10 +218,12 @@ Rails.application.routes.draw do
end
# Assignment V2 Routes
resources :assignment_policies
resources :assignment_policies do
resources :inboxes, only: [:index, :create, :destroy], module: :assignment_policies
end
resources :inboxes, only: [] do
resource :assignment_policy, only: [:show, :create, :destroy], controller: 'inbox_assignment_policies'
resource :assignment_policy, only: [:show, :create, :destroy], module: :inboxes
end
namespace :twitter do

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