Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 22fd5a872b feat(whatsapp-call): direct browser-Meta WebRTC, no media server 2026-04-29 15:23:32 +07:00
Muhsin KelothandGitHub fa582b31fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-28 11:00:45 +04:00
Muhsin KelothandGitHub 8aa71f4a9d Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-28 09:43:04 +04:00
224556fd1b feat: onboarding account details with enriched data [UPM-17][UPM-18] (#13979)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-04-28 10:35:51 +05:30
Tanmay Deep SharmaandGitHub 51eb626b88 feat: allow disabling 2FA with a backup code (#14102)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6883/allow-disabling-2fa-using-a-backup-code

## Description

When a user loses access to their authenticator app, they can now
disable 2FA using one of their saved backup codes (in addition to their
password), so they can re-enroll a new authenticator. The disable dialog
includes a toggle to switch between entering a verification code and a
backup code.


## Type of change

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

## How Has This Been Tested?

- Via UI flows
<img width="495" height="423" alt="Screenshot 2026-04-20 at 2 17 21 PM"
src="https://github.com/user-attachments/assets/cc6b3dc5-39e6-4104-b5b9-cdabdc46947e"
/>
<img width="475" height="409" alt="Screenshot 2026-04-20 at 2 17 36 PM"
src="https://github.com/user-attachments/assets/97c7304d-4adb-42ed-b7b4-50f5b38585a3"
/>


## 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
2026-04-28 10:09:41 +07:00
b0aa844a32 fix(portals): handle integer blob_id in process_attached_logo without 500 (#14274)
Updating portal settings (name, header text, page title, homepage link)
on a portal that already has a logo attached returns 500. The error is
\`NoMethodError: undefined method 'valid_encoding?' for an instance of
Integer\`. The fix is a two-character change in
\`process_attached_logo\`.

Closes #13300

## Root cause

\`ActiveStorage::Blob.find_signed\` expects a signed ID string (e.g.
\`"eyJfcmFpbH..."\`). Internally it calls \`valid_encoding?\` on the
argument to validate the signature payload — a method that exists on
\`String\` but not \`Integer\`.

When a portal already has a logo, the frontend includes the blob's raw
database integer ID (e.g. \`blob_id: 170\`) in the update request
payload. The controller passes this integer directly to \`find_signed\`,
which immediately raises \`NoMethodError\` before any database query is
made.

\`\`\`ruby
# before, crashes when blob_id is an Integer
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)  # NoMethodError here
@portal.logo.attach(blob)
\`\`\`

## What changed

\`\`\`ruby
# after, safe for any input type
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
\`\`\`

\`.to_s\` on an Integer produces a plain decimal string (\`"170"\`),
which is not a valid signed ID. \`find_signed\` returns \`nil\` for any
invalid signature rather than raising, so the nil guard prevents a
broken \`attach\` call. The existing logo remains attached and the
settings update succeeds.

## Trade-offs considered

| Option | Decision |
|---|---|
| \`find(blob_id)\` when input is an Integer | Bypasses signature
verification — any authenticated user knowing a blob ID could attach
arbitrary files to a portal. Security risk. Rejected. |
| Raise a 422 for non-string blob_id | Overly strict — the frontend
sending an integer is pre-existing behaviour this PR shouldn't break. |
| Silently no-op for invalid blob_id (chosen) | Correct product
behaviour: if no valid signed upload is provided, leave the logo
unchanged. The settings update still succeeds. |

## Known limitation

The correct long-term fix is also on the frontend: it should only send
\`blob_id\` when attaching a **new** upload (using the signed ID from
the direct-upload flow), not when re-submitting the existing logo's raw
database integer ID. This PR makes the server robust against the current
frontend behaviour without requiring a coordinated frontend change.

## How to reproduce

1. Create a Help Center portal and upload a logo
2. Update any text field via \`PUT /api/v1/accounts/:id/portals/:slug\`
while including \`blob_id: <integer>\` in the payload
3. Observe 500 with \`NoMethodError: undefined method 'valid_encoding?'
for an instance of Integer\`

After this fix, the request returns 200, settings are updated, and the
existing logo is preserved.

Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
2026-04-28 01:14:51 +05:30
Sony MathewandGitHub c8e551820b fix: [CW-6940] Fix SSRF issue for webhook trigger used by macros and automations (#14155)
This routes external downloads used by webhook fetch used by macros and
acutomations through SafeFetch. It closes the SSRF exposure from raw
Down.download paths, preserves provider-specific auth and header flows,
and adds regression coverage for blocked internal URLs plus
authenticated downloads.

Fixes # (issue):
[CW-6940](https://linear.app/chatwoot/issue/CW-6940/ssrf-via-webhooksautomationmacros-non-upload-non-avatar)
2026-04-27 20:30:59 +05:30
035d2858f5 fix(agent-bots): destroy permissibles on AgentBot deletion and skip orphans in index (#14273)
\`GET /platform/api/v1/agent_bots\` returns 500 when any \`AgentBot\`
that was previously registered with a Platform App has since been
deleted. The bug was introduced by a missing \`dependent: :destroy\` on
the \`AgentBot\` model — deleting a bot left orphaned rows in
\`platform_app_permissibles\`, which the index action later iterated
over and crashed rendering with a \`NoMethodError\` on \`nil\`.

Closes #13407

## Root cause

The index action loads all \`platform_app_permissibles\` for the
platform app and passes each \`resource.permissible\` (the associated
\`AgentBot\`) to a Jbuilder partial. When the \`AgentBot\` no longer
exists, \`resource.permissible\` returns \`nil\` and the partial crashes
calling \`.id\`, \`.name\`, etc. on it.

Every other \`AgentBot\` association (\`agent_bot_inboxes\`,
\`messages\`, \`assigned_conversations\`) had a \`dependent:\` option —
\`platform_app_permissibles\` was the only one missing it. There was
also an N+1 query: the index fired a separate SQL query per permissible
to load each bot.

## What changed

**1. Model — prevent orphans at deletion time**
\`\`\`ruby
has_many :platform_app_permissibles, as: :permissible, dependent:
:destroy
\`\`\`

**2. Controller — eager-load to eliminate N+1**
\`\`\`ruby
@resources = @platform_app.platform_app_permissibles
               .where(permissible_type: 'AgentBot')
               .includes(:permissible)
\`\`\`

**3. Jbuilder — defensive nil guard for pre-existing orphans**
\`\`\`ruby
bot = resource.permissible
next if bot.nil?
json.partial! '...', resource: bot
\`\`\`

## Trade-offs considered

| Option | Decision |
|---|---|
| Rescue \`NoMethodError\` in jbuilder | Hides the failure rather than
fixing it. Rejected. |
| Only add the nil guard, skip the model fix | Leaves the data integrity
gap open — future deletions continue creating orphans. Rejected. |
| Both layers (chosen) | Model fix prevents new orphans; nil guard is
defence-in-depth for any orphans that survived before deployment. |
| \`dependent: :nullify\` | Doesn't apply — a nullified permissible
would still cause the same nil dereference. Rejected. |

## How to reproduce

1. Create an AgentBot via the Platform API
2. Delete the AgentBot via any path (admin UI, API, or direct model
call)
3. Call \`GET /platform/api/v1/agent_bots\` with a Platform App token
4. Observe 500

After this fix, the endpoint returns 200 with an empty array.

Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
2026-04-27 19:17:32 +05:30
16b8693e1b fix: standardize contact company field on company_name (#14099)
Standardizes the contact company import/filter/automation contract on
`company_name`.

Closes #14096
Revives #9907

## Why

Contact company is read across the current CRM/contact UI from
`additional_attributes['company_name']`, but CSV import and a few
backend filter/automation paths still used the older `company` key. That
meant imported company values could be saved in a place the dashboard,
sorting, filters, and automation conditions did not consistently read
from.

Based on the production data check, the legacy `company` automation
configuration is effectively dead: the affected account did not have
contacts populated with `additional_attributes['company']`. So this PR
intentionally avoids adding long-term fallback behavior and uses
`company_name` as the single key going forward.

## What changed

- Contact CSV import now writes only `company_name` into
`additional_attributes['company_name']`.
- The example contact import CSV now uses the `company_name` header.
- Contact company sorting/filter config now uses `company_name`.
- Automation condition config now uses `company_name`.
- Existing standard automation conditions with `attribute_key:
'company'` are migrated to `company_name`.
- Existing saved contact filters with standard `attribute_key:
'company'` are migrated to `company_name`.
- Custom attributes named `company` are preserved and are not rewritten
by the migration.

## How to test

- Import a contact CSV with a `company_name` column and confirm the
Contact Company field is populated.
- Sort contacts by Company and confirm imported contacts are ordered
correctly.
- Create/edit an automation with Company as a condition and confirm it
saves with `company_name`.
- Verify existing saved contact filters and automation rules using the
old standard `company` key are migrated to `company_name`.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-27 18:43:26 +05:30
Aakash BakhleandGitHub 279dd1876c fix: make captain datetime aware (#14069)
# Pull Request Template

## Description

Captain currently cannot discern today, tomorrow etc. This PR adds
datetime awareness to the system prompt

Fixes:
https://linear.app/chatwoot/issue/AI-148/captain-should-be-aware-of-datetime

## Type of change

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

## How Has This Been Tested?

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

Locally

<img width="696" height="247" alt="CleanShot 2026-04-27 at 14 47 47"
src="https://github.com/user-attachments/assets/6a73a8d9-f48e-46bb-a306-7b9a28a5fa9c"
/>


## 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
2026-04-27 15:51:49 +05:30
2266eb493b fix: Add validation to the name attribute in user (#10805)
With this change, the form will start displaying a required field. While
validation is already enforced in APIs and other areas, the super_admin
console—being autogenerated—will throw an error since this requirement
isn’t explicitly defined in the model.

<img width="670" alt="Screenshot 2025-01-30 at 2 12 43 PM"
src="https://github.com/user-attachments/assets/e0ab3ace-3649-4ef2-bc94-8d4d80453dd1"
/>

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <ynos1234@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-04-27 15:47:11 +05:30
Sojan JoseandGitHub 0920a01e66 fix(i18n): align pluralization with locale rules (#14266)
Loads Rails locale-specific pluralization rules so languages with an
`other`-only plural model can safely use Crowdin exports without
maintaining duplicate `one` keys.

## Closes

None

## Why

Crowdin exports Rails YAML pluralized strings using each target
language's plural categories. These categories come from Unicode CLDR
and represent grammatical forms, not a literal "number is 1" bucket.

Some languages need separate forms such as `one` and `other`, but
languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and
Chinese use the same form for `1`, `2`, `5`, and larger counts in these
strings. For those locales, CLDR correctly models the plural category as
`other` only.

Before this change, Chatwoot still relied on Rails' default
English-style plural behavior for these locales. That meant a valid
Crowdin export containing only `other` could fail at runtime when Rails
received `count: 1` and looked for a missing `one` branch.

Keeping duplicate `one` keys would only fight Crowdin on every
translation sync. The runtime should instead follow the locale's plural
rules.

## What changed

- Added `rails-i18n` and enabled only its pluralization module.
- Added explicit `other`-only plural rules for Chatwoot's underscore
Chinese locale aliases, `zh_CN` and `zh_TW`.
- Removed redundant `one` keys from the affected Devise and `time_units`
translations.

## Validation

- Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`,
`zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and
`time_units.days` resolve with only `other` for `count: 1`.
- Ran YAML parse validation for all edited locale files.
- Ran `bundle exec rubocop Gemfile config/application.rb
config/initializers/i18n_pluralization.rb`.
2026-04-27 15:40:00 +05:30
Sivin VargheseandGitHub 06467057be fix: oversized email signature images in Letter render (#14144)
# Pull Request Template

## Description

This PR fixes an issue where signature images (with
`?cw_image_height=...`) render at their original large size in the email
bubble.

### Cause

Renderer output:

```html
<img src="..." height="24px" width="auto" />
```

Email UI and clients (Gmail, Outlook) apply CSS like:
`img { max-width: 100%; height: auto; }`
This overrides `height="24px"`.
Other channels work because they use inline styles (`style="height:
24px;"`).


### Solution

Use inline style instead:

```html
<img src="..." style="height: 24px;" />
```


### Why backend fix

* Fixes root cause and aligns Ruby + JS renderers
* Works in both Chatwoot UI and recipient inboxes
* Covers all email-rendered content
* Minimal change


Fixes
https://linear.app/chatwoot/issue/CW-6948/email-signature-image-renders-oversized-in-chatwoot-ui

## Type of change

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

## How Has This Been Tested?

#### Screenshots

**Before**
<img width="1637" height="377" alt="image"
src="https://github.com/user-attachments/assets/0477f6fb-3b95-4fc3-9ea8-f59b71e27f47"
/>


**After**
<img width="1637" height="289" alt="image"
src="https://github.com/user-attachments/assets/de5ea4c1-8452-4c5f-aeb1-e1e11e0fe7d5"
/>



## 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
2026-04-27 13:31:43 +05:30
Sivin VargheseandGitHub 8faa5a74b1 fix: prevent focus jump to title after new article auto-creates (#14145)
# Pull Request Template

## Description

When creating a help center article, typing a title and navigating into
the content auto-creates the article and switches the route
(`/articles/new` → `/articles/.../edit/:slug`). During this transition,
focus was jumping back to the title, interrupting editing.

This happened because `ArticleEditor` always autofocuses the title. On
route change, the component remounts and re-triggers focus. Now, after
auto-create, focus stays in the body as expected.


Fixes
https://linear.app/chatwoot/issue/CW-6951/issue-with-the-cursor-position-on-the-help-center-article-when

## Type of change

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

## How Has This Been Tested?

**Screencast**


https://github.com/user-attachments/assets/dac3f7c6-08c4-4df2-afb0-7731ee76424b



## 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
2026-04-27 13:30:51 +05:30
Aakash BakhleandGitHub a651949c33 fix: improve FAQ generation [AI-145] (#14062)
# Pull Request Template

## Description

- Fetch main content only from Firecrawl, exclude some tags to remove
boilerplate
- Prompt changes for FAQ generation

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

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

tested locally

## 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
2026-04-27 13:01:44 +05:30
2ada713f29 feat: Add bulk actions for help center articles (translate, status change, delete) (#14137)
Fixes
https://linear.app/chatwoot/issue/CW-6950/support-bulk-actions-for-publish-archive-move-to-draft-delete-in

How to test

1. Go to Help Center → Articles
2. Select articles using checkboxes → bulk bar appears
3. Click Publish/Draft/Archive → articles update, list refreshes
4. Click Delete → confirmation dialog → articles removed
5. Click Translate (requires Captain enabled) → select locale + category
→ translation starts
6. Try translating to a locale that already has translations → warning
with links to existing articles → "Overwrite and translate" proceeds
8. Single article: click three-dot menu → Translate → same dialog opens
for that article



https://github.com/user-attachments/assets/7c76495e-f89e-4456-92bd-a6639a9992f4

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:13:43 -07:00
PranavandGitHub 751c28d94d feat(ee): Add article translation via LLM in help center (#14136)
Adds the ability to translate help center articles to other languages using Captain's LLM infrastructure. Translated articles are created as drafts linked to the source article.

Fixes
https://linear.app/chatwoot/issue/CW-6901/translate-article-to-another-language

**How to test**

1. Navigate to Help Center → Articles for a portal with multiple locales
2. Click the three-dot menu on any article → "Translate"
3. Select a target language and category → click Translate
4. Switch to the target locale — the translated article appears as a
draft
5. Try translating the same article again — a warning shows the existing
translation with a link to open it in a new tab
6. Click "Overwrite and translate" to replace the existing translation


https://github.com/user-attachments/assets/1d2e991b-f0ac-403a-bcc1-2181b5731ea4
2026-04-24 08:51:26 -07:00
Sony MathewandGitHub 4959a1ff1e style: [CW-6876] Updated designs for invite email (#14090)
Improved the design for the invite emails
2026-04-24 19:56:01 +05:30
Sony MathewandGitHub 661608c0b1 fix: [CW-6931] Harden external downloads against SSRF [avatar from url job] (#14153)
This routes external downloads used by avatar sync through SafeFetch. It closes the SSRF exposure from raw Down.download paths, preserves provider-specific auth and header flows, and adds regression coverage
for blocked internal URLs plus authenticated downloads.
Fixes # (issue): [CW-6931](https://linear.app/chatwoot/issue/CW-6931/avatarwidget-url-ssrf-downdownload-unprotected-unauth)
2026-04-24 18:59:45 +05:30
Kenta IshizakiandGitHub c5fb8d73cc fix: Prepend UTF-8 BOM to contact CSV export for non-ASCII character support (#14123)
## Description

Spreadsheet applications such as Microsoft Excel do not auto-detect
UTF-8 encoding when opening CSV files. This causes non-ASCII characters
(Arabic, Japanese, Chinese, Korean, etc.) to appear garbled in the
exported contacts CSV.

This PR prepends the UTF-8 Byte Order Mark (`EF BB BF`) to the CSV
output in `Account::ContactsExportJob`, which signals to spreadsheet
applications that the file is UTF-8 encoded.

Fixes: #13998
2026-04-24 18:06:25 +05:30
Kenta IshizakiandGitHub 9a89e1f522 fix: Strip UTF-8 BOM in DataImportJob#csv_reader before parsing CSV (#14126)
## Description

`DataImportJob#csv_reader` reads CSV data with `force_encoding('UTF-8')`
but does not strip the UTF-8 Byte Order Mark (`EF BB BF`). If a CSV file
containing a BOM is imported, the first header key is prefixed with
`\uFEFF`, which causes key mismatches in `DataImport::ContactManager`
when the first column is one of the recognized keys (`:email`,
`:identifier`, `:phone_number`, `:name`).

This was identified during review of #14123 (see #14124 for the tracking
issue).

Fixes #14124

## Type of change

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

## How Has This Been Tested?

- Added a new fixture (`spec/fixtures/data_import/with_bom.csv`)
containing a UTF-8 BOM followed by valid contact data.
- Added a new spec (`will strip UTF-8 BOM and import contacts
correctly`) that imports the BOM fixture and verifies that `name`,
`email`, and `phone_number` are all correctly parsed.
- All existing examples in `spec/jobs/data_import_job_spec.rb` continue
to pass.

## 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
2026-04-24 17:03:03 +05:30
667cd1ba1f fix: widget et translation (#14119)
Added missing et translations for widget

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-04-24 16:41:30 +05:30
Vishnu NarayananandGitHub ecdeb891ff fix(spec): pin SafeFetch error classes to described_class to survive Zeitwerk reload (#14139) 2026-04-23 18:18:05 +04:00
Muhsin KelothandGitHub 223e6402d8 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-23 11:32:13 +04:00
Aakash BakhleandGitHub 2182165201 feat: sync documents job [AI-142] (#14057)
# Pull Request Template

## Description

Document auto-sync job pipeline without wiring to controllers or FE

Fixes # (issue)

## Type of change

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

## How Has This Been Tested?

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

## 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
2026-04-22 23:36:32 +05:30
Vishnu NarayananandGitHub ce34f93917 fix: index email subject from conversation for outbound messages (#14122)
## Summary

Outbound email messages never populate
`content_attributes.email.subject`. The subject lives only on the parent
conversation's `additional_attributes.mail_subject`. The search index
doc built by `Messages::SearchDataPresenter` pulls from the
message-level field only, so outbound email subjects are unsearchable
for accounts on the advanced_search (Elasticsearch) path.

This change makes the presenter fall back to
`conversation.additional_attributes.mail_subject` when the message-level
subject is blank. Inbound email messages keep their existing behavior
(message-level subject takes precedence).

Closes https://linear.app/chatwoot/issue/CW-6877

## What changes

- `Messages::SearchDataPresenter#content_attributes_data` now falls back
to the conversation's `mail_subject` when the message-level subject is
blank.
- Added specs covering the fallback, precedence, and the neither-set
case.

## What does not change

- No schema changes, no migrations, no backfill of existing OpenSearch
documents.
- Searchkick's `after_commit :reindex_for_search` on `Message` will pick
up the new field for all newly created or updated messages via the
existing indexing path.
- Postgres search path (free accounts, fallback) is untouched. Broader
subject search for those users is a separate follow-up.
2026-04-22 20:36:35 +05:30
Sivin VargheseandGitHub 475db85318 fix: prevent template picker dropdown cut-off in compose form (#14129) 2026-04-22 17:13:26 +05:30
f12118a3c0 fix: void topup invoice when card payment fails (#14104)
When a credit top-up's card charge fails, the finalized invoice was left
in an open state with no hook back into our fulfillment service. If that
invoice was later paid (manually via the hosted invoice page, or by a
future dunning flow), the account would never receive its credits —
silent revenue loss.

This change voids the invoice the moment the charge fails, so a failed
top-up cannot turn into a paid-but-unfulfilled invoice.

## Closes

<!-- add the relevant issue / Linear link -->

## How to test

1. On a Business-plan account with a Stripe customer, attach a test card
that will decline on charge (e.g. `4000 0000 0000 0002`).
2. From the dashboard, open the credit top-up flow and purchase a credit
pack.
3. Observe the API returns an error and the account's captain credits
are unchanged.
4. In the Stripe dashboard, confirm the corresponding invoice is in
`void` status (not `open`).
5. Repeat with a good card (`4242 4242 4242 4242`) and confirm the happy
path still fulfills credits.

## What changed

- `finalize_and_pay` in `Enterprise::Billing::TopupCheckoutService` now
rescues `Stripe::CardError`, voids the open invoice via
`Stripe::Invoice.void_invoice`, and re-raises so the controller surfaces
the original decline error to the client.
- Rescue is intentionally narrow to `Stripe::CardError` (declines,
insufficient funds, SCA `authentication_required`). Transient errors
like `APIConnectionError` / `RateLimitError` are left to propagate — the
charge may have actually succeeded and voiding could be wrong.
- Invoices are created with `auto_advance: false`, so Stripe's Smart
Retries won't collect on an open invoice; voiding is the correct
terminal state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:21:26 +07:00
Muhsin KelothandGitHub ca66218cb9 Revert: Validate Twilio webhook signatures (X-Twilio-Signature) (#14125)
Reverts chatwoot/chatwoot#13638
2026-04-21 19:16:08 +04:00
Muhsin KelothandGitHub e6138739ad Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-21 13:23:15 +04:00
Muhsin KelothandGitHub 6d6133d5c9 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-21 13:23:06 +04:00
Muhsin KelothandGitHub 6cf5f323fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 20:28:13 +04:00
Muhsin KelothandGitHub 85f522c693 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 20:28:02 +04:00
Muhsin KelothandGitHub 17a17ad00c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 15:26:25 +04:00
Muhsin KelothandGitHub d365a5fe15 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 15:26:12 +04:00
Muhsin 98ba6e5200 refactor(voice): drop content_attributes.data from voice_call messages
Nothing reads it anymore — frontend uses message.call, CallMessageBuilder
uses call.message (the FK), and the jsonb fallback finder was dead code
(call.message_id is always linked immediately after message creation, in
the same transaction).

New voice_call messages are now created with blank content_attributes.
Existing rows keep their legacy payload; nothing reads it, so it's inert.
Safe to backfill with `Message.voice_calls.update_all(content_attributes: {})`
if you want a tidy table.
2026-04-20 14:38:57 +04:00
Muhsin 02f1fdf3b6 fix(voice): avoid Jbuilder's reserved call keyword in message serializer
Jbuilder defines Jbuilder#call(object, *attributes) as its core API for
extracting attributes. Using `json.call do ... end` collided with that
signature and raised "wrong number of arguments (given 0, expected 1+)".

Use `json.set! :call, message.call.push_event_data` instead — also
deduplicates the field list between the jbuilder and Message#push_event_data
(both now go through Call#push_event_data).
2026-04-20 14:30:42 +04:00
Muhsin c7b2a6f12c refactor(voice): stop duplicating call state in message.content_attributes
Now that the frontend reads from message.call (previous commit), the
message row is just a display anchor — all call state lives on the Call
record and is surfaced via the serializer / push_event_data.

- CallMessageBuilder shrinks to creating the bare voice_call message.
  content_attributes.data keeps only { call_sid } as a breadcrumb and a
  finder fallback for the edge case where message_id isn't linked yet.
- CallStatus::Manager stops re-writing the message on status transitions.
  call.message.touch triggers the standard message.updated dispatcher so
  sockets re-broadcast with the fresh Call embedded.
- Spec assertions switch from content_attributes.data.* to call.* +
  message.call object identity; status update spec asserts message is
  touched.
2026-04-20 13:42:11 +04:00
Muhsin c2a851c756 refactor(voice): read call state from message.call on the frontend
- VoiceCall bubble, voice.js helper, ConversationCard, and the store
  mutation all switch from message.content_attributes.data to message.call.
- Message.vue gains a `call` prop that's provided through
  useMessageContext for the bubble.
- UPDATE_MESSAGE_CALL_STATUS mutation now updates message.call.status,
  matched by provider_call_id.
- content_attributes.data is still emitted by the backend for this
  commit; backend-side trim lands in a follow-up.
2026-04-20 13:36:26 +04:00
Muhsin 5ce77eedbe feat(voice): embed Call in voice_call message payload
Adds a nested `call` object to the voice_call message JSON / push
payload so the frontend can read call state directly from message.call
instead of duplicating it in content_attributes.data.

- Message gains has_one :call (inverse of existing Call#belongs_to :message)
  via enterprise module prepend.
- Message serializer (_message.json.jbuilder) emits a call block for
  voice_call messages when the Call is linked.
- Message#push_event_data mirrors the payload into socket broadcasts.
- MessageFinder eager-loads the call (+ contact + inbox.channel) to
  avoid N+1 when listing conversation messages.
- Call gains helper methods: display_status, from_number, to_number,
  recording_url, push_event_data. These centralize the direction- and
  status-aware derivations so jbuilder and push_event_data stay in sync.

Frontend continues to read content_attributes.data — this commit only
adds the new shape; a follow-up swaps readers to message.call.
2026-04-20 13:33:43 +04:00
Muhsin KelothandGitHub 811d50043c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 12:27:32 +04:00
Muhsin KelothandGitHub 1a2e518341 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 12:25:00 +04:00
Muhsin 117742a779 fix(voice): guarantee conference_sid at TwiML render, consistent Call factory
- conference_twiml now ensures call.conference_sid is present before
  rendering; backfills via Voice::Conference::Name.for(call) if missing.
  Prevents invalid TwiML on Call rows created without the sid set.
- Call factory derives account/inbox/contact from the associated
  conversation so create(:call) is always internally consistent (no more
  mismatched account_id across associations).
2026-04-18 10:37:47 +04:00
Muhsin 081e578a57 fix(voice): validate conversation_id when resolving Call in ConferenceController
Require the resolved Call to belong to the passed conversation_id so a
request with mismatched (conversation_id, call_sid) fails instead of
silently operating on the call from call_sid. Restores the implicit
authorization the previous conversation-based lookup provided.
2026-04-17 23:38:40 +04:00
Muhsin 7635a538af fix(voice): harden agent-label parsing and prevent started_at drift
- Conference::Manager.extract_user_id now requires the account id in the
  participant label to match call.account_id, so a spoofed label can't
  attach a user from another account to the call.
- CallStatus::Manager keeps the earliest started_at across multiple
  in-progress webhooks (Twilio can emit answered + in-progress, plus
  retries) so duration_seconds isn't undercounted.
2026-04-17 23:02:28 +04:00
Muhsin d6d6f87145 fix(voice): O(1) voice_call message lookup and nil-safe content_attributes
- find_message prefers call.message (via the already-linked message_id)
  instead of scanning every voice_call message in the conversation. Falls
  back to a jsonb-indexed query by call_sid when message_id isn't set yet.
- update_message! tolerates a nil content_attributes column (nullable in
  the schema).
2026-04-17 22:52:13 +04:00
Muhsin d62037676c fix(voice): scope all VoiceController Call lookups to the inbox
Every Call lookup in the Twilio webhook controller is now constrained
to the inbox resolved from the URL phone number. Prevents tampered
params from attaching the agent leg or outbound leg to a Call belonging
to another inbox/account.

Factored out the common `inbox_calls` scope used by
find_call_for_agent, sync_outbound_leg, and find_call_for_conference!.
2026-04-17 22:49:46 +04:00
Muhsin 91da28a329 fix(voice): scope inbound Call lookup and handle create races
- Scope the existing-call pre-check to the inbound account + inbox.
- Rescue ActiveRecord::RecordNotUnique so a concurrent Twilio retry
  doesn't 500 when two webhooks race to create the same Call.
2026-04-17 22:45:39 +04:00
Muhsin 4282dec7d6 fix(voice): thread callSid through agent leg + conference API clients
Required by the stricter server-side call_sid requirements from the
previous commit. Without this, agents would fail to join or leave calls
because the client side still sent only conversation_id.

- TwilioVoiceClient.joinClientCall now forwards callSid as a Device.connect
  param, which becomes params[:call_sid] on the TwiML webhook.
- VoiceAPI.leaveConference takes callSid and passes it as a query param
  so the DELETE /conference endpoint can resolve the exact call.
- useCallSession and FloatingCallWidget pass callSid through.
2026-04-17 22:41:20 +04:00
Muhsin 12dc22b693 fix(voice): address codex review feedback on Call-model PR
- Guard against delayed conference-start retries rolling a progressed
  call back to ringing (Conference::Manager#mark_ringing!).
- Scope conference-status webhook Call lookup to the resolved inbox
  (VoiceController#find_call_for_conference!).
- Require explicit call_sid on the agent TwiML leg and on
  ConferenceController#create/destroy so multi-call conversations can't
  attach the wrong call (no more most-recent-active fallback).
- Scope StatusUpdateService Call lookup to the webhook's account for
  defense-in-depth.
- Update UPDATE_MESSAGE_CALL_STATUS mutation to match the voice_call
  message by call_sid; helper/voice.js now passes callSid. Prevents an
  older call's status update from clobbering the newest bubble.
2026-04-17 22:28:10 +04:00
Muhsin 07de9ae1f1 test(voice): update specs to match Call-model-backed voice flow
- Add calls factory.
- voice_controller_spec, conference_controller_spec, and the voice
  service specs now assert against Call records instead of
  conversation.identifier / additional_attributes.
- Scope Call lookup in ConferenceController to the authorized inbox so
  call_sid alone can't cross-inbox escalate.
2026-04-17 20:24:37 +04:00
Muhsin 47788f4d9c Delete voice_call_wiring.md 2026-04-17 19:47:38 +04:00
Muhsin bb4feec53e feat(voice): wire Twilio voice flow through unified Call model
Moves call state off conversation.additional_attributes and
conversation.identifier onto first-class Call records, one per call.

- Call is the source of truth for status, direction, duration, started_at,
  accepted_by_agent, and conference_sid (in meta).
- Conference names key off Call.id (conf_account_{aid}_call_{cid}), so
  multiple calls on one conversation no longer collide. lock_to_single_conversation
  inboxes append each call as a new voice_call bubble in the existing thread.
- Agent identity on conference join webhooks is parsed from the Twilio
  ParticipantLabel (agent-{user_id}-account-{account_id}); stateless and
  independent of the /conference#create API call order.
- ConversationCard.vue derives the call badge from the latest voice_call
  message, not a denormalized cache on the conversation. Removes the stale
  "Call ended" state that lingered after subsequent text messages.
2026-04-17 19:44:11 +04:00
Muhsin KelothandGitHub 3b89787c7f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-17 12:16:55 +04:00
Muhsin 94766e1200 chore: rescue errors only in delete_twiml_app 2026-04-14 15:29:16 +04:00
Muhsin 5a16a5a0ee test: stub incoming_phone_numbers in voice teardown spec
Teardown now also clears number-level voice_url/status_callback, so
the Twilio client double needs incoming_phone_numbers.list stubbed.
2026-04-14 15:24:44 +04:00
Muhsin b8df28c8a3 chore: restrict TwilioSms attrs to SMS medium 2026-04-14 14:36:44 +04:00
Muhsin a77a1c8ab4 chore: skip TwiML provisioning if no phone number 2026-04-14 14:31:04 +04:00
Muhsin 57821ab6f9 chore: clear Twilio number webhooks during teardown 2026-04-14 14:30:23 +04:00
Muhsin KelothandGitHub b4e27ed4cb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-14 14:10:12 +04:00
Muhsin KelothandGitHub 726193e3ac Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:45:18 +04:00
Muhsin KelothandGitHub 55fd3c62cc Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:16:05 +04:00
Muhsin KelothandGitHub 1a63c4089a Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-11 10:51:31 +04:00
Muhsin KelothandGitHub b98ce4b18c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 20:06:48 +04:00
Muhsin ddbd81b85e Update voice_teardown_service.rb 2026-04-08 13:32:14 +04:00
Muhsin 986f3ebc3a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:22:16 +04:00
Muhsin a6a612568a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:21:00 +04:00
Muhsin KelothandGitHub 7ebe9db77c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:18:34 +04:00
Muhsin KelothandGitHub fdf22a034f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:09:59 +04:00
Muhsin KelothandGitHub 14100580fb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 16:46:02 +04:00
Muhsin KelothandGitHub 453fcecfb4 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 15:45:18 +04:00
Muhsin KelothandGitHub 401ec4cd81 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 14:04:33 +04:00
Muhsin 38867ba75f chore: use API key client for token validation 2026-04-06 13:27:52 +04:00
Muhsin d3b7d7e79a chore: show voice config only if inbox webhook set 2026-04-06 12:29:51 +04:00
Muhsin cb275ce1d9 chore: remove voiceCall gating for tabs and settings 2026-04-06 11:29:48 +04:00
Muhsin e96fa6f561 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-06 11:27:24 +04:00
Muhsin 78296b9ce3 chore: require voice_call content for Twilio incoming 2026-04-06 11:27:16 +04:00
Muhsin KelothandGitHub 9c85a92e86 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 11:14:24 +04:00
Muhsin KelothandGitHub f7d4d2d58c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-04 17:48:37 +04:00
Muhsin KelothandGitHub fdce5910e0 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 16:10:34 +04:00
Muhsin aa15ddfef8 chore: enable channel_voice in inboxes spec 2026-04-01 15:38:58 +04:00
Muhsin d80a7454ce chore: run teardown_voice callback only on update 2026-04-01 15:28:08 +04:00
Muhsin b684f75631 Update twilio_sms_voice_spec.rb 2026-04-01 15:04:53 +04:00
Muhsin 6e0b10cb42 chore: expose API key secret and require it in UI 2026-04-01 15:01:54 +04:00
Muhsin 020bf6af93 chore: don't provision Twiml app if sid present 2026-04-01 14:58:50 +04:00
Muhsin a51af8ca68 chore: fix review comments 2026-04-01 14:28:11 +04:00
Muhsin dfdfc669ea chore: reserve api_key_secret and update voice UI 2026-04-01 14:22:50 +04:00
Muhsin 987d4d4a01 chore: revert unrelated changes 2026-04-01 13:04:49 +04:00
Muhsin 3471296394 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-01 13:01:57 +04:00
Muhsin 465b70ce27 chore: revert unrelated changes 2026-04-01 13:01:46 +04:00
Muhsin KelothandGitHub c90fdd6594 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 12:41:07 +04:00
Muhsin 6890293e60 chore: extract Twilio voice teardown into service 2026-03-28 12:39:02 +04:00
Muhsin 5c704691d7 chore: validate Twilio number voice capability 2026-03-27 15:01:45 +04:00
Muhsin 1946c373d7 chore: teardown Twilio voice resources when disabled 2026-03-27 14:16:17 +04:00
Muhsin 797d7790db chore: add feature flags 2026-03-27 13:43:30 +04:00
Muhsin 1d555cebfc chore: handle Twilio API Key SID conditionally 2026-03-27 13:37:04 +04:00
Muhsin 2b5d3300f3 chore: add Voice configuration UI and API fields 2026-03-26 18:28:29 +04:00
Muhsin ddfa6cea32 chore: fix the specs 2026-03-26 17:15:52 +04:00
Muhsin 85438cf58a chore: add Twilio voice support to TwilioSms channel 2026-03-26 16:40:33 +04:00
256 changed files with 8004 additions and 2397 deletions
+1
View File
@@ -84,6 +84,7 @@ gem 'barnes'
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
+4
View File
@@ -727,6 +727,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.5.2)
actionpack (= 7.1.5.2)
activesupport (= 7.1.5.2)
@@ -1125,6 +1128,7 @@ DEPENDENCIES
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails-i18n (~> 7.0)
redis
redis-namespace
responders (>= 3.1.1)
+5 -1
View File
@@ -44,7 +44,11 @@ class AccountBuilder
end
def create_account
@account = Account.create!(name: account_name, locale: I18n.locale)
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account
end
+2 -1
View File
@@ -29,8 +29,9 @@ class AgentBuilder
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
@@ -0,0 +1,43 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
end
private
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
email = account_params[:email].presence || @user&.email
return if email.blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
@@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
def show; end
@@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code]
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
).authenticate
return if authenticated
@@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :password)
params.permit(:otp_code, :backup_code, :password)
end
end
@@ -1,90 +0,0 @@
module TwilioSignatureVerifyConcern
extend ActiveSupport::Concern
included do
before_action :verify_twilio_signature!
end
private
def verify_twilio_signature!
channel = find_twilio_channel
return log_and_reject_missing_channel if channel.blank?
return if channel.api_key_sid.present? && log_api_key_skip(channel)
head :forbidden unless valid_signature?(channel)
end
def log_and_reject_missing_channel
Rails.logger.warn(
'[TWILIO] Channel not found for webhook ' \
"account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \
"to=#{params[:To]} from=#{params[:From]}"
)
head :forbidden
end
def log_api_key_skip(channel)
Rails.logger.warn(
'[TWILIO] Signature validation skipped: channel uses API key authentication. ' \
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id}"
)
end
def valid_signature?(channel)
signature = request.headers['X-Twilio-Signature']
if signature.blank?
Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}")
return false
end
validator = Twilio::Security::RequestValidator.new(channel.auth_token)
request_url = reconstruct_url
return true if validator.validate(request_url, request.request_parameters, signature)
Rails.logger.warn(
'[TWILIO] Signature validation failed ' \
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}"
)
false
end
def find_twilio_channel
if params[:MessagingServiceSid].present?
channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
if channel.present?
return channel if params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]
return nil
end
end
return if params[:AccountSid].blank?
find_channel_by_phone_number
end
def find_channel_by_phone_number
channel_lookup_phone_numbers.each do |phone|
channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone)
return channel if channel
end
nil
end
def channel_lookup_phone_numbers
[params[:To], params[:From]].compact_blank
end
def reconstruct_url
url = request.original_url
url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request?
url
end
def https_request?
return true if request.ssl?
forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?)
forwarded_proto&.casecmp?('https')
end
end
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
end
def show; end
@@ -1,6 +1,4 @@
class Twilio::CallbackController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
@@ -1,6 +1,4 @@
class Twilio::DeliveryStatusController < ApplicationController
include TwilioSignatureVerifyConcern
def create
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
@@ -20,8 +18,4 @@ class Twilio::DeliveryStatusController < ApplicationController
:ErrorMessage
)
end
def channel_lookup_phone_numbers
[params[:From]].compact_blank
end
end
+3 -1
View File
@@ -11,7 +11,9 @@ class MessageFinder
private
def conversation_messages
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
scope
end
def messages
+3 -3
View File
@@ -59,7 +59,6 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -107,8 +106,9 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
this.getAccount(this.currentAccountId);
account;
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
@@ -131,7 +131,7 @@ export default {
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
v-if="!authUIFlags.isFetching"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
async joinClientCall({ to, conversationId, callSid }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
To: to,
is_agent: 'true',
conversation_id: conversationId,
call_sid: callSid,
};
const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
leaveConference({ inboxId, conversationId, callSid }) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
params: { conversation_id: conversationId, call_sid: callSid },
})
.then(r => r.data);
}
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
category_slug: categorySlug,
});
}
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
return axios.post(
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
{ ids: articleIds, locale, category_id: categoryId, force }
);
}
bulkUpdateStatus({ portalSlug, articleIds, status }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
{ ids: articleIds, status }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
{ data: { ids: articleIds } }
);
}
}
export default new ArticlesAPI();
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, otpCode) {
disable(password, { otpCode, backupCode } = {}) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode },
data: { password, otp_code: otpCode, backup_code: backupCode },
});
}
@@ -0,0 +1,68 @@
/* global axios */
import ApiClient from './ApiClient';
class VoiceCallsAPI extends ApiClient {
constructor() {
super('voice_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`);
}
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
// it to Meta via pre_accept_call+accept_call.
accept(callId, { sdpAnswer } = {}) {
return axios.post(`${this.url}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`);
}
// Outbound: browser builds the offer first; Rails ships it to Meta and
// creates the Call record. Meta delivers its SDP answer later via the
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
initiate(conversationId, provider, { sdpOffer } = {}) {
return axios.post(`${this.url}/initiate`, {
conversation_id: conversationId,
provider,
sdp_offer: sdpOffer,
});
}
// Get the current agent's active call (if any). Used on page load to detect
// a stale active session so we can terminate it cleanly.
active() {
return axios.get(`${this.url}/active`);
}
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
// controller attaches it to the call's voice_call message; the after-create
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
uploadRecording(callId, blob, filename) {
const fd = new FormData();
fd.append('recording', blob, filename);
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
// needs the absolute account-scoped path).
uploadRecordingUrl(callId) {
return `${this.url}/${callId}/upload_recording`;
}
terminateUrl(callId) {
return `${this.url}/${callId}/terminate`;
}
}
export default new VoiceCallsAPI();
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -40,7 +40,6 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
@@ -68,15 +67,16 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.setActiveCall({
id: response.call_id,
callId: response.call_sid,
provider: response.provider || 'twilio',
direction: 'outbound',
status: 'ringing',
conversationId: response.conversation_id,
caller: null,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -9,11 +9,15 @@ import {
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -44,14 +48,46 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['openArticle', 'articleAction']);
const emit = defineEmits([
'openArticle',
'articleAction',
'toggleSelect',
'hover',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const articleMenuItems = computed(() => {
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
(acc, [key, item]) => {
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
const statusItems = (
ARTICLE_MENU_OPTIONS[props.status] ||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
).map(key => commonItems[key]);
)
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
});
@@ -123,14 +161,27 @@ const handleClick = id => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
</div>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
:autofocus="isNewArticle"
@blur="handleCreateArticle"
/>
<ArticleEditorControls
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
"
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
:autofocus="false"
:autofocus="!isNewArticle"
/>
</template>
</HelpCenterLayout>
@@ -20,8 +20,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
selectedArticleIds: {
type: Set,
default: () => new Set(),
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
const { ARTICLE_STATUS_TYPES } = wootConstants;
const router = useRouter();
@@ -30,12 +36,26 @@ const store = useStore();
const { t } = useI18n();
const localArticles = ref(props.articles);
const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
// Enable dragging only for category articles and when there's more than one article
return props.isCategoryArticles && localArticles.value?.length > 1;
return (
props.isCategoryArticles &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
});
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
const shouldShowSelectionControl = id => {
return hoveredArticleId.value === id || hasBulkSelection.value;
};
const handleCardHover = (isHovered, id) => {
hoveredArticleId.value = isHovered ? id : null;
};
const getCategoryById = useMapGetter('categories/categoryById');
const openArticle = id => {
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
};
const updateArticle = ({ action, value, id }) => {
if (action === 'translate') {
emit('translateArticle', id);
return;
}
const status = action !== 'delete' ? getArticleStatus(value) : null;
handleArticleAction(action, { status, id });
};
@@ -187,9 +211,14 @@ watch(
:category="getCategory(element.category.id)"
:views="element.views || 0"
:updated-at="element.updatedAt"
:is-selected="selectedArticleIds.has(element.id)"
selectable
:show-selection-control="shouldShowSelectionControl(element.id)"
:class="{ 'cursor-grab': dragEnabled }"
@open-article="openArticle"
@article-action="updateArticle"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, element.id)"
/>
</li>
</template>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
articles: {
@@ -39,7 +47,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['pageChange', 'fetchPortal']);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -47,6 +55,42 @@ const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetching = useMapGetter('articles/isFetching');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const { isEnterprise } = useConfig();
const isTranslationAvailable = computed(
() =>
isEnterprise &&
isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN_TASKS
)
);
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
const selectAllLabel = computed(() => {
if (!visibleArticleIds.value.length) return '';
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
count: visibleArticleIds.value.length,
});
});
const selectedCountLabel = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
count: selectedArticleIds.value.size,
})
);
const bulkTranslateDialogRef = ref(null);
const hasNoArticles = computed(
() => !isFetching.value && !props.articles.length
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
params: { categorySlug, locale },
});
};
const handleToggleSelect = articleId => {
const newSet = new Set(selectedArticleIds.value);
if (newSet.has(articleId)) {
newSet.delete(articleId);
} else {
newSet.add(articleId);
}
selectedArticleIds.value = newSet;
};
const clearSelection = () => {
selectedArticleIds.value = new Set();
};
const handleTranslateArticle = articleId => {
selectedArticleIds.value = new Set([articleId]);
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const openTranslateDialog = () => {
bulkTranslateDialogRef.value?.dialogRef?.open();
};
const onBulkActionSuccess = message => {
useAlert(message);
clearSelection();
emit('refreshArticles');
};
const bulkUpdateStatus = async status => {
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
const bulkDelete = async () => {
try {
await articlesAPI.bulkDelete({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
});
deleteConfirmDialogRef.value?.close();
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
);
} catch (error) {
deleteConfirmDialogRef.value?.close();
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
);
}
};
// Clear selection when articles change (page change, filter change)
watch(
() => props.articles,
() => clearSelection()
);
</script>
<template>
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
>
<Spinner />
</div>
<ArticleList
v-else-if="!hasNoArticles"
:articles="articles"
:is-category-articles="isCategoryArticles"
/>
<template v-else-if="!hasNoArticles">
<div
v-if="selectedArticleIds.size > 0"
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectedArticleIds"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
"
class="!px-1.5"
@click="clearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<Button
sm
faded
slate
icon="i-lucide-check"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('published')"
/>
<Button
sm
faded
slate
icon="i-lucide-pencil-line"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('draft')"
/>
<Button
sm
faded
slate
icon="i-lucide-archive-restore"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<Button
v-if="isTranslationAvailable"
sm
faded
slate
icon="i-lucide-languages"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="openTranslateDialog"
/>
<Button
sm
faded
ruby
icon="i-lucide-trash"
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="confirmBulkDelete"
/>
</div>
</template>
</BulkSelectBar>
</div>
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@toggle-select="handleToggleSelect"
/>
</template>
<ArticleEmptyState
v-else
class="pt-14"
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
@click="navigateToNewArticlePage"
/>
</template>
<BulkTranslateDialog
ref="bulkTranslateDialogRef"
:selected-article-ids="[...selectedArticleIds]"
:allowed-locales="allowedLocales"
@translate-started="clearSelection"
/>
<Dialog
ref="deleteConfirmDialogRef"
type="alert"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
selectedArticleIds.size
)
"
:description="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
selectedArticleIds.size
)
"
:confirm-button-label="
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
"
@confirm="bulkDelete"
/>
</HelpCenterLayout>
</template>
@@ -0,0 +1,249 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedArticleIds: {
type: Array,
default: () => [],
},
allowedLocales: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['translateStarted']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const dialogRef = ref(null);
const isSubmitting = ref(false);
const selectedLocale = ref('');
const selectedCategoryId = ref('');
const targetCategories = ref([]);
const isFetchingCategories = ref(false);
const duplicateArticles = ref([]);
const currentLocale = computed(() => route.params.locale);
const localeOptions = computed(() => {
return props.allowedLocales
.filter(locale => locale.code !== currentLocale.value)
.map(locale => ({
value: locale.code,
label: `${locale.name} (${locale.code})`,
}));
});
const categoryOptions = computed(() => {
return targetCategories.value.map(category => ({
value: category.id,
label: category.name,
}));
});
const articleCount = computed(() => props.selectedArticleIds.length);
const dialogTitle = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
);
const description = computed(() =>
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
);
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
const confirmLabel = computed(() => {
if (hasDuplicates.value) {
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
}
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
});
const isConfirmDisabled = computed(() => {
return !selectedLocale.value || isSubmitting.value;
});
const articleEditUrl = articleId => {
const { portalSlug, categorySlug, tab } = route.params;
const resolved = router.resolve({
name: 'portals_articles_edit',
params: {
portalSlug,
locale: selectedLocale.value,
categorySlug,
tab,
articleSlug: articleId,
},
});
return resolved.href;
};
const fetchCategoriesForLocale = async locale => {
if (!locale) {
targetCategories.value = [];
return;
}
isFetchingCategories.value = true;
try {
const { data } = await categoriesAPI.get({
portalSlug: route.params.portalSlug,
locale,
});
targetCategories.value = data.payload;
} catch {
targetCategories.value = [];
} finally {
isFetchingCategories.value = false;
}
};
watch(selectedLocale, newLocale => {
selectedCategoryId.value = '';
duplicateArticles.value = [];
fetchCategoriesForLocale(newLocale);
});
const resetForm = () => {
selectedLocale.value = '';
selectedCategoryId.value = '';
targetCategories.value = [];
duplicateArticles.value = [];
};
const submitTranslation = async (force = false) => {
isSubmitting.value = true;
try {
await store.dispatch('articles/bulkTranslate', {
portalSlug: route.params.portalSlug,
articleIds: props.selectedArticleIds,
locale: selectedLocale.value,
categoryId: selectedCategoryId.value,
force,
});
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
resetForm();
dialogRef.value?.close();
emit('translateStarted');
} catch (error) {
if (error.response?.status === 409) {
duplicateArticles.value = error.response.data.duplicate_articles;
return;
}
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
);
} finally {
isSubmitting.value = false;
}
};
const onConfirm = () => {
if (isConfirmDisabled.value) return;
submitTranslation(hasDuplicates.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="description"
:confirm-button-label="confirmLabel"
:disable-confirm-button="isConfirmDisabled"
:is-loading="isSubmitting"
@close="resetForm"
@confirm="onConfirm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
</span>
<ComboBox
v-model="selectedLocale"
:options="localeOptions"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
<span class="text-n-slate-10 font-normal">
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
</span>
</span>
<ComboBox
v-model="selectedCategoryId"
:options="categoryOptions"
:disabled="!selectedLocale || isFetchingCategories"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
<div
v-if="hasDuplicates"
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
>
<Icon
icon="i-lucide-triangle-alert"
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
/>
<div class="flex flex-col gap-2 min-w-0">
<p class="text-sm text-n-amber-12 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
duplicateArticles.length
)
}}
</p>
<div class="flex flex-col gap-1">
<a
v-for="article in duplicateArticles"
:key="article.id"
:href="articleEditUrl(article.id)"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
>
{{ article.title }}
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
</a>
</div>
<p class="text-xs text-n-amber-11 m-0">
{{
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
)
}}
</p>
</div>
</div>
</div>
</Dialog>
</template>
@@ -233,6 +233,7 @@ onMounted(() => resetContacts());
<Popover
ref="popoverRef"
:align="align"
:show-content-border="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -20,6 +18,7 @@ const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false },
isTwilioWhatsAppInbox: { type: Boolean, default: false },
// eslint-disable-next-line vue/no-unused-properties
messageTemplates: { type: Array, default: () => [] },
channelType: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
@@ -29,6 +28,7 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -81,11 +81,13 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const voiceCallEnabled = computed(() => props.voiceEnabled);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
props.hasSelectedInbox &&
isRegularMessageMode.value &&
!voiceCallEnabled.value
);
});
@@ -110,7 +112,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && !voiceCallEnabled.value) setSignature();
});
},
{ immediate: true }
@@ -198,7 +200,6 @@ useEventListener(document, 'paste', onPaste);
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<ContentTemplateSelector
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -442,6 +444,7 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<ContentTemplateParser
:template="template"
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import ContentTemplateForm from './ContentTemplateForm.vue';
const props = defineProps({
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const contentTemplates = computed(() => {
const inboxData = inbox.value(props.inboxId);
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
);
});
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ph-whatsapp-logo"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
<div class="w-full">
<Input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
)
"
custom-input-class="ltr:pl-10 rtl:pr-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
<div
v-for="template in filteredTemplates"
:key="template.content_sid"
tabindex="0"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-12">{{
template.friendly_name
}}</span>
</div>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<ContentTemplateForm
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<ContentTemplateForm
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
const handlePopoverShow = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
selectedTemplate.value = null;
};
const handlePopoverHide = () => {
selectedTemplate.value = null;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
const handleSendMessage = (template, hide) => {
emit('sendMessage', template);
selectedTemplate.value = null;
hide();
};
</script>
<template>
<div class="relative">
<Popover
align="start"
disable-mobile-view
@show="handlePopoverShow"
@hide="handlePopoverHide"
>
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<template #content="{ hide }">
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
v-if="!selectedTemplate"
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
<div class="relative w-full">
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
}}
</p>
</template>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
<WhatsappTemplate
v-else
:template="selectedTemplate"
@send-message="payload => handleSendMessage(payload, hide)"
@back="handleBack"
/>
</template>
</Popover>
</template>
@@ -24,9 +24,7 @@ const handleBack = () => {
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@@ -135,6 +135,16 @@ export function useContactFilterContext() {
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
@@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = {
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
COMPANY_NAME: 'company_name',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -30,6 +30,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
@@ -82,6 +86,7 @@ onMounted(() => {
defineExpose({
focus: () => inlineInputRef.value?.focus(),
blur: () => inlineInputRef.value?.blur(),
});
</script>
@@ -106,6 +111,7 @@ defineExpose({
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@@ -113,6 +113,7 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
@@ -1,7 +1,10 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -30,8 +33,11 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const router = useRouter();
const { contentAttributes, messageType, attachments } = useMessageContext();
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
// applies useCamelCase(messages, { deep: true }) before rendering.
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
@@ -40,6 +46,52 @@ const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
// Call source and metadata — all camelCase due to deep transform
const callSource = computed(() => data.value?.callSource);
const isVoiceCall = computed(() =>
['whatsapp', 'twilio'].includes(callSource.value)
);
const callId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
// Recording and transcript live on the first audio attachment. After the
// call ends, CallRecordingFetchJob creates an Attachment on this message;
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
const audioAttachment = computed(
() => attachments.value?.find(a => a.fileType === 'audio') || null
);
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
const transcript = computed(() => audioAttachment.value?.transcribedText);
const isJoining = ref(false);
const isRejecting = ref(false);
const showTranscript = ref(false);
// Reject is shown only for ringing inbound calls — outbound calls and
// in-progress calls don't need a reject button (initiator hangs up via the
// floating widget).
const showRejectButton = computed(
() =>
isVoiceCall.value &&
!isOutbound.value &&
status.value === VOICE_CALL_STATUS.RINGING
);
const formattedDuration = computed(() => {
const seconds = durationSeconds.value;
if (!seconds || seconds <= 0) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
});
// Show join/accept button logic
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
// DTLS fingerprint), so only ringing calls show the Accept button.
const showJoinButton = computed(
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -53,6 +105,19 @@ const labelKey = computed(() => {
});
const subtextKey = computed(() => {
// acceptedBy on outbound calls is the initiator, not the contact — so keep
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
// should suppress the subtext in favor of the acceptedBy line.
if (
!isOutbound.value &&
acceptedBy.value?.name &&
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
status.value
)
) {
return null;
}
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
@@ -64,12 +129,50 @@ const subtextKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const answeredByText = computed(() => {
if (isOutbound.value) return '';
if (!acceptedBy.value?.name) return '';
return acceptedBy.value.name;
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const handleJoinCall = async () => {
if (isJoining.value || !isVoiceCall.value) return;
isJoining.value = true;
try {
const result = await acceptVoiceCallById(callId.value);
if (result?.success && result.call) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: result.call.conversationId },
});
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Accept from bubble failed:', err);
} finally {
isJoining.value = false;
}
};
const handleRejectCall = async () => {
if (isRejecting.value || !callId.value) return;
isRejecting.value = true;
try {
await VoiceCallsAPI.reject(callId.value);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Reject from bubble failed:', err);
} finally {
isRejecting.value = false;
}
};
</script>
<template>
@@ -90,14 +193,90 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
<span v-if="answeredByText" class="text-xs text-n-slate-11">
{{
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
name: answeredByText,
})
}}
</span>
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
<span
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
class="text-xs text-n-slate-10"
>
{{ formattedDuration }}
</span>
</div>
<button
v-if="showRejectButton"
:disabled="isRejecting"
:title="$t('WHATSAPP_CALL.REJECT')"
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isRejecting }"
@click="handleRejectCall"
>
<i
v-if="isRejecting"
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
/>
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
</button>
<button
v-if="showJoinButton"
:disabled="isJoining"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isJoining }"
@click="handleJoinCall"
>
<i
v-if="isJoining"
class="i-ph-circle-notch-bold text-sm animate-spin"
/>
<i v-else class="i-ph-phone-bold text-sm" />
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
</button>
</div>
<div
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-2"
>
<audio controls class="w-full h-8" :src="recordingUrl">
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
</audio>
</div>
<div
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-3"
>
<button
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
@click="showTranscript = !showTranscript"
>
<i
class="text-sm"
:class="
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
"
/>
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
</button>
<p
v-if="showTranscript"
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
>
{{ transcript }}
</p>
</div>
</div>
</BaseBubble>
@@ -12,6 +12,14 @@ const props = defineProps({
default: 'end',
validator: v => ['start', 'end'].includes(v),
},
disableMobileView: {
type: Boolean,
default: false,
},
showContentBorder: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['show', 'hide']);
@@ -22,7 +30,8 @@ const popoverRef = ref(null);
const mobileContentRef = ref(null);
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
const belowMd = breakpoints.smaller('md');
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
const showPopover = computed(() => isActive.value && !isMobile.value);
const { fixedPosition, updatePosition } = useDropdownPosition(
@@ -99,9 +108,13 @@ defineExpose({ show, hide, toggle });
{ ignore: clickOutsideIgnore },
]"
data-popover-content
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
>
<slot name="content" :hide="hide" />
</div>
</div>
</div>
@@ -113,9 +126,14 @@ defineExpose({ show, hide, toggle });
data-popover-content
:class="fixedPosition.class"
:style="fixedPosition.style"
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
>
<slot name="content" :hide="hide" />
<div
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
:class="{ 'border border-n-strong': showContentBorder }"
>
<slot name="content" :hide="hide" />
</div>
</div>
</TeleportWithDirection>
</template>
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
@@ -0,0 +1,244 @@
<script setup>
import { watch, onUnmounted, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const { t } = useI18n();
const router = useRouter();
const {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
} = useVoiceCallSession();
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
// completes (not when the agent clicks accept). Listen for this event.
const onAgentWebRTCConnected = () => {
startDurationTimer();
};
const onPermissionGranted = ({ contactName }) => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
type: 'success',
});
};
onMounted(() => {
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.on('voice_call:permission_granted', onPermissionGranted);
});
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
const startAutoRejectTimer = call => {
if (autoRejectTimers.has(call.callId)) return;
const timer = setTimeout(() => {
dismissIncomingCall(call);
autoRejectTimers.delete(call.callId);
}, 30000);
autoRejectTimers.set(call.callId, timer);
};
const clearAutoRejectTimer = callId => {
const timer = autoRejectTimers.get(callId);
if (timer) {
clearTimeout(timer);
autoRejectTimers.delete(callId);
}
};
const handleAccept = async call => {
clearAutoRejectTimer(call.callId);
await acceptCall(call);
if (activeCall.value) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
const handleReject = async call => {
clearAutoRejectTimer(call.callId);
await rejectCall(call);
};
const handleEndCall = async () => {
await endActiveCall();
};
// Start auto-reject timers for each newly added incoming call
watch(
incomingCalls,
calls => {
calls.forEach(call => startAutoRejectTimer(call));
},
{ immediate: true, deep: true }
);
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.off('voice_call:permission_granted', onPermissionGranted);
});
</script>
<template>
<div
v-if="hasIncomingCall || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
>
<!-- Error banner -->
<div
v-if="callError"
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
>
{{ callError }}
</div>
<!-- Incoming calls (shown when there's no active call yet) -->
<template v-if="!hasActiveCall">
<div
v-for="call in incomingCalls"
:key="call.callId"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="call.caller?.avatar"
:name="call.caller?.name || call.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
call.caller?.name ||
call.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{
call.provider === 'twilio'
? 'Incoming voice call'
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.REJECT')"
@click="handleReject(call)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
:disabled="isAccepting"
:title="t('WHATSAPP_CALL.ACCEPT')"
@click="handleAccept(call)"
>
<i
v-if="isAccepting"
class="text-lg text-white i-ph-circle-notch animate-spin"
/>
<i v-else class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</template>
<!-- Active call widget -->
<div
v-if="hasActiveCall"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': isOutboundRinging }"
>
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
activeCall.caller?.name ||
activeCall.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
<template v-if="isOutboundRinging">
{{ t('WHATSAPP_CALL.RINGING') }}
</template>
<template v-else>
{{ formattedCallDuration }}
</template>
</p>
</div>
<div class="flex shrink-0 gap-2">
<!-- Mute toggle -->
<button
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-4 hover:bg-n-slate-5'
"
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
@click="toggleMute"
>
<i
class="text-base text-white"
:class="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
/>
</button>
<!-- Hang up -->
<button
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.HANG_UP')"
@click="handleEndCall"
>
<i class="text-base text-white i-ph-phone-x-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const showMetaSection = computed(() => {
return (
@@ -13,6 +13,14 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import {
prepareOutboundOffer,
cleanupInboundWebRTC,
} from 'dashboard/composables/useVoiceCallSession';
const props = defineProps({
chat: {
@@ -30,7 +38,10 @@ const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
useInbox();
const voiceCallsStore = useVoiceCallsStore();
const isInitiatingCall = ref(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -91,6 +102,99 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
// Returns the provider to use for an outbound call, based on the inbox
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
// use :twilio. Returns null if outbound calling isn't supported on this
// channel.
const callProvider = computed(() => {
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
return 'whatsapp';
}
if (voiceCallEnabled.value) {
return 'twilio';
}
return null;
});
const canInitiateVoiceCall = computed(() => {
if (!callProvider.value) return false;
if (voiceCallsStore.hasVoiceCall) return false;
return true;
});
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
// the browser builds the SDP offer locally before any network call, so Meta
// gets the agent's real fingerprint and the eventual answer applies cleanly
// in the cable handler. Twilio still POSTs first and waits for the
// synchronous agent_offer in the response (its OutboundCallBuilder produces
// the SDP server-side). On 138006 (no call permission) we tear down the local
// PC immediately so the mic light goes off while the contact's phone shows
// the consent prompt.
const initiateWhatsappCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
return;
isInitiatingCall.value = true;
const isWhatsApp = callProvider.value === 'whatsapp';
try {
let sdpOffer = null;
if (isWhatsApp) {
sdpOffer = await prepareOutboundOffer({});
}
const response = await VoiceCallsAPI.initiate(
currentChat.value.id,
callProvider.value,
isWhatsApp ? { sdpOffer } : {}
);
const callStatus = response.data?.status;
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
// Tear down the local PC; we'll rebuild from scratch on retry.
cleanupInboundWebRTC();
const message =
callStatus === 'permission_requested'
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
: t('WHATSAPP_CALL.PERMISSION_PENDING');
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
return;
}
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
const callData = response.data || {};
voiceCallsStore.setActiveCall({
id: callData.id,
callId: callData.call_id,
provider: callData.provider || callProvider.value,
direction: 'outbound',
status: 'ringing',
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
cleanupInboundWebRTC();
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
</script>
<template>
@@ -152,6 +256,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width"
class="hidden md:flex"
/>
<button
v-if="canInitiateVoiceCall"
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
:disabled="isInitiatingCall"
@click="initiateWhatsappCall"
>
<i
v-if="isInitiatingCall"
class="text-base i-ph-circle-notch animate-spin"
/>
<i v-else class="text-base i-ph-phone-bold" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -47,7 +47,11 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -211,11 +215,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
// Test Voice (Twilio with voice_enabled)
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
expect(wrapper.vm.voiceCallEnabled).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -274,7 +278,8 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
expectedProperties.forEach(prop => {
@@ -0,0 +1,36 @@
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { useAlert } from 'dashboard/composables';
/**
* Detects a stale active call on page load and cleans it up. Direct
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
* Best we can do is terminate the stranded Call so the agent isn't shown
* "in a call" forever.
*/
export function useCallReconnection() {
const callsStore = useVoiceCallsStore();
const { t } = useI18n();
onMounted(async () => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
try {
const { data } = await VoiceCallsAPI.active();
if (!data?.call && !data?.id) return;
const activeCallData = data.call || data;
try {
await VoiceCallsAPI.terminate(activeCallData.id);
} catch {
// best-effort — Rails-side cleanup
}
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
} catch {
callsStore.clearActiveCall();
}
});
}
@@ -42,8 +42,8 @@ export function useCallSession() {
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
@@ -66,6 +66,7 @@ export function useCallSession() {
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
callSid,
});
callsStore.setCallActive(callSid);
@@ -1,7 +1,11 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
return {
inbox,
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
voiceCallEnabled,
voiceCallProvider,
};
};
@@ -0,0 +1,654 @@
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Timer from 'dashboard/helper/Timer';
import { emitter } from 'shared/helpers/mitt';
// ─────────────────────────────────────────────────────────────────────────────
// Module-level WebRTC + recorder state. One active peer per browser tab.
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
// disambiguates so beforeunload doesn't warn during a clean hangup.
// ─────────────────────────────────────────────────────────────────────────────
let inboundPc = null;
let inboundStream = null;
let inboundAudio = null;
let audioContext = null;
let mediaRecorder = null;
let recordingMime = null;
let recordingChunks = [];
let recordingFilename = null;
let intentionallyClosing = false;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
function pickRecorderMime() {
if (typeof MediaRecorder === 'undefined') return null;
return (
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
);
}
function teardownRecorder() {
mediaRecorder = null;
recordingChunks = [];
recordingMime = null;
recordingFilename = null;
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
}
function extensionFromMime(mime) {
if (!mime) return 'webm';
if (mime.startsWith('audio/webm')) return 'webm';
if (mime.startsWith('audio/ogg')) return 'ogg';
return 'webm';
}
// Mix the agent's mic and Meta's remote audio into a single stream so the
// resulting Blob is a real conversation rather than two interleaved channels.
// Web Audio handles the mix; we never apply gain (raw levels matter for
// Whisper's speech-detection floor).
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
if (mediaRecorder || !localStream || !remoteStream) return;
const mime = pickRecorderMime();
if (!mime) {
// eslint-disable-next-line no-console
console.warn(
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
);
return;
}
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000,
});
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
const localSource = audioContext.createMediaStreamSource(localStream);
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
const mixDest = audioContext.createMediaStreamDestination();
localSource.connect(mixDest);
remoteSource.connect(mixDest);
recordingMime = mime;
recordingChunks = [];
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size) recordingChunks.push(event.data);
};
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
// beacon to capture most of the call if the user closes the tab.
mediaRecorder.start(5000);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recorder setup failed:', err);
teardownRecorder();
}
}
// stop() returns nothing useful; the final dataavailable fires on the next
// task and onstop fires after that. Wait for onstop, then assemble the blob.
async function stopRecorderAndGetBlob() {
if (!mediaRecorder) return null;
const mr = mediaRecorder;
if (mr.state === 'inactive') {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
teardownRecorder();
return { blob, filename: recordingFilename };
}
return new Promise(resolve => {
const cleanup = () => {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const filename = recordingFilename;
teardownRecorder();
resolve({ blob, filename });
};
mr.onstop = cleanup;
try {
mr.stop();
} catch {
cleanup();
}
});
}
function cleanupInboundWebRTC() {
// Stop recorder first so the source nodes still see live tracks during
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
teardownRecorder();
if (inboundStream) {
inboundStream.getTracks().forEach(track => track.stop());
inboundStream = null;
}
if (inboundPc) {
inboundPc.close();
inboundPc = null;
}
if (inboundAudio) {
inboundAudio.srcObject = null;
if (inboundAudio.parentNode) {
inboundAudio.parentNode.removeChild(inboundAudio);
}
inboundAudio = null;
}
}
function waitForIceGatheringComplete(pc) {
return new Promise((resolve, reject) => {
if (pc.iceGatheringState === 'complete') {
resolve();
return;
}
let timeout = null;
const cleanup = () => {
clearTimeout(timeout);
pc.onicegatheringstatechange = null;
pc.oniceconnectionstatechange = null;
};
timeout = setTimeout(() => {
cleanup();
// eslint-disable-next-line no-console
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
resolve();
}, 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
cleanup();
resolve();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
cleanup();
reject(new Error('ICE connection failed'));
}
};
});
}
function attachRemoteTrackHandler(pc, callId, providerCallId) {
pc.ontrack = event => {
let remoteStream = event.streams && event.streams[0];
if (!remoteStream) {
if (!event.track) return;
remoteStream = new MediaStream([event.track]);
}
if (!inboundAudio) {
const audio = document.createElement('audio');
audio.autoplay = true;
document.body.appendChild(audio);
inboundAudio = audio;
}
inboundAudio.srcObject = remoteStream;
inboundAudio.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[Voice Call] audio.play() rejected:', err);
});
if (inboundStream) {
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
}
};
}
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
// cable event, opens its mic, builds the answer, and ships it back so Rails
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
async function prepareInboundAnswer({
callId,
providerCallId,
sdpOffer,
iceServers,
}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Outbound: browser builds the offer locally, before any network call. Rails
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
// later via the voice_call.outbound_connected cable event.
async function prepareOutboundOffer({
callId,
providerCallId,
iceServers,
} = {}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
// from the actionCable handler when voice_call.outbound_connected lands.
async function applyOutboundAnswer(sdpAnswer) {
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
if (inboundPc.signalingState !== 'have-local-offer') {
// Already applied — duplicate cable delivery. No-op rather than detonate.
return;
}
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Some browsers fire ontrack synchronously inside setRemoteDescription;
// others delay it. If we already have a remote stream, kick the recorder.
if (inboundAudio?.srcObject && inboundStream) {
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
}
}
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
// Standalone for the message bubble's Accept button. Branches on whether the
// store has an SDP offer (WhatsApp direct mode = always yes).
export async function acceptVoiceCallById(callId) {
const callsStore = useVoiceCallsStore();
if (callsStore.hasActiveCall) {
return { success: false, error: 'active_call_exists' };
}
let call = callsStore.incomingCalls.find(
c => c.id === callId || c.callId === String(callId)
);
let sdpOffer = call?.sdpOffer;
let iceServers = call?.iceServers;
let providerCallId = call?.callId;
if (!sdpOffer) {
const { data } = await VoiceCallsAPI.show(callId);
if (data.status !== 'ringing') {
return { success: false, error: 'not_ringing' };
}
sdpOffer = data.sdp_offer;
iceServers = data.ice_servers;
providerCallId = data.call_id;
call = {
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
sdpOffer,
iceServers,
};
callsStore.addIncomingCall(call);
}
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
// network call so the cable handler doesn't drop a frame on a null guard.
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
return { success: true, call: activeCallData };
} catch (err) {
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall?.();
throw err;
}
}
// Best-effort upload. Errors are swallowed because the call has already
// completed by the time we get here; surfacing failures isn't actionable.
async function uploadRecordingBlob(callId) {
if (!mediaRecorder && recordingChunks.length === 0) return;
try {
const result = await stopRecorderAndGetBlob();
if (!result?.blob || result.blob.size === 0) return;
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recording upload failed:', err);
}
}
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
export function useVoiceCallSession() {
const { t } = useI18n();
const callsStore = useVoiceCallsStore();
const getters = useStoreGetters();
const accountId = computed(() => getters.getCurrentAccountId.value);
const isAccepting = ref(false);
const isMuted = ref(false);
const callError = ref(null);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
const isOutboundRinging = computed(
() =>
activeCall.value?.direction === 'outbound' &&
activeCall.value?.status === 'ringing'
);
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
// the only one that ships audio to the server. uploadRecordingBlob
// gathers the blob via the recorder's onstop listener and then frees the
// recorder — only AFTER that completes do we close the PC and stop tracks.
callsStore.registerCleanupCallback(async callId => {
const idForUpload = callId || activeCall.value?.id;
durationTimer.stop();
callDuration.value = 0;
try {
if (idForUpload) await uploadRecordingBlob(idForUpload);
} finally {
cleanupInboundWebRTC();
}
});
// Warn the user if they try to navigate away mid-call. The actual cleanup
// happens in the pagehide handler — beforeunload is purely the warning.
const handleBeforeUnload = e => {
if (!callsStore.activeCall || intentionallyClosing) return undefined;
e.preventDefault();
e.returnValue = '';
// Some browsers (older Safari/Firefox) only honour a returned string.
// eslint-disable-next-line consistent-return
return '';
};
// Fires after the user confirms close (or on tab switch). We use fetch
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
// authenticates via devise-token-auth headers (`access-token`, `client`,
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
// fetch+keepalive sends both cookies AND custom headers and the request
// survives page unload (capped at ~64KB body, fine for terminate).
const handlePageHide = () => {
const call = callsStore.activeCall;
if (!call || intentionallyClosing) return;
// Stop the recorder synchronously; final dataavailable already fired via
// the 5s timeslice, so chunks[] is approximately current.
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
const sessionHeaders = (() => {
try {
return JSON.parse(
decodeURIComponent(
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
[])[2] || '%7B%7D'
)
);
} catch {
return {};
}
})();
const authHeaders = {
'access-token': sessionHeaders['access-token'] || '',
client: sessionHeaders.client || '',
uid: sessionHeaders.uid || '',
expiry: sessionHeaders.expiry || '',
'token-type': sessionHeaders['token-type'] || 'Bearer',
};
if (recordingChunks.length > 0 && accountId.value) {
try {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const fd = new FormData();
fd.append(
'recording',
blob,
recordingFilename || `call_${call.id}.webm`
);
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
{
method: 'POST',
body: fd,
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
if (accountId.value) {
try {
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
{
method: 'POST',
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
cleanupInboundWebRTC();
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
// Start timer when an outbound call transitions to connected.
watch(activeCall, call => {
if (
call?.direction === 'outbound' &&
call?.status === 'connected' &&
!durationTimer.intervalId
) {
durationTimer.start();
}
});
// Floating-widget Accept button. Same WhatsApp-direct flow as
// acceptVoiceCallById — see comments there.
const acceptCall = async call => {
if (isAccepting.value) return;
isAccepting.value = true;
callError.value = null;
const sdpOffer = call.sdpOffer;
const iceServers = call.iceServers;
if (!sdpOffer) {
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
isAccepting.value = false;
return;
}
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId: call.callId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
durationTimer.start();
emitter.emit('voice_call:agent_webrtc_connected');
} catch (err) {
callError.value =
err?.name === 'NotAllowedError'
? t('WHATSAPP_CALL.MIC_DENIED')
: t('WHATSAPP_CALL.CALL_FAILED');
// eslint-disable-next-line no-console
console.error('[Voice Call] acceptCall error:', err);
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall();
} finally {
isAccepting.value = false;
}
};
const rejectCall = async call => {
try {
await VoiceCallsAPI.reject(call.id);
} catch {
// Best effort
} finally {
callsStore.removeIncomingCall(call.callId);
}
};
const endActiveCall = async () => {
const call = activeCall.value;
if (!call) return;
intentionallyClosing = true;
try {
await uploadRecordingBlob(call.id);
} catch {
// Best effort
}
try {
await VoiceCallsAPI.terminate(call.id);
} catch {
// Best effort
} finally {
cleanupInboundWebRTC();
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
intentionallyClosing = false;
}
};
const toggleMute = () => {
if (!inboundStream) return;
const audioTrack = inboundStream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
};
const dismissIncomingCall = call => {
callsStore.removeIncomingCall(call.callId);
};
const startDurationTimer = () => {
durationTimer.start();
};
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
durationTimer.stop();
});
return {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
firstIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
};
}
@@ -109,11 +109,6 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
+1
View File
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
});
@@ -4,6 +4,8 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
const { isImpersonating } = useImpersonation();
@@ -34,6 +36,11 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.accepted': this.onVoiceCallAccepted,
'voice_call.ended': this.onVoiceCallEnded,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
};
}
@@ -200,6 +207,69 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.addIncomingCall({
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
// the bubble's Accept button can build the WebRTC answer locally
// without re-fetching from /show.
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
});
};
onVoiceCallAccepted = data => {
const voiceCallsStore = useVoiceCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
if (data.accepted_by_agent_id !== currentUserId) {
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
}
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.handleCallEnded(data.call_id);
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
// contact picks up. Apply it to the existing local RTCPeerConnection so
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
// by prepareOutboundOffer when the agent clicked the call button.
const voiceCallsStore = useVoiceCallsStore();
const activeCall = voiceCallsStore.activeCall;
if (!activeCall || activeCall.callId !== data.call_id) return;
if (!data.sdp_answer) return;
applyOutboundAnswer(data.sdp_answer)
.then(() => {
voiceCallsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallPermissionGranted = data => {
emitter.emit('voice_call:permission_granted', {
contactName: data.contact_name,
});
};
}
export default {
+20 -10
View File
@@ -11,9 +11,28 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -30,7 +49,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -45,7 +63,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -58,7 +75,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -97,9 +113,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -142,9 +155,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
action: 'archive',
icon: 'i-lucide-archive-restore',
},
translate: {
label:
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
value: 'translate',
action: 'translate',
icon: 'i-lucide-languages',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
};
export const ARTICLE_TABS = {
+9 -7
View File
@@ -23,11 +23,11 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
const call = message?.call || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
callSid: call.provider_call_id,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
@@ -60,9 +60,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
conversationId,
callStatus: status,
callSid,
});
const isNewCall =
status === 'ringing' &&
@@ -182,6 +182,7 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "Company",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -387,6 +387,7 @@
"IDENTIFIER": "Identifier",
"COUNTRY": "Country",
"CITY": "City",
"COMPANY": "Company",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity",
"REFERER_LINK": "Referer link",
@@ -83,7 +83,11 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"ANSWERED_BY": "Answered by {name}",
"ACCEPT_CALL": "Accept",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
"TRANSCRIPT": "Transcript"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent WebRTC connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -36,9 +36,11 @@ import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappCall from './whatsappCall.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import yearInReview from './yearInReview.json';
export default {
@@ -80,8 +82,10 @@ export default {
...sla,
...snooze,
...teamsSettings,
...whatsappCall,
...whatsappTemplates,
...contentTemplates,
...mfa,
...onboarding,
...yearInReview,
};
@@ -51,10 +51,14 @@
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -0,0 +1,34 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "Select timezone",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "Saving...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -0,0 +1,20 @@
{
"WHATSAPP_CALL": {
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
"ACCEPT": "Accept",
"REJECT": "Reject",
"HANG_UP": "Hang Up",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"INITIATE_CALL": "Call via WhatsApp",
"CALLING": "Calling…",
"RINGING": "Ringing…",
"CALL_FAILED": "Call failed. Please try again.",
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
"UNKNOWN_CALLER": "Unknown caller",
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
}
}
@@ -60,9 +60,7 @@ const onDelete = async hide => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-80 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-80 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
@@ -72,9 +72,7 @@ const onMergeContacts = async (parentContactId, hide) => {
<Popover @hide="$emit('close')">
<slot name="trigger" />
<template #content="{ hide }">
<div
class="w-full md:w-96 p-6 flex flex-col gap-4 border-0 md:border rounded-xl md:border-n-strong"
>
<div class="w-full md:w-96 p-6 flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ $t('MERGE_CONTACTS.TITLE') }}
@@ -20,11 +20,17 @@ const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
const WhatsappCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
export default {
components: {
@@ -36,6 +42,7 @@ export default {
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
WhatsappCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -44,6 +51,13 @@ export default {
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
const voiceCallsStore = useVoiceCallsStore();
// Detect a stale active WhatsApp call left over from a previous tab /
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
// path because Meta caches our DTLS fingerprint; the only safe action
// is to clean up server-side state and tell the agent the call dropped.
useCallReconnection();
return {
uiSettings,
@@ -51,8 +65,15 @@ export default {
accountId,
upgradePageRef,
windowWidth,
// Twilio voice still flows through useCallSession + the calls store.
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
// voiceCalls store. Both widgets can mount simultaneously; only one
// ever has state at a time because they listen to disjoint cable
// events.
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
};
},
data() {
@@ -163,6 +184,7 @@ export default {
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -53,6 +53,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'company_name',
attributeI18nKey: 'COMPANY',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
@@ -124,6 +132,10 @@ export const filterAttributeGroups = [
key: 'city',
i18nKey: 'CITY',
},
{
key: 'company_name',
i18nKey: 'COMPANY',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
@@ -12,6 +12,7 @@ import { routes as captainRoutes } from './captain/captain.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
export default {
routes: [
@@ -31,6 +32,14 @@ export default {
...campaignsRoutes.routes,
],
},
{
path: frontendURL('accounts/:accountId/onboarding'),
name: 'onboarding_account_details',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -119,6 +119,7 @@ watch(
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
@refresh-articles="fetchArticles"
/>
</div>
</template>
@@ -0,0 +1,417 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
COMPANY_SIZE_OPTIONS,
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
const userRole = ref('');
const website = ref('');
const locale = ref('');
const timezone = ref('');
const companySize = ref('');
const industry = ref('');
const referralSource = ref('');
const isSubmitting = ref(false);
const isEditingWebsite = ref(false);
const websiteInput = ref(null);
const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
locale: {},
timezone: {},
companySize: {},
industry: {},
referralSource: {},
};
const v$ = useVuelidate(validationRules, {
userRole,
website,
locale,
timezone,
companySize,
industry,
referralSource,
});
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
return (square || logos[0])?.url || '';
});
const languageOptions = computed(() => {
const langs = [...(enabledLanguages || [])];
return langs
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
});
const timezoneOptions = computed(() => {
try {
return Intl.supportedValuesOf('timeZone').map(tz => ({
value: tz,
label: tz.replace(/_/g, ' '),
}));
} catch {
return [];
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = account?.domain || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
};
const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
showErrorOnFields.value = true;
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
return;
}
isSubmitting.value = true;
try {
await updateAccount({
name: accountName.value,
locale: locale.value,
domain: website.value,
industry: industry.value,
company_size: companySize.value,
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
referral_source: referralSource.value,
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<OnboardingLayout
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
:is-loading="isSubmitting"
:disabled="isEnriching"
>
<OnboardingSection
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
icon="i-lucide-user"
>
<div class="flex items-center gap-2 px-3 py-3">
<Avatar :name="userName" :size="16" rounded-full />
<span class="text-sm font-medium text-n-slate-12">
{{ userName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
icon="i-lucide-mail"
>
<div class="flex items-center justify-end gap-1.5">
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
<Icon
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
icon="i-lucide-circle-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
icon="i-lucide-user"
>
<OnboardingFormSelect
v-model="userRole"
:has-error="showErrorOnFields && v$.userRole.$error"
:options="USER_ROLE_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
/>
</OnboardingFormRow>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
icon="i-lucide-briefcase-business"
>
<div
v-if="isEnriching"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
</span>
</div>
<template v-else>
<div class="flex items-center gap-2 px-3 py-3">
<img
v-if="companyLogo"
:src="companyLogo"
:alt="accountName"
class="size-4 object-contain"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ accountName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
icon="i-lucide-globe"
>
<div class="flex items-center justify-end gap-2">
<InlineInput
ref="websiteInput"
v-model="website"
:readonly="!isEditingWebsite"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
:custom-input-class="[
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
{ 'animate-shake': showErrorOnFields && v$.website.$error },
]"
@enter-press="handleWebsiteEnter"
@blur="isEditingWebsite = false"
/>
<NextButton
type="button"
icon="i-lucide-pencil"
slate
xs
ghost
@click="enableWebsiteEditing"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
icon="i-lucide-languages"
>
<OnboardingFormSelect
v-model="locale"
:has-error="showErrorOnFields && v$.locale.$error"
:options="languageOptions"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
icon="i-lucide-clock"
>
<OnboardingFormSelect
v-model="timezone"
:has-error="showErrorOnFields && v$.timezone.$error"
:options="timezoneOptions"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
icon="i-lucide-factory"
>
<OnboardingFormSelect
v-model="industry"
:has-error="showErrorOnFields && v$.industry.$error"
:options="INDUSTRY_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
icon="i-lucide-users"
>
<OnboardingFormSelect
v-model="companySize"
:has-error="showErrorOnFields && v$.companySize.$error"
:options="COMPANY_SIZE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
icon="i-lucide-megaphone"
>
<OnboardingFormSelect
v-model="referralSource"
:has-error="showErrorOnFields && v$.referralSource.$error"
:options="REFERRAL_SOURCE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
"
/>
</OnboardingFormRow>
</template>
</OnboardingSection>
</OnboardingLayout>
</form>
</template>
@@ -0,0 +1,18 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
<div class="flex items-center gap-2">
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
<span class="text-n-slate-11">{{ title }}</span>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
hasError: { type: Boolean, default: false },
});
defineEmits(['update:modelValue']);
</script>
<template>
<div class="relative flex items-center justify-end">
<select
:value="modelValue"
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
:class="[
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
{ 'animate-shake': hasError },
]"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<Icon
icon="i-lucide-chevron-down"
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
/>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
</script>
<template>
<div
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
>
<!-- Grid background with corner fade -->
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[580px]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
width="1"
height="100%"
preserveAspectRatio="none"
>
<line
x1="0"
y1="0"
x2="0"
y2="97%"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="3 3"
/>
</svg>
<!-- Greeting -->
<div class="mb-6 -ms-12 flex items-start gap-4">
<div
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
>
<slot name="greeting-icon">
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
</slot>
</div>
<div>
<h1 class="text-heading-1 text-n-slate-12">
{{ greeting }}
</h1>
<p v-if="subtitle" class="text-sm text-n-slate-11">
{{ subtitle }}
</p>
</div>
</div>
<!-- Sections -->
<slot />
</div>
<!-- Continue button with curved connector -->
<div class="relative ps-12 overflow-visible">
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
<svg
width="48"
height="40"
viewBox="0 0 47 40"
fill="none"
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
>
<defs>
<linearGradient
id="line-gradient"
x1="15"
y1="0"
x2="48"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
</linearGradient>
</defs>
<path
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
stroke="url(#line-gradient)"
stroke-width="1"
stroke-dasharray="3 3"
fill="none"
/>
</svg>
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="mb-5">
<!-- Section header with icon + triangles -->
<div class="flex items-center gap-4 mb-3 -ms-12">
<div class="flex flex-col items-center z-10 flex-shrink-0">
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
</svg>
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
>
<Icon :icon="icon" class="size-4 text-n-slate-11" />
</div>
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
</svg>
</div>
<span class="text-heading-3 text-n-slate-12">
{{ title }}
</span>
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,68 @@
export const COMPANY_SIZE_OPTIONS = [
{ value: '1-10', label: '1 - 10' },
{ value: '11-50', label: '11 - 50' },
{ value: '51-200', label: '51 - 200' },
{ value: '201-500', label: '201 - 500' },
{ value: '500+', label: '500+' },
];
export const INDUSTRY_OPTIONS = [
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
{
value: 'Automotive & Transportation',
label: 'Automotive & Transportation',
},
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
{
value: 'Construction & Built Environment',
label: 'Construction & Built Environment',
},
{
value: 'Consumer Packaged Goods (CPG)',
label: 'Consumer Packaged Goods (CPG)',
},
{ value: 'Education', label: 'Education' },
{ value: 'Entertainment', label: 'Entertainment' },
{ value: 'Finance', label: 'Finance' },
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
{ value: 'Healthcare', label: 'Healthcare' },
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
{ value: 'News & Media', label: 'News & Media' },
{
value: 'Professional Services & Agencies',
label: 'Professional Services & Agencies',
},
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
{ value: 'Sports', label: 'Sports' },
{ value: 'Technology', label: 'Technology' },
{ value: 'Telecommunications', label: 'Telecommunications' },
{ value: 'Other', label: 'Other' },
];
export const REFERRAL_SOURCE_OPTIONS = [
{ value: 'google', label: 'Google' },
{ value: 'reddit', label: 'Reddit' },
{ value: 'twitter', label: 'Twitter/X' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'friend', label: 'Friend/Colleague' },
{ value: 'blog', label: 'Blog/Article' },
{ value: 'github', label: 'GitHub' },
{ value: 'other', label: 'Other' },
];
export const USER_ROLE_OPTIONS = [
{ value: 'founder', label: 'Founder/CEO' },
{ value: 'product_manager', label: 'Product Manager' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'support_lead', label: 'Support Lead' },
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'other', label: 'Other' },
];
@@ -74,6 +74,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'labels',
name: 'LABELS',
@@ -180,6 +186,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'referer',
name: 'REFERER_LINK',
@@ -314,6 +326,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
@@ -460,6 +478,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -590,6 +614,12 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -145,6 +145,7 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -46,6 +47,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -169,19 +171,17 @@ export default {
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,7 +197,6 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -232,6 +231,24 @@ export default {
];
}
if (
this.isATwilioChannel &&
this.inbox.phone_number &&
this.inbox.medium === 'sms' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'voice-configuration',
name: this.$t('INBOX_MGMT.TABS.VOICE'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -812,7 +829,6 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1240,6 +1256,12 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,6 +12,10 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -30,7 +34,6 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,24 +208,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -0,0 +1,156 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextInput,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
};
},
computed: {
isVoiceConfigured() {
return !!this.inbox.voice_configured;
},
hasApiKeySid() {
return !!this.inbox.api_key_sid;
},
hasExistingCredentials() {
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
},
needsCredentials() {
return (
this.voiceEnabled &&
!this.isVoiceConfigured &&
!this.hasExistingCredentials
);
},
needsApiKeySid() {
return this.needsCredentials && !this.hasApiKeySid;
},
isSubmitDisabled() {
if (!this.voiceEnabled) return false;
if (this.needsCredentials) {
if (this.needsApiKeySid && !this.apiKeySid) return true;
return !this.apiKeySecret;
}
return false;
},
},
watch: {
'inbox.voice_enabled'(val) {
this.voiceEnabled = val || false;
},
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
},
methods: {
async updateVoiceSettings() {
this.isUpdating = true;
try {
const channelPayload = { voice_enabled: this.voiceEnabled };
if (this.needsCredentials) {
if (this.needsApiKeySid) {
channelPayload.api_key_sid = this.apiKeySid;
}
channelPayload.api_key_secret = this.apiKeySecret;
}
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: channelPayload,
});
this.apiKeySecret = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
@@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -54,10 +56,17 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: disableOtpCode.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -157,12 +168,32 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-if="!useBackupCodeToDisable"
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
<Input
v-else
v-model="disableBackupCode"
type="text"
maxlength="8"
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
/>
<Button
link
sm
type="button"
:label="
useBackupCodeToDisable
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
"
@click="toggleDisableMethod"
/>
</div>
</Dialog>
@@ -104,9 +104,9 @@ const cancelSetup = () => {
};
// Disable MFA
const disableMfa = async ({ password, otpCode }) => {
const disableMfa = async ({ password, otpCode, backupCode }) => {
try {
await mfaAPI.disable(password, otpCode);
await mfaAPI.disable(password, { otpCode, backupCode });
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
+24 -6
View File
@@ -4,13 +4,15 @@ import { frontendURL } from '../helper/URLHelper';
import dashboard from './dashboard/dashboard.routes';
import store from 'dashboard/store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const routes = [...dashboard.routes];
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = (to, next) => {
export const validateAuthenticateRoutePermission = async (to, next) => {
const { isLoggedIn, getCurrentUser: user } = store.getters;
if (!isLoggedIn) {
@@ -27,8 +29,25 @@ export const validateAuthenticateRoutePermission = (to, next) => {
return next(frontendURL('no-accounts'));
}
const routeAccountId = Number(to.params?.accountId || accountId);
const userAccount = accounts.find(a => a.id === routeAccountId);
const isAdmin = userAccount?.role === 'administrator';
const isActive = userAccount?.status === 'active';
const needsOnboarding =
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
isAdmin &&
isActive;
if (to.name === 'no_accounts' || !to.name) {
return next(frontendURL(`accounts/${accountId}/dashboard`));
const target = needsOnboarding ? 'onboarding' : 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
@@ -38,15 +57,14 @@ export const validateAuthenticateRoutePermission = (to, next) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
userAuthentication.then(() => {
return validateAuthenticateRoutePermission(to, next, store);
});
await userAuthentication;
await validateAuthenticateRoutePermission(to, next, store);
});
};
@@ -12,7 +12,9 @@ vi.mock('../store', () => ({
id: null,
accounts: [],
},
'accounts/getAccount': () => ({}),
},
dispatch: vi.fn(() => Promise.resolve()),
},
}));
@@ -60,14 +62,14 @@ describe('#validateAuthenticateRoutePermission', () => {
});
describe('when route is not accessible to current user', () => {
it('should redirect to dashboard', () => {
it('should redirect to dashboard', async () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
validateAuthenticateRoutePermission(to, next);
await validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
});
@@ -90,14 +92,14 @@ describe('#validateAuthenticateRoutePermission', () => {
};
});
it('should go to the intended route', () => {
it('should go to the intended route', async () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
validateAuthenticateRoutePermission(to, next);
await validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith();
});
@@ -54,18 +54,19 @@ export const getters = {
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
get: async ({ commit }, { silent } = {}) => {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
}
try {
const response = await AccountAPI.get();
commit(types.default.ADD_ACCOUNT, response.data);
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch {
// silent failure
} finally {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
}
}
},
update: async ({ commit }, { options, ...updateObj }) => {
@@ -269,6 +269,20 @@ export const mutations = {
accounts,
};
},
[types.RESET_ONBOARDING](_state, accountId) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === accountId) {
const { onboarding_step, ...rest } = account;
return rest;
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser;
},
@@ -307,34 +307,21 @@ export const mutations = {
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
[types.UPDATE_MESSAGE_CALL_STATUS](
_state,
{ conversationId, callStatus }
{ conversationId, callStatus, callSid }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
const message = (chat.messages || []).find(
m =>
m.content_type === CONTENT_TYPES.VOICE_CALL &&
m.call?.provider_call_id === callSid
);
if (!message?.call) return;
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
message.call = { ...message.call, status: callStatus };
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
@@ -2,55 +2,18 @@ import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
it('does nothing if no matching voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
@@ -59,6 +22,7 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
@@ -66,7 +30,7 @@ describe('#mutations', () => {
});
});
it('updates the last voice call message status', () => {
it('updates only the voice call message matching the given callSid', () => {
const state = {
allConversations: [
{
@@ -75,12 +39,12 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
call: { provider_call_id: 'CA111', status: 'ringing' },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
call: { provider_call_id: 'CA222', status: 'ringing' },
},
],
},
@@ -89,34 +53,15 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA111',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
expect(state.allConversations[0].messages[0].call.status).toBe(
'in-progress'
);
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
});
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
it('preserves existing call fields when updating status', () => {
const state = {
allConversations: [
{
@@ -125,8 +70,11 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
call: {
provider_call_id: 'CA123',
status: 'ringing',
direction: 'incoming',
duration_seconds: null,
},
},
],
@@ -136,12 +84,13 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA123',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA123',
status: 'in-progress',
direction: 'incoming',
duration_seconds: null,
});
});
@@ -152,8 +101,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages).toEqual([]);
});
it('does nothing if matching message has no call object yet', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
call: { provider_call_id: 'CA-OTHER' },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
callSid: 'CA-MISSING',
});
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA-OTHER',
});
});
});
});
@@ -166,4 +166,18 @@ export const actions = {
throw error;
}
},
bulkTranslate: async (
_,
{ portalSlug, articleIds, locale, categoryId, force = false }
) => {
const { data } = await articlesAPI.bulkTranslate({
portalSlug,
articleIds,
locale,
categoryId,
force,
});
return data;
},
};
@@ -57,4 +57,46 @@ describe('#mutations', () => {
);
});
});
describe('#RESET_ONBOARDING', () => {
it('removes onboarding_step from the targeted account', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{
id: 1,
onboarding_step: 'account_details',
role: 'administrator',
},
],
},
};
mutations[types.RESET_ONBOARDING](state, 1);
expect(state.currentUser.accounts[0]).not.toHaveProperty(
'onboarding_step'
);
expect(state.currentUser.accounts[0].role).toEqual('administrator');
});
it('targets the route account, not currentUser.account_id', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{ id: 1, onboarding_step: 'account_details' },
{ id: 2, onboarding_step: 'account_details' },
],
},
};
mutations[types.RESET_ONBOARDING](state, 2);
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
'account_details'
);
expect(state.currentUser.accounts[1]).not.toHaveProperty(
'onboarding_step'
);
});
});
});
@@ -4,6 +4,7 @@ export default {
SET_CURRENT_USER: 'SET_CURRENT_USER',
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
RESET_ONBOARDING: 'RESET_ONBOARDING',
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
@@ -51,7 +52,6 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
export const useVoiceCallsStore = defineStore('voiceCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action (per account)
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
cleanupCallback: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
hasVoiceCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
actions: {
addIncomingCall(callData) {
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
if (exists) return;
this.incomingCalls.push(callData);
},
removeIncomingCall(callId) {
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
},
setActiveCall(callData) {
this.activeCall = callData;
},
clearActiveCall() {
this.activeCall = null;
},
markActiveCallConnected() {
if (this.activeCall) {
this.activeCall = { ...this.activeCall, status: 'connected' };
}
},
registerCleanupCallback(callback) {
this.cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
this.removeIncomingCall(callId);
},
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
// Snapshot the DB id before nulling activeCall so the composable can
// POST upload_recording with the right path even after we clear state.
const dbCallId = this.activeCall?.id;
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
this.activeCall = null;
}
},
},
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
voiceCallEnabled() {
return isVoiceCallEnabled(this.inbox);
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
+84 -84
View File
@@ -1,153 +1,153 @@
{
"COMPONENTS": {
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."
"DOWNLOAD": "Laadi alla",
"UPLOADING": "Üleslaadimine..."
},
"FORM_BUBBLE": {
"SUBMIT": "Submit"
"SUBMIT": "Saada"
},
"MESSAGE_BUBBLE": {
"RETRY": "Send message again",
"ERROR_MESSAGE": "Couldn't send, try again"
"RETRY": "Saada sõnum uuesti",
"ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
}
},
"THUMBNAIL": {
"AUTHOR": {
"NOT_AVAILABLE": "Not available"
"NOT_AVAILABLE": "Pole saadaval"
}
},
"TEAM_AVAILABILITY": {
"ONLINE": "We are online",
"OFFLINE": "We are away at the moment",
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
"ONLINE": "Oleme võrgus",
"OFFLINE": "Oleme hetkel eemal",
"BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day",
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
"BACK_AT_TIME": "We will be back online at {time}",
"BACK_ON_DAY": "We will be back online on {day}",
"BACK_TOMORROW": "We will be back online tomorrow",
"BACK_IN_SOME_TIME": "We will be back online in some time"
"IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
"IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
"IN_A_DAY": "Tavaliselt vastame päeva jooksul",
"BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
"BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
"BACK_AT_TIME": "Oleme tagasi kell {time}",
"BACK_ON_DAY": "Oleme tagasi {day}",
"BACK_TOMORROW": "Oleme tagasi homme",
"BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
},
"DAY_NAMES": {
"SUNDAY": "Sunday",
"MONDAY": "Monday",
"TUESDAY": "Tuesday",
"WEDNESDAY": "Wednesday",
"THURSDAY": "Thursday",
"FRIDAY": "Friday",
"SATURDAY": "Saturday"
"SUNDAY": "Pühapäev",
"MONDAY": "Esmaspäev",
"TUESDAY": "Teisipäev",
"WEDNESDAY": "Kolmapäev",
"THURSDAY": "Neljapäev",
"FRIDAY": "Reede",
"SATURDAY": "Laupäev"
},
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
"YOU": "You",
"START_NEW_CONVERSATION": "Start a new conversation",
"VIEW_UNREAD_MESSAGES": "You have unread messages",
"START_CONVERSATION": "Alusta vestlust",
"END_CONVERSATION": "Lõpeta vestlus",
"CONTINUE_CONVERSATION": "Jätka vestlust",
"YOU": "Sina",
"START_NEW_CONVERSATION": "Alusta uut vestlust",
"VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
"UNREAD_VIEW": {
"VIEW_MESSAGES_BUTTON": "See new messages",
"CLOSE_MESSAGES_BUTTON": "Close",
"COMPANY_FROM": "from",
"VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
"CLOSE_MESSAGES_BUTTON": "Sulge",
"COMPANY_FROM": "saatjalt",
"BOT": "Bot"
},
"BUBBLE": {
"LABEL": "Chat with us"
"LABEL": "Vestle meiega"
},
"POWERED_BY": "Powered by Chatwoot",
"EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"POWERED_BY": "Toetab Chatwoot",
"EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
"CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
"TODAY": "Täna",
"YESTERDAY": "Eile",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
"REQUIRED_ERROR": "Full Name is required"
"LABEL": "Täisnimi",
"PLACEHOLDER": "Palun sisesta oma täisnimi",
"REQUIRED_ERROR": "Täisnimi on kohustuslik"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
"REQUIRED_ERROR": "Email Address is required",
"VALID_ERROR": "Please enter a valid email address"
"LABEL": "E-posti aadress",
"PLACEHOLDER": "Palun sisesta oma e-posti aadress",
"REQUIRED_ERROR": "E-posti aadress on kohustuslik",
"VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
},
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter your phone number",
"REQUIRED_ERROR": "Phone Number is required",
"DIAL_CODE_VALID_ERROR": "Please select a country code",
"VALID_ERROR": "Please enter a valid phone number",
"DROPDOWN_EMPTY": "No results found",
"DROPDOWN_SEARCH": "Search country"
"LABEL": "Telefoninumber",
"PLACEHOLDER": "Palun sisesta oma telefoninumber",
"REQUIRED_ERROR": "Telefoninumber on kohustuslik",
"DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
"VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
"DROPDOWN_EMPTY": "Tulemusi ei leitud",
"DROPDOWN_SEARCH": "Otsi riiki"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter your message",
"ERROR": "Message too short"
"LABEL": "Sõnum",
"PLACEHOLDER": "Palun sisesta oma sõnum",
"ERROR": "Sõnum on liiga lühike"
}
},
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
"IS_REQUIRED": "is required",
"REQUIRED": "Required",
"REGEX_ERROR": "Please provide a valid input"
"CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
"IS_REQUIRED": "on kohustuslik",
"REQUIRED": "Kohustuslik",
"REGEX_ERROR": "Palun sisesta korrektne väärtus"
},
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
"CHAT_FORM": {
"INVALID": {
"FIELD": "Invalid field"
"FIELD": "Vigane väli"
}
},
"EMOJI": {
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
"PLACEHOLDER": "Otsi emotikone",
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
"ARIA_LABEL": "Emotikonide valija"
},
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
"PLACEHOLDER": "Tell us more..."
"TITLE": "Hinda oma vestlust",
"SUBMITTED_TITLE": "Täname hinnangu eest",
"PLACEHOLDER": "Räägi meile rohkem..."
},
"EMAIL_TRANSCRIPT": {
"BUTTON_TEXT": "Request a conversation transcript",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again"
"BUTTON_TEXT": "Taotle vestluse koopiat",
"SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
"SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
},
"INTEGRATIONS": {
"DYTE": {
"CLICK_HERE_TO_JOIN": "Click here to join",
"LEAVE_THE_ROOM": "Leave the call"
"CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
"LEAVE_THE_ROOM": "Lahku kõnest"
}
},
"PORTAL": {
"POPULAR_ARTICLES": "Popular Articles",
"VIEW_ALL_ARTICLES": "View all articles",
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
"POPULAR_ARTICLES": "Populaarsed artiklid",
"VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
"IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
},
"ATTACHMENTS": {
"image": {
"CONTENT": "Picture message"
"CONTENT": "Pildisõnum"
},
"audio": {
"CONTENT": "Audio message"
"CONTENT": "Helisõnum"
},
"video": {
"CONTENT": "Video message"
"CONTENT": "Videosõnum"
},
"file": {
"CONTENT": "File Attachment"
"CONTENT": "Faili manus"
},
"location": {
"CONTENT": "Location"
"CONTENT": "Asukoht"
},
"fallback": {
"CONTENT": "has shared a url"
"CONTENT": "jagas URL-i"
}
},
"FOOTER_REPLY_TO": {
"REPLY_TO": "Replying to:"
"REPLY_TO": "Vastus sõnumile:"
}
}
+6 -1
View File
@@ -42,8 +42,13 @@ class Account::ContactsExportJob < ApplicationJob
def attach_export_file(csv_data)
return if csv_data.blank?
# Prepend UTF-8 BOM so that spreadsheet applications (e.g. Excel)
# correctly recognise the file encoding for non-ASCII characters
# such as Arabic, Japanese, and Chinese.
bom = "\xEF\xBB\xBF"
@account.contacts_export.attach(
io: StringIO.new(csv_data),
io: StringIO.new("#{bom}#{csv_data}"),
filename: "#{@account.name}_#{@account.id}_contacts.csv",
content_type: 'text/csv'
)
+2 -2
View File
@@ -1,6 +1,6 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
retry_on Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
url, payload, webhook_type = job.arguments
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
@@ -9,7 +9,7 @@ class AgentBots::WebhookJob < WebhookJob
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
rescue Webhooks::Trigger::RetryableError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise
end

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