Compare commits

..
Author SHA1 Message Date
Shivam Mishra 1e1f79025e chore: add scripts to test 2025-11-14 15:18:30 +05:30
Tanmay Deep Sharma e84efdccbd Merge remote-tracking branch 'origin/assignment_v2/assignment_service' into assignment_v2/assignment_service 2025-11-12 18:00:34 +05:30
Tanmay Deep Sharma 44da47c845 review changes 2025-11-12 17:59:52 +05:30
Tanmay Deep SharmaandGitHub 0ebe187c8a Merge branch 'develop' into assignment_v2/assignment_service 2025-11-06 01:48:23 +05:30
PranavandGitHub 5491ca2470 feat: Differentiate bot and user in the summary (#12801)
While generating the summary, use the appropriate sender type for the
message.
2025-11-05 11:42:21 -08:00
72391f9c36 fix: Video bubble click and play issue (#12764)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-11-05 15:40:11 +05:30
Sojan JoseandGitHub f89d9a4401 feat: Bulk delete for contacts (#12778)
Introduces a new bulk action `delete` for contacts

ref: https://github.com/chatwoot/chatwoot/pull/12763

## Screens

<img width="1492" height="973" alt="Screenshot 2025-10-31 at 6 27 21 PM"
src="https://github.com/user-attachments/assets/30dab1bb-2c2c-4168-9800-44e0eb5f8e3a"
/>
<img width="1492" height="985" alt="Screenshot 2025-10-31 at 6 27 32 PM"
src="https://github.com/user-attachments/assets/5be610c4-b19e-4614-a164-103b22337382"
/>
2025-11-04 17:47:53 -08:00
Sojan JoseandGitHub e8ae73230d fix: Gate Sidekiq dequeue logger behind env (#12790)
## Summary
- wrap the dequeue middleware registration in a boolean env flag
- document the ENABLE_SIDEKIQ_DEQUEUE_LOGGER option in .env.example
2025-11-04 16:01:47 -08:00
PranavandGitHub 40c75941f5 fix: Avoid introducing new attributes in search (#12791)
Fix `Limit of total fields [1000] has been exceeded`


https://linear.app/chatwoot/issue/CW-5861/searchkickimporterror-type-=-illegal-argument-exception-reason-=-limit#comment-6b6e41bd
2025-11-04 13:28:51 -08:00
d9b840f161 fix: Optimize Message search_data to prevent OpenSearch field explosion (#12786)
## Description

Refactored the `Message#search_data` method to prevent exceeding
OpenSearch's 1000 field limit during reindex operations.

**Problem:** The previous implementation serialized entire ActiveRecord
objects (Inbox, Sender, Conversation) with all their JSONB fields,
causing dynamic field explosion in OpenSearch. This resulted in
`Searchkick::ImportError` with "Limit of total fields [1000] has been
exceeded".

**Solution:** Whitelisted only necessary fields for search and
filtering, and flattened JSONB `custom_attributes` into key-value pair
arrays to prevent unbounded field creation.

Linked to: CW-5861

## Type of change

- [x] 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)
- [x] This change requires a documentation update

## How Has This Been Tested?

- Verified rubocop passes with no offenses
- Code review of search field usage from
`enterprise/app/services/enterprise/search_service.rb`
- Analyzed actual search queries to determine required indexed fields

**Still needed:**
- Full reindex test on staging/production environment
- Verify search functionality still works after reindex
- Confirm field count is under 1000 limit

## Changes Made

### Before
- Indexed 1000+ fields (entire AR objects with JSONB)
- `inbox` = full Inbox object (23+ fields + JSONB)
- `sender` = full Contact/User/AgentBot object (10+ fields + JSONB)
- `conversation` = full push_event_data
- Dynamic JSONB keys creating unlimited fields

### After
- ~35-40 controlled fields
- Whitelisted search fields: `content`, `attachment_transcribed_text`,
`email_subject`
- Filter fields: `account_id`, `inbox_id`, `conversation_id`,
`sender_id`, `sender_type`, etc.
- Flattened `custom_attributes`: `[{key, value, value_type}]` format
- Helper methods: `search_conversation_data`, `search_inbox_data`,
`search_sender_data`, `search_additional_data`

## Checklist:

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

## Post-merge Steps

After merging, the following steps are required:

1. **Reindex all messages:**
   ```bash
   bundle exec rails runner "Message.reindex"
   ```

2. **Verify field count:**
   ```bash
   bundle exec rails runner "
     client = Searchkick.client
     index_name = Message.searchkick_index.name
     mapping = client.indices.get_mapping(index: index_name)
     fields = mapping.dig(index_name, 'mappings', 'properties')
     puts 'Total fields: ' + fields.keys.count.to_s
   "
   ```

3. **Test search functionality** to ensure queries still work as
expected

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-11-03 17:37:51 -08:00
1fbdd68222 feat: Add company auto-association for contacts (CW-5726 Part 2) (#12711)
## Description

Implements real-time company auto-association for contacts based on
email domains. This is **Part 2** of the company model production
rollout (CW-5726).

**Task:**
- When a contact is created with a business email, automatically create
and associate a company from the email domain
- When a contact is updated with an email for the first time (email was
previously nil), associate with a company
- Preserve existing company associations when email changes to avoid
user confusion
- Skip free email providers and disposable domains

**Dependencies:**
⚠️ Requires PR #12657 (Part 1: Backfill migration) to be merged first

**Linear ticket:**
[CW-5726](https://linear.app/chatwoot/issue/CW-5726/company-model-setting-it-up-on-production)

## Type of change

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

## How Has This Been Tested?

- Service specs: Tests business email detection, company creation,
association logic, edge cases (existing companies, free emails, nil
emails)
- Integration specs: Tests full callback flow for contact create/update
scenarios
- All tests passing: 10 examples, 0 failures
- RuboCop: 0 offenses

## 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
- [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 (PR #12657 pending)

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-11-03 20:36:13 +05:30
ef54f07d5b feat: Add company backfill migration for existing contacts (Part 1) (#12657)
## Description

Implements company backfill migration infrastructure for existing
contacts. This is **Part 1 of 2** for the company model production
rollout as described in
[CW-5726](https://linear.app/chatwoot/issue/CW-5726/company-model-setting-it-up-on-production).

Creates jobs and services to associate existing contacts with companies
based on their email domains, filtering out free email providers (gmail,
yahoo, etc.) and disposable addresses.
 

**What's included:**
- Business email detector service with ValidEmail2 (uses
`disposable_domain?` to avoid DNS lookups)
- Per-account batch job to process contacts for one account
- Orchestrator job to iterate all accounts
- Rake task: `bundle exec rake companies:backfill`

~~*NOTE*: I'm using a hard-coded approach to determine if something is a
"business" email by filtering out emails that are usually personal. I've
also added domains that are common to some of our customers' regions.
This should be simpler. I looked into `Valid_Email2` and I couldn't find
anything to dictate whether an email is a personal email or a business
one. I don't think the approach used in the frontend is valid here.~~
UPDATE: Using `email_provider_info` gem instead.


**Pending - Part 2 (separate PR):** Real-time company creation for new
contacts

## Type of change

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

## How Has This Been Tested?

```bash
# Run all new tests
bundle exec rspec spec/enterprise/services/companies/business_email_detector_service_spec.rb \\
                   spec/enterprise/jobs/migration/company_account_batch_job_spec.rb \\
                   spec/enterprise/jobs/migration/company_backfill_job_spec.rb

# Run RuboCop
bundle exec rubocop enterprise/app/services/companies/business_email_detector_service.rb \\
                     enterprise/app/jobs/migration/company_account_batch_job.rb \\
                     enterprise/app/jobs/migration/company_backfill_job.rb \\
                     lib/tasks/companies.rake
```

**Performance optimization:**
- Uses `disposable_domain?` instead of `disposable?` to avoid DNS MX
lookups (discovered via tcpdump analysis - `disposable?` was making
network calls for every email, causing 100x slowdown)

## 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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-11-03 20:03:47 +05:30
Chatwoot BotandGitHub e771d99552 chore: Update translations (#12748) 2025-11-03 15:45:32 +05:30
Muhsin KelothandGitHub 358a3924b8 chore: Add dependant destroy_async for sla events (#12774)
Added the destroy_async to prevent timeout during SLA policy deletion by
processing SLA events asynchronously.
2025-10-31 09:19:56 +05:30
Sivin VargheseandGitHub 6b87d6784e chore: Make contacts bulk action bar sticky (#12773)
# Pull Request Template

## Description

This PR makes the contacts bulk action bar sticky while scrolling.

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1080" height="300" alt="image"
src="https://github.com/user-attachments/assets/21f8f3c6-813e-4ef6-b40a-8dd14e6ffb26"
/>
<img width="1080" height="300" alt="image"
src="https://github.com/user-attachments/assets/bb939f1d-9a13-4f9f-953d-b9872c984b74"
/>



## 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-10-30 11:57:46 -07:00
Vishnu NarayananandGitHub faaf67129e feat: Enable opensearch on paid plans automatically (#12770)
- enable `advanced_search feature` on all paid plans automatically

ref: https://github.com/chatwoot/chatwoot/pull/12503
2025-10-30 08:47:37 -07:00
159c810117 feat: Bulk actions for contacts (#12763)
Introduces APIs and UI for bulk actions in contacts table. The initial
action available will be assign labels

Fixes: #8536 #12253 

## Screens

<img width="1350" height="747" alt="Screenshot 2025-10-29 at 4 05 08 PM"
src="https://github.com/user-attachments/assets/0792dff5-0371-4b2e-bdfb-cd32db773402"
/>
<img width="1345" height="717" alt="Screenshot 2025-10-29 at 4 05 19 PM"
src="https://github.com/user-attachments/assets/ae510404-c6de-4c15-a720-f6d10cdac25b"
/>

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-10-30 15:28:28 +05:30
ce400a36d7 feat: Always process email content (#12734)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-30 13:36:39 +05:30
Shivam MishraandGitHub d20de25da1 fix: run captain v2 outside the transaction (#12756) 2025-10-30 13:35:20 +05:30
Sivin VargheseandGitHub c31d693add chore: Improvements in pending FAQs (#12755)
# Pull Request Template

## Description

**This PR includes:**

1. Added URL-based filter persistence for the responses pages, including
page and search parameters.
2. Introduced a new empty state variant for pending FAQs — without a
backdrop and with a “Clear Filters” option.
3. Made the actions, filter, and search row remain fixed at the top
while scrolling.

Fixes
https://linear.app/chatwoot/issue/CW-5852/improvements-in-pending-faqs

## Type of change

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

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/1d9eee68c0684f0ab05e08b4ca1e0ce9


## 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-10-29 14:34:28 -07:00
Vishnu NarayananandGitHub 31497d9c63 fix: update omniauth to latest to resolve heroku deployment issues (#12749)
# Pull Request Template

## Description

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

Heroku build was failing due to `omniauth` version mismatch. Also, added
`NODE_OPTIONS=--max-old-space-size=4096` to handle OOM during Vite
build.

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

- Tested on heroku

## 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
- [x] 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
- [x] Any dependent changes have been merged and published in downstream
modules
2025-10-29 08:54:29 -07:00
a35c3e4c06 feat: Template types components (#12714)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5806/create-the-story-book-components-for-template-typestext-media-list

**Pending**
Need to standardize the structure to match the template/campaigns.


## Type of change

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

## How Has This Been Tested?

### Screenshots

<img width="669" height="179" alt="image"
src="https://github.com/user-attachments/assets/42efd292-8520-4b05-81ec-8bc526fc12db"
/>
<img width="646" height="304" alt="image"
src="https://github.com/user-attachments/assets/431dd964-006c-4877-a693-dae39b90df4c"
/>
<img width="646" height="380" alt="image"
src="https://github.com/user-attachments/assets/9052e31f-9292-4afb-8897-13931655fa00"
/>
<img width="646" height="272" alt="image"
src="https://github.com/user-attachments/assets/873d2488-e856-4a0d-8579-cc1bcc61cc8e"
/>
<img width="646" height="490" alt="image"
src="https://github.com/user-attachments/assets/14c2aa42-bf27-475f-aa70-fe59c1d00e9b"
/>
<img width="646" height="281" alt="image"
src="https://github.com/user-attachments/assets/1f42408e-03e8-4863-b4c7-715d13d67686"
/>



## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-29 17:06:32 +05:30
Muhsin KelothandGitHub 344e8d5016 fix: Exclude authentication templates from WhatsApp template selection (#12753)
This PR add the changes for excluding the authentication templates from
the WhatsApp template selection in the frontend, as these templates are
not supported at the moment. Reference:
https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates#what-is-not-supported
2025-10-29 14:03:43 +05:30
3e27e28848 chore: Update captain pending FAQ interface (#12752)
# Pull Request Template

## Description

**This PR includes,**
- Added new pending FAQs view with approve/edit/delete actions for each
response.
- Implemented banner notification showing pending FAQ count on main
approved responses page.
- Created dedicated route for pending FAQs review at
/captain/responses/pending.
- Added automatic pending count updates when switching assistants or
routes.
- Modified ResponseCard component to show action buttons instead of
dropdown in pending view.

Fixes
https://linear.app/chatwoot/issue/CW-5833/pending-faqs-in-a-different-ux

## Type of change

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

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/5fe8f79b04cd4681b9360c48710b9373


## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-28 20:47:42 -07:00
38af08534c fix: Captain response builder not getting triggered (#12729)
## Summary
- Fix captain response builder not getting triggered for cases where
responses are created as completed.

## Testing Instructions 
- Test articles with firecrawl
- Test articles without firecrawl
- Test PDF documents

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-28 18:31:08 -07:00
ee1ea9576b fix: Timezone offset reports broken by DST transition (#12747)
## Description

Fixes timezone offset parameter in V2 reports API that was broken by DST
transitions. The issue occurred when UK DST ended on October 26, 2025,
causing the test to fail starting October 27th.

~~**Initial diagnosis:** The root cause was that
`timezone_name_from_offset` used `zone.now.utc_offset` to match
timezones, which changes based on the current date's DST status rather
than the data being queried.~~

**Actual root cause:** The test was accidentally passing before DST
transition. During BST, `timezone_name_from_offset(0)` matched "Azores"
(UTC-1) instead of "Edinburgh" (UTC+0), and the -1 hour offset
coincidentally split midnight data into [1,5]. After DST ended, it
correctly matched "Edinburgh" (UTC+0), but this grouped all
conversations into one day [6], exposing that the test data was flawed.

The real issue: Test data created all 6 conversations starting at
midnight on a single day, which cannot produce a [1,5] split in true
UTC.

Fixes CW-5846

## Type of change

- [x] 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 that was failing:**
```bash
bundle exec rspec spec/controllers/api/v2/accounts/reports_controller_spec.rb:25
```

**Changes:**
~~1. Fixed `timezone_name_from_offset` to use January 1st as reference
date instead of current date~~
~~2. Converted timezone string to `ActiveSupport::TimeZone` object for
`group_by_period` compatibility~~

**Revised approach:**
1. Freeze test time to January 2024 using `travel_to`, making timezone
matching deterministic and aligned with test data period
2. Start test conversations at 23:00 instead of midnight to properly
span two days and test timezone boundary grouping
3. Keep `zone.now.utc_offset` (correct behavior for real users during
DST)

**Why this works:**
- Test runs "in January 2024" → `zone.now.utc_offset` returns January
offsets consistently
- Offset `-8` correctly matches Pacific Standard Time (UTC-8 in January)
- Real users in PDT (summer) with offset `-7` → correctly match Pacific
Daylight Time
- No production impact, test is deterministic year-round

**Verification:**
- Test now passes consistently regardless of current DST status
- Timezone matching works correctly for real users during DST periods
- Reports correctly group data by timezone offset across all seasons

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-28 19:26:04 +05:30
Muhsin KelothandGitHub 26ea87a6cb fix: Extend phone number normalization to Twilio WhatsApp (#12655)
### Problem
WhatsApp Cloud channels already handle Brazil/Argentina phone number
format mismatches (PRs #12492, #11173), but Twilio WhatsApp channels
were creating duplicate contacts
  when:
  - Template sent to new format: `whatsapp:+5541988887777` (13 digits)
  - User responds from old format: `whatsapp:+554188887777` (12 digits)

### Solution

The solution extends the existing phone number normalization
infrastructure to support both WhatsApp providers while handling their
different payload formats:

  ### Provider Format Differences
  - **WhatsApp Cloud**: `wa_id: "919745786257"` (clean number)
- **Twilio WhatsApp**: `From: "whatsapp:+919745786257"` (prefixed
format)
  
  
 ### Test Coverage

#### Brazil Phone Number Tests
  **Case 1: New Format (13 digits with "9")**
- **Test 1**: No existing contact → Creates new contact with original
format
- **Test 2**: Contact exists in same format → Appends to existing
conversation

  **Case 2: Old Format (12 digits without "9")**
- **Test 3**: Contact exists in old format → Appends to existing
conversation
- **Test 4** *(Critical)*: Contact exists in new format, message in old
format → Finds existing contact, prevents duplicate
- **Test 5**: No contact exists → Creates new contact with incoming
format

#### Argentina Phone Number Tests
  **Case 3: With "9" after country code**
  - **Test 6**: No existing contact → Creates new contact
- **Test 7**: Contact exists in normalized format → Uses existing
contact

  **Case 4: Without "9" after country code**
  - **Test 8**: Contact exists in same format → Appends to existing
  - **Test 9**: No contact exists → Creates new contact

Fixes
https://linear.app/chatwoot/issue/CW-5565/inconsistencies-for-mobile-numbersargentina-brazil-and-mexico-numbers
2025-10-28 18:16:29 +05:30
Muhsin KelothandGitHub 7e8fe78ecd perf: Add database index on conversations identifier (#12715)
**Problem**
Slack webhook processing was failing with 500 errors due to database
timeouts. The query `Conversation.where(identifier:
params[:event][:thread_ts]).first` was performing full table scans and
hitting PostgreSQL statement timeout.

**Solution**
Added database index on conversations.identifier and account_id.
2025-10-28 15:12:46 +05:30
f3176afc1c chore: Update translations (#12722)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-10-27 17:54:15 +05:30
Muhsin KelothandGitHub c4ba925ae7 chore: Remove linear integration feature flag (#12716)
This PR removes the linear integration feature flag since the
integration is pretty much stable and we do display the Linear CTA for
users who aren't connected.
Fixes
https://linear.app/chatwoot/issue/CW-5819/remove-linear-feature-flag-from-front-end
2025-10-27 15:29:57 +05:30
1f0b56b96e feat: Changelog card components (#12673)
# Pull Request Template

## Description

This PR introduces a new changelog component that can be used in the
sidebar.

Fixes
https://linear.app/chatwoot/issue/CW-5776/changelog-card-ui-component

## Type of change

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

## How Has This Been Tested?

### Screencast



https://github.com/user-attachments/assets/42e77e82-388a-4fc9-9b37-f3d0ea1a9d7f







## Checklist:

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

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
2025-10-27 14:39:49 +05:30
e7177364d4 chore: Add tab params for inbox configuration (#12665)
# Pull Request Template

## Description

This PR enables active tabs in inbox settings to persist in the URL for
easy sharing.

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/63820ecb17ea491a9082339f8bb457b6?sid=4fef1acd-b4fd-431f-855c-7647015a330f


## Checklist:

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

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
2025-10-27 12:01:01 +05:30
Sivin VargheseandGitHub f726dc2419 chore: Adds URL-based search and tab selection (#12663)
# Pull Request Template

## Description

This PR enables URL-based search and tab selection, allowing search
queries and active tabs to persist in the URL for easy sharing.

Fixes
[CW-5766](https://linear.app/chatwoot/issue/CW-5766/cannot-impersonate-an-account),
https://github.com/chatwoot/chatwoot/issues/12623

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/422a1d61f3fe4278a88e352ef98d2b78?sid=35fabee7-652f-4e17-83bd-e066a3bb804c

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-10-27 11:17:04 +05:30
PranavandGitHub 5891fd6f49 feat(ee): Add a service to fetch website content and prepare a persona of Captain Assistant (#12732)
This PR is the first of many to simplify the process of building an
assistant. The new flow will only require the user’s website. We’ll
automatically crawl it, identify the business name and what the business
does, and then generate a suggested assistant persona, complete with a
proposed name and description.

This service returns the following.
Example: tooljet.com
<img width="795" height="217" alt="Screenshot 2025-10-25 at 2 55 04 PM"
src="https://github.com/user-attachments/assets/9cb3594a-9c9c-4970-a0a1-4c9c8869c193"
/>

Example: replit.com
<img width="797" height="176" alt="Screenshot 2025-10-25 at 2 56 42 PM"
src="https://github.com/user-attachments/assets/6a1b4266-aab6-455f-a5e3-696d3a8243c9"
/>
2025-10-25 15:50:50 -07:00
PranavandGitHub b9864fe1f6 fix: Use gap-4 instead of margins to define space between elements (#12728)
We should avoid using margins to define space between elements, instead
use the gap utility.

The problem with this particular instance was that if Google auth was
turned off and SSO is available, there is a weird spacing at the top
caused by the margin from the SSO element.

This PR will fix that. It also introduces a gap between the divider and
the button, but that should be okay.
2025-10-24 17:22:44 -07:00
Sivin VargheseandGitHub 5f97a2fd7b chore: Remove channel icons from the create inbox page (#12727)
# Pull Request Template

## Description
This PR removes the frame containing all channel icons from the “Create
Inbox” page.

## Type of change

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

## How Has This Been Tested?

### Screenshots

**Before**
<img width="1314" height="1016" alt="image"
src="https://github.com/user-attachments/assets/2b773495-9ddb-48b4-b15d-9aef18259ce1"
/>


**After**
<img width="1314" height="979" alt="image"
src="https://github.com/user-attachments/assets/f4dc64cf-516c-4faf-a45c-2f7de05cc29b"
/>



## 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-10-24 23:01:30 +05:30
Shivam MishraandGitHub 74fc9c0321 fix: parameterize agent name (#12709) 2025-10-23 13:51:07 +05:30
Sojan JoseandGitHub 9898ccee9e chore: Enforce custom role permissions on conversation access (#12583)
## Summary
- ensure conversation lookup uses the permission filter before fetching
records
- add request specs covering custom role access to unassigned
conversations

## Testing
- bundle exec rspec
spec/enterprise/controllers/api/v1/accounts/conversations_controller_spec.rb

------
https://chatgpt.com/codex/tasks/task_e_68de1f62b9b883268a54882e608a8bb8
2025-10-22 20:23:37 -07:00
Sojan JoseandGitHub eabdfc8168 chore(sidekiq): log ActiveJob class and job_id on dequeue (#12704)
## Context

Sidekiq logs only showed the Sidekiq wrapper class and JID, which wasn’t
helpful when debugging ActiveJobs.

## Changes

- Updated `ChatwootDequeuedLogger` to log the actual `ActiveJob class`
and `job_id` instead of the generic Sidekiq wrapper and JID.

> Example
> ```
> Dequeued ActionMailer::MailDeliveryJob
123e4567-e89b-12d3-a456-426614174000 from default
> ```

- Remove sidekiq worker and unify everything to `ActiveJob`
2025-10-22 20:20:37 -07:00
Chatwoot BotandGitHub 9bd5f15450 chore: Update translations (#12708) 2025-10-22 14:10:48 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aea1585fa3 chore(deps-dev): bump vite from 5.4.20 to 5.4.21 (#12700)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite)
from 5.4.20 to 5.4.21.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>v5.4.21</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/v5.4.21/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted -->5.4.21 (2025-10-20)<!-- raw HTML omitted
--></h2>
<ul>
<li>fix(dev): trim trailing slash before <code>server.fs.deny</code>
check (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/20968">#20968</a>)
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/20970">#20970</a>)
(<a
href="https://github.com/vitejs/vite/commit/cad1d31d0635dd8fd4ddfe6e5a92eb9ff13cd06c">cad1d31</a>),
closes <a
href="https://redirect.github.com/vitejs/vite/issues/20968">#20968</a>
<a
href="https://redirect.github.com/vitejs/vite/issues/20970">#20970</a></li>
<li>chore: update CHANGELOG (<a
href="https://github.com/vitejs/vite/commit/ca88ed7398288ce0c60176ac9a6392f10654c67c">ca88ed7</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/adce3c22c64cc9d44cc8f45cc92b543e3e4bf385"><code>adce3c2</code></a>
release: v5.4.21</li>
<li><a
href="https://github.com/vitejs/vite/commit/cad1d31d0635dd8fd4ddfe6e5a92eb9ff13cd06c"><code>cad1d31</code></a>
fix(dev): trim trailing slash before <code>server.fs.deny</code> check
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/20968">#20968</a>)
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/20970">#20970</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/ca88ed7398288ce0c60176ac9a6392f10654c67c"><code>ca88ed7</code></a>
chore: update CHANGELOG</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite/commits/v5.4.21/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vite&package-manager=npm_and_yarn&previous-version=5.4.20&new-version=5.4.21)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/chatwoot/chatwoot/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 16:43:23 -07:00
254d5dcf9a chore: Migrate mailers from the worker to jobs (#12331)
Previously, email replies were handled inside workers. There was no
execution logs. This meant if emails silently failed (as reported by a
customer), we had no way to trace where the issue happened, the only
assumption was “no error = mail sent.”

By moving email handling into jobs, we now have proper execution logs
for each attempt. This makes it easier to debug delivery issues and
would have better visibility when investigating customer reports.

Fixes
https://linear.app/chatwoot/issue/CW-5538/emails-are-not-sentdelivered-to-the-contact

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-10-21 16:36:37 -07:00
Chatwoot BotandGitHub b4c4f328b2 chore: Update translations (#12625) 2025-10-21 21:29:25 +05:30
Shivam MishraandGitHub a509ef826a feat: single query for reporting event stats (#12664)
This PR collapses multiple queries fetching stats from a single table to
a single query

```sql
SELECT 
  user_id as user_id,
  COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count,
  AVG(CASE WHEN name = 'conversation_resolved' THEN value END) as avg_resolution_time,
  AVG(CASE WHEN name = 'first_response' THEN value END) as avg_first_response_time,
  AVG(CASE WHEN name = 'reply_time' THEN value END) as avg_reply_time 
FROM "reporting_events"
WHERE 
  "reporting_events"."account_id" = <account_id> AND 
  "reporting_events"."created_at" >= '2025-09-14 18:30:00' AND 
  "reporting_events"."created_at" < '2025-10-14 18:29:59'
GROUP BY "reporting_events"."user_id";
```

### Why this works?

Here's why this optimization is faster based on PostgreSQL internals:

- Single Table Scan vs Multiple Scans: Earlier we did 4 sequential scans
(or 4 index scans) of the same data, with the same where clause, now in
a single scan all 4 `CASE` expressions are evaluated in a single pass.
- Shared Buffer Cache Efficiency: PostgreSQL's shared buffer cache
stores recently accessed pages, with this, pages are loaded once and
re-used for all aggregation, earlier with separate queries we were
forced to re-read all from the disk each time
- Reduced planning and network overhead (4 vs 1 query)


### How is it tested

1. The specs all pass without making any changes
2. Verified the reports side by side after generating from report seeder

#### How to test

Generate seed data using the following command

```bash
ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
```

Once done download the reports, checkout to this branch and download the
reports again and compare them
2025-10-16 16:08:26 -07:00
Sivin VargheseandGitHub 2180edc14a chore: Hide "Learn More" button in feature spotlight for self-hosted (#12675) 2025-10-16 12:04:53 +05:30
Sojan Jose de2a35b617 Merge branch 'release/4.7.0' into develop 2025-10-15 22:11:12 -07:00
Sojan Jose 40577e6a50 Bump version to 4.7.0 2025-10-15 22:05:13 -07:00
368d7c4608 feat: Add support for HTML emails in outgoing messages (#12662)
This PR adds sending custom HTML content in outgoing email messages
through Chatwoot's Email channels, while maintaining backward
compatibility with existing markdown rendering.

###  API Usage

**Endpoint:** `POST
/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages`

```json
  {
   "content": "Fallback text content",
   "email_html_content": "<div><h1>Welcome!</h1><p>This is <strong>custom HTML</strong></p></div>"
  }
```

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
2025-10-15 13:22:23 +05:30
f1f1ce644c feat: Overview heatmap improvements (#12359)
This PR adds inbox filtering to the conversation traffic heatmap,
allowing users to analyze patterns for specific inboxes. Additionally,
it also adds a new resolution count heatmap that shows when support
teams are most active in resolving conversations, using a green color to
distinguish it from the blue conversation heatmap.

The PR also reorganizes heatmap components into a cleaner structure with
a shared `BaseHeatmapContainer` that handles common functionality like
date range selection, inbox filtering, and data fetching. This makes it
easy to add new heatmap metrics in the future - just create a wrapper
component specifying the metric type and color scheme.

<img width="1926" height="1670" alt="CleanShot 2025-10-13 at 14 01
35@2x"
src="https://github.com/user-attachments/assets/67822a34-6170-4d19-9e11-7ad4ded5c388"
/>

<img width="1964" height="1634" alt="CleanShot 2025-10-13 at 14 03
00@2x"
src="https://github.com/user-attachments/assets/e4613c08-64b8-4fa6-91d8-7510946dd75d"
/>


Unrelated change, the data seeder conversation resolution would not work
correctly, we've fixed it.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 19:15:57 +05:30
38f16ba677 feat: Secure external credentials with database encryption (#12648)
## Changelog

- Added conditional Active Record encryption to every external
credential we store (SMTP/IMAP passwords, Twilio tokens,
Slack/OpenAI hook tokens, Facebook/Instagram tokens, LINE/Telegram keys,
Twitter secrets) so new writes are encrypted
whenever Chatwoot.encryption_configured? is true; legacy installs still
receive plaintext until their secrets are
    updated.
- Tuned encryption settings in config/application.rb to allow legacy
reads (support_unencrypted_data) and to extend
deterministic queries so lookups continue to match plaintext rows during
the rollout; added TODOs to retire the
    fallback once encryption becomes mandatory.
- Introduced an MFA-pipeline test suite
(spec/models/external_credentials_encryption_spec.rb) plus shared
examples to
verify each attribute encrypts at rest and that plaintext records
re-encrypt on update, with a dedicated Telegram case.
The existing MFA GitHub workflow now runs these tests using the
preconfigured encryption keys.

fixes:
https://linear.app/chatwoot/issue/CW-5453/encrypt-sensitive-credentials-stored-in-plain-text-in-database

## Testing Instructions

 1. Instance without encryption keys
- Unset ACTIVE_RECORD_ENCRYPTION_* vars (or run in an environment where
they’re absent).
      - Create at least one credentialed channel (e.g., Email SMTP).
- Confirm workflows still function (send/receive mail or a similar
sanity check).
- In the DB you should still see plaintext values—this confirms the
guard prevents encryption when keys are missing.
  2. Instance with encryption keys
      - Configure the three encryption env vars and restart.
- Pick a couple of representative integrations (e.g., Email SMTP +
Twilio SMS).
      - Legacy channel check:
- Use existing records created before enabling keys. Trigger their
workflow (send an email / SMS, or hit the
            webhook) to ensure they still authenticate.
- Inspect the raw column—value remains plaintext until changed.
      - Update legacy channel:
- Edit one legacy channel’s credential (e.g., change SMTP password).
- Verify the operation still works and the stored value is now encrypted
(raw column differs, accessor returns
            original).
      - New channel creation:
- Create a new channel of the same type; confirm functionality and that
the stored credential is encrypted from
            the start.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 18:05:12 +05:30
e7b01d80b3 chore: add script to throttle bulkreindex job creation and increase meta timeouts(#12626)
- scripts to throttle reindex job creation and monitor progress

```

RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb

RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
```

---------
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-10-13 16:21:45 +05:30
ec9a82a017 feat: Open conversation when agent bot webhook fails (#12379)
# Changelog

When an agent bot webhook fails, we now flip any pending conversation
back to an open state so a human agent can pick
it up immediately. There will be an clear activity message giving the
team clear visibility into what went
wrong. This keeps customers from getting stuck in limbo when their
connected bot goes offline.

# Testing instructions 

1. Initial setup: Create an agent bot with a working webhook URL and
connect it to a test inbox. Send a message from a
contact (e.g., via the widget) so a conversation is created; it should
enter the Pending state while the bot handles
     the reply.
2. Introduce failure: Edit that agent bot and swap the webhook URL for a
dummy endpoint that will fail. Have the same
contact send another message in the existing conversation. Because the
webhook call now fails, the conversation should flip from Pending back
to Open, making it visible to agents. Also verify the activity message
3. New conversation check: With the dummy URL still in place, start a
brand-new conversation from a contact. When the
bot tries (and fails) to respond, confirm that the conversation appears
immediately as Open rather than remaining Pending. Also the activity
message is visible
4. Subsequent messages in open conversations will show no change

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 15:59:59 +05:30
KarimandGitHub cdd3b73fc9 fix: Duplicate contacts creating for Argentina numbers (#11173) 2025-10-13 11:07:07 +05:30
dependabot[bot]andGitHub 610495123e chore(deps): bump rack from 3.2.2 to 3.2.3 (#12642)
Bumps rack from 3.2.2 to 3.2.3.
2025-10-11 17:05:38 +05:30
cb65d615ea chore: Add auto-refresh and self-hosted redirect logic to the billing page (#12615)
# Pull Request Template

## Description

This PR includes billing page improvements with the following updates:

### Self-hosted Users

* Automatically redirected to the dashboard when accessing the billing
page.

### Cloud Users – No Billing Plan (First Visit)

* Shows a loading spinner with the `Your billing account is being
configured. Please refresh the page and try again.` message.
* Automatically refreshes the page after 5 seconds to check for billing
setup.

### Cloud Users – No Billing Plan (After Refresh)

* Prevents infinite refresh loops using `sessionStorage` tracking.
* Displays the standard `Your billing account is being configured.
Please refresh the page and try again.` message without further refresh
attempts.
* Cleans up session flags for future visits.

### Cloud Users – With Billing Plan

* Displays the existing billing page normally with no refresh or
redirection logic.


Fixes
https://linear.app/chatwoot/issue/CW-5559/your-billing-page-is-being-set-up-message-on-billing-page-is-confusing

## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/d0ea13d6b90b4ab1acbc581b524f6382?sid=d3dd19f3-85aa-4127-9233-7eecb1be0884

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-09 10:57:30 -07:00
Muhsin KelothandGitHub d8da1f5bf3 fix: Handle video file types in Slack file shares (#12630)
Fixes https://linear.app/chatwoot/issue/CW-5752/fix-nomethoderror-when-processing-video-files-in-slack-integration
#### Problem
When users shared video files (like MP4) through Slack, the `file_type`
method in `SlackMessageHelper` would return `nil` for unsupported file
types. This caused a `NoMethodError (undefined method 'to_sym' for nil)`
when the attachment was being processed, as the system expected a symbol
value for the `file_type` attribute.

#### Solution
- Added video file type support in the `file_type` method case statement
- Added `else` clause to default unknown file types to `:file` instead
of returning `nil`
- This ensures `file_type` always returns a symbol, preventing the
`to_sym` error
2025-10-09 21:05:53 +05:30
Shivam MishraandGitHub 6829328182 fix(filters): correct null matching logic [CW-5741] (#12627)
This PR fixes a bug came from assuming the old null check only mattered
for the `is_not_present` filter.

The fix keeps `not_equal_to` working but lets each operator decide what
to do with `null`. Presence filters look at a shared `isNullish` flag,
text filters still rely on `contains`, and date filters skip
conversations with no timestamp. The new spec covers the null-assignee
scenario for both `equal_to` and `not_equal_to` so we don’t miss this
again.
2025-10-09 18:19:20 +05:30
Sivin VargheseandGitHub 6cc69f444b chore: Include 11:59 PM slot in business hours display (#12610) 2025-10-09 16:51:25 +05:30
Shivam MishraandGitHub f89ed56258 feat: update rack version (#12628)
Fixes CI failing at bundle audit for a [rack
vulnerability](https://github.com/rack/rack/security/advisories/GHSA-wpv5-97wm-hp9c)
2025-10-09 16:50:28 +05:30
Vishnu NarayananandGitHub 7c5bb343c6 fix: Optimize message reindexing to reduce sidekiq job creation (#12618)
Changes searchkick callback behavior to check `should_index?` before
creating reindex jobs, preventing unnecessary job creation for messages
that don't need indexing (activity messages, unpaid accounts, etc.).

Previously, `callbacks: :async` created reindex jobs for all messages
(~5,100/min or 7.3M/day in production), which were then filtered by
`should_index?` inside the job worker - resulting in 98% wasted jobs,
Redis memory pressure, and avoidable p0 alerts.

Now, `should_index?` is checked before job creation via `after_commit`
callback, reducing job creation to actual incoming/outgoing messages
from paid accounts.

  Changes:
  - Disable automatic searchkick callbacks
  - Add manual `after_commit` callback with `should_index?` condition
  - Add specs to verify callback behavior

  Expected impact:
  - 98% reduction in sidekiq job creation (~7.3M → ~150K jobs/day)
  - Reduced redis memory usage
  - Same async indexing behavior for eligible messages
2025-10-09 16:04:50 +05:30
Vinay KeerthiandGitHub 170ea7691f feat: Add company model and API with tests (#12548)
# Pull Request Template

## Description

* add Company model with validations for name, domain, description and
  avatar
* Add database migration fo
* Implement endpoints for company CRUD operations
* Add optional company relationship for contacts
* Add test for models, controllers, factories and policies
* Add authorization policies restricting delete to admins
* support JSON API responses
Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.

Fixes #(cw-5650)

## Type of change

Please delete options that are not relevant.

- [x] 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?

Tests are implemented using `RSpec`

```
$ bundle exec rails db:migrate
$ bundle exec rspec spec/models/company_spec.rb spec/controllers/api/v1/accounts/companies_controller_spec.rb
```

## 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
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-10-08 07:53:43 -07:00
606adffeeb fix: I18n::MissingInterpolationArgument for assignee activity messages (#12617)
# Pull Request Template

## Description

This PR fixes the following error:
`I18n::MissingInterpolationArgument: missing interpolation argument
:assignee_name in "Asignado a %{assignee_name} por %{user_name}"
({user_name: "Marketing Telpronet"} given)
(I18n::MissingInterpolationArgument)`

**Issue**

In the Spanish locale, an `I18n::MissingInterpolationArgument` error
occurred during bulk assignee operations.
This happened because `assignee&.name` was returning `nil`, and the
`.compact` method removed the `assignee_name` key entirely from the
params.

<img width="1744" height="164" alt="image"
src="https://github.com/user-attachments/assets/3c15ed77-48c0-4938-a7fd-356bdb07da39"
/>


**Solution**

* Always include the `assignee_name` key with an empty string (`''`)
when its value is `nil`.
* Removed the `.compact` method call to ensure the interpolation key is
always present.


Fixes
https://linear.app/chatwoot/issue/CW-5747/i18nmissinginterpolationargument-missing-interpolation-argument

## Type of change

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



## Checklist:

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

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-08 18:39:51 +05:30
Vishnu NarayananandGitHub e9c1c61fe4 chore(deps): bump uri from 1.0.3 to 1.0.4 (#12619)
fix CVE-2025-61594
2025-10-08 17:27:52 +05:30
78ebdbbbd8 fix: Normalize URLs with spaces in WhatsApp template parameters (#12594)
This PR fixes URL parsing errors when WhatsApp template parameters
contain URLs with spaces or special characters. The solution adds proper
URL normalization using Addressable::URI before validation, which
automatically handles space encoding and special character
normalization.

Related with https://github.com/chatwoot/chatwoot/pull/12462

## Type of change

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

## 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
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-08 15:33:06 +05:30
978f4c431a feat: Add relay state for SAML SSO (#12597)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-07 20:32:29 +05:30
Chatwoot BotandGitHub 4b2ebb8877 chore: Update translations (#12598) 2025-10-07 19:17:38 +05:30
Sivin VargheseandGitHub c4c1f3eb63 chore: Adjust debounce timeouts for conversation stats fetch (#12609) 2025-10-07 18:43:54 +05:30
829142c808 fix: Update max_turns config (#12604)
Pass max_turns in the run config than during the initialization.

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-10-07 11:21:18 +05:30
3a71829b46 feat: Improve captain conversation handling (#12599)
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-10-06 10:51:58 -07:00
Sivin VargheseandGitHub 0974aea300 chore: Increase custom filter limit from 50 to 1000 per user (#12603)
# Pull Request Template

## Description

This PR increases the custom filter limit from 50 to 1000 per user

## Type of change

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

## How Has This Been Tested?

### Screenshot

<img width="1264" height="71" alt="image"
src="https://github.com/user-attachments/assets/e12667bb-147c-4115-b8a8-9113fca471db"
/>



## 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
- [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
2025-10-06 10:41:26 -07:00
9fb0dfa4a7 feat: Add UI for custom tools (#12585)
### Tools list

<img width="2316" height="666" alt="CleanShot 2025-10-03 at 20 42 41@2x"
src="https://github.com/user-attachments/assets/ccbffd16-804d-4eb8-9c64-2d1cfd407e4e"
/>

### Tools form 

<img width="2294" height="2202" alt="CleanShot 2025-10-03 at 20 43
05@2x"
src="https://github.com/user-attachments/assets/9f49aa09-75a1-4585-a09d-837ca64139b8"
/>

## Response

<img width="800" height="2144" alt="CleanShot 2025-10-03 at 20 45 56@2x"
src="https://github.com/user-attachments/assets/b0c3c899-6050-4c51-baed-c8fbec5aae61"
/>

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-06 09:05:54 -07:00
8bbb8ba5a4 feat(ee): Captain custom http tools (#12584)
To test this out, use the following PR:
https://github.com/chatwoot/chatwoot/pull/12585

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-10-06 07:53:15 -07:00
Muhsin KelothandGitHub a8aefa0c73 chore: Add account health missing translations (#12596)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
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?

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


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-10-06 13:00:11 +05:30
Chatwoot BotandGitHub b536f35fa7 chore: Update translations (#12580) 2025-10-06 10:00:26 +05:30
Tanmay Deep SharmaandGitHub 20d6a11dd9 Merge branch 'develop' into assignment_v2/assignment_service 2025-10-05 15:02:08 +02:00
Sivin VargheseandGitHub 0d721bc898 chore: UI improvement in auth screens (#12573)
# Pull Request Template

## Type of change

### Screenshots

**Before**
<img width="694" height="767" alt="image"
src="https://github.com/user-attachments/assets/4a92816a-c13e-4750-88fc-b05fd6d05db6"
/>

<img width="395" height="690" alt="image"
src="https://github.com/user-attachments/assets/eac0f15c-7c0f-4c20-942d-fbebdaec903e"
/>

<img width="506" height="753" alt="image"
src="https://github.com/user-attachments/assets/b14bbf5a-5e0a-4ca5-91e2-dcc93b22ac26"
/>


**After**
<img width="694" height="767" alt="image"
src="https://github.com/user-attachments/assets/6984c8af-0e98-4688-bda4-fc5ceb3227ca"
/>

<img width="411" height="682" alt="image"
src="https://github.com/user-attachments/assets/3b0f2c13-e4ea-4edc-9146-3c017d301a13"
/>
<img width="509" height="682" alt="image"
src="https://github.com/user-attachments/assets/2090d3ed-36ef-4684-8185-3a0e6b1b0c15"
/>



## 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-10-03 15:31:58 +05:30
Chatwoot BotandGitHub c29a08f0ca chore: Update translations (#12555) 2025-10-02 18:24:12 +05:30
66cfef9298 feat: Add WhatsApp health monitoring and self-service registration completion (#12556)
Fixes
https://linear.app/chatwoot/issue/CW-5692/whatsapp-es-numbers-stuck-in-pending-due-to-premature-registration


###  Problem  
Multiple customers reported that their WhatsApp numbers remain stuck in
**Pending** in WhatsApp Manager even after successful onboarding.

- Our system triggers a **registration call**
(`/<PHONE_NUMBER_ID>/register`) as soon as the number is OTP verified.
- In many cases, Meta hasn’t finished **display name
review/provisioning**, so the call fails with:

  ```
  code: 100, error_subcode: 2388001
  error_user_title: "Cannot Create Certificate"
error_user_msg: "Your display name could not be processed. Please edit
your display name and try again."
  ```

- This leaves the number stuck in Pending, no messaging can start until
we manually retry registration.
- Some customers have reported being stuck in this state for **7+
days**.

###  Root cause  
- We only check `code_verification_status = VERIFIED` before attempting
registration.
- We **don’t wait** for display name provisioning (`name_status` /
`platform_type`) to be complete.
- As a result, registration fails prematurely and the number never
transitions out of Pending.

### Solution  

#### 1. Health Status Monitoring  
- Build a backend service to fetch **real-time health data** from Graph
API:
  - `code_verification_status`  
  - `name_status` / `display_name_status`  
  - `platform_type`  
  - `throughput.level`  
  - `messaging_limit_tier`  
  - `quality_rating`  
- Expose health data via API
(`/api/v1/accounts/:account_id/inboxes/:id/health`).
- Display this in the UI as an **Account Health tab** with clear badges
and direct links to WhatsApp Manager.

#### 2. Smarter Registration Logic  
- Update `WebhookSetupService` to include a **dual-condition check**:  
  - Register if:  
    1. Phone is **not verified**, OR  
2. Phone is **verified but provisioning incomplete** (`platform_type =
NOT_APPLICABLE`, `throughput.level = NOT_APPLICABLE`).
- Skip registration if number is already provisioned.  
- Retry registration automatically when stuck.  
- Provide a UI banner with complete registration button so customers can
retry without manual support.

### Screenshot
<img width="2292" height="1344" alt="CleanShot 2025-09-30 at 16 01
03@2x"
src="https://github.com/user-attachments/assets/1c417d2a-b11c-475e-b092-3c5671ee59a7"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-10-02 11:25:48 +05:30
Sivin VargheseandGitHub 109aaa2341 fix: Display proper conversation ID with click-to-open in SLA reports (#12570) 2025-10-02 08:18:54 +05:30
Shivam MishraandGitHub ecff66146a fix: Rendering on email without html content (#12561)
<img width="983" height="579" alt="image"
src="https://github.com/user-attachments/assets/2972e8d9-5145-4958-8f66-9e84bd9c8c4b"
/>
2025-10-01 13:43:05 +05:30
Shivam MishraandGitHub 21366e1c3b feat: allow quoted email thread in reply (#12545)
This PR adds the ability to include the thread history as a quoted text

## Preview


https://github.com/user-attachments/assets/c96a85e5-8ac8-4021-86ca-57509b4eea9f
2025-09-30 17:47:09 +05:30
406a470c81 feat: Log push notification error (#12543)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-29 15:19:45 +05:30
Muhsin KelothandGitHub 6c6aaf573c fix: Optimize Slack channel fetching to avoid rate limiting issues (#12542)
### Problem
The Slack integration fails when fetching channels from workspaces with
many channels due to rate limiting errors. The current implementation
makes a single API call requesting both public and private channels
simultaneously with `types: 'public_channel,private_channel'`, which
causes Slack's API to apply complex filtering and hit rate limits more
frequently.

  When testing with a csutomer workspace containing 157 channels:
- Combined request: Hit rate limits after a few pages, required 22+ API
calls with long delays
- Separate requests: Private channels (1 channel) load instantly, public
channels (185 channels) load quickly
  
 
  ### Solution

  Split the channel fetching into two sequential steps:
1. **Fetch private channels first** with `limit: 1000` (expects very
few)
  2. **Fetch public channels second** with pagination as needed

This approach leverages the fact that Slack's API handles single-type
requests much more efficiently than mixed-type requests, avoiding the
rate limiting issues entirely while maintaining the same functionality.
2025-09-29 14:41:48 +05:30
Chatwoot BotandGitHub 487209574c chore: Update translations (#12535) 2025-09-29 11:16:22 +05:30
7f1671c083 feat: Form validation message for password input (#11705)
Fixes https://github.com/chatwoot/chatwoot/issues/10914

# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## Checklist:

- [x ] 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: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-09-29 11:12:04 +05:30
b00261d7c2 feat: Add password visibility toggle to form input (#12524)
# Pull Request Template

## Description

This PR adds a password visibility toggle to the auth form input
component.

## Type of change

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

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/17652e86-e823-46e6-a3ba-80af37c78906




## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-25 20:14:36 +05:30
fcb91ab88a fix: Auto resolution flaky spec (#11964)
The test was failing because Current.contact was not being cleared when
testing system auto-resolution. Added Current.contact = nil to ensure
the system auto-resolution message is triggered instead of contact
resolution.

🤖 Generated with [Claude Code](https://claude.ai/code)

# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
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?

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


## Checklist:

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-25 19:36:38 +05:30
59f7c8aa55 perf: Contact optimisation fixes (#12016)
- Avoids the duplicate count queries for contact end point

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-25 18:59:55 +05:30
Muhsin KelothandGitHub cd2c58726f fix: Ensure message is always present in conversation_created webhook for WhatsApp attachment messages (#12507)
Fixes https://github.com/chatwoot/chatwoot/issues/11753 and
https://github.com/chatwoot/chatwoot/issues/12442

**Problem**
When a WhatsApp conversation started with a media message, the
conversation created webhook would sometimes fire before the message and
its relationships were fully committed to the database. This resulted in
the message being missing
from the webhook payload, breaking external automations that rely on
this field.

**Solution**

Added `ActiveRecord::Base.transaction` wrapper around the core message
processing operations in `Whatsapp::IncomingMessageBaseService` to
ensure atomic execution:

- `set_conversation` (creates conversation)
- `create_messages` (creates message with account_id)
- `clear_message_source_id_from_redis` (cleanup)

Now the webhook only triggers after all related data is fully persisted,
guaranteeing message availability.
2025-09-25 18:19:09 +05:30
03d0688cc2 chore: Update translations (#12519)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-25 15:59:03 +05:30
Shivam MishraandGitHub b75ea7a762 feat: Use resolved contacts as base relation for filtering (#12520)
This PR has two changes to speed up contact filtering

### Updated Base Relation

Update the `base_relation` to use resolved contacts scope to improve
perf when filtering conversations. This narrows the search space
drastically, and what is usually a sequential scan becomes a index scan
for that `account_id`

ref: https://github.com/chatwoot/chatwoot/pull/9347
ref: https://github.com/chatwoot/chatwoot/pull/7175/

Result: https://explain.dalibo.com/plan/c8a8gb17f0275fgf#plan


## Selective filtering in Compose New Conversation

We also cost of filtering in compose new conversation dialog by reducing
the search space based on the search candidate. For instance, a search
term that obviously can’t be a phone, we exclude that from the filter.
Similarly we skip name lookups for email-shaped queries.

Removing the phone number took the query times from 50 seconds to under
1 seconds

### Comparison

1. Only Email: https://explain.dalibo.com/plan/h91a6844a4438a6a 
2. Email + Name: https://explain.dalibo.com/plan/beg3aah05ch9ade0
3. Email + Name + Phone:
https://explain.dalibo.com/plan/c8a8gb17f0275fgf
2025-09-25 15:26:44 +05:30
Tanmay Deep SharmaandGitHub 5dc5037903 Merge branch 'develop' into assignment_v2/assignment_service 2025-09-24 15:50:27 +02:00
Tanmay Sharma 3c7b4f6011 Merge remote-tracking branch 'origin/assignment_v2/assignment_service' into assignment_v2/assignment_service 2025-09-11 13:38:56 +05:30
Tanmay Sharma 73f2aea514 fix user duplication issue for assignment user list 2025-09-11 13:38:22 +05:30
Tanmay Deep Sharma 02424a5a09 fix the exclusion rules 2025-09-10 20:36:08 +05:30
Tanmay Deep Sharma b015c61d71 fix specs 2025-09-04 19:11:18 +07:00
Tanmay Deep Sharma d483974ef1 fix specs 2025-09-04 18:45:22 +07:00
Tanmay Deep Sharma b16b1b6a49 fix balanced assignment spec 2025-09-04 16:53:17 +07:00
Tanmay Deep Sharma b28d00a117 fix rubocop 2025-09-04 16:21:29 +07:00
Tanmay Deep Sharma 2b3ad3dc74 Merge remote-tracking branch 'origin/assignment_v2/assignment_service' into assignment_v2/assignment_service 2025-09-04 16:12:08 +07:00
Tanmay Deep Sharma b95944dd4e update the spec file paths 2025-09-04 16:11:06 +07:00
Tanmay Deep SharmaandGitHub 75ba8e9186 Merge branch 'develop' into assignment_v2/assignment_service 2025-09-03 09:25:54 +07:00
Tanmay Deep Sharma 31969428ae Merge branch 'develop' into assignment_v2/assignment_service 2025-09-02 15:59:11 +07:00
Tanmay Sharma a4fab22e28 fix review changes 2025-08-29 16:26:19 +07:00
Tanmay Sharma 5d27fd798f fix feature listing 2025-08-29 15:49:05 +07:00
Tanmay Sharma ca14eba448 fix review changes 2025-08-29 14:49:13 +07:00
Tanmay Sharma 199a55a8e3 fix codex review 2025-08-28 21:06:06 +07:00
Tanmay Sharma 873094175b add exclusion_rules support 2025-08-28 20:39:30 +07:00
Muhsin KelothandGitHub 096bffa893 Merge branch 'develop' into assignment_v2/assignment_service 2025-08-28 18:33:02 +05:30
Tanmay Sharma ccbc2c5d56 Merge remote-tracking branch 'origin/develop' into assignment_v2/assignment_service 2025-08-28 18:44:11 +07:00
Tanmay Sharma 2c0f235d1d remove capacity status 2025-08-28 18:26:20 +07:00
Tanmay Sharma fc067b7f66 fix capacity test 2025-08-28 17:39:01 +07:00
Tanmay Sharma eacfa2e19a feat: add assignment service 2025-08-28 17:03:11 +07:00
1081 changed files with 51067 additions and 6802 deletions
+2
View File
@@ -256,6 +256,8 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance
# control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features
+1
View File
@@ -70,6 +70,7 @@ jobs:
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \
--format documentation
env:
+2 -2
View File
@@ -23,7 +23,7 @@ Metrics/MethodLength:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength:
Max: 25
Max: 50
Style/Documentation:
Enabled: false
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
Enabled: false
+1
View File
@@ -21,6 +21,7 @@ gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
+6 -4
View File
@@ -270,6 +270,7 @@ GEM
concurrent-ruby (~> 1.0)
http (>= 3.0)
ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13)
erubi (1.13.0)
et-orbi (1.2.11)
@@ -594,7 +595,7 @@ GEM
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.3)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
@@ -644,7 +645,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.0)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -653,7 +654,7 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (4.1.1)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
@@ -935,7 +936,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.3)
uri (1.0.4)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1016,6 +1017,7 @@ DEPENDENCIES
dotenv-rails (>= 3.0.0)
down
elastic-apm
email-provider-info
email_reply_trimmer
facebook-messenger
factory_bot_rails (>= 6.4.3)
+4
View File
@@ -36,6 +36,10 @@
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
}
},
"formation": {
+79
View File
@@ -7,6 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
@@ -20,6 +21,9 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.save!
@message
end
@@ -92,6 +96,14 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails
end
def process_email_content
return unless should_process_email_content?
@message.content_attributes ||= {}
email_attributes = build_email_attributes
@message.content_attributes[:email] = email_attributes
end
def process_email_string(email_string)
return [] if email_string.blank?
@@ -153,4 +165,71 @@ class Messages::MessageBuilder
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
def email_inbox?
@conversation.inbox&.inbox_type == 'Email'
end
def should_process_email_content?
email_inbox? && !@private && @message.content.present?
end
def build_email_attributes
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(normalized_content)
end
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes
end
def build_html_content(normalized_content)
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
rendered_html = render_email_html(normalized_content)
html_content[:full] = rendered_html
html_content[:reply] = rendered_html
html_content
end
def build_text_content(normalized_content)
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
text_content[:full] = normalized_content
text_content[:reply] = normalized_content
text_content
end
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
def build_custom_html_content
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
html_content[:full] = @params[:email_html_content]
html_content[:reply] = @params[:email_html_content]
html_content
end
end
+22 -16
View File
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
load_reporting_events_data
end
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
def reporting_events
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
load_reporting_events_data
end
def fetch_conversations_count
@@ -1,13 +1,12 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create
if type_matches?
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: permitted_params
)
case normalized_type
when 'Conversation'
enqueue_conversation_job
head :ok
when 'Contact'
check_authorization_for_contact_action
enqueue_contact_job
head :ok
else
render json: { success: false }, status: :unprocessable_entity
@@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private
def type_matches?
['Conversation'].include?(params[:type])
def normalized_type
params[:type].to_s.camelize
end
def permitted_params
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def delete_contact_action?
params[:action_name] == 'delete'
end
def check_authorization_for_contact_action
authorize(Contact, :destroy?) if delete_contact_action?
end
def conversation_params
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
# and then remove this method in favor of a common params method.
base = params.permit(
:snoozed_until,
fields: [:status, :assignee_id, :team_id]
)
append_common_bulk_attributes(base)
end
def contact_params
# TODO: remove this method in favor of a common params method.
# once legacy conversation payloads are migrated.
append_common_bulk_attributes({})
end
def append_common_bulk_attributes(base_params)
# NOTE: Conversation payloads historically diverged per action. Going forward we
# want all objects to share a common contract: `{ action_name, action_attributes }`
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
base_params.merge(common)
end
end
@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts)
@contacts_count = @contacts.total_count
end
def search
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def show; end
@@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contacts(contacts)
contacts_with_avatar = filtrate(contacts)
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
# Build includes hash to avoid separate query when contact_inboxes are needed
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_avatar
filtrate(contacts)
.includes(includes_hash)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def build_contact_inbox
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation.inbox, :show?
authorize @conversation, :show?
end
end
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation.inbox, :show?
authorize @conversation, :show?
end
def inbox
@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
private
def authorize_request
authorize @conversation.inbox, :show?
authorize @conversation, :show?
end
def render_response(response)
@@ -14,6 +14,7 @@ module AccessTokenAuthHelper
ensure_access_token
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
# NOTE: This ensures that current_user is set and available for the rest of the controller actions
@resource = @access_token.owner
Current.user = @resource if allowed_current_user_type?(@resource)
end
@@ -23,29 +23,24 @@ module RequestExceptionHandler
Current.reset
end
def render_error(message, status, error = nil)
log_handled_error(error) if error
render json: { error: message }, status: status
end
def render_unauthorized(message)
render_error(message, :unauthorized)
render json: { error: message }, status: :unauthorized
end
def render_not_found_error(message)
render_error(message, :not_found)
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render_error(message, :unprocessable_entity)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
render_error(message, :payment_required)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message)
render_error(message, :internal_server_error)
render json: { error: message }, status: :internal_server_error
end
def render_record_invalid(exception)
@@ -62,23 +57,6 @@ module RequestExceptionHandler
end
def log_handled_error(exception)
return unless exception
logger.info("Handled error: #{exception.inspect}")
report_to_apms(exception)
end
def report_to_apms(exception)
apm_reporters = {
'NewRelic::Agent' => -> { ::NewRelic::Agent.notice_error(exception) },
'Datadog::Tracing' => -> { ::Datadog::Tracing.active_trace&.set_error(exception) },
'ElasticAPM' => -> { ::ElasticAPM.report(exception) },
'ScoutApm::Error' => -> { ::ScoutApm::Error.capture(exception) },
'Sentry' => -> { ::Sentry.capture_exception(exception) }
}
apm_reporters.each do |module_name, reporter|
reporter.call if Object.const_defined?(module_name)
end
end
end
@@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
def sign_in_user_on_mobile
@resource.skip_confirmation! if confirmable_enabled?
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
# that will log them in
encoded_email = ERB::Util.url_encode(@resource.email)
params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
end
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
@@ -0,0 +1,36 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainCustomTools extends ApiClient {
constructor() {
super('captain/custom_tools', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: { page, searchKey },
});
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
create(data = {}) {
return axios.post(this.url, {
custom_tool: data,
});
}
update(id, data = {}) {
return axios.put(`${this.url}/${id}`, {
custom_tool: data,
});
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new CaptainCustomTools();
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios';
import ApiClient from './ApiClient';
import { CHANGELOG_API_URL } from 'shared/constants/links';
class ChangelogApi extends ApiClient {
constructor() {
super('changelog', { apiVersion: 'v1' });
}
// eslint-disable-next-line class-methods-use-this
fetchFromHub() {
return axios.get(CHANGELOG_API_URL);
}
}
export default new ChangelogApi();
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class InboxHealthAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
}
export default new InboxHealthAPI();
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({
selectedContact: {
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<Policy :permissions="['administrator']">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</Policy>
</template>
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const emit = defineEmits([
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
]);
const { t } = useI18n();
@@ -88,111 +97,148 @@ const onClickExpand = () => {
};
const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
<div class="relative">
<CardLayout
:key="id"
layout="row"
:class="{
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
}"
>
<div class="flex items-center justify-start flex-1 gap-4">
<div
class="relative"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
>
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
</div>
</template>
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
selectedContact: {
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
<Policy :permissions="['administrator']">
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</template>
@@ -10,7 +10,15 @@ import {
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({ contacts: { type: Array, required: true } });
const props = defineProps({
contacts: { type: Array, required: true },
selectedContactIds: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['toggleContact']);
const { t } = useI18n();
const store = useStore();
@@ -20,6 +28,9 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => {
try {
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script>
<template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
<div class="flex flex-col gap-4">
<div v-for="contact in contacts" :key="contact.id" class="relative">
<ContactsCard
:id="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
:selectable="shouldShowSelection(contact.id)"
:is-selected="isSelected(contact.id)"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div>
</template>
@@ -14,6 +14,10 @@ defineProps({
type: Array,
default: () => [],
},
showBackdrop: {
type: Boolean,
default: true,
},
});
</script>
@@ -25,14 +29,24 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-6">
<div
class="flex flex-col items-center justify-center gap-6"
:class="{
'mt-48': !showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@@ -40,6 +54,7 @@ defineProps({
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
@@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => {
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
};
const handleDropdownUpdate = (type, value) => {
@@ -114,6 +114,7 @@ const handlePageChange = event => {
<slot name="action" />
</div>
</div>
<slot name="subHeader" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
@@ -64,20 +64,23 @@ const bulkCheckboxState = computed({
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
<span
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
>
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -59,6 +60,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
showActions: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
<div class="inline-flex items-center">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
>
<div
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
icon="i-lucide-circle-check-big"
sm
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'approve', value: 'approve' })
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
icon="i-lucide-pencil-line"
sm
slate
link
class="hover:!no-underline"
@click="
handleAssistantAction({
action: 'edit',
value: 'edit',
})
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
icon="i-lucide-trash"
sm
ruby
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'delete', value: 'delete' })
"
/>
</div>
</Policy>
<div
class="flex items-center gap-3"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-3 min-w-0">
<span
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
v-if="status === 'approved'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
<Icon icon="i-woot-captain" class="size-3.5" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-ph-files-light"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-ph-user-circle-plus"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-ph-chat-circle-dots"
class="size-3.5"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate"
:title="documentable.name"
>
{{ documentable.name }}
</span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span
class="max-w-96 truncate"
v-else-if="documentable.type === 'User'"
class="truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="inline-flex items-center gap-1 group cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
<span
v-else-if="documentable.type === 'Conversation'"
class="hover:underline truncate cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</span>
<span v-else />
</div>
</div>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</div>
</CardLayout>
</template>
@@ -10,6 +10,10 @@ const props = defineProps({
type: String,
required: true,
},
translationKey: {
type: String,
required: true,
},
entity: {
type: Object,
required: true,
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const deleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
return props.translationKey || props.type.toUpperCase();
});
const deleteEntity = async payload => {
if (!payload) return;
@@ -0,0 +1,73 @@
<script setup>
import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
authType: {
type: String,
required: true,
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
},
});
const { t } = useI18n();
const authConfig = defineModel('authConfig', {
type: Object,
default: () => ({}),
});
watch(
() => props.authType,
() => {
authConfig.value = {};
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<Input
v-if="authType === 'bearer'"
v-model="authConfig.token"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
"
/>
<template v-else-if="authType === 'basic'">
<Input
v-model="authConfig.username"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER')
"
/>
<Input
v-model="authConfig.password"
type="password"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER')
"
/>
</template>
<template v-else-if="authType === 'api_key'">
<Input
v-model="authConfig.name"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER')
"
/>
<Input
v-model="authConfig.key"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER')
"
/>
</template>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import CustomToolForm from './CustomToolForm.vue';
const props = defineProps({
selectedTool: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const updateTool = toolDetails =>
store.dispatch('captainCustomTools/update', {
id: props.selectedTool.id,
...toolDetails,
});
const i18nKey = computed(
() => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}`
);
const createTool = toolDetails =>
store.dispatch('captainCustomTools/create', toolDetails);
const handleSubmit = async updatedTool => {
try {
if (props.type === 'edit') {
await updateTool(updatedTool);
} else {
await createTool(updatedTool);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
width="2xl"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<CustomToolForm
:mode="type"
:tool="selectedTool"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
authType: {
type: String,
default: 'none',
},
updatedAt: {
type: Number,
required: true,
},
createdAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const timestamp = computed(() =>
dynamicTime(props.updatedAt || props.createdAt)
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const authTypeLabel = computed(() => {
return t(
`CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}`
);
});
</script>
<template>
<CardLayout class="relative">
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
{{ title }}
</span>
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAction($event)"
/>
</Policy>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3 flex-1">
<span
v-if="description"
class="text-sm truncate text-n-slate-11 flex-1"
>
{{ description }}
</span>
<span
v-if="authType !== 'none'"
class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-lucide-lock text-base" />
{{ authTypeLabel }}
</span>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ timestamp }}
</span>
</div>
</CardLayout>
</template>
@@ -0,0 +1,271 @@
<script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ParamRow from './ParamRow.vue';
import AuthConfig from './AuthConfig.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
tool: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainCustomTools/getUIFlags'),
};
const initialState = {
title: '',
description: '',
endpoint_url: '',
http_method: 'GET',
request_template: '',
response_template: '',
auth_type: 'none',
auth_config: {},
param_schema: [],
};
const state = reactive({ ...initialState });
// Populate form when in edit mode
watch(
() => props.tool,
newTool => {
if (props.mode === 'edit' && newTool && newTool.id) {
state.title = newTool.title || '';
state.description = newTool.description || '';
state.endpoint_url = newTool.endpoint_url || '';
state.http_method = newTool.http_method || 'GET';
state.request_template = newTool.request_template || '';
state.response_template = newTool.response_template || '';
state.auth_type = newTool.auth_type || 'none';
state.auth_config = newTool.auth_config || {};
state.param_schema = newTool.param_schema || [];
}
},
{ immediate: true }
);
const DEFAULT_PARAM = {
name: '',
type: 'string',
description: '',
required: false,
};
const validationRules = {
title: { required },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
};
const httpMethodOptions = computed(() => [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
]);
const authTypeOptions = computed(() => [
{ value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') },
{ value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') },
{ value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') },
{
value: 'api_key',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
},
]);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() =>
props.mode === 'edit'
? formState.uiFlags.value.updatingItem
: formState.uiFlags.value.creatingItem
);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'),
}));
const paramsRef = useTemplateRef('paramsRef');
const isParamsValid = () => {
if (!paramsRef.value || paramsRef.value.length === 0) {
return true;
}
return paramsRef.value.every(param => param.validate());
};
const removeParam = index => {
state.param_schema.splice(index, 1);
};
const addParam = () => {
state.param_schema.push({ ...DEFAULT_PARAM });
};
const handleCancel = () => emit('cancel');
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid || !isParamsValid()) {
return;
}
emit('submit', state);
};
</script>
<template>
<form
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
@submit.prevent="handleSubmit"
>
<Input
v-model="state.title"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')"
:rows="2"
/>
<div class="flex gap-2">
<div class="flex flex-col gap-1 w-28">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }}
</label>
<ComboBox
v-model="state.http_method"
:options="httpMethodOptions"
class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]"
/>
</div>
<Input
v-model="state.endpoint_url"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')"
:message="formErrors.endpoint_url"
:message-type="formErrors.endpoint_url ? 'error' : 'info'"
class="flex-1"
/>
</div>
<div class="flex flex-col gap-1">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }}
</label>
<ComboBox
v-model="state.auth_type"
:options="authTypeOptions"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<AuthConfig
v-model:auth-config="state.auth_config"
:auth-type="state.auth_type"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
</label>
<p class="text-xs text-n-slate-11 -mt-1">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }}
</p>
<ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none">
<ParamRow
v-for="(param, index) in state.param_schema"
:key="index"
ref="paramsRef"
v-model:name="param.name"
v-model:type="param.type"
v-model:description="param.description"
v-model:required="param.required"
@remove="removeParam(index)"
/>
</ul>
<Button
type="button"
sm
ghost
blue
icon="i-lucide-plus"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')"
@click="addParam"
/>
</div>
<TextArea
v-if="state.http_method === 'POST'"
v-model="state.request_template"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')"
:rows="4"
class="[&_textarea]:font-mono"
/>
<TextArea
v-model="state.response_template"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER')
"
:rows="4"
class="[&_textarea]:font-mono"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="
t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')
"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>
@@ -0,0 +1,113 @@
<script setup>
import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const name = defineModel('name', {
type: String,
required: true,
});
const type = defineModel('type', {
type: String,
required: true,
});
const description = defineModel('description', {
type: String,
default: '',
});
const required = defineModel('required', {
type: Boolean,
default: false,
});
const paramTypeOptions = computed(() => [
{ value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') },
{ value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') },
{
value: 'boolean',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'),
},
{ value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') },
{ value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') },
]);
const validationError = computed(() => {
if (!name.value || name.value.trim() === '') {
return 'PARAM_NAME_REQUIRED';
}
return null;
});
watch([name, type, description, required], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
:class="{
'animate-wiggle border-n-ruby-9': showErrors && validationError,
}"
>
<div class="flex flex-col flex-1 gap-3">
<div class="grid grid-cols-3 gap-2">
<Input
v-model="name"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')"
class="col-span-2"
/>
<ComboBox
v-model="type"
:options="paramTypeOptions"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="description"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER')
"
/>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="required" />
<span class="text-sm text-n-slate-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }}
</span>
</label>
</div>
<Button
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click.stop="emit('remove')"
/>
</div>
<span
v-if="showErrors && validationError"
class="block mt-1 text-sm text-n-ruby-11"
>
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
</span>
</li>
</template>
@@ -1,4 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -20,6 +22,7 @@ const onClick = () => {
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
class="mb-8"
:hide-actions="!isOnChatwootCloud"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
@@ -0,0 +1,29 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="min-h-[600px]" />
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>
@@ -1,4 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
@@ -19,6 +21,7 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
learn-more-url="https://chwt.app/captain-document"
:hide-actions="!isOnChatwootCloud"
class="mb-8"
/>
<EmptyStateLayout
@@ -1,32 +1,63 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
import { computed } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
};
const onClearFilters = () => {
emit('clearFilters');
};
</script>
<template>
<FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
:hide-actions="!isOnChatwootCloud"
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:title="
isPending
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:show-backdrop="isApproved"
>
<template #empty-state-item>
<template v-if="isApproved" #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
@@ -42,11 +73,21 @@ const onClick = () => {
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<div class="flex flex-col items-center gap-3">
<Button
v-if="isApproved"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { ref } from 'vue';
import GroupedStackedChangelogCard from './GroupedStackedChangelogCard.vue';
const sampleCards = [
{
id: 'chatwoot-captain',
title: 'Chatwoot Captain',
meta_title: 'Chatwoot Captain',
meta_description:
'Watch how our latest feature can transform your workflow with powerful automation tools.',
slug: 'chatwoot-captain',
feature_image:
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
},
{
id: 'smart-routing',
title: 'Smart Routing Forms',
meta_title: 'Smart Routing Forms',
meta_description:
'Screen bookers with intelligent forms and route them to the right team member.',
slug: 'smart-routing',
feature_image: 'https://www.chatwoot.com/images/dashboard-dark.webp',
},
{
id: 'instant-meetings',
title: 'Instant Meetings',
meta_title: 'Instant Meetings',
meta_description: 'Start instant meetings directly from shared links.',
slug: 'instant-meetings',
feature_image:
'https://images.unsplash.com/photo-1587614382346-4ec70e388b28?w=600',
},
{
id: 'analytics',
title: 'Advanced Analytics',
meta_title: 'Advanced Analytics',
meta_description:
'Track meeting performance, conversion, and response rates in one place.',
slug: 'analytics',
feature_image:
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=500',
},
{
id: 'team-collaboration',
title: 'Team Collaboration',
meta_title: 'Team Collaboration',
meta_description:
'Coordinate with your team seamlessly using shared availability.',
slug: 'team-collaboration',
feature_image:
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400',
},
];
const visibleCards = ref([...sampleCards]);
const currentIndex = ref(0);
const dismissingCards = ref([]);
const handleReadMore = slug => {
console.log(`Read more: ${slug}`);
};
const handleDismiss = slug => {
dismissingCards.value.push(slug);
setTimeout(() => {
const idx = visibleCards.value.findIndex(c => c.slug === slug);
if (idx !== -1) visibleCards.value.splice(idx, 1);
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
}, 200);
};
const handleImgClick = data => {
currentIndex.value = data.index;
console.log(`Card clicked: ${visibleCards.value[data.index].title}`);
};
const resetDemo = () => {
visibleCards.value = [...sampleCards];
currentIndex.value = 0;
dismissingCards.value = [];
};
</script>
<template>
<Story
title="Components/ChangelogCard/GroupedStackedChangelogCard"
:layout="{ type: 'grid', width: '320px' }"
>
<Variant title="Interactive Demo">
<div class="p-4 bg-n-solid-2 rounded-md mx-auto w-64 h-[400px]">
<GroupedStackedChangelogCard
:posts="visibleCards"
:current-index="currentIndex"
:is-active="currentIndex === 0"
:dismissing-slugs="dismissingCards"
class="min-h-[270px]"
@read-more="handleReadMore"
@dismiss="handleDismiss"
@img-click="handleImgClick"
/>
<button
class="mt-3 px-3 py-1 text-xs font-medium bg-n-brand text-white rounded hover:bg-n-brand/80 transition"
@click="resetDemo"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ 'Reset Cards' }}
</button>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,74 @@
<script setup>
import { computed } from 'vue';
import StackedChangelogCard from './StackedChangelogCard.vue';
const props = defineProps({
posts: {
type: Array,
required: true,
},
currentIndex: {
type: Number,
default: 0,
},
dismissingSlugs: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
const stackedPosts = computed(() => props.posts?.slice(0, 5));
const isPostDismissing = post => props.dismissingSlugs.includes(post.slug);
const handleReadMore = post => emit('readMore', post.slug);
const handleDismiss = post => emit('dismiss', post.slug);
const handlePostClick = (post, index) => {
if (index === props.currentIndex && !isPostDismissing(post)) {
emit('imgClick', { slug: post.slug, index });
}
};
const getCardClasses = index => {
const pos =
(index - props.currentIndex + stackedPosts.value.length) %
stackedPosts.value.length;
const base =
'relative transition-all duration-500 ease-out col-start-1 row-start-1';
const layers = [
'z-50 scale-100 translate-y-0 opacity-100',
'z-40 scale-[0.95] -translate-y-3 opacity-90',
'z-30 scale-[0.9] -translate-y-6 opacity-70',
'z-20 scale-[0.85] -translate-y-9 opacity-50',
'z-10 scale-[0.8] -translate-y-12 opacity-30',
];
return pos < layers.length
? `${base} ${layers[pos]}`
: `${base} opacity-0 scale-75 -translate-y-16`;
};
</script>
<template>
<div class="overflow-hidden">
<div class="relative grid grid-cols-1 pt-8 pb-1 px-2">
<div
v-for="(post, index) in stackedPosts"
:key="post.slug || index"
:class="getCardClasses(index)"
>
<StackedChangelogCard
:card="post"
:is-active="index === currentIndex"
:is-dismissing="isPostDismissing(post)"
@read-more="handleReadMore(post)"
@dismiss="handleDismiss(post)"
@img-click="handlePostClick(post, index)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,46 @@
<script setup>
import StackedChangelogCard from './StackedChangelogCard.vue';
const imageCards = {
id: 'chatwoot-captain',
title: 'Chatwoot Captain',
meta_title: 'Chatwoot Captain',
meta_description:
'Watch how our latest feature can transform your workflow with powerful automation tools.',
slug: 'chatwoot-captain',
feature_image:
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
};
const handleReadMore = () => {
console.log(`Read more: ${imageCards.title}`);
};
const handleDismiss = () => {
console.log(`Dismissed: ${imageCards.title}`);
};
const handleImgClick = () => {
console.log(`Card clicked: ${imageCards.title}`);
};
</script>
<template>
<Story
title="Components/ChangelogCard/StackedChangelogCard"
:layout="{ type: 'grid', width: '260px' }"
>
<Variant title="Single Card - With Image">
<div class="p-3 bg-n-solid-2 w-56">
<StackedChangelogCard
:card="imageCards"
is-active
:is-dismissing="false"
@read-more="handleReadMore(imageCards)"
@dismiss="handleDismiss(imageCards)"
@img-click="handleImgClick(imageCards)"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,119 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
card: {
type: Object,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
isDismissing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
const handleReadMore = () => {
emit('readMore');
};
const handleDismiss = () => {
emit('dismiss');
};
const handleImgClick = () => {
emit('imgClick');
};
</script>
<template>
<div
data-testid="changelog-card"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
:class="{
'animate-fade-out pointer-events-none': isDismissing,
'hover:shadow': isActive,
}"
>
<div>
<h5
:title="card.meta_title"
class="mb-1 text-sm font-semibold line-clamp-1 text-n-slate-12"
>
{{ card.meta_title }}
</h5>
<p
:title="card.meta_description"
class="mb-0 text-xs leading-relaxed text-n-slate-11 line-clamp-2"
>
{{ card.meta_description }}
</p>
</div>
<div
v-if="card.feature_image"
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
>
<img
:src="card.feature_image"
:alt="`${card.title} preview image`"
class="object-cover w-full h-24 rounded-md cursor-pointer"
loading="lazy"
@click.stop="handleImgClick"
/>
</div>
<div
v-else
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
>
<img
:src="card.feature_image"
:alt="`${card.title} preview image`"
class="object-cover w-full h-24 rounded-md cursor-pointer"
loading="lazy"
@click.stop="handleImgClick"
/>
</div>
<div class="flex justify-between items-center mt-1">
<Button
label="Read more"
color="slate"
link
sm
class="text-xs font-normal hover:!no-underline"
@click.stop="handleReadMore"
/>
<Button
label="Dismiss"
color="slate"
link
sm
class="text-xs font-normal hover:!no-underline"
@click.stop="handleDismiss"
/>
</div>
</div>
</template>
<style scoped>
@keyframes fade-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
.animate-fade-out {
animation: fade-out 0.2s ease-out forwards;
}
</style>
@@ -10,6 +10,7 @@ defineProps({
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
});
const imageError = ref(false);
@@ -65,7 +66,7 @@ const openLink = link => {
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
<div class="flex gap-3">
<div v-if="!hideActions" class="flex gap-3">
<slot name="actions">
<Button
v-if="videoUrl"
@@ -13,6 +13,7 @@ defineProps({
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
});
const imageError = ref(false);
@@ -92,7 +93,7 @@ const openLink = link => {
{{ note }}
</p>
<div class="flex gap-3 justify-between w-full">
<div v-if="!hideActions" class="flex gap-3 justify-between w-full">
<slot name="actions">
<Button
v-if="videoUrl"
@@ -5,9 +5,9 @@ import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
@@ -47,6 +47,13 @@ const originalEmailHtml = computed(
originalEmailText.value
);
const hasEmailContent = computed(() => {
return (
contentAttributes?.value?.email?.textContent?.full ||
contentAttributes?.value?.email?.htmlContent?.full
);
});
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
@@ -137,7 +144,7 @@ const handleSeeOriginal = () => {
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
v-if="isOutgoing && content && !hasEmailContent"
class="text-n-slate-12"
:content="messageContent"
/>
@@ -0,0 +1,29 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -47,6 +47,7 @@ const isReel = computed(() => {
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@click.stop
@error="handleError"
/>
</div>
@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -13,6 +13,7 @@ import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -32,11 +33,15 @@ const emit = defineEmits([
'closeMobileSidebar',
]);
const { accountScopedRoute } = useAccount();
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -232,6 +237,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_responses_index'),
},
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
to: accountScopedRoute('captain_tools_index'),
},
],
},
{
@@ -527,20 +537,20 @@ const menuItems = computed(() => {
]"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 size-6 place-content-center">
<div class="flex gap-2 items-center px-2 min-w-0">
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow min-w-0 -mx-1"
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</div>
<div class="flex gap-2 px-2">
<RouterLink
:to="{ name: 'search' }"
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
@@ -565,7 +575,7 @@ const menuItems = computed(() => {
</ComposeConversation>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
@@ -575,11 +585,21 @@ const menuItems = computed(() => {
</ul>
</nav>
<section
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
<div
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
</div>
</section>
</aside>
</template>
@@ -0,0 +1,110 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import changelogAPI from 'dashboard/api/changelog';
const MAX_DISMISSED_SLUGS = 5;
const { uiSettings, updateUISettings } = useUISettings();
const posts = ref([]);
const currentIndex = ref(0);
const dismissingCards = ref([]);
const isLoading = ref(false);
// Get current dismissed slugs from ui_settings
const dismissedSlugs = computed(() => {
return uiSettings.value.changelog_dismissed_slugs || [];
});
// Get un dismissed posts - these are the changelog posts that should be shown
const unDismissedPosts = computed(() => {
return posts.value.filter(post => !dismissedSlugs.value.includes(post.slug));
});
// Fetch changelog posts from API
const fetchChangelog = async () => {
isLoading.value = true;
try {
const response = await changelogAPI.fetchFromHub();
posts.value = response.data.posts || [];
// Clean up dismissed slugs - remove any that are no longer in the current feed
const currentSlugs = posts.value.map(post => post.slug);
const cleanedDismissedSlugs = dismissedSlugs.value.filter(slug =>
currentSlugs.includes(slug)
);
// Update ui_settings if cleanup occurred
if (cleanedDismissedSlugs.length !== dismissedSlugs.value.length) {
updateUISettings({
changelog_dismissed_slugs: cleanedDismissedSlugs,
});
}
// eslint-disable-next-line no-empty
} catch (err) {
} finally {
isLoading.value = false;
}
};
// Dismiss a changelog post
const dismissPost = slug => {
const currentDismissed = [...dismissedSlugs.value];
// Add new slug if not already present
if (!currentDismissed.includes(slug)) {
currentDismissed.push(slug);
// Keep only the most recent MAX_DISMISSED_SLUGS entries
if (currentDismissed.length > MAX_DISMISSED_SLUGS) {
currentDismissed.shift(); // Remove oldest entry
}
updateUISettings({
changelog_dismissed_slugs: currentDismissed,
});
}
};
const handleDismiss = slug => {
dismissingCards.value.push(slug);
setTimeout(() => {
dismissPost(slug);
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
if (currentIndex.value >= unDismissedPosts.value.length)
currentIndex.value = 0;
}, 200);
};
const handleReadMore = () => {
const currentPost = unDismissedPosts.value[currentIndex.value];
if (currentPost?.slug) {
window.open(`https://www.chatwoot.com/blog/${currentPost.slug}`, '_blank');
}
};
const handleImgClick = ({ index }) => {
currentIndex.value = index;
handleReadMore();
};
onMounted(() => {
fetchChangelog();
});
</script>
<template>
<GroupedStackedChangelogCard
v-if="unDismissedPosts.length > 0"
:posts="unDismissedPosts"
:current-index="currentIndex"
:dismissing-slugs="dismissingCards"
class="min-h-[240px] z-10"
@read-more="handleReadMore"
@dismiss="handleDismiss"
@img-click="handleImgClick"
/>
<template v-else />
</template>
@@ -118,6 +118,14 @@ export default {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -125,6 +133,7 @@ export default {
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -249,6 +258,11 @@ export default {
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
quotedReplyToggleTooltip() {
return this.quotedReplyEnabled
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
@@ -339,6 +353,16 @@ export default {
sm
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
quotedEmailText: {
type: String,
required: true,
},
previewText: {
type: String,
required: true,
},
});
const emit = defineEmits(['toggle']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const formattedQuotedEmailText = computed(() => {
if (!props.quotedEmailText) {
return '';
}
return formatMessage(props.quotedEmailText, false, false, true);
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<div class="mt-2">
<div
class="relative rounded-md px-3 py-2 text-xs text-n-slate-12 bg-n-slate-3 dark:bg-n-solid-3"
>
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
<NextButton
v-tooltip="
isExpanded
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.COLLAPSE')
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.EXPAND')
"
ghost
slate
xs
:icon="isExpanded ? 'i-lucide-minimize' : 'i-lucide-maximize'"
@click="toggleExpand"
/>
<NextButton
v-tooltip="t('CONVERSATION.REPLYBOX.QUOTED_REPLY.REMOVE_PREVIEW')"
ghost
slate
xs
icon="i-lucide-x"
@click="emit('toggle')"
/>
</div>
<div
v-dompurify-html="formattedQuotedEmailText"
class="w-full max-w-none break-words prose prose-sm dark:prose-invert cursor-pointer ltr:pr-8 rtl:pl-8"
:class="{
'line-clamp-1': !isExpanded,
'max-h-60 overflow-y-auto': isExpanded,
}"
:title="previewText"
@click="toggleExpand"
/>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
@@ -16,6 +17,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
@@ -32,6 +34,12 @@ import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import {
extractQuotedEmailText,
buildQuotedEmailHeader,
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
@@ -65,6 +73,7 @@ export default {
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -80,6 +89,8 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
@@ -89,6 +100,8 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
};
},
@@ -130,6 +143,8 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
@@ -367,6 +382,51 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
return false;
}
return !!this.fetchQuotedReplyFlagFromUISettings(this.channelType);
},
lastEmailWithQuotedContent() {
if (!this.isAnEmailChannel) {
return null;
}
const lastEmail = this.lastEmail;
if (!lastEmail || lastEmail.private) {
return null;
}
return lastEmail;
},
quotedEmailText() {
return extractQuotedEmailText(this.lastEmailWithQuotedContent);
},
quotedEmailPreviewText() {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
},
shouldShowQuotedPreview() {
return (
this.shouldShowQuotedReplyToggle &&
this.quotedReplyPreference &&
!!this.quotedEmailText
);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -516,6 +576,36 @@ export default {
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
}
const nextValue = !this.quotedReplyPreference;
this.setQuotedReplyFlagForInbox(this.channelType, nextValue);
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
);
},
getMessageWithQuotedEmailText(message) {
if (!this.shouldIncludeQuotedEmail()) {
return message;
}
const quotedText = this.quotedEmailText || '';
const header = buildQuotedEmailHeader(
this.lastEmailWithQuotedContent,
this.currentContact,
this.inbox
);
return appendQuotedTextToMessage(message, quotedText, header);
},
resetRecorderAndClearAttachments() {
// Reset audio recorder UI state
this.resetAudioRecorderInput();
@@ -965,9 +1055,11 @@ export default {
return multipleMessagePayload;
},
getMessagePayload(message) {
const messageWithQuote = this.getMessageWithQuotedEmailText(message);
let messagePayload = {
conversationId: this.currentChat.id,
message,
message: messageWithQuote,
private: this.isPrivate,
sender: this.sender,
};
@@ -995,7 +1087,6 @@ export default {
if (this.toEmails && !this.isOnPrivateNote) {
messagePayload.toEmails = this.toEmails;
}
return messagePayload;
},
setCcEmails(value) {
@@ -1160,6 +1251,12 @@ export default {
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
@@ -1195,6 +1292,8 @@ export default {
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
@@ -1206,6 +1305,7 @@ export default {
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<WhatsappTemplates
:inbox-id="inbox.id"
@@ -1,100 +1,129 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
export default {
components: {
NextButton,
},
emits: ['update', 'close', 'assign'],
data() {
return {
query: '',
selectedLabels: [],
};
},
computed: {
...mapGetters({ labels: 'labels/getLabels' }),
filteredLabels() {
return this.labels.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
isLabelSelected(label) {
return this.selectedLabels.includes(label);
},
assignLabels(key) {
this.$emit('update', key);
},
onClose() {
this.$emit('close');
},
},
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div v-on-clickaway="onClose" class="labels-container">
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="labels-list">
<header class="labels-list__header">
<div
class="flex items-center justify-between h-8 gap-2 label-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm label--search_input"
/>
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul class="labels-list__body">
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="label__list-item"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="item"
:class="{ 'label-selected': isLabelSelected(label.title) }"
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="label-checkbox"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="label-pill"
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<footer class="labels-list__footer">
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="$emit('assign', selectedLabels)"
@click="handleAssign"
/>
</footer>
</div>
@@ -102,107 +131,11 @@ export default {
</template>
<style scoped lang="scss">
.labels-list {
@apply flex flex-col max-h-[15rem] min-h-[auto];
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
.labels-list__header {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
.labels-list__body {
@apply flex-1 overflow-y-auto py-2.5 mx-0;
}
.labels-list__footer {
@apply p-2;
button {
@apply w-full;
.button__content {
@apply text-center;
}
}
}
}
.label-list-search {
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.label--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
.labels-container {
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply max-h-[15rem] overflow-y-auto;
.label__list-container {
@apply h-full;
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
}
.labels-placeholder {
@apply p-2;
}
.label__list-item {
@apply my-1 mx-0 py-0 px-2.5;
.item {
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
&.label-selected {
@apply bg-n-slate-2;
}
span {
@apply text-sm;
}
.label-checkbox {
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
}
.label-title {
@apply flex-grow w-full;
}
.label-pill {
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
}
}
}
.search-container {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
}
</style>
@@ -13,6 +13,7 @@ const getUISettingsMock = ref({
conversation_sidebar_items_order: DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
vi.mock('dashboard/composables/store', () => ({
@@ -37,6 +38,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
});
@@ -51,6 +53,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -65,6 +68,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -100,6 +104,7 @@ describe('useUISettings', () => {
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
email_signature_enabled: true,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -109,6 +114,26 @@ describe('useUISettings', () => {
expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined);
});
it('sets quoted reply flag for inbox correctly', () => {
const { setQuotedReplyFlagForInbox } = useUISettings();
setQuotedReplyFlagForInbox('Channel::Email', false);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: false,
},
});
});
it('fetches quoted reply flag from UI settings correctly', () => {
const { fetchQuotedReplyFlagFromUISettings } = useUISettings();
expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => {
getUISettingsMock.value.enter_to_send_enabled = false;
const { isEditorHotKeyEnabled } = useUISettings();
@@ -87,6 +87,13 @@ const setSignatureFlagForInbox = (channelType, value, updateUISettings) => {
updateUISettings({ [`${slugifiedChannel}_signature_enabled`]: value });
};
const setQuotedReplyFlagForInbox = (channelType, value, updateUISettings) => {
if (!channelType) return;
const slugifiedChannel = slugifyChannel(channelType);
updateUISettings({ [`${slugifiedChannel}_quoted_reply_enabled`]: value });
};
/**
* Fetches the signature flag for a specific channel type from UI settings.
* @param {string} channelType - The type of the channel.
@@ -100,6 +107,13 @@ const fetchSignatureFlagFromUISettings = (channelType, uiSettings) => {
return uiSettings.value[`${slugifiedChannel}_signature_enabled`];
};
const fetchQuotedReplyFlagFromUISettings = (channelType, uiSettings) => {
if (!channelType) return false;
const slugifiedChannel = slugifyChannel(channelType);
return uiSettings.value[`${slugifiedChannel}_quoted_reply_enabled`];
};
/**
* Checks if a specific editor hotkey is enabled.
* @param {string} key - The key to check.
@@ -147,6 +161,10 @@ export function useUISettings() {
setSignatureFlagForInbox(channelType, value, updateUISettings),
fetchSignatureFlagFromUISettings: channelType =>
fetchSignatureFlagFromUISettings(channelType, uiSettings),
setQuotedReplyFlagForInbox: (channelType, value) =>
setQuotedReplyFlagForInbox(channelType, value, updateUISettings),
fetchQuotedReplyFlagFromUISettings: channelType =>
fetchQuotedReplyFlagFromUISettings(channelType, uiSettings),
isEditorHotKeyEnabled: key => isEditorHotKeyEnabled(key, uiSettings),
};
}
+1 -1
View File
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
};
export const PREMIUM_FEATURES = [
@@ -48,6 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
];
@@ -10,6 +10,8 @@ const QUOTE_INDICATORS = [
'[class*="Quote"]',
];
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
@@ -36,6 +38,8 @@ export class EmailQuoteExtractor {
});
});
this.removeTrailingBlockquote(tempDiv);
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
@@ -62,6 +66,10 @@ export class EmailQuoteExtractor {
}
}
if (this.findTrailingBlockquote(tempDiv)) {
return true;
}
// Check for text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
return textNodeQuotes.length > 0;
@@ -123,4 +131,26 @@ export class EmailQuoteExtractor {
return null;
}
/**
* Remove fallback blockquote if it is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
*/
static removeTrailingBlockquote(rootElement) {
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
trailingBlockquote?.remove();
}
/**
* Locate a fallback blockquote that is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
* @returns {Element|null} The trailing blockquote element if present
*/
static findTrailingBlockquote(rootElement) {
const lastElement = rootElement.lastElementChild;
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
return lastElement;
}
return null;
}
}
@@ -68,13 +68,17 @@ export const registerSubscription = (onSuccess = () => {}) => {
.then(() => {
onSuccess();
})
.catch(() => {
.catch(error => {
// eslint-disable-next-line no-console
console.error('Push subscription registration failed:', error);
useAlert('This browser does not support desktop notification');
});
};
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
// eslint-disable-next-line no-console
console.warn('Notification is not supported');
useAlert('This browser does not support desktop notification');
} else if (Notification.permission === 'granted') {
registerSubscription(onSuccess);
@@ -0,0 +1,332 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
/**
* Extracts plain text from HTML content
* @param {string} html - HTML content to convert
* @returns {string} Plain text content
*/
export const extractPlainTextFromHtml = html => {
if (!html) {
return '';
}
if (typeof document === 'undefined') {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
};
/**
* Extracts sender name from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender name
*/
export const getEmailSenderName = (lastEmail, contact) => {
const senderName = lastEmail?.sender?.name;
if (senderName && senderName.trim()) {
return senderName.trim();
}
const contactName = contact?.name;
return contactName && contactName.trim() ? contactName.trim() : '';
};
/**
* Extracts sender email from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender email address
*/
export const getEmailSenderEmail = (lastEmail, contact) => {
const senderEmail = lastEmail?.sender?.email;
if (senderEmail && senderEmail.trim()) {
return senderEmail.trim();
}
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.from) && emailMeta.from.length > 0) {
const fromAddress = emailMeta.from[0];
if (fromAddress && fromAddress.trim()) {
return fromAddress.trim();
}
}
const contactEmail = contact?.email;
return contactEmail && contactEmail.trim() ? contactEmail.trim() : '';
};
/**
* Extracts date from email message
* @param {Object} lastEmail - Last email message object
* @returns {Date|null} Email date
*/
export const getEmailDate = lastEmail => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (emailMeta.date) {
const parsedDate = parseISO(emailMeta.date);
if (isValidDate(parsedDate)) {
return parsedDate;
}
}
const createdAt = lastEmail?.created_at;
if (createdAt) {
const timestamp = Number(createdAt);
if (!Number.isNaN(timestamp)) {
const milliseconds = timestamp > 1e12 ? timestamp : timestamp * 1000;
const derivedDate = new Date(milliseconds);
if (!Number.isNaN(derivedDate.getTime())) {
return derivedDate;
}
}
}
return null;
};
/**
* Formats date for quoted email header
* @param {Date} date - Date to format
* @returns {string} Formatted date string
*/
export const formatQuotedEmailDate = date => {
try {
return format(date, "EEE, MMM d, yyyy 'at' p");
} catch (error) {
const fallbackDate = new Date(date);
if (!Number.isNaN(fallbackDate.getTime())) {
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
}
}
return '';
};
/**
* Extracts inbox email address from last email message
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Inbox email address
*/
export const getInboxEmail = (lastEmail, inbox) => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.to) && emailMeta.to.length > 0) {
const toAddress = emailMeta.to[0];
if (toAddress && toAddress.trim()) {
return toAddress.trim();
}
}
const inboxEmail = inbox?.email;
return inboxEmail && inboxEmail.trim() ? inboxEmail.trim() : '';
};
/**
* Builds quoted email header from contact (for incoming messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromContact = (lastEmail, contact) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const senderEmail = getEmailSenderEmail(lastEmail, contact);
if (!quotedDate || !senderEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const senderName = getEmailSenderName(lastEmail, contact);
const hasName = !!senderName;
const contactLabel = hasName
? `${senderName} <${senderEmail}>`
: `<${senderEmail}>`;
return `On ${formattedDate} ${contactLabel} wrote:`;
};
/**
* Builds quoted email header from inbox (for outgoing messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromInbox = (lastEmail, inbox) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const inboxEmail = getInboxEmail(lastEmail, inbox);
if (!quotedDate || !inboxEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const inboxName = inbox?.name;
const hasName = !!inboxName;
const inboxLabel = hasName
? `${inboxName} <${inboxEmail}>`
: `<${inboxEmail}>`;
return `On ${formattedDate} ${inboxLabel} wrote:`;
};
/**
* Builds quoted email header based on message type
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeader = (lastEmail, contact, inbox) => {
if (!lastEmail) {
return '';
}
// MESSAGE_TYPE.OUTGOING = 1, MESSAGE_TYPE.INCOMING = 0
const isOutgoing = lastEmail.message_type === 1;
if (isOutgoing) {
return buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
}
return buildQuotedEmailHeaderFromContact(lastEmail, contact);
};
/**
* Formats text as markdown blockquote
* @param {string} text - Text to format
* @param {string} header - Optional header to prepend
* @returns {string} Formatted blockquote
*/
export const formatQuotedTextAsBlockquote = (text, header = '') => {
const normalizedLines = text
? String(text).replace(/\r\n/g, '\n').split('\n')
: [];
if (!header && !normalizedLines.length) {
return '';
}
const quotedLines = [];
if (header) {
quotedLines.push(`> ${header}`);
quotedLines.push('>');
}
normalizedLines.forEach(line => {
const trimmedLine = line.trimEnd();
quotedLines.push(trimmedLine ? `> ${trimmedLine}` : '>');
});
return quotedLines.join('\n');
};
/**
* Extracts quoted email text from last email message
* @param {Object} lastEmail - Last email message object
* @returns {string} Quoted email text
*/
export const extractQuotedEmailText = lastEmail => {
if (!lastEmail) {
return '';
}
const contentAttributes =
lastEmail.contentAttributes || lastEmail.content_attributes || {};
const emailContent = contentAttributes.email || {};
const textContent = emailContent.textContent || emailContent.text_content;
if (textContent?.reply) {
return textContent.reply;
}
if (textContent?.full) {
return textContent.full;
}
const htmlContent = emailContent.htmlContent || emailContent.html_content;
if (htmlContent?.reply) {
return extractPlainTextFromHtml(htmlContent.reply);
}
if (htmlContent?.full) {
return extractPlainTextFromHtml(htmlContent.full);
}
const fallbackContent =
lastEmail.content || lastEmail.processed_message_content || '';
return fallbackContent;
};
/**
* Truncates text for preview display
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length (default: 80)
* @returns {string} Truncated text
*/
export const truncatePreviewText = (text, maxLength = 80) => {
const preview = text.trim().replace(/\s+/g, ' ');
if (!preview) {
return '';
}
if (preview.length <= maxLength) {
return preview;
}
return `${preview.slice(0, maxLength - 3)}...`;
};
/**
* Appends quoted text to message
* @param {string} message - Original message
* @param {string} quotedText - Text to quote
* @param {string} header - Quote header
* @returns {string} Message with quoted text appended
*/
export const appendQuotedTextToMessage = (message, quotedText, header) => {
const baseMessage = message ? String(message) : '';
const quotedBlock = formatQuotedTextAsBlockquote(quotedText, header);
if (!quotedBlock) {
return baseMessage;
}
if (!baseMessage) {
return quotedBlock;
}
let separator = '\n\n';
if (baseMessage.endsWith('\n\n')) {
separator = '';
} else if (baseMessage.endsWith('\n')) {
separator = '\n';
}
return `${baseMessage}${separator}${quotedBlock}`;
};
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
const SAMPLE_EMAIL_HTML = `
<p>method</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:18 PM John <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Hi</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Yes, it is.</p>
<p>On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot &lt; shaneforwoot@gmail.com&gt; wrote:</p>
<blockquote>
<p>Hey</p>
<p>On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:</p>
<p>This is another quoted quoted text reply</p>
<p>This is nice</p>
<p>On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot &lt; &gt; shaneforwoot@gmail.com&gt; wrote:</p>
<p>Hey there, this is a reply from Chatwoot, notice the quoted text</p>
<p>Hey there</p>
<p>This is an email text, enjoy reading this</p>
<p>-- Shivam Mishra, Chatwoot</p>
</blockquote>
</blockquote>
</blockquote>
`;
const EMAIL_WITH_SIGNATURE = `
<p>Latest reply here.</p>
<p>Thanks,</p>
<p>Jane Doe</p>
<blockquote>
<p>On Mon, Sep 22, Someone wrote:</p>
<p>Previous reply content</p>
</blockquote>
`;
const EMAIL_WITH_FOLLOW_UP_CONTENT = `
<blockquote>
<p>Inline quote that should stay</p>
</blockquote>
<p>Internal note follows</p>
<p>Regards,</p>
`;
describe('EmailQuoteExtractor', () => {
it('removes blockquote-based quotes from the email body', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelectorAll('blockquote').length).toBe(0);
expect(container.textContent?.trim()).toBe('method');
expect(container.textContent).not.toContain(
'On Mon, Sep 29, 2025 at 5:18 PM'
);
});
it('keeps blockquote fallback when it is not the last top-level element', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
EMAIL_WITH_FOLLOW_UP_CONTENT
);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelector('blockquote')).not.toBeNull();
expect(container.lastElementChild?.tagName).toBe('P');
});
it('detects quote indicators in nested blockquotes', () => {
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
expect(result).toBe(true);
});
it('does not flag blockquotes that are followed by other elements', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe(
false
);
});
it('returns false when no quote indicators are present', () => {
const html = '<p>Plain content</p>';
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('removes trailing blockquotes while preserving trailing signatures', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
expect(cleanedHtml).toContain('<p>Thanks,</p>');
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
expect(cleanedHtml).not.toContain('<blockquote');
});
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
});
@@ -0,0 +1,441 @@
import {
extractPlainTextFromHtml,
getEmailSenderName,
getEmailSenderEmail,
getEmailDate,
formatQuotedEmailDate,
getInboxEmail,
buildQuotedEmailHeader,
buildQuotedEmailHeaderFromContact,
buildQuotedEmailHeaderFromInbox,
formatQuotedTextAsBlockquote,
extractQuotedEmailText,
truncatePreviewText,
appendQuotedTextToMessage,
} from '../quotedEmailHelper';
describe('quotedEmailHelper', () => {
describe('extractPlainTextFromHtml', () => {
it('returns empty string for null or undefined', () => {
expect(extractPlainTextFromHtml(null)).toBe('');
expect(extractPlainTextFromHtml(undefined)).toBe('');
});
it('strips HTML tags and returns plain text', () => {
const html = '<p>Hello <strong>world</strong></p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello world');
});
it('handles complex HTML structure', () => {
const html = '<div><p>Line 1</p><p>Line 2</p></div>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
});
describe('getEmailSenderName', () => {
it('returns sender name from lastEmail', () => {
const lastEmail = { sender: { name: 'John Doe' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
it('returns contact name if sender name not available', () => {
const lastEmail = { sender: {} };
const contact = { name: 'Jane Smith' };
const result = getEmailSenderName(lastEmail, contact);
expect(result).toBe('Jane Smith');
});
it('returns empty string if neither available', () => {
const result = getEmailSenderName({}, {});
expect(result).toBe('');
});
it('trims whitespace from names', () => {
const lastEmail = { sender: { name: ' John Doe ' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
});
describe('getEmailSenderEmail', () => {
it('returns sender email from lastEmail', () => {
const lastEmail = { sender: { email: 'john@example.com' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
it('returns email from contentAttributes if sender email not available', () => {
const lastEmail = {
contentAttributes: {
email: { from: ['jane@example.com'] },
},
};
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('jane@example.com');
});
it('returns contact email as fallback', () => {
const lastEmail = {};
const contact = { email: 'contact@example.com' };
const result = getEmailSenderEmail(lastEmail, contact);
expect(result).toBe('contact@example.com');
});
it('trims whitespace from emails', () => {
const lastEmail = { sender: { email: ' john@example.com ' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
});
describe('getEmailDate', () => {
it('returns parsed date from email metadata', () => {
const lastEmail = {
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns date from created_at timestamp', () => {
const lastEmail = { created_at: 1705318200 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('handles millisecond timestamps', () => {
const lastEmail = { created_at: 1705318200000 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns null if no valid date found', () => {
const result = getEmailDate({});
expect(result).toBeNull();
});
});
describe('formatQuotedEmailDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15T10:30:00Z');
const result = formatQuotedEmailDate(date);
expect(result).toMatch(/Mon, Jan 15, 2024 at/);
});
it('returns empty string for invalid date', () => {
const result = formatQuotedEmailDate('invalid');
expect(result).toBe('');
});
});
describe('getInboxEmail', () => {
it('returns email from contentAttributes.email.to', () => {
const lastEmail = {
contentAttributes: {
email: { to: ['inbox@example.com'] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
it('returns inbox email as fallback', () => {
const lastEmail = {};
const inbox = { email: 'support@example.com' };
const result = getInboxEmail(lastEmail, inbox);
expect(result).toBe('support@example.com');
});
it('returns empty string if no email found', () => {
expect(getInboxEmail({}, {})).toBe('');
});
it('trims whitespace from emails', () => {
const lastEmail = {
contentAttributes: {
email: { to: [' inbox@example.com '] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
});
describe('buildQuotedEmailHeaderFromContact', () => {
it('builds complete header with name and email', () => {
const lastEmail = {
sender: { name: 'John Doe', email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('John Doe');
expect(result).toContain('john@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
sender: { email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('<john@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromContact(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromContact({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeaderFromInbox', () => {
it('builds complete header with inbox name and email', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support Team', email: 'support@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('Support Team');
expect(result).toContain('support@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['inbox@example.com'],
},
},
};
const inbox = { email: 'inbox@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('<inbox@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromInbox(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromInbox({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeader', () => {
it('uses inbox email for outgoing messages (message_type: 1)', () => {
const lastEmail = {
message_type: 1,
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'John Doe', email: 'john@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Support');
expect(result).toContain('support@example.com');
expect(result).not.toContain('John Doe');
});
it('uses contact email for incoming messages (message_type: 0)', () => {
const lastEmail = {
message_type: 0,
sender: { name: 'Jane Smith', email: 'jane@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'Jane Smith', email: 'jane@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Jane Smith');
expect(result).toContain('jane@example.com');
expect(result).not.toContain('Support');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeader(null, {}, {})).toBe('');
expect(buildQuotedEmailHeader({}, {}, {})).toBe('');
});
});
describe('formatQuotedTextAsBlockquote', () => {
it('formats single line text', () => {
const result = formatQuotedTextAsBlockquote('Hello world');
expect(result).toBe('> Hello world');
});
it('formats multi-line text', () => {
const text = 'Line 1\nLine 2\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2\n> Line 3');
});
it('includes header if provided', () => {
const result = formatQuotedTextAsBlockquote('Hello', 'Header text');
expect(result).toContain('> Header text');
expect(result).toContain('>\n> Hello');
});
it('handles empty lines correctly', () => {
const text = 'Line 1\n\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n>\n> Line 3');
});
it('returns empty string for empty input', () => {
expect(formatQuotedTextAsBlockquote('')).toBe('');
expect(formatQuotedTextAsBlockquote('', '')).toBe('');
});
it('handles Windows line endings', () => {
const text = 'Line 1\r\nLine 2';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2');
});
});
describe('extractQuotedEmailText', () => {
it('extracts text from textContent.reply', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { reply: 'Reply text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Reply text');
});
it('falls back to textContent.full', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { full: 'Full text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Full text');
});
it('extracts from htmlContent and converts to plain text', () => {
const lastEmail = {
contentAttributes: {
email: { htmlContent: { reply: '<p>HTML reply</p>' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('HTML reply');
});
it('uses fallback content if structured content not available', () => {
const lastEmail = { content: 'Fallback content' };
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Fallback content');
});
it('returns empty string for null or missing email', () => {
expect(extractQuotedEmailText(null)).toBe('');
expect(extractQuotedEmailText({})).toBe('');
});
});
describe('truncatePreviewText', () => {
it('returns full text if under max length', () => {
const text = 'Short text';
const result = truncatePreviewText(text, 80);
expect(result).toBe('Short text');
});
it('truncates text exceeding max length', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text, 80);
expect(result).toHaveLength(80);
expect(result).toContain('...');
});
it('collapses multiple spaces', () => {
const text = 'Text with spaces';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('trims whitespace', () => {
const text = ' Text with spaces ';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('returns empty string for empty input', () => {
expect(truncatePreviewText('')).toBe('');
expect(truncatePreviewText(' ')).toBe('');
});
it('uses default max length of 80', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text);
expect(result).toHaveLength(80);
});
});
describe('appendQuotedTextToMessage', () => {
it('appends quoted text to message', () => {
const message = 'My reply';
const quotedText = 'Original message';
const header = 'On date sender wrote:';
const result = appendQuotedTextToMessage(message, quotedText, header);
expect(result).toContain('My reply');
expect(result).toContain('> On date sender wrote:');
expect(result).toContain('> Original message');
});
it('returns only quoted text if message is empty', () => {
const result = appendQuotedTextToMessage('', 'Quoted', 'Header');
expect(result).toContain('> Header');
expect(result).toContain('> Quoted');
expect(result).not.toContain('\n\n\n');
});
it('returns message if no quoted text', () => {
const result = appendQuotedTextToMessage('Message', '', '');
expect(result).toBe('Message');
});
it('handles proper spacing with double newline', () => {
const result = appendQuotedTextToMessage('Message', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
it('does not add extra newlines if message already ends with newlines', () => {
const result = appendQuotedTextToMessage(
'Message\n\n',
'Quoted',
'Header'
);
expect(result).not.toContain('\n\n\n');
});
it('adds single newline if message ends with one newline', () => {
const result = appendQuotedTextToMessage('Message\n', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
});
});
@@ -24,7 +24,7 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found for",
"NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
@@ -571,6 +571,16 @@
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
}
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldnt complete the search. Please try again."
@@ -227,6 +227,13 @@
"YES": "Send",
"CANCEL": "Cancel"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -605,8 +607,64 @@
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
@@ -618,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -752,8 +752,118 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"EMPTY_STATE": {
"TITLE": "No custom tools available",
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "Password",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Type"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "Number",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "Required"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
@@ -793,6 +903,10 @@
"APPROVED": "Approved",
"ALL": "All"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
"TITLE": "Add an FAQ",
@@ -823,13 +937,15 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
},
"OPTIONS": {
"APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete FAQ"
"APPROVE": "Approve",
"EDIT_RESPONSE": "Edit",
"DELETE_RESPONSE": "Delete"
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -24,7 +24,7 @@
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login",
"SAML": {
"LABEL": "Log in via SSO",
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -51,6 +51,7 @@
},
"DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
@@ -266,6 +267,8 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -467,6 +470,13 @@
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Loading agent metrics...",
@@ -304,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -27,15 +27,20 @@
"LABEL": "Password",
"PLACEHOLDER": "Password",
"ERROR": "Password is too short.",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character."
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "Confirm password",
"PLACEHOLDER": "Confirm password",
"ERROR": "Password doesnot match."
"ERROR": "Passwords do not match."
},
"API": {
"SUCCESS_MESSAGE": "Registration Successfull",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"SUBMIT": "Create account",
@@ -24,7 +24,7 @@
},
"LABELS": {
"ASSIGN_LABELS": "إضافة وسم",
"NO_LABELS_FOUND": "لم يتم العثور على تسميات لـ",
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.",
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى."
@@ -571,6 +571,16 @@
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "تعيين التسميات",
"ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldnt complete the search. Please try again."
@@ -227,6 +227,13 @@
"YES": "إرسال",
"CANCEL": "إلغاء"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لك ولأعضاء فريقك",
@@ -5,6 +5,8 @@
"PLACEHOLDER": "بحث",
"EMPTY_STATE": "لم يتم العثور على النتائج"
},
"CLOSE": "أغلق"
"CLOSE": "أغلق",
"BETA": "تجريبي",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -5,6 +5,8 @@
"LEARN_MORE": "تعلم المزيد عن صناديق البريد",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "لا توجد صناديق وارد لقنوات تواصل مرتبطة بهذا الحساب."
},
@@ -605,8 +607,64 @@
"BUSINESS_HOURS": "ساعات العمل",
"WIDGET_BUILDER": "منشئ اللايف شات",
"BOT_CONFIGURATION": "اعدادات البوت",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "تقييم رضاء العملاء"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "مباشر"
}
}
},
"SETTINGS": "الإعدادات",
"FEATURES": {
"LABEL": "الخصائص",
@@ -618,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "كود \"الماسنجر\"",
"MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "وكيل الدعم",
"INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا",
"AGENT_ASSIGNMENT": "تعيين المحادثة",
@@ -752,8 +752,118 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"EMPTY_STATE": {
"TITLE": "No custom tools available",
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "نعم، احذف",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "الوصف",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "لا شيء",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "مفتاح API"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "كلمة المرور",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "النوع"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "العدد",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "مطلوب"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
@@ -793,6 +903,10 @@
"APPROVED": "Approved",
"ALL": "الكل"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
"TITLE": "Add an FAQ",
@@ -823,13 +937,15 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
},
"OPTIONS": {
"APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete FAQ"
"APPROVE": "Approve",
"EDIT_RESPONSE": "تعديل",
"DELETE_RESPONSE": "حذف"
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -24,7 +24,7 @@
"CREATE_NEW_ACCOUNT": "إنشاء حساب جديد",
"SUBMIT": "تسجيل الدخول",
"SAML": {
"LABEL": "Log in via SSO",
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -51,6 +51,7 @@
},
"DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "آخر 7 أيام",
"LAST_14_DAYS": "آخر 14 يوماً",
"LAST_30_DAYS": "آخر 30 يوماً",
"LAST_3_MONTHS": "آخر 3 أشهر",
"LAST_6_MONTHS": "آخر 6 أشهر",
@@ -266,6 +267,8 @@
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_INBOX_REPORTS": "تحميل تقارير صندوق الوارد",
"FILTER_DROPDOWN_LABEL": "اختر صندوق الوارد",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "المحادثات",
@@ -467,6 +470,13 @@
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "تنزيل التقرير"
},
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "تنزيل التقرير"
},
"AGENT_CONVERSATIONS": {
"HEADER": "المحادثات من قبل الوكلاء",
"LOADING_MESSAGE": "جاري تحميل مقاييس الوكيل...",
@@ -304,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "الرئيسية",
"AGENTS": "وكيل الدعم",
"AGENT_BOTS": "الروبوتات",
@@ -27,15 +27,20 @@
"LABEL": "كلمة المرور",
"PLACEHOLDER": "كلمة المرور",
"ERROR": "كلمة المرور قصيرة جداً",
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد"
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "تأكيد كلمة المرور",
"PLACEHOLDER": "تأكيد كلمة المرور",
"ERROR": "كلمة المرور غير متطابقة"
"ERROR": "كلمة المرور غير متطابقة."
},
"API": {
"SUCCESS_MESSAGE": "تم التسجيل بنجاح",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"SUBMIT": "إرسال",
@@ -24,7 +24,7 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found for",
"NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
@@ -571,6 +571,16 @@
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldnt complete the search. Please try again."
@@ -227,6 +227,13 @@
"YES": "Send",
"CANCEL": "Cancel"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -605,8 +607,64 @@
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
@@ -618,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -752,8 +752,118 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"EMPTY_STATE": {
"TITLE": "No custom tools available",
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "Password",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Type"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "Number",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "Required"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
@@ -793,6 +903,10 @@
"APPROVED": "Approved",
"ALL": "All"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
"TITLE": "Add an FAQ",
@@ -823,13 +937,15 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
},
"OPTIONS": {
"APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete FAQ"
"APPROVE": "Approve",
"EDIT_RESPONSE": "Edit",
"DELETE_RESPONSE": "Delete"
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."

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