Chore/merge upstream 4.8.0 (#150)

* chore: Hide "Learn More" button in feature spotlight for self-hosted (#12675)

* 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

* chore: Update translations (#12625)

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

* 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="cad1d31d06">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="ca88ed7398">ca88ed7</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="adce3c22c6"><code>adce3c2</code></a>
release: v5.4.21</li>
<li><a
href="cad1d31d06"><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="ca88ed7398"><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>

* chore: Update translations (#12708)

* 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`

* 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

* fix: parameterize agent name (#12709)

* 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

* 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.

* 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"
/>

* 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

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

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

* 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

* chore: Update translations (#12722)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* 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.

* 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

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

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

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

* 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

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

* 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

* 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

* fix: run captain v2 outside the transaction (#12756)

* feat: Always process email content (#12734)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>

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

* 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

* 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

* 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.

* chore: Update translations (#12748)

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

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

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

* 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

* 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

* 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"
/>

* fix: Video bubble click and play issue (#12764)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>

* feat: Differentiate bot and user in the summary (#12801)

While generating the summary, use the appropriate sender type for the
message.

* fix: Invalid image URL issue in Help Center articles (#12806)

* feat: allow bots to handle campaigns when sender_id is nil (#12805)

* fix: Add empty line before signature in compose conversation editor (#12702)

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* feat: Enhance button interactions (#12738)

* fix: Remove the same account validation for whatsapp channels (#12811)

## Description

Modified the phone number validation in Whatsapp::ChannelCreationService
to check for duplicate phone numbers across ALL accounts, not just
within the current account.

## Type of change

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

## How Has This Been Tested?

- Added test coverage for cross-account phone number validation
- Using actual UI flow 
<img width="1493" height="532" alt="image"
src="https://github.com/user-attachments/assets/67d2bb99-2eb9-4115-8d56-449e4785e0d8"
/>


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

* feat: Update Captain navigation structure (#12761)

# Pull Request Template

## Description

This PR includes an update to the Captain navigation structure.

## Route Structure

```javascript
1. captain_assistants_responses_index    → /captain/:assistantId/faqs
2. captain_assistants_documents_index    → /captain/:assistantId/documents
3. captain_assistants_scenarios_index    → /captain/:assistantId/scenarios
4. captain_assistants_playground_index   → /captain/:assistantId/playground
5. captain_assistants_inboxes_index      → /captain/:assistantId/inboxes
6. captain_tools_index                   → /captain/tools
7. captain_assistants_settings_index     → /captain/:assistantId/settings
8. captain_assistants_guardrails_index   → /captain/:assistantId/settings/guardrails
9. captain_assistants_guidelines_index   → /captain/:assistantId/settings/guidelines
10. captain_assistants_index             → /captain/:navigationPath
```

**How it works:**

1. User clicks sidebar item → Routes to `captain_assistants_index` with
`navigationPath`
2. `AssistantsIndexPage` validates route and gets last active assistant,
if not redirects to assistant create page.
3. Routes to actual page: `/captain/:assistantId/:page`
4. Page loads with correct assistant context

Fixes
https://linear.app/chatwoot/issue/CW-5832/updating-captain-navigation

## Type of change

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

## How Has This Been Tested?




## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>

* fix: Handle login when there are no accounts (#12816)

* chore: Update translations (#12794)

* chore(docs): Fix typos in some files (#12817)

This PR fixes typos in the file file using codespell.

* refactor: strategy pattern for mailbox conversation finding (#12766)

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>

* fix: Issue with processing variables in outgoing email content (#12799)

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>

* fix: hide pdf citations in captain faq responses (#12839)

* fix: Use contact_id instead of sender_id for Instagram message locks (#12841)

Previously, the lock key for Instagram used sender_id, which for echo
messages (outgoing) would be the account's own ID. This caused all
outgoing messages to compete for the same lock, creating a bottleneck
during bulk messaging.

The fix introduces contact_instagram_id method that correctly identifies
the contact's ID regardless of message direction:
- For echo messages (outgoing): uses recipient.id (the contact)
- For incoming messages: uses sender.id (the contact)

This ensures each conversation has a unique lock, allowing parallel
processing of webhooks while maintaining race condition protection
within individual conversations.

Fixes lock acquisition errors in Sidekiq when processing bulk Instagram
messages.

Fixes
https://linear.app/chatwoot/issue/CW-5931/p0-mutexapplicationjoblockacquisitionerror-failed-to-acquire-lock-for

## Type of change

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

* fix: label tags for contactable inboxes (#12838)

* chore: Improve captain layout (#12820)

* feat: allow selecting month range in overview reports (#12701)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>

* fix: respect status parameter when creating articles via API (#12846)

## Description

The Articles API was ignoring the `status` parameter when creating new
articles. All articles were forced to be drafts due to a hardcoded
`@article.draft!` call in the controller, even when users explicitly
sent `status: 1` (published) in their API request.

This PR removes the hardcoded draft enforcement and allows the status
parameter to be respected while maintaining backward compatibility.

Fixes #12063

## Type of change

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

## How Has This Been Tested?

**Before:**
- API POST with `status: 1` → Created as draft (ignored parameter)
- API POST without status → Created as draft

**After:**
- API POST with `status: 1` → Created as published 
- API POST without status → Created as draft (backward compatible) 
- UI creates articles → Still creates as draft (UI doesn't send status)


**Tests run:**
```bash
bundle exec rspec spec/controllers/api/v1/accounts/articles_controller_spec.rb
# 17 examples, 0 failures
```

Updated tests:
1. Changed 2 existing tests that were verifying the broken behavior
(expecting draft when published was sent)
2. Added new test to verify articles default to draft when status is not
provided
3. All existing tests pass, confirming backward compatibility

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [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

Co-authored-by: Sojan Jose <sojan@pepalo.com>

* feat: allow querying reporting events via the API (#12832)

* feat(webhooks): add name to webhook (#12641)

## Description

When working with webhooks, it's easy to lose track of which URL is
which. Adding a `name` (optional) column to the webhook model is a
straight-forward solution to make it significantly easier to identify
webhooks.

## Type of change

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

## How Has This Been Tested?

Model and controller specs, and also running in production over several
months without any issues.

| Before | After |
| --- | --- |
| <img width="949" height="990" alt="image copy 3"
src="https://github.com/user-attachments/assets/6b33c072-7d16-4a9c-a129-f9c0751299f5"
/> | <img width="806" height="941" alt="image"
src="https://github.com/user-attachments/assets/77f3cb3a-2eb0-41ac-95bf-d02915589690"
/> |
| <img width="1231" height="650" alt="image copy 2"
src="https://github.com/user-attachments/assets/583374af-96e0-4436-b026-4ce79b7f9321"
/> | <img width="1252" height="650" alt="image copy"
src="https://github.com/user-attachments/assets/aa81fb31-fd18-4e21-a40e-d8ab0dc76b4e"
/> |


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

* perf: speed up docker builds (#12859)

- Use separate keys to avoid cache overwrites across different
architecture builds


https://linear.app/chatwoot/issue/CW-5945/perf-speed-up-docker-builds

### 25 mins  ---> 5mins


## before

<img width="971" height="452" alt="image"
src="https://github.com/user-attachments/assets/535cebd6-6c16-48d1-a62d-ffb6f2fc9b08"
/>


## after
<img width="940" height="428" alt="image"
src="https://github.com/user-attachments/assets/359eb313-4bb5-4e0e-9492-a8ad48645159"
/>

* chore: Update missing places with new colors (#12862)

# Pull Request Template

## Description

This PR updates the colors in places that were missed during the color
update migration.

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

* fix: Brand installation name not showing (#12861)

# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5946/fix-brand-installation-name-issue-in-dyte

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

* fix: migrate from deprecated annotate gem to annotaterb (#12845)

## Description

The `annotate` gem has been deprecated and users are experiencing
annotation errors with the new Rails 7 `serialize` syntax. This PR
migrates to `annotaterb`, the actively maintained fork.

Users reported errors when running `make db`:
```
Unable to annotate app/models/installation_config.rb: no implicit conversion of Hash into String  
Unable to annotate app/models/installation_config.rb: no implicit conversion of nil into Array
```

This PR updates the Gemfile and rake configuration to use `annotaterb`
instead.

Fixes #11673

## Type of change

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

## How Has This Been Tested?

Tested locally with the following steps:
1. Run `bundle install` - successfully installed annotaterb 4.20.0
2. Run `RAILS_ENV=development bundle exec rails db:chatwoot_prepare` -
completed without annotation errors
3. Run `RAILS_ENV=development bundle exec rails annotate_rb:models` -
successfully annotated all models including InstallationConfig
4. Verified InstallationConfig model annotations are present and correct

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes

* chore: disable worker MemoryHigh throttling in systemd unit (#12871)

- set MemoryHigh to infinity in deployment/chatwoot-worker.1.service so
the worker is throttled only by the existing
    MemoryMax hard limit
- prevents cgroup reclaim from slowing Sidekiq under transient spikes
while still keeping the hard stop at 1.5 GB

* chore: Update translations (#12818)

* fix: revert annotaterb migration due to persistent annotation errors (#12881)

## Description

This PR reverts the migration from the `annotate` gem to `annotaterb`
introduced in PR #12845. The annotation errors reported in #11673
persist with both gems, and the old `annotate` gem handles the errors
more gracefully by continuing to process other models instead of
crashing.

**Testing reveals both gems fail with the same underlying issue:**

**Old annotate gem (3.2.0):**
```
Unable to annotate app/models/installation_config.rb: no implicit conversion of Hash into String
Unable to annotate app/models/installation_config.rb: no implicit conversion of nil into Array
Model files unchanged.
```
(Logs error but continues processing)

**New annotaterb gem (4.20.0):**
```
❯ bundle exec annotaterb models
ruby/3.4.4/lib/ruby/gems/3.4.0/gems/reline-0.3.6/lib/reline/terminfo.rb:2: warning: ruby/3.4.4/lib/ruby/3.4.0/fiddle.rb was loaded from the standard library, but will no longer be part of the default gems starting from Ruby 3.5.0.
You can add fiddle to your Gemfile or gemspec to silence this warning.
Also please contact the author of reline-0.3.6 to request adding fiddle into its gemspec.
Annotating models
bundler: failed to load command: annotaterb (ruby/3.4.4/bin/annotaterb)
ruby/3.4.4/lib/ruby/3.4.0/psych/parser.rb:62:in 'Psych::Parser#_native_parse': no implicit conversion of Hash into String (TypeError)

      _native_parse @handler, yaml, path
                    ^^^^^^^^^^^^^^^^^^^^
        from ruby/3.4.4/lib/ruby/3.4.0/psych/parser.rb:62:in 'Psych::Parser#parse'
        from ruby/3.4.4/lib/ruby/3.4.0/psych.rb:457:in 'Psych.parse_stream'
        from ruby/3.4.4/lib/ruby/3.4.0/psych.rb:401:in 'Psych.parse'
        from ruby/3.4.4/lib/ruby/3.4.0/psych.rb:325:in 'Psych.safe_load'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activerecord-7.1.5.2/lib/active_record/coders/yaml_column.rb:37:in 'ActiveRecord::Coders::YAMLColumn::SafeCoder#load'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activerecord-7.1.5.2/lib/active_record/coders/column_serializer.rb:37:in 'ActiveRecord::Coders::ColumnSerializer#load'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activerecord-7.1.5.2/lib/active_record/type/serialized.rb:22:in 'ActiveRecord::Type::Serialized#deserialize'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activemodel-7.1.5.2/lib/active_model/attribute.rb:175:in 'ActiveModel::Attribute::FromDatabase#type_cast'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activemodel-7.1.5.2/lib/active_model/attribute.rb:43:in 'ActiveModel::Attribute#value'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activemodel-7.1.5.2/lib/active_model/attribute_set.rb:37:in 'block in ActiveModel::AttributeSet#to_hash'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activesupport-7.1.5.2/lib/active_support/core_ext/enumerable.rb:78:in 'block in Enumerable#index_with'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activesupport-7.1.5.2/lib/active_support/core_ext/enumerable.rb:78:in 'Array#each'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activesupport-7.1.5.2/lib/active_support/core_ext/enumerable.rb:78:in 'Enumerable#index_with'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activemodel-7.1.5.2/lib/active_model/attribute_set.rb:37:in 'ActiveModel::AttributeSet#to_hash'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/activerecord-7.1.5.2/lib/active_record/model_schema.rb:499:in 'ActiveRecord::ModelSchema::ClassMethods#column_defaults'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/model_wrapper.rb:68:in 'AnnotateRb::ModelAnnotator::ModelWrapper#column_defaults'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/model_wrapper.rb:139:in 'block in AnnotateRb::ModelAnnotator::ModelWrapper#built_attributes'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/model_wrapper.rb:136:in 'Array#map'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/model_wrapper.rb:136:in 'AnnotateRb::ModelAnnotator::ModelWrapper#built_attributes'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/column_annotation/annotation_builder.rb:15:in 'AnnotateRb::ModelAnnotator::ColumnAnnotation::AnnotationBuilder#build'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb:52:in 'block in AnnotateRb::ModelAnnotator::Annotation::AnnotationBuilder::Annotation#columns'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb:51:in 'Array#map'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb:51:in 'AnnotateRb::ModelAnnotator::Annotation::AnnotationBuilder::Annotation#columns'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb:26:in 'AnnotateRb::ModelAnnotator::Annotation::AnnotationBuilder::Annotation#body'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb:35:in 'AnnotateRb::ModelAnnotator::Annotation::AnnotationBuilder::Annotation#build'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotation/annotation_builder.rb:71:in 'AnnotateRb::ModelAnnotator::Annotation::AnnotationBuilder#build'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/project_annotator.rb:43:in 'AnnotateRb::ModelAnnotator::ProjectAnnotator#build_instructions_for_file'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/project_annotator.rb:17:in 'block in AnnotateRb::ModelAnnotator::ProjectAnnotator#annotate'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/project_annotator.rb:13:in 'Array#map'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/project_annotator.rb:13:in 'AnnotateRb::ModelAnnotator::ProjectAnnotator#annotate'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotator.rb:21:in 'AnnotateRb::ModelAnnotator::Annotator#do_annotations'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/model_annotator/annotator.rb:8:in 'AnnotateRb::ModelAnnotator::Annotator.do_annotations'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/commands/annotate_models.rb:17:in 'AnnotateRb::Commands::AnnotateModels#call'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/runner.rb:38:in 'AnnotateRb::Runner#run'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/lib/annotate_rb/runner.rb:11:in 'AnnotateRb::Runner.run'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/annotaterb-4.20.0/exe/annotaterb:18:in '<top (required)>'
        from ruby/3.4.4/bin/annotaterb:25:in 'Kernel#load'
        from ruby/3.4.4/bin/annotaterb:25:in '<top (required)>'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/cli/exec.rb:58:in 'Kernel.load'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/cli/exec.rb:58:in 'Bundler::CLI::Exec#kernel_load'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/cli/exec.rb:23:in 'Bundler::CLI::Exec#run'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/cli.rb:455:in 'Bundler::CLI#exec'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/vendor/thor/lib/thor/command.rb:28:in 'Bundler::Thor::Command#run'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/vendor/thor/lib/thor/invocation.rb:127:in 'Bundler::Thor::Invocation#invoke_command'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/vendor/thor/lib/thor.rb:527:in 'Bundler::Thor.dispatch'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/cli.rb:35:in 'Bundler::CLI.dispatch'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/vendor/thor/lib/thor/base.rb:584:in 'Bundler::Thor::Base::ClassMethods#start'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/cli.rb:29:in 'Bundler::CLI.start'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/exe/bundle:28:in 'block in <top (required)>'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/friendly_errors.rb:117:in 'Bundler.with_friendly_errors'
        from ruby/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/exe/bundle:20:in '<top (required)>'
        from ruby/3.4.4/bin/bundle:25:in 'Kernel#load'
        from ruby/3.4.4/bin/bundle:25:in '<main>'


```
(Crashes immediately, stops all processing)

**Root cause:** The `InstallationConfig` model uses YAML serialization
(`serialize :serialized_value, coder: YAML`) on a JSONB database column.
When annotation tools read column defaults, PostgreSQL returns JSONB as
a Hash, but YAML expects a String, causing the type error.

The migration to annotaterb doesn't solve the problem - both gems
encounter the same error. The old gem is preferable as it continues
working despite the error.

Reverts #12845
Related to #11673

## Type of change

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

## How Has This Been Tested?

1. Reverted commit 559d1b657
2. Ran `bundle install` to reinstall annotate gem v3.2.0
3. Ran `RAILS_ENV=development bundle exec annotate` 
- Result: Logs errors for InstallationConfig but completes successfully
4. Re-applied the annotaterb changes and tested `bundle exec annotaterb
models`
   - Result: Crashes with full stack trace and stops processing

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] New and existing unit tests pass locally with my changes


---
*Edited to truncate environment-specific info from error dump*

* chore: Hide assistant switcher on paywall screen (#12875)

* feat: Assignment service (v2) (#12320)

## Linear Link

 
## Description

This PR introduces a new robust auto-assignment system for conversations
in Chatwoot. The system replaces the existing round-robin assignment
with a more sophisticated service-based architecture that supports
multiple assignment strategies, rate limiting, and Enterprise features
like capacity-based assignment and balanced distribution.

## Type of change

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

## How Has This Been Tested?

- Unit test cases
- Test conversations getting assigned on status change to open
- Test the job directly via rails console

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds a new service-based auto-assignment system with scheduled jobs,
rate limiting, enterprise capacity/balanced selection, and wiring via
inbox/handler; includes Redis helpers and comprehensive tests.
> 
> - **Auto-assignment v2 (core services)**:
> - Add `AutoAssignment::AssignmentService` with bulk assignment,
configurable conversation priority, RR selection, and per-agent rate
limiting via `AutoAssignment::RateLimiter`.
>   - Add `AutoAssignment::RoundRobinSelector` for agent selection.
> - **Jobs & scheduling**:
> - Add `AutoAssignment::AssignmentJob` (per-inbox bulk assign;
env-based limit) and `AutoAssignment::PeriodicAssignmentJob` (batch over
accounts/inboxes).
> - Schedule periodic run in `config/schedule.yml`
(`periodic_assignment_job`).
> - **Model/concerns wiring**:
> - Include `InboxAgentAvailability` in `Inbox`; add
`Inbox#auto_assignment_v2_enabled?`.
> - Update `AutoAssignmentHandler` to trigger v2 job when
`auto_assignment_v2_enabled?`, else fallback to legacy.
> - **Enterprise extensions**:
> - Add `Enterprise::InboxAgentAvailability` (capacity-aware filtering)
and `Enterprise::Concerns::Inbox` association `inbox_capacity_limits`.
> - Extend service via `Enterprise::AutoAssignment::AssignmentService`
(policy-driven config, capacity filtering, exclusion rules) and add
selectors/services: `BalancedSelector`, `CapacityService`.
> - **Infrastructure**:
> - Enhance `Redis::Alfred` with `expire`, key scan/count, and extended
ZSET helpers (`zadd`, `zcount`, `zcard`, `zrangebyscore`).
> - **Tests**:
> - Add specs for jobs, core service, rate limiter, RR selector, and
enterprise features (capacity, balanced selection, exclusions).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0ebe187c8aea73765b0122a44b18d6f465c2477f. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* fix: Change contact_inboxes.source_id to text column (#12882)

## Description

Fixes CW-5961 where IMAP email processing failed with
`ActiveRecord::RecordInvalid: Validation failed: Source is too long
(maximum is 255 characters)` error.

This changes the `contact_inboxes.source_id` column from `string` (255
character limit) to `text` (unlimited) to accommodate long email message
IDs that were causing validation failures.

Fixes CW-5961

## Type of change

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

## How Has This Been Tested?

- Added spec test validating `source_id` values longer than 255
characters (300 chars)
- All existing `contact_inbox_spec.rb` tests pass (7 examples, 0
failures)
- Migration applied successfully with reversible up/down methods
- Verified `source_id` column type changed to `text` with `null: false`
constraint preserved

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [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

* feat: allow configuring attachment upload limit (#12835)

## Summary
- add a configurable MAXIMUM_FILE_UPLOAD_SIZE installation setting and
surface it through super admin and global config payloads
- apply the configurable limit to attachment validations and shared
upload helpers on dashboard and widget
- introduce a reusable helper with unit tests for parsing the limit and
extend attachment specs for configurability


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_6912644786b08326bc8dee9401af6d0a)

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>

* feat: Customizable webhook timeout configuration (#12777)

## Summary
- Ability to configure the webhook timeout for Chatwoot self hosted
installations

fixes: https://github.com/chatwoot/chatwoot/issues/12754

* feat: Control the allowed login methods via Super Admin (#12892)

- Control the allowed authentication methods for a chatwoot installation
via super admin configs. [SAML, Google Auth etc]
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_6917d503b6e48326a261672c1de91462)

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* chore: Update translations (#12876)

* feat: Backend - Companies API endpoint with pagination and search (#12840)

## Description

Adds API endpoint to list companies with pagination, search, and
sorting.

Fixes
https://linear.app/chatwoot/issue/CW-5930/add-backend-routes-to-get-companies-result
Parent issue:
https://linear.app/chatwoot/issue/CW-5928/add-companies-tab-to-dashboard

## Type of change

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

## How Has This Been Tested?

Added comprehensive specs to
`spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`:
- Pagination (25 per page, multiple pages)
- Search by name and domain (case-insensitive)
- Counter cache for contacts_count
- Account scoping
- Authorization

To reproduce:
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
bundle exec rubocop enterprise/app/controllers/api/v1/accounts/companies_controller.rb
```

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

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>

* feat: Companies page (#12842)

# Pull Request Template

## Description

This PR introduces a new Companies section in the Chatwoot dashboard. It
lists all companies associated with the account and includes features
such as **search**, **sorting**, and **pagination** to enable easier
navigation and efficient management.

Fixes
https://linear.app/chatwoot/issue/CW-5928/add-companies-tab-to-dashboard

## Type of change

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

## How Has This Been Tested?

### Screenshot
<img width="1619" height="1200" alt="image"
src="https://github.com/user-attachments/assets/21f0a666-c3d6-4dec-bd02-1e38e0cd9542"
/>



## 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: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* feat: Add Amazon SES inbound email support (#12893)

## Summary
- add AWS ActionMailbox SES gems
- document SES as incoming email provider
- note SES option in configuration

## Testing
- `bundle exec rubocop config/initializers/mailer.rb
config/environments/production.rb Gemfile`


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_68bbb7d482288326b8f04bb795af0322)

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>

* feat: hide email forwarding address if INBOUND_EMAIL_DOMAIN is not configured (#12768)

#### Summary

- Improved email inbox setup flow to handle cases where inbound email
forwarding is not configured on the installation
- Added conditional display of email forwarding address based on
MAILER_INBOUND_EMAIL_DOMAIN environment variable availability
- Enhanced user messaging to guide users toward configuring SMTP/IMAP
settings when forwarding is unavailable

#### Changes

**Backend (app/views/api/v1/models/_inbox.json.jbuilder)**
- Added forwarding_enabled boolean flag to inbox API response based on
MAILER_INBOUND_EMAIL_DOMAIN presence
- Made forward_to_email conditional - only included when forwarding is
enabled

  **Frontend - Inbox Creation Flow**
- Created new EmailInboxFinish.vue component to handle email inbox setup
completion
  - Shows different messages based on whether forwarding is enabled:
- With forwarding: displays forwarding address and encourages SMTP/IMAP
configuration
- Without forwarding: warns that SMTP/IMAP configuration is required for
emails to be processed
- Added link to configuration page for easy access to SMTP/IMAP settings

<img width="988" height="312" alt="Screenshot 2025-11-18 at 3 27 27 PM"
src="https://github.com/user-attachments/assets/928aff78-df73-49fa-9a26-dbbd1297b26a"
/>

<img width="765" height="489" alt="Screenshot 2025-11-18 at 3 24 46 PM"
src="https://github.com/user-attachments/assets/6a182c7d-087f-4e88-92a5-30f147a567a7"
/>


Fixes
https://linear.app/chatwoot/issue/CW-5881/hide-forwaring-email-section-if-inbound-email-domain-is-not-configured


## Type of change

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

## How Has This Been Tested?

- Tested locally

## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>

* feat: APIs to assign agents_bots as assignee in conversations (#12836)

## Summary
- add an assignee_agent_bot_id column as an initital step to prototype
this before fully switching to polymorphic assignee
- update assignment APIs and conversation list / show endpoints to
reflect assignee as agent bot
- ensure webhook payloads contains agent bot assignee


[Codex
Task](https://chatgpt.com/codex/tasks/task_e_6912833377e48326b6641b9eee32d50f)

---------

Co-authored-by: Pranav <pranav@chatwoot.com>

* Bump version to 4.8.0

* chore: remove migration

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Chatwoot Bot <92152627+chatwoot-bot@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Co-authored-by: Lê Nam Khánh <55955273+khanhkhanhlele@users.noreply.github.com>
This commit is contained in:
Gabriel Jablonski 2025-11-19 16:25:58 -03:00 committed by GitHub
parent 7f0748460e
commit b03dfdb751
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
861 changed files with 39544 additions and 9519 deletions

View File

@ -105,6 +105,7 @@ MAILER_INBOUND_EMAIL_DOMAIN=
# mandrill for Mandrill # mandrill for Mandrill
# postmark for Postmark # postmark for Postmark
# sendgrid for Sendgrid # sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE= RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service # Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
@ -114,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY= MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY= MANDRILL_INGRESS_API_KEY=
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
# Configure only if the rails_inbound_email_service = ses
ACTION_MAILBOX_SES_SNS_TOPIC=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format: # Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
@ -256,6 +261,8 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance ## Change these values to fine tune performance
# control the concurrency setting of sidekiq # control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10 # SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features # AI powered features

View File

@ -36,5 +36,5 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false
load: false load: false
cache-from: type=gha cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=${{ matrix.platform }}

View File

@ -21,6 +21,7 @@ gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
@ -54,6 +55,9 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing' gem 'image_processing'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'pg' gem 'pg'

View File

@ -136,9 +136,13 @@ GEM
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.760.0) aws-partitions (1.760.0)
aws-sdk-core (3.171.1) aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
@ -146,10 +150,13 @@ GEM
aws-sdk-kms (1.64.0) aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.122.0) aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.5.2) aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9) barnes (0.0.9)
@ -270,6 +277,7 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
http (>= 3.0) http (>= 3.0)
ruby2_keywords ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
@ -594,7 +602,7 @@ GEM
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.3) omniauth (2.1.4)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger logger
rack (>= 2.2.3) rack (>= 2.2.3)
@ -653,7 +661,7 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (4.1.1) rack-protection (4.2.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
@ -996,6 +1004,7 @@ DEPENDENCIES
annotate annotate
attr_extras attr_extras
audited (~> 5.4, >= 5.4.1) audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
aws-sdk-s3 aws-sdk-s3
azure-storage-blob! azure-storage-blob!
barnes barnes
@ -1018,6 +1027,7 @@ DEPENDENCIES
dotenv-rails (>= 3.0.0) dotenv-rails (>= 3.0.0)
down down
elastic-apm elastic-apm
email-provider-info
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails (>= 6.4.3) factory_bot_rails (>= 6.4.3)

View File

@ -36,6 +36,10 @@
"REDIS_OPENSSL_VERIFY_MODE":{ "REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none" "value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
} }
}, },
"formation": { "formation": {

View File

@ -1,5 +1,8 @@
class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
include ::FileTypeHelper include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity def initialize(user, conversation, params) # rubocop:disable Metrics/CyclomaticComplexity
@ -25,7 +28,7 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
process_emails process_emails
# When the message has no quoted content, it will just be rendered as a regular message # When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case # The frontend is equipped to handle this case
process_email_content if @account.feature_enabled?(:quoted_email_reply) process_email_content
@message.save! @message.save!
@message @message
end end
@ -40,30 +43,12 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
params = convert_to_hash(@params) params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {}) content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String) return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash) return content_attributes if content_attributes.is_a?(Hash)
{} {}
end end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments def process_attachments
return if @attachments.blank? return if @attachments.blank?
@ -128,12 +113,6 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
email_string.gsub(/\s+/, '').split(',') email_string.gsub(/\s+/, '').split(',')
end end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@ -197,14 +176,17 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content) normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content # Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided? email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content build_custom_html_content
else else
build_html_content(normalized_content) build_html_content(processed_content)
end end
email_attributes[:text_content] = build_text_content(normalized_content) email_attributes[:text_content] = build_text_content(processed_content)
email_attributes email_attributes
end end
@ -223,22 +205,6 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
text_content text_content
end 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? def custom_email_content_provided?
@params[:email_html_content].present? @params[:email_html_content].present?
end end
@ -251,4 +217,27 @@ class Messages::MessageBuilder # rubocop:disable Metrics/ClassLength
html_content html_content
end end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end end

View File

@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
def load_data def load_data
@conversations_count = fetch_conversations_count @conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count load_reporting_events_data
@avg_resolution_time = fetch_average_time('conversation_resolved') end
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time') 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 end
def reporting_events def reporting_events
@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method # Override this method
end 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 def group_by_key
# Override this method # Override this method
end end
@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
# Override this method # Override this method
end end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end end

View File

@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def load_data def load_data
@conversations_count = fetch_conversations_count @conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count load_reporting_events_data
@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')
end end
def fetch_conversations_count def fetch_conversations_count

View File

@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :agent_bot, except: [:index, :create] before_action :agent_bot, except: [:index, :create]
def index def index
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id]) @agent_bots = AgentBot.accessible_to(Current.account)
end end
def show; end def show; end
@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
private private
def agent_bot def agent_bot
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show' @agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id]) @agent_bot ||= Current.account.agent_bots.find(params[:id])
end end

View File

@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end def edit; end
def create def create
@article = @portal.articles.create!(article_params) params_with_defaults = article_params
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article.associate_root_article(article_params[:associated_article_id]) @article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end

View File

@ -1,13 +1,12 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create def create
if type_matches? case normalized_type
::BulkActionsJob.perform_later( when 'Conversation'
account: @current_account, enqueue_conversation_job
user: current_user, head :ok
params: permitted_params when 'Contact'
) check_authorization_for_contact_action
enqueue_contact_job
head :ok head :ok
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private private
def type_matches? def normalized_type
['Conversation'].include?(params[:type]) params[:type].to_s.camelize
end end
def permitted_params def enqueue_conversation_job
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) ::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
end end

View File

@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation # assigns agent/team to a conversation
def create def create
if params.key?(:assignee_id) if params.key?(:assignee_id) || agent_bot_assignment?
set_agent set_agent
elsif params.key?(:team_id) elsif params.key?(:team_id)
set_team set_team
@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private private
def set_agent def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id]) resource = Conversations::AssignmentService.new(
@conversation.assignee = @agent conversation: @conversation,
@conversation.save! assignee_id: params[:assignee_id],
render_agent assignee_type: params[:assignee_type]
).perform
render_agent(resource)
end end
def render_agent def render_agent(resource)
if @agent.nil? case resource
render json: nil when User
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
else else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent } render json: nil
end end
end end
@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team) @conversation.update!(team: @team)
render json: @team render json: @team
end end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
end
end end

View File

@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation.inbox, :show? authorize @conversation, :show?
end end
end end

View File

@ -164,7 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation.inbox, :show? authorize @conversation, :show?
end end
def inbox def inbox

View File

@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
private private
def authorize_request def authorize_request
authorize @conversation.inbox, :show? authorize @conversation, :show?
end end
def render_response(response) def render_response(response)

View File

@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController
private private
def set_global_config def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') @global_config = GlobalConfig.get(
'LOGO_THUMBNAIL',
'BRAND_NAME',
'WIDGET_BRAND_URL',
'MAXIMUM_FILE_UPLOAD_SIZE',
'INSTALLATION_NAME'
)
end end
def set_contact def set_contact

View File

@ -14,6 +14,7 @@ module AccessTokenAuthHelper
ensure_access_token ensure_access_token
render_unauthorized('Invalid Access Token') && return if @access_token.blank? 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 @resource = @access_token.owner
Current.user = @resource if allowed_current_user_type?(@resource) Current.user = @resource if allowed_current_user_type?(@resource)
end end

View File

@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper
end end
def account_accessible_for_bot?(account) def account_accessible_for_bot?(account)
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) return if @resource.account_id == account.id
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
render_unauthorized('Bot is not authorized to access this account')
end end
end end

View File

@ -1,6 +1,31 @@
class DashboardController < ActionController::Base class DashboardController < ActionController::Base
include SwitchLocale include SwitchLocale
GLOBAL_CONFIG_KEYS = %w[
LOGO
LOGO_DARK
LOGO_THUMBNAIL
INSTALLATION_NAME
WIDGET_BRAND_URL
TERMS_URL
BRAND_URL
BRAND_NAME
PRIVACY_URL
DISPLAY_MANIFEST
CREATE_NEW_ACCOUNT_FROM_DASHBOARD
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
LOGOUT_REDIRECT_LINK
DISABLE_USER_PROFILE_UPDATE
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
].freeze
before_action :set_application_pack before_action :set_application_pack
before_action :set_global_config before_action :set_global_config
before_action :set_dashboard_scripts before_action :set_dashboard_scripts
@ -19,25 +44,7 @@ class DashboardController < ActionController::Base
end end
def set_global_config def set_global_config
@global_config = GlobalConfig.get( @global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config)
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'TERMS_URL',
'BRAND_URL', 'BRAND_NAME',
'PRIVACY_URL',
'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
'CHATWOOT_INBOX_TOKEN',
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN'
).merge(app_config)
end end
def set_dashboard_scripts def set_dashboard_scripts
@ -71,10 +78,18 @@ class DashboardController < ActionController::Base
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods
} }
end end
def allowed_login_methods
methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false'
methods
end
def set_application_pack def set_application_pack
@application_pack = if request.path.include?('/auth') || request.path.include?('/login') @application_pack = if request.path.include?('/auth') || request.path.include?('/login')
'v3app' 'v3app'

View File

@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end end
def create def create
errors = []
params['app_config'].each do |key, value| params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key) next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create!(value: value, locked: false) i = InstallationConfig.where(name: key).first_or_create!(value: value, locked: false)
i.value = value i.value = value
i.save! errors.concat(i.errors.full_messages) unless i.save
end
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end end
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end end
private private
@ -42,10 +48,13 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI] 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
} }
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) @allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
)
end end
end end

View File

@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base
private private
def set_global_config def set_global_config
@global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME') @global_config = GlobalConfig.get(
'LOGO_THUMBNAIL',
'BRAND_NAME',
'WIDGET_BRAND_URL',
'DIRECT_UPLOADS_ENABLED',
'MAXIMUM_FILE_UPLOAD_SIZE',
'INSTALLATION_NAME'
)
end end
def set_web_widget def set_web_widget

View File

@ -0,0 +1,24 @@
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
# This module contains helper methods for converting between different data types,
# normalizing hashes, and safely handling JSON operations.
module DataHelper
# Ensures a hash supports indifferent access (string or symbol keys).
# Returns an empty hash if the input is blank.
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
def safe_parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
end

View File

@ -4,6 +4,19 @@ module EmailHelper
domain.split('.').first domain.split('.').first
end end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
def validate_email_addresses(emails_to_test)
emails_to_test&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
# ref: https://www.rfc-editor.org/rfc/rfc5233.html # ref: https://www.rfc-editor.org/rfc/rfc5233.html
# This is not a mandatory requirement for email addresses, but it is a common practice. # This is not a mandatory requirement for email addresses, but it is a common practice.
# john+test@xyc.com is the same as john@xyc.com # john+test@xyc.com is the same as john@xyc.com
@ -21,6 +34,10 @@ module EmailHelper
end end
end end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def modified_liquid_content(email) def modified_liquid_content(email)
# This regex is used to match the code blocks in the content # This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks # We don't want to process liquid in code blocks
@ -29,7 +46,10 @@ module EmailHelper
def message_drops(conversation) def message_drops(conversation)
{ {
'contact' => ContactDrop.new(conversation.contact) 'contact' => ContactDrop.new(conversation.contact),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(conversation.inbox),
'account' => AccountDrop.new(conversation.account)
} }
end end
end end

View File

@ -9,6 +9,13 @@ captain:
icon: 'icon-captain' icon: 'icon-captain'
config_key: 'captain' config_key: 'captain'
enterprise: true enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
enabled: <%= ChatwootApp.enterprise? %>
icon: 'icon-lock-line'
config_key: 'saml'
enterprise: true
custom_branding: custom_branding:
name: 'Custom Branding' name: 'Custom Branding'
description: 'Apply your own branding to this installation.' description: 'Apply your own branding to this installation.'

View File

@ -1,6 +1,5 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
@ -25,7 +24,6 @@ export default {
name: 'App', name: 'App',
components: { components: {
AddAccountModal,
LoadingState, LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
@ -51,7 +49,6 @@ export default {
}, },
data() { data() {
return { return {
showAddAccountModal: false,
latestChatwootVersion: null, latestChatwootVersion: null,
reconnectService: null, reconnectService: null,
}; };
@ -64,21 +61,12 @@ export default {
authUIFlags: 'getAuthUIFlags', authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags', accountUIFlags: 'accounts/getUIFlags',
}), }),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
hideOnOnboardingView() { hideOnOnboardingView() {
return !isOnOnboardingView(this.$route); return !isOnOnboardingView(this.$route);
}, },
}, },
watch: { watch: {
currentUser() {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
},
currentAccountId: { currentAccountId: {
immediate: true, immediate: true,
handler() { handler() {
@ -156,7 +144,6 @@ export default {
<component :is="Component" /> <component :is="Component" />
</transition> </transition>
</router-view> </router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox /> <WootSnackbarBox />
<NetworkNotification /> <NetworkNotification />
</div> </div>

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();

View File

@ -0,0 +1,37 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
class CompanyAPI extends ApiClient {
constructor() {
super('companies', { accountScoped: true });
}
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
return axios.get(requestURL);
}
}
export default new CompanyAPI();

View File

@ -63,10 +63,9 @@ class ConversationApi extends ApiClient {
} }
assignAgent({ conversationId, agentId }) { assignAgent({ conversationId, agentId }) {
return axios.post( return axios.post(`${this.url}/${conversationId}/assignments`, {
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`, assignee_id: agentId,
{} });
);
} }
assignTeam({ conversationId, teamId }) { assignTeam({ conversationId, teamId }) {

View File

@ -0,0 +1,142 @@
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
it('creates correct instance', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#get with default params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=2&sort=domain'
);
});
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
});
});

View File

@ -92,8 +92,10 @@ describe('#ConversationAPI', () => {
it('#assignAgent', () => { it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith( expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments?assignee_id=34`, `/api/v1/conversations/12/assignments`,
{} {
assignee_id: 34,
}
); );
}); });

View File

@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow } from 'date-fns';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
updatedAt: { type: [String, Number], default: null },
});
const emit = defineEmits(['showCompany']);
const { t } = useI18n();
const onClickViewDetails = () => emit('showCompany', props.id);
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
});
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:username="displayName"
:src="avatarSource"
class="shrink-0"
:name="name"
:size="48"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<span class="text-base font-medium truncate text-n-slate-12">
{{ displayName }}
</span>
<span
v-if="domain && description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<span
v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { count: contactsCount }) }}
</span>
</div>
<span
v-if="updatedAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedUpdatedAt }}
</span>
</div>
</div>
</div>
</CardLayout>
</template>

View File

@ -0,0 +1,55 @@
<script setup>
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
</div>
</div>
</header>
</template>

View File

@ -0,0 +1,116 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'name',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('COMPANIES.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.DOMAIN'),
value: 'domain',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('COMPANIES.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('COMPANIES.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return selectedMenu?.label || t('COMPANIES.SORT_BY.LABEL');
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('COMPANIES.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('COMPANIES.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('COMPANIES.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
import CompanyHeader from './CompaniesHeader/CompanyHeader.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
currentPage: { type: Number, default: 1 },
totalItems: { type: Number, default: 100 },
activeSort: { type: String, default: 'name' },
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
:search-value="searchValue"
:header-title="headerTitle"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<slot name="default" />
</div>
</main>
<footer class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
@update:current-page="updateCurrentPage"
/>
</footer>
</div>
</section>
</template>

View File

@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({ defineProps({
selectedContact: { selectedContact: {
@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
</script> </script>
<template> <template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5"> <Policy :permissions="['administrator']">
<Button <div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')" <Button
sm :label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
link sm
slate link
class="hover:!no-underline text-n-slate-12" slate
icon="i-lucide-chevron-down" class="hover:!no-underline text-n-slate-12"
trailing-icon icon="i-lucide-chevron-down"
@click="toggleDeleteSection()" trailing-icon
/> @click="toggleDeleteSection()"
/>
<div <div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden" class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class=" :class="
showDeleteSection showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2' ? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0' : 'grid-rows-[0fr] opacity-0 mt-0'
" "
> >
<div class="overflow-hidden min-h-0"> <div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1"> <span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }} {{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button <Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')" :label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm sm
ruby ruby
link link
@click="openConfirmDeleteContactDialog()" @click="openConfirmDeleteContactDialog()"
/> />
</span> </span>
</div>
</div> </div>
</div> </div>
</div> <ConfirmContactDeleteDialog
<ConfirmContactDeleteDialog ref="confirmDeleteContactDialogRef"
ref="confirmDeleteContactDialogRef" :selected-contact="selectedContact"
:selected-contact="selectedContact" />
/> </Policy>
</template> </template>

View File

@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.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'; import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null }, availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { 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(); const { t } = useI18n();
@ -88,111 +97,148 @@ const onClickExpand = () => {
}; };
const onClickViewDetails = () => emit('showContact', props.id); const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script> </script>
<template> <template>
<CardLayout :key="id" layout="row"> <div class="relative">
<div class="flex items-center justify-start flex-1 gap-4"> <CardLayout
<Avatar :key="id"
:name="name" layout="row"
:src="thumbnail" :class="{
:size="48" 'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
:status="availabilityStatus" }"
hide-offline-status >
rounded-full <div class="flex items-center justify-start flex-1 gap-4">
/> <div
<div class="flex flex-col gap-0.5 flex-1"> class="relative"
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"> @mouseenter="handleAvatarHover(true)"
<span class="text-base font-medium truncate text-n-slate-12"> @mouseleave="handleAvatarHover(false)"
{{ name }} >
</span> <Avatar
<span class="inline-flex items-center gap-1"> :name="name"
<span :src="thumbnail"
v-if="additionalAttributes?.companyName" :size="48"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5" :status="availabilityStatus"
/> hide-offline-status
<span rounded-full
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" /> <template v-if="selectable" #overlay="{ size }">
{{ formattedLocation }} <label
</span> class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" /> :style="{ width: `${size}px`, height: `${size}px` }"
<Button @click.stop
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')" >
variant="link" <Checkbox
size="xs" :model-value="isSelected"
@click="onClickViewDetails" @change="event => toggleSelect(event.target.checked)"
/> />
</label>
</template>
</Avatar>
</div> </div>
</div> <div class="flex flex-col gap-0.5 flex-1">
</div> <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
<Button {{ name }}
icon="i-lucide-chevron-down" </span>
variant="ghost" <span class="inline-flex items-center gap-1">
color="slate" <span
size="xs" v-if="additionalAttributes?.companyName"
:class="{ 'rotate-180': isExpanded }" class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
@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> <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> </div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div> </div>
</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> </template>

View File

@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({ const props = defineProps({
selectedContact: { selectedContact: {
@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
@click="updateContact" @click="updateContact"
/> />
</div> </div>
<div <Policy :permissions="['administrator']">
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong" <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"> <div class="flex flex-col gap-2">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} <h6 class="text-base font-medium text-n-slate-12">
</h6> {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
<span class="text-sm text-n-slate-11"> </h6>
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} <span class="text-sm text-n-slate-11">
</span> {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div> </div>
<Button <ConfirmContactDeleteDialog
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')" ref="confirmDeleteContactDialogRef"
color="ruby" :selected-contact="selectedContact"
@click="openConfirmDeleteContactDialog" @go-to-contacts-list="emit('goToContactsList')"
/> />
</div> </Policy>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div> </div>
</template> </template>

View File

@ -10,7 +10,15 @@ import {
} from 'shared/helpers/CustomErrors'; } from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; 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 { t } = useI18n();
const store = useStore(); const store = useStore();
@ -20,6 +28,9 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags'); const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating); const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null); const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => { const updateContact = async updatedData => {
try { try {
@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => { const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : 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> </script>
<template> <template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6"> <div class="flex flex-col gap-4">
<ContactsCard <div v-for="contact in contacts" :key="contact.id" class="relative">
v-for="contact in contacts" <ContactsCard
:id="contact.id" :id="contact.id"
:key="contact.id" :name="contact.name"
:name="contact.name" :email="contact.email"
:email="contact.email" :thumbnail="contact.thumbnail"
:thumbnail="contact.thumbnail" :phone-number="contact.phoneNumber"
:phone-number="contact.phoneNumber" :additional-attributes="contact.additionalAttributes"
:additional-attributes="contact.additionalAttributes" :availability-status="contact.availabilityStatus"
:availability-status="contact.availabilityStatus" :is-expanded="expandedCardId === contact.id"
:is-expanded="expandedCardId === contact.id" :is-updating="isUpdating"
:is-updating="isUpdating" :selectable="shouldShowSelection(contact.id)"
@toggle="toggleExpanded(contact.id)" :is-selected="isSelected(contact.id)"
@update-contact="updateContact" @toggle="toggleExpanded(contact.id)"
@show-contact="onClickViewDetails" @update-contact="updateContact"
/> @show-contact="onClickViewDetails"
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div> </div>
</template> </template>

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue'; import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
</script> </script>
<template> <template>
<div <ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1" class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
> >
<Button <Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')" v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost ghost
slate slate
sm sm
class="!rounded-full" class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
:class="{ :class="{
'bg-n-alpha-2': isContactSidebarOpen, 'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
}" }"
icon="i-ph-user-bold" icon="i-ph-user-bold"
@click="handleConversationSidebarToggle" @click="handleConversationSidebarToggle"
@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')" v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost ghost
slate slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm sm
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
:class="{
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
isCopilotPanelOpen,
}"
icon="i-woot-captain" icon="i-woot-captain"
@click="handleCopilotSidebarToggle" @click="handleCopilotSidebarToggle"
/> />
</div> </ButtonGroup>
</template> </template>

View File

@ -21,6 +21,10 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] }, enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false }, enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@ -100,6 +104,10 @@ watch(
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions" :enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools" :enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"

View File

@ -14,6 +14,10 @@ defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
showBackdrop: {
type: Boolean,
default: true,
},
}); });
</script> </script>
@ -25,14 +29,24 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
> >
<div <div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none" class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
> >
<slot name="empty-state-item" /> <slot name="empty-state-item" />
</div> </div>
<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"> <div class="flex flex-col items-center justify-center gap-3">
<h2 <h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay" class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@ -40,6 +54,7 @@ defineProps({
{{ title }} {{ title }}
</h2> </h2>
<p <p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]" class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
> >
{{ subtitle }} {{ subtitle }}

View File

@ -14,6 +14,7 @@ import {
} from 'dashboard/helper/portalHelper'; } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
:disabled="!articleId" :disabled="!articleId"
@click="previewArticle" @click="previewArticle"
/> />
<div class="flex items-center"> <ButtonGroup class="flex items-center">
<Button <Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')" :label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm" size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none" class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing" :is-loading="isArticlePublishing"
:disabled=" :disabled="
status === ARTICLE_STATUSES.PUBLISHED || status === ARTICLE_STATUSES.PUBLISHED ||
@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
size="sm" size="sm"
:disabled="!articleId" :disabled="!articleId"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none" class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu" @click.stop="showArticleActionMenu = !showArticleActionMenu"
/> />
@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
/> />
</OnClickOutside> </OnClickOutside>
</div> </div>
</div> </ButtonGroup>
</div> </div>
</div> </div>
</div> </div>

View File

@ -98,7 +98,6 @@ const setSignature = () => {
const toggleMessageSignature = () => { const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
}; };
// Added this watch to dynamically set signature on target inbox change. // Added this watch to dynamically set signature on target inbox change.

View File

@ -205,16 +205,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = []; state.attachedFiles = [];
}; };
const removeTargetInbox = value => { const removeSignatureFromMessage = () => {
v$.value.$reset(); // Always remove the signature from message content when inbox/contact is removed
// Remove the signature from message content // to ensure no leftover signature content remains
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp) const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
if (props.sendWithSignature) { ? props.messageSignature
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget : extractTextFromMarkdown(props.messageSignature);
? props.messageSignature if (signatureToRemove) {
: extractTextFromMarkdown(props.messageSignature);
state.message = removeSignature(state.message, signatureToRemove); state.message = removeSignature(state.message, signatureToRemove);
} }
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
@ -222,6 +226,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => { const clearSelectedContact = () => {
emit('clearSelectedContact'); emit('clearSelectedContact');
state.attachedFiles = []; state.attachedFiles = [];
removeSignatureFromMessage();
}; };
const onClickInsertEmoji = emoji => { const onClickInsertEmoji = emoji => {
@ -362,6 +367,7 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0" :has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/> />
<AttachmentPreviews <AttachmentPreviews

View File

@ -17,6 +17,7 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false }, hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
}); });
const { t } = useI18n(); const { t } = useI18n();
@ -90,6 +91,10 @@ const replaceText = async message => {
" "
enable-variables enable-variables
:show-character-count="false" :show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/> />
</template> </template>
<template v-else> <template v-else>

View File

@ -36,6 +36,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' }, icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false }, trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false }, isLoading: { type: Boolean, default: false },
noAnimation: { type: Boolean, default: false },
}); });
const slots = useSlots(); const slots = useSlots();
@ -179,12 +180,18 @@ const STYLE_CONFIG = {
md: 'text-sm font-medium', md: 'text-sm font-medium',
lg: 'text-base', lg: 'text-base',
}, },
clickAnimation: {
xs: 'active:enabled:scale-[0.97]',
sm: 'active:enabled:scale-[0.97]',
md: 'active:enabled:scale-[0.98]',
lg: 'active:enabled:scale-[0.98]',
},
justify: { justify: {
start: 'justify-start', start: 'justify-start',
center: 'justify-center', center: 'justify-center',
end: 'justify-end', end: 'justify-end',
}, },
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50', base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
}; };
const variantClasses = computed(() => { const variantClasses = computed(() => {
@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
return classes.join(' '); return classes.join(' ');
}); });
const animationClasses = computed(() => {
return props.noAnimation
? ''
: STYLE_CONFIG.clickAnimation[computedSize.value];
});
</script> </script>
<template> <template>
@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
[STYLE_CONFIG.base]: true, [STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true, [isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true, [STYLE_CONFIG.fontSize[computedSize]]: true,
[animationClasses]: true,
[STYLE_CONFIG.justify[computedJustify]]: true, [STYLE_CONFIG.justify[computedJustify]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly, 'flex-row-reverse': trailingIcon && !isIconOnly,
}" }"

View File

@ -0,0 +1,20 @@
<script setup>
defineProps({
noAnimation: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
:class="
noAnimation
? ''
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
"
>
<slot />
</div>
</template>

View File

@ -1,11 +1,17 @@
<script setup> <script setup>
import { computed } from 'vue'; import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { usePolicy } from 'dashboard/composables/usePolicy'; import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue'; import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
const props = defineProps({ const props = defineProps({
currentPage: { currentPage: {
@ -56,11 +62,36 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showAssistantSwitcher: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['click', 'close', 'update:currentPage']); const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { t } = useI18n();
const route = useRoute();
const { shouldShowPaywall } = usePolicy(); const { shouldShowPaywall } = usePolicy();
const showAssistantSwitcherDropdown = ref(false);
const createAssistantDialogRef = ref(null);
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const currentAssistantId = computed(() => route.params.assistantId);
const isFetchingAssistants = computed(() => uiFlags.value?.fetchingList);
const activeAssistantName = computed(() => {
return (
assistants.value?.find(
assistant => assistant.id === Number(currentAssistantId.value)
)?.name || t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')
);
});
const showPaywall = computed(() => { const showPaywall = computed(() => {
return shouldShowPaywall(props.featureFlag); return shouldShowPaywall(props.featureFlag);
}); });
@ -72,6 +103,15 @@ const handleButtonClick = () => {
const handlePageChange = event => { const handlePageChange = event => {
emit('update:currentPage', event); emit('update:currentPage', event);
}; };
const toggleAssistantSwitcher = () => {
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
};
const handleCreateAssistant = () => {
showAssistantSwitcherDropdown.value = false;
createAssistantDialogRef.value.dialogRef.open();
};
</script> </script>
<template> <template>
@ -81,39 +121,86 @@ const handlePageChange = event => {
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
<div class="flex gap-4 items-center"> <div class="flex gap-3 items-center">
<BackButton v-if="backUrl" :to="backUrl" /> <BackButton v-if="backUrl" :back-url="backUrl" />
<slot name="headerTitle">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
</slot>
<div <div
v-if="!isEmpty && showKnowMore" v-if="showAssistantSwitcher && !showPaywall"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" /> <div class="flex items-center gap-2">
<slot name="knowMore" /> <span
v-if="!isFetchingAssistants"
class="text-xl font-medium truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
<div class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:disabled="isFetchingAssistants"
:is-loading="isFetchingAssistants"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
v-if="showAssistantSwitcher && !showPaywall && headerTitle"
class="w-0.5 h-4 rounded-2xl bg-n-weak"
/>
<span
v-if="headerTitle"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div> </div>
</div> </div>
<div <div class="flex gap-2">
v-if="!showPaywall && buttonLabel" <slot name="search" />
v-on-clickaway="() => emit('close')" <div
class="relative group/campaign-button" v-if="!showPaywall && buttonLabel"
> v-on-clickaway="() => emit('close')"
<Policy :permissions="buttonPolicy"> class="relative group/captain-button"
<Button >
:label="buttonLabel" <Policy :permissions="buttonPolicy">
icon="i-lucide-plus" <Button
size="sm" :label="buttonLabel"
class="group-hover/campaign-button:brightness-110" icon="i-lucide-plus"
@click="handleButtonClick" size="sm"
/> class="group-hover/captain-button:brightness-110"
</Policy> @click="handleButtonClick"
<slot name="action" /> />
</Policy>
<slot name="action" />
</div>
</div> </div>
</div> </div>
<slot name="subHeader" />
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
@ -143,5 +230,6 @@ const handlePageChange = event => {
@update:current-page="handlePageChange" @update:current-page="handlePageChange"
/> />
</footer> </footer>
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
</section> </section>
</template> </template>

View File

@ -1,91 +0,0 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>

View File

@ -76,12 +76,11 @@ const handleAction = ({ action, value }) => {
<template> <template>
<CardLayout> <CardLayout>
<div class="flex justify-between w-full gap-1"> <div class="flex justify-between w-full gap-1">
<router-link <h6
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }" class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
> >
{{ name }} {{ name }}
</router-link> </h6>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue'; import MessageList from './MessageList.vue';
@ -29,6 +29,16 @@ const resetConversation = () => {
newMessage.value = ''; newMessage.value = '';
}; };
// Watch for assistant ID changes and reset conversation
watch(
() => assistantId,
(newId, oldId) => {
if (oldId && newId !== oldId) {
resetConversation();
}
}
);
const sendMessage = async () => { const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return; if (!newMessage.value.trim() || isLoading.value) return;
@ -65,16 +75,17 @@ const sendMessage = async () => {
<template> <template>
<div <div
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11" class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
> >
<div class="mb-4"> <div class="mb-8 px-6">
<div class="flex justify-between items-center mb-1"> <div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium"> <h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }} {{ t('CAPTAIN.PLAYGROUND.HEADER') }}
</h3> </h3>
<NextButton <NextButton
ghost ghost
size="small" sm
slate
icon="i-lucide-rotate-ccw" icon="i-lucide-rotate-ccw"
@click="resetConversation" @click="resetConversation"
/> />
@ -87,17 +98,17 @@ const sendMessage = async () => {
<MessageList :messages="messages" :is-loading="isLoading" /> <MessageList :messages="messages" :is-loading="isLoading" />
<div <div
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3" class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
> >
<input <input
v-model="newMessage" v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0" class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')" :placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage" @keyup.enter="sendMessage"
/> />
<NextButton <NextButton
ghost ghost
size="small" sm
:disabled="!newMessage.trim()" :disabled="!newMessage.trim()"
icon="i-lucide-send" icon="i-lucide-send"
@click="sendMessage" @click="sendMessage"

View File

@ -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" 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-3">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5 min-w-0">
<Checkbox <Checkbox
v-model="bulkCheckboxState" v-model="bulkCheckboxState"
:indeterminate="isIndeterminate" :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 }} {{ selectAllLabel }}
</span> </span>
</div> </div>
<span class="text-sm text-n-slate-10 tabular-nums"> <span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }} {{ selectedCountLabel }}
</span> </span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div> </div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount"> <slot name="actions" :selected-count="selectedCount">
<Button <Button

View File

@ -35,8 +35,8 @@ const getAvatarName = sender =>
const getMessageStyle = sender => const getMessageStyle = sender =>
isUserMessage(sender) isUserMessage(sender)
? 'bg-n-strong text-n-white' ? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
: 'bg-n-solid-iris text-n-slate-12'; : 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
const scrollToBottom = async () => { const scrollToBottom = async () => {
await nextTick(); await nextTick();
@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
</script> </script>
<template> <template>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2"> <div
ref="messageContainer"
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
>
<div <div
v-for="(message, index) in messages" v-for="(message, index) in messages"
:key="index" :key="index"
@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
:class="getMessageAlignment(message.sender)" :class="getMessageAlignment(message.sender)"
> >
<div <div
class="flex items-start gap-1.5" class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
:class="getMessageDirection(message.sender)" :class="getMessageDirection(message.sender)"
> >
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" /> <Avatar
:name="getAvatarName(message.sender)"
rounded-full
:size="24"
class="shrink-0"
/>
<div <div
class="max-w-[80%] rounded-lg p-3 text-sm" class="px-4 py-3 text-sm [overflow-wrap:break-word]"
:class="getMessageStyle(message.sender)" :class="getMessageStyle(message.sender)"
> >
<div class="break-words" v-html="formatMessage(message.content)" /> <div v-html="formatMessage(message.content)" />
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@ -59,6 +60,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showActions: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['action', 'navigate', 'select', 'hover']); const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5"> <span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }} {{ answer }}
</span> </span>
<div v-if="!compact" class="items-center justify-between hidden lg:flex"> <div
<div class="inline-flex items-center"> v-if="!compact"
<span class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1" >
> <Policy v-if="showActions" :permissions="['administrator']">
<i class="i-woot-captain" /> <div class="flex items-center gap-2 sm:gap-5 w-full">
{{ assistant?.name || '' }} <Button
</span> v-if="status === 'pending'"
<div :label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
v-if="documentable" icon="i-lucide-circle-check-big"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3" 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 <span
v-if="documentable.type === 'Captain::Document'" v-if="status === 'approved'"
class="inline-flex items-center gap-1 truncate over" class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
> >
<i class="i-ph-files-light text-base" /> <Icon icon="i-woot-captain" class="size-3.5" />
<span class="max-w-96 truncate" :title="documentable.name"> {{ 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 }} {{ documentable.name }}
</span> </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 <span
class="max-w-96 truncate" v-else-if="documentable.type === 'User'"
class="truncate"
:title="documentable.available_name" :title="documentable.available_name"
> >
{{ documentable.available_name }} {{ documentable.available_name }}
</span> </span>
</span> <span
<span v-else-if="documentable.type === 'Conversation'"
v-else-if="documentable.type === 'Conversation'" class="hover:underline truncate cursor-pointer"
class="inline-flex items-center gap-1 group cursor-pointer" role="button"
role="button" @click="handleDocumentableClick"
@click="handleDocumentableClick" >
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
{{ {{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, { t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id, id: documentable.display_id,
}) })
}} }}
</span> </span>
</span> </div>
<span v-else />
</div> </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"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
> >
<i <Icon icon="i-ph-calendar-dot" class="size-3.5" />
class="i-ph-stack text-base" {{ timestamp }}
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
</div> </div>
</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> </div>
</CardLayout> </CardLayout>
</template> </template>

View File

@ -1,68 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
assistantId: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const isFilterOpen = ref(false);
const assistants = useMapGetter('captainAssistants/getRecords');
const assistantOptions = computed(() => [
{
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
value: 'all',
action: 'filter',
},
...assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
action: 'filter',
})),
]);
const selectedAssistantLabel = computed(() => {
const assistant = assistantOptions.value.find(
option => option.value === props.assistantId
);
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
selected: assistant ? assistant.label : '',
});
});
const handleAssistantFilterChange = ({ value }) => {
isFilterOpen.value = false;
emit('update', value);
};
</script>
<template>
<OnClickOutside @trigger="isFilterOpen = false">
<Button
:label="selectedAssistantLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isFilterOpen = !isFilterOpen"
/>
<DropdownMenu
v-if="isFilterOpen"
:menu-items="assistantOptions"
class="mt-2"
@action="handleAssistantFilterChange"
/>
</OnClickOutside>
</template>

View File

@ -18,7 +18,7 @@ const props = defineProps({
validator: value => ['create', 'edit'].includes(value), validator: value => ['create', 'edit'].includes(value),
}, },
}); });
const emit = defineEmits(['close']); const emit = defineEmits(['close', 'created']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@ -35,8 +35,18 @@ const i18nKey = computed(
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}` () => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
); );
const createAssistant = assistantDetails => const createAssistant = async assistantDetails => {
store.dispatch('captainAssistants/create', assistantDetails); try {
const newAssistant = await store.dispatch(
'captainAssistants/create',
assistantDetails
);
emit('created', newAssistant);
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleSubmit = async updatedAssistant => { const handleSubmit = async updatedAssistant => {
try { try {

View File

@ -1,333 +0,0 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
welcomeMessage: '',
handoffMessage: '',
resolutionMessage: '',
instructions: '',
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
welcomeMessage: { minLength: minLength(1) },
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
welcomeMessage: getErrorMessage('welcomeMessage'),
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.welcomeMessage = config.welcome_message;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
},
};
emit('submit', payload);
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.welcomeMessage.$validate(),
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
welcome_message: state.welcomeMessage,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
},
};
emit('submit', payload);
};
const handleInstructionsUpdate = async () => {
const result = await v$.value.instructions.$validate();
if (!result) return;
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
emit('submit', payload);
};
const handleFeaturesUpdate = () => {
const payload = {
config: {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<Accordion
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
is-open
>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicInfoUpdate"
>
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleInstructionsUpdate"
/>
</div>
</div>
</Accordion>
<!-- Greeting Messages Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</Accordion>
<!-- Features Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleFeaturesUpdate"
/>
</div>
</div>
</Accordion>
</form>
</template>

View File

@ -3,6 +3,8 @@ import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators'; import { minLength } from '@vuelidate/validators';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
@ -17,10 +19,16 @@ const props = defineProps({
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
const { t } = useI18n(); const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const initialState = { const initialState = {
handoffMessage: '', handoffMessage: '',
resolutionMessage: '', resolutionMessage: '',
instructions: '',
temperature: 1, temperature: 1,
}; };
@ -29,6 +37,7 @@ const state = reactive({ ...initialState });
const validationRules = { const validationRules = {
handoffMessage: { minLength: minLength(1) }, handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) }, resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
}; };
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
@ -40,20 +49,30 @@ const getErrorMessage = field => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'), handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'), resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
})); }));
const updateStateFromAssistant = assistant => { const updateStateFromAssistant = assistant => {
const { config = {} } = assistant; const { config = {} } = assistant;
state.handoffMessage = config.handoff_message; state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message; state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1; state.temperature = config.temperature || 1;
}; };
const handleSystemMessagesUpdate = async () => { const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([ const validations = [
v$.value.handoffMessage.$validate(), v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(), v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean)); ];
if (!isCaptainV2Enabled.value) {
validations.push(v$.value.instructions.$validate());
}
const result = await Promise.all(validations).then(results =>
results.every(Boolean)
);
if (!result) return; if (!result) return;
const payload = { const payload = {
@ -65,6 +84,10 @@ const handleSystemMessagesUpdate = async () => {
}, },
}; };
if (!isCaptainV2Enabled.value) {
payload.config.instructions = state.instructions;
}
emit('submit', payload); emit('submit', payload);
}; };
@ -95,6 +118,16 @@ watch(
:message-type="formErrors.resolutionMessage ? 'error' : 'info'" :message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/> />
<Editor
v-if="!isCaptainV2Enabled"
v-model="state.instructions"
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12"> <label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }} {{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}

View File

@ -8,6 +8,13 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue'; import DocumentForm from './DocumentForm.vue';
defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@ -48,7 +55,11 @@ defineExpose({ dialogRef });
:show-confirm-button="false" :show-confirm-button="false"
@close="handleClose" @close="handleClose"
> >
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" /> <DocumentForm
:assistant-id="assistantId"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer /> <template #footer />
</Dialog> </Dialog>
</template> </template>

View File

@ -2,7 +2,7 @@
import { reactive, computed, ref, nextTick } from 'vue'; import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, minLength, requiredIf, url } from '@vuelidate/validators'; import { minLength, requiredIf, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
@ -10,6 +10,13 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['submit', 'cancel']); const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@ -18,13 +25,11 @@ const { t } = useI18n();
const formState = { const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'), uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
}; };
const initialState = { const initialState = {
name: '', name: '',
url: '', url: '',
assistantId: null,
documentType: 'url', documentType: 'url',
pdfFile: null, pdfFile: null,
}; };
@ -38,19 +43,11 @@ const validationRules = {
url: requiredIf(() => state.documentType === 'url' && url), url: requiredIf(() => state.documentType === 'url' && url),
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)), minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
}, },
assistantId: { required },
pdfFile: { pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'), required: requiredIf(() => state.documentType === 'pdf'),
}, },
}; };
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const documentTypeOptions = [ const documentTypeOptions = [
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') }, { value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') }, { value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
@ -70,7 +67,6 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'), url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'), pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
})); }));
@ -106,7 +102,7 @@ const openFileDialog = () => {
const prepareDocumentDetails = () => { const prepareDocumentDetails = () => {
const formData = new FormData(); const formData = new FormData();
formData.append('document[assistant_id]', state.assistantId); formData.append('document[assistant_id]', props.assistantId);
if (state.documentType === 'url') { if (state.documentType === 'url') {
formData.append('document[external_link]', state.url); formData.append('document[external_link]', state.url);
@ -218,21 +214,6 @@ const handleSubmit = async () => {
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')" :placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/> />
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
:message="formErrors.assistantId"
/>
</div>
<div class="flex gap-3 justify-between items-center w-full"> <div class="flex gap-3 justify-between items-center w-full">
<Button <Button
type="button" type="button"

View File

@ -1,4 +1,5 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.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'; import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@ -20,6 +22,7 @@ const onClick = () => {
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg" fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/captain-assistant" learn-more-url="https://chwt.app/captain-assistant"
class="mb-8" class="mb-8"
:hide-actions="!isOnChatwootCloud"
/> />
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"

View File

@ -1,4 +1,5 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.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'; import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@ -19,6 +21,7 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg" fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg" fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
learn-more-url="https://chwt.app/captain-document" learn-more-url="https://chwt.app/captain-document"
:hide-actions="!isOnChatwootCloud"
class="mb-8" class="mb-8"
/> />
<EmptyStateLayout <EmptyStateLayout

View File

@ -1,32 +1,63 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue'; import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue'; import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js'; 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 = () => { const onClick = () => {
emit('click'); emit('click');
}; };
const onClearFilters = () => {
emit('clearFilters');
};
</script> </script>
<template> <template>
<FeatureSpotlight <FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')" :title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')" :note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg" fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg" fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
learn-more-url="https://chwt.app/captain-faq" learn-more-url="https://chwt.app/captain-faq"
:hide-actions="!isOnChatwootCloud"
class="mb-8" class="mb-8"
/> />
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')" :title="
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')" 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']" :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"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard <ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)" v-for="(response, index) in responsesList.slice(0, 5)"
@ -42,11 +73,21 @@ const onClick = () => {
</div> </div>
</template> </template>
<template #actions> <template #actions>
<Button <div class="flex flex-col items-center gap-3">
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')" <Button
icon="i-lucide-plus" v-if="isApproved"
@click="onClick" :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> </template>
</EmptyStateLayout> </EmptyStateLayout>
</template> </template>

View File

@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseForm from './ResponseForm.vue'; import ResponseForm from './ResponseForm.vue';
@ -21,6 +22,7 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const route = useRoute();
const dialogRef = ref(null); const dialogRef = ref(null);
const responseForm = ref(null); const responseForm = ref(null);
@ -39,9 +41,15 @@ const createResponse = responseDetails =>
const handleSubmit = async updatedResponse => { const handleSubmit = async updatedResponse => {
try { try {
if (props.type === 'edit') { if (props.type === 'edit') {
await updateResponse(updatedResponse); await updateResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
});
} else { } else {
await createResponse(updatedResponse); await createResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
});
} }
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`)); useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close(); dialogRef.value.close();

View File

@ -8,7 +8,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({ const props = defineProps({
mode: { mode: {
@ -21,18 +20,17 @@ const props = defineProps({
default: () => ({}), default: () => ({}),
}, },
}); });
const emit = defineEmits(['submit', 'cancel']); const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n(); const { t } = useI18n();
const formState = { const formState = {
uiFlags: useMapGetter('captainResponses/getUIFlags'), uiFlags: useMapGetter('captainResponses/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
}; };
const initialState = { const initialState = {
question: '', question: '',
answer: '', answer: '',
assistantId: null,
}; };
const state = reactive({ ...initialState }); const state = reactive({ ...initialState });
@ -40,16 +38,8 @@ const state = reactive({ ...initialState });
const validationRules = { const validationRules = {
question: { required, minLength: minLength(1) }, question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) }, answer: { required, minLength: minLength(1) },
assistantId: { required },
}; };
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem); const isLoading = computed(() => formState.uiFlags.value.creatingItem);
@ -63,7 +53,6 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'), question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'), answer: getErrorMessage('answer', 'ANSWER'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
})); }));
const handleCancel = () => emit('cancel'); const handleCancel = () => emit('cancel');
@ -71,7 +60,6 @@ const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({ const prepareDocumentDetails = () => ({
question: state.question, question: state.question,
answer: state.answer, answer: state.answer,
assistant_id: state.assistantId,
}); });
const handleSubmit = async () => { const handleSubmit = async () => {
@ -86,12 +74,11 @@ const handleSubmit = async () => {
const updateStateFromResponse = response => { const updateStateFromResponse = response => {
if (!response) return; if (!response) return;
const { question, answer, assistant } = response; const { question, answer } = response;
Object.assign(state, { Object.assign(state, {
question, question,
answer, answer,
assistantId: assistant.id,
}); });
}; };
@ -115,7 +102,6 @@ watch(
:message="formErrors.question" :message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'" :message-type="formErrors.question ? 'error' : 'info'"
/> />
<Editor <Editor
v-model="state.answer" v-model="state.answer"
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')" :label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
@ -124,22 +110,6 @@ watch(
:max-length="10000" :max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'" :message-type="formErrors.answer ? 'error' : 'info'"
/> />
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3"> <div class="flex items-center justify-between w-full gap-3">
<Button <Button
type="button" type="button"

View File

@ -0,0 +1,148 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createAssistant']);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const assistants = useMapGetter('captainAssistants/getRecords');
const currentAssistantId = computed(() => route.params.assistantId);
const isAssistantActive = assistant => {
return assistant.id === Number(currentAssistantId.value);
};
const fetchDataForRoute = async (routeName, assistantId) => {
const dataFetchMap = {
captain_assistants_responses_index: async () => {
await store.dispatch('captainResponses/get', { assistantId });
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
},
captain_assistants_responses_pending: async () => {
await store.dispatch('captainResponses/get', {
assistantId,
status: 'pending',
});
},
captain_assistants_documents_index: async () => {
await store.dispatch('captainDocuments/get', { assistantId });
},
captain_assistants_scenarios_index: async () => {
await store.dispatch('captainScenarios/get', { assistantId });
},
captain_assistants_playground_index: () => {
// Playground doesn't need pre-fetching, it loads on interaction
},
captain_assistants_inboxes_index: async () => {
await store.dispatch('captainInboxes/get', { assistantId });
},
captain_tools_index: async () => {
await store.dispatch('captainCustomTools/get', { page: 1 });
},
captain_assistants_settings_index: async () => {
await store.dispatch('captainAssistants/show', assistantId);
},
};
const fetchFn = dataFetchMap[routeName];
if (fetchFn) {
await fetchFn();
}
};
const handleAssistantChange = async assistant => {
if (isAssistantActive(assistant)) return;
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
await fetchDataForRoute(targetRouteName, assistant.id);
await router.push({
name: targetRouteName,
params: {
accountId: route.params.accountId,
assistantId: assistant.id,
},
});
emit('close');
};
const openCreateAssistantDialog = () => {
emit('createAssistant');
emit('close');
};
</script>
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
</h2>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="assistant in assistants"
:key="assistant.id"
:label="assistant.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
size="sm"
@click="handleAssistantChange(assistant)"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"
:size="20"
icon-name="i-lucide-bot"
rounded-full
/>
</Button>
</div>
<div v-else class="flex flex-col items-center gap-2 px-4 py-3">
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANT_SWITCHER.EMPTY_LIST') }}
</p>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@ -96,6 +96,7 @@ watch(
:label="selectedLabel" :label="selectedLabel"
trailing-icon trailing-icon
:disabled="disabled" :disabled="disabled"
no-animation
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand" class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{ :class="{
focused: open, focused: open,

View File

@ -78,8 +78,10 @@ const handleSuggestion = opt => {
</p> </p>
<router-link <router-link
:to="{ :to="{
name: 'captain_assistants_index', name: 'captain_assistants_create_index',
params: { accountId: route.params.accountId }, params: {
accountId: route.params.accountId,
},
}" }"
class="text-n-slate-11 underline hover:text-n-slate-12" class="text-n-slate-11 underline hover:text-n-slate-12"
> >

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@ -53,14 +54,17 @@ const toggleSidebar = () => {
v-if="showCopilotLauncher" v-if="showCopilotLauncher"
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50" class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
> >
<div class="rounded-full bg-n-alpha-2 p-1"> <ButtonGroup
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button <Button
icon="i-woot-captain" icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl" no-animation
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg lg
@click="toggleSidebar" @click="toggleSidebar"
/> />
</div> </ButtonGroup>
</div> </div>
<template v-else /> <template v-else />
</template> </template>

View File

@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
menuItems: { menuItems: {
type: Array, type: Array,
required: true, default: () => [],
validator: value => { validator: value => {
return value.every(item => item.action && item.value && item.label); return value.every(item => item.action && item.value && item.label);
}, },
}, },
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: { thumbnailSize: {
type: Number, type: Number,
default: 20, default: 20,
@ -42,19 +46,62 @@ const { t } = useI18n();
const searchInput = ref(null); const searchInput = ref(null);
const searchQuery = ref(''); const searchQuery = ref('');
const filteredMenuItems = computed(() => { const hasSections = computed(() => props.menuSections.length > 0);
if (!searchQuery.value) return props.menuItems;
return props.menuItems.filter(item => const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase()) item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
); );
}); });
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (!searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleAction = item => { const handleAction = item => {
const { action, value, ...rest } = item; const { action, value, ...rest } = item;
emit('action', { action, value, ...rest }); emit('action', { action, value, ...rest });
}; };
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => { onMounted(() => {
if (searchInput.value && props.showSearch) { if (searchInput.value && props.showSearch) {
searchInput.value.focus(); searchInput.value.focus();
@ -64,54 +111,122 @@ onMounted(() => {
<template> <template>
<div <div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg" class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
> >
<div v-if="showSearch" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div <div
v-if="filteredMenuItems.length === 0" v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5" class="text-sm text-n-slate-11 px-2 py-1.5"
> >
{{ {{

View File

@ -10,6 +10,7 @@ defineProps({
fallbackThumbnail: { type: String, default: '' }, fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' }, fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' }, learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
}); });
const imageError = ref(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"> <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> <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"> <slot name="actions">
<Button <Button
v-if="videoUrl" v-if="videoUrl"

View File

@ -13,6 +13,7 @@ defineProps({
fallbackThumbnail: { type: String, default: '' }, fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' }, fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' }, learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
}); });
const imageError = ref(false); const imageError = ref(false);
@ -92,7 +93,7 @@ const openLink = link => {
{{ note }} {{ note }}
</p> </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"> <slot name="actions">
<Button <Button
v-if="videoUrl" v-if="videoUrl"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -47,6 +47,7 @@ const isReel = computed(() => {
'max-w-48': isReel, 'max-w-48': isReel,
'max-w-full': !isReel, 'max-w-full': !isReel,
}" }"
@click.stop
@error="handleError" @error="handleError"
/> />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
defineProps({ defineProps({
isMobileSidebarOpen: { isMobileSidebarOpen: {
@ -45,14 +46,17 @@ const toggleSidebar = () => {
}, },
]" ]"
> >
<div class="rounded-full bg-n-alpha-2 p-1"> <ButtonGroup
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button <Button
icon="i-lucide-menu" icon="i-lucide-menu"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl" no-animation
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg lg
@click="toggleSidebar" @click="toggleSidebar"
/> />
</div> </ButtonGroup>
</div> </div>
<template v-else /> <template v-else />
</template> </template>

View File

@ -13,6 +13,7 @@ import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue'; import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import ChannelLeaf from './ChannelLeaf.vue'; import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue'; import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue'; import Logo from 'next/icon/Logo.vue';
@ -32,11 +33,15 @@ const emit = defineEmits([
'closeMobileSidebar', 'closeMobileSidebar',
]); ]);
const { accountScopedRoute } = useAccount(); const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const store = useStore(); const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']); const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n(); const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const toggleShortcutModalFn = show => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@ -218,26 +223,70 @@ const menuItems = computed(() => {
name: 'Captain', name: 'Captain',
icon: 'i-woot-captain', icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'), label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [ children: [
{ {
name: 'Assistants', name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'), label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_assistants_index'), activeOn: [
'captain_assistants_responses_index',
'captain_assistants_responses_pending',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_responses_index',
}),
}, },
{ {
name: 'Documents', name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'), label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
to: accountScopedRoute('captain_documents_index'), activeOn: ['captain_assistants_documents_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_documents_index',
}),
}, },
{ {
name: 'Responses', name: 'Scenarios',
label: t('SIDEBAR.CAPTAIN_RESPONSES'), label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
to: accountScopedRoute('captain_responses_index'), activeOn: ['captain_assistants_scenarios_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_scenarios_index',
}),
},
{
name: 'Playground',
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
activeOn: ['captain_assistants_playground_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_playground_index',
}),
},
{
name: 'Inboxes',
label: t('SIDEBAR.CAPTAIN_INBOXES'),
activeOn: ['captain_assistants_inboxes_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_inboxes_index',
}),
}, },
{ {
name: 'Tools', name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'), label: t('SIDEBAR.CAPTAIN_TOOLS'),
to: accountScopedRoute('captain_tools_index'), activeOn: ['captain_tools_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
activeOn: [
'captain_assistants_settings_index',
'captain_assistants_guidelines_index',
'captain_assistants_guardrails_index',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_settings_index',
}),
}, },
], ],
}, },
@ -304,6 +353,23 @@ const menuItems = computed(() => {
}, },
], ],
}, },
{
name: 'Companies',
label: t('SIDEBAR.COMPANIES'),
icon: 'i-lucide-building-2',
children: [
{
name: 'All Companies',
label: t('SIDEBAR.ALL_COMPANIES'),
to: accountScopedRoute(
'companies_dashboard_index',
{},
{ page: 1, search: undefined }
),
activeOn: ['companies_dashboard_index'],
},
],
},
{ {
name: 'Reports', name: 'Reports',
label: t('SIDEBAR.REPORTS'), label: t('SIDEBAR.REPORTS'),
@ -551,20 +617,20 @@ const menuItems = computed(() => {
]" ]"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2"> <div class="flex gap-2 items-center px-2 min-w-0">
<div class="grid flex-shrink-0 size-6 place-content-center"> <div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" /> <Logo class="size-4" />
</div> </div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" /> <div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher <SidebarAccountSwitcher
class="flex-grow min-w-0 -mx-1" class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')" @show-create-account-modal="emit('showCreateAccountModal')"
/> />
</div> </div>
<div class="flex gap-2 px-2"> <div class="flex gap-2 px-2">
<RouterLink <RouterLink
:to="{ name: 'search' }" :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-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left"> <span class="flex-grow text-left">
@ -589,7 +655,7 @@ const menuItems = computed(() => {
</ComposeConversation> </ComposeConversation>
</div> </div>
</section> </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"> <ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup <SidebarGroup
v-for="item in menuItems" v-for="item in menuItems"
@ -599,11 +665,21 @@ const menuItems = computed(() => {
</ul> </ul>
</nav> </nav>
<section <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 <div
@open-key-shortcut-modal="emit('openKeyShortcutModal')" 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> </section>
</aside> </aside>
</template> </template>

View File

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

View File

@ -19,19 +19,46 @@ export function useSidebarContext() {
return '/'; return '/';
}; };
// Helper to find route definition by name without resolving
const findRouteByName = name => {
const routes = router.getRoutes();
return routes.find(route => route.name === name);
};
const resolvePermissions = to => { const resolvePermissions = to => {
if (to) return router.resolve(to)?.meta?.permissions ?? []; if (!to) return [];
return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.permissions ?? [];
}
return router.resolve(to)?.meta?.permissions ?? [];
}; };
const resolveFeatureFlag = to => { const resolveFeatureFlag = to => {
if (to) return router.resolve(to)?.meta?.featureFlag || ''; if (!to) return '';
return '';
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.featureFlag || '';
}
return router.resolve(to)?.meta?.featureFlag || '';
}; };
const resolveInstallationType = to => { const resolveInstallationType = to => {
if (to) return router.resolve(to)?.meta?.installationTypes || []; if (!to) return [];
return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.installationTypes || [];
}
return router.resolve(to)?.meta?.installationTypes || [];
}; };
const isAllowed = to => { const isAllowed = to => {

View File

@ -1,5 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({ const props = defineProps({
initialActiveTab: { initialActiveTab: {
type: Number, type: Number,
@ -22,6 +24,32 @@ const emit = defineEmits(['tabChanged']);
const activeTab = computed(() => props.initialActiveTab); const activeTab = computed(() => props.initialActiveTab);
const tabRefs = ref([]);
const indicatorStyle = ref({});
const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
});
const selectTab = index => { const selectTab = index => {
emit('tabChanged', props.tabs[index]); emit('tabChanged', props.tabs[index]);
}; };
@ -37,20 +65,30 @@ const showDivider = index => {
</script> </script>
<template> <template>
<div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit"> <div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
:style="indicatorStyle"
/>
<template v-for="(tab, index) in tabs" :key="index"> <template v-for="(tab, index) in tabs" :key="index">
<button <button
class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand" :ref="el => (tabRefs[index] = el)"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[ :class="[
activeTab === index activeTab === index
? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent' ? 'text-n-blue-text scale-100'
: 'text-n-slate-10 outline-transparent h-8', : 'text-n-slate-10 scale-[0.98]',
]" ]"
@click="selectTab(index)" @click="selectTab(index)"
> >
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }} {{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button> </button>
<div <div
v-if="index < tabs.length - 1"
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out" class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class=" :class="
showDivider(index) showDivider(index)

View File

@ -15,6 +15,7 @@ import {
CMD_RESOLVE_CONVERSATION, CMD_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore(); const store = useStore();
@ -133,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div class="relative flex items-center justify-end resolve-actions">
<div <ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0" class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
@ -142,6 +143,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')" :label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0" class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdResolveConversation" @click="onCmdResolveConversation"
@ -151,6 +153,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')" :label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0" class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
@ -160,6 +163,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.OPEN_ACTION')" :label="t('CONVERSATION.HEADER.OPEN_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
/> />
@ -169,12 +173,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
:disabled="isLoading" :disabled="isLoading"
size="sm" size="sm"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0" class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
color="slate" color="slate"
trailing-icon trailing-icon
@click="openDropdown" @click="openDropdown"
/> />
</div> </ButtonGroup>
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"

View File

@ -23,6 +23,7 @@ defineProps({
slate slate
sm sm
class="relative" class="relative"
no-animation
:icon="icon" :icon="icon"
:trailing-icon="trailingIcon" :trailing-icon="trailingIcon"
> >

View File

@ -317,7 +317,16 @@ function isBodyEmpty(content) {
} }
function handleEmptyBodyWithSignature() { function handleEmptyBodyWithSignature() {
const { schema, tr } = state; const { schema, tr, doc } = state;
const isEmptyParagraph = node =>
node && node.type === schema.nodes.paragraph && node.content.size === 0;
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
if (isEmptyParagraph(doc.firstChild)) {
focusEditorInputField('start');
return;
}
// create a paragraph node and // create a paragraph node and
// start a transaction to append it at the end // start a transaction to append it at the end

View File

@ -59,7 +59,7 @@ const translateValue = computed(() => {
<template> <template>
<button <button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0" class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled" :disabled="disabled"
:class="{ :class="{
'cursor-not-allowed': disabled, 'cursor-not-allowed': disabled,

View File

@ -30,7 +30,7 @@ const assignedAgent = computed({
return currentChat.value?.meta?.assignee; return currentChat.value?.meta?.assignee;
}, },
set(agent) { set(agent) {
const agentId = agent ? agent.id : 0; const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', agent); store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', { store.dispatch('assignAgent', {
conversationId: currentChat.value?.id, conversationId: currentChat.value?.id,

View File

@ -1,100 +1,129 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
export default { import NextButton from 'dashboard/components-next/button/Button.vue';
components: { import Input from 'dashboard/components-next/input/Input.vue';
NextButton,
}, const emit = defineEmits(['close', 'assign']);
emits: ['update', 'close', 'assign'],
data() { const { t } = useI18n();
return {
query: '', const labels = useMapGetter('labels/getLabels');
selectedLabels: [],
}; const query = ref('');
}, const selectedLabels = ref([]);
computed: {
...mapGetters({ labels: 'labels/getLabels' }), const filteredLabels = computed(() => {
filteredLabels() { if (!query.value) return labels.value;
return this.labels.filter(label => return labels.value.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase()) label.title.toLowerCase().includes(query.value.toLowerCase())
); );
}, });
},
methods: { const hasLabels = computed(() => labels.value.length > 0);
isLabelSelected(label) { const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
return this.selectedLabels.includes(label);
}, const isLabelSelected = label => {
assignLabels(key) { return selectedLabels.value.includes(label);
this.$emit('update', key); };
},
onClose() { const onClose = () => {
this.$emit('close'); emit('close');
}, };
},
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
}; };
</script> </script>
<template> <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"> <div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24"> <svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" /> <path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg> </svg>
</div> </div>
<div class="flex items-center justify-between header"> <div class="flex items-center justify-between p-2.5">
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span> <span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div> </div>
<div class="labels-list"> <div class="flex flex-col max-h-60 min-h-0">
<header class="labels-list__header"> <header class="py-2 px-2.5">
<div <Input
class="flex items-center justify-between h-8 gap-2 label-list-search" v-model="query"
> type="search"
<fluent-icon icon="search" class="search-icon" size="16" /> :placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
<input icon-left="i-lucide-search"
v-model="query" size="sm"
type="search" class="w-full"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm label--search_input" />
/>
</div>
</header> </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 <li
v-for="label in filteredLabels" v-for="label in filteredLabels"
:key="label.id" :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 <label
class="item" 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"
:class="{ 'label-selected': isLabelSelected(label.title) }"
> >
<input <input
v-model="selectedLabels" v-model="selectedLabels"
type="checkbox" type="checkbox"
:value="label.title" :value="label.title"
class="label-checkbox" class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/> />
<span <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 }} {{ label.title }}
</span> </span>
<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 }" :style="{ backgroundColor: label.color }"
/> />
</label> </label>
</li> </li>
</ul> </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 <NextButton
sm sm
type="submit" type="submit"
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')" class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length" :disabled="!selectedLabels.length"
@click="$emit('assign', selectedLabels)" @click="handleAssign"
/> />
</footer> </footer>
</div> </div>
@ -102,107 +131,11 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.labels-list { .triangle {
@apply flex flex-col max-h-[15rem] min-h-[auto]; @apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
.labels-list__header { svg path {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5; @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> </style>

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