Compare commits

..
Author SHA1 Message Date
Shivam Mishra eeff17dfd7 chore: remove proposal.md 2026-07-01 17:52:08 +05:30
Shivam Mishra 02d4de7357 feat: add captain conversation facts 2026-07-01 17:47:36 +05:30
Shivam Mishra afa2b49e45 docs: expand captain reporting proposal 2026-07-01 17:47:16 +05:30
Shivam Mishra 6ba86788b2 feat: add reporting event actor attribution 2026-07-01 17:11:48 +05:30
Shivam Mishra 3c4225cf57 docs: propose reporting event actor attribution 2026-07-01 17:09:53 +05:30
Shivam Mishra d0e7753258 fix: anchor captain overview ranges to the viewer timezone 2026-07-01 16:11:13 +05:30
Shivam Mishra 563619c95d fix: match captain reopens by reopen time to survive inference event ordering 2026-07-01 15:44:47 +05:30
Shivam Mishra f5c6d375ef fix: render captain rate-card trends as points, not percent 2026-07-01 15:25:33 +05:30
Shivam Mishra da8ce630db test: cover captain reopen rate, range whitelist, and summary caching 2026-07-01 15:20:28 +05:30
Shivam Mishra c964f53288 fix: match captain reopens to their resolve event 2026-07-01 15:01:26 +05:30
Shivam Mishra b9e7030908 fix: derive captain reopen cohort from handled conversations 2026-07-01 12:16:37 +05:30
Shivam Mishra 76dc037c1a chore: remove queries scratch file 2026-06-30 19:04:07 +05:30
Shivam Mishra 67f55c5b7f fix: avoid caching captain overview summary failures 2026-06-30 17:11:38 +05:30
Shivam Mishra fd6dc62780 fix: reset captain inbox banner dismissal when assistant changes 2026-06-30 17:09:56 +05:30
Shivam Mishra b1f8792e5f fix: exclude stale reopens from captain overview reopen rate 2026-06-30 16:32:33 +05:30
Shivam Mishra c1eceb8f98 fix: count time-based captain resolves in overview reopen rate 2026-06-30 16:31:46 +05:30
Shivam Mishra ba474a4348 refactor: scope captain overview summary cache per viewer 2026-06-30 16:21:48 +05:30
Shivam Mishra 741e536f89 fix: clamp previous-month overview window to its own month 2026-06-30 16:21:48 +05:30
Shivam Mishra 67568f37ca fix: scope captain resolution metrics to the selected window 2026-06-30 16:21:48 +05:30
Shivam Mishra f96c3609a4 refactor: constrain captain overview stats range to known windows 2026-06-30 16:21:48 +05:30
Shivam MishraandGitHub cd1472b0ac Merge branch 'develop' into feat/assisant-overview-page 2026-06-30 15:44:21 +05:30
Shivam Mishra 806d15c13b feat: add exists guard 2026-06-30 15:39:54 +05:30
Shivam Mishra 963f182466 feat: update index to include created 2026-06-30 15:39:12 +05:30
Shivam Mishra 5b4ff80777 feat: merge query 2026-06-30 15:36:20 +05:30
Shivam Mishra 9f17fbe697 feat: add index for stats perf 2026-06-30 15:25:00 +05:30
Shivam Mishra 5c39d896d9 feat: remove scratch files 2026-06-30 15:22:12 +05:30
Aakash BakhleandGitHub 926a9d8a69 fix(captain): default temperature to 0.5 and remove UI control (#14879)
# Pull Request Template

## Description

- Default temperature to 0.5 and remove UI control
- No migrations needed for existing accounts, their current settings are
preserved

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

locally and spec

## 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-06-30 15:14:03 +05:30
Aakash BakhleandGitHub 9caceea858 fix(deps): update msgpack for CVE-2026-54522 (#14898)
# Pull Request Template

## Description

This updates the locked `msgpack` gem from `1.8.0` to `1.8.3` so the
bundle-audit check no longer flags CVE-2026-54522. The upgrade stays
within the existing transitive dependency constraints used by `bootsnap`
and `datadog`.

Fixes:
https://app.circleci.com/pipelines/github/chatwoot/chatwoot/114757/workflows/f8c7b37f-27d5-45d4-9f1b-1d1782ebc4e3/jobs/162250

## Type of change

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

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec bundle audit update && bundle
exec bundle audit check -v`
- `eval "$(rbenv init -)" && bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `eval "$(rbenv init -)" && RUBOCOP_CACHE_ROOT=tmp/rubocop_cache bundle
exec rubocop --no-server Gemfile`

## 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-06-30 14:56:34 +05:30
Shivam MishraandGitHub 8670f66155 feat: broaden search scope for help center generation (#14880)
**Broaden the Firecrawl `map` search term list so help-center onboarding
doesn't skip sites with non-standard docs paths**

Help-center onboarding was skipping ~70% of new accounts, and ~60% of
those skips came from a single failure: `"map returned no links"`. The
root cause was an overly narrow hardcoded search query passed to
Firecrawl's `map` endpoint.

The curator (`Onboarding::HelpCenterCurator`) calls `Firecrawl.map(url,
search: MAP_SEARCH)` to discover candidate pages before the LLM curation
step. `MAP_SEARCH` was hardcoded to `"docs help support faq"` — a 4-term
list that only matched sites whose help content sat at `/docs`, `/help`,
`/support`, or `/faq`. Sites using `/resources`, `/guides`, `/kb`,
`/articles`, `/handbook`, `/learn`, `/how-to`, `/tutorial`,
`/troubleshooting`, or a docs subdomain found nothing, so the job raised
`CurationSkipped` and left the portal empty.

Firecrawl's `search` param is a grep-style substring filter across URL,
title, and description (not a semantic query), so the fix is to broaden
the term list rather than drop it. The LLM curator downstream
(`Captain::Llm::HelpCenterCurationService`) already filters returned
links by quality — it has the full URL-path-priority prompt and a
25-article hard ceiling — so a wider crawl net is safe and doesn't
change the final article quality bar.

**What changed**

- `enterprise/app/services/onboarding/help_center_curator.rb`:
`MAP_SEARCH` broadened from `"docs help support faq"` to a 13-term list
covering common help-content path hints. Comment added documenting why
the term list exists and that the LLM curator does the real filtering.
2026-06-30 14:37:15 +05:30
Shivam Mishra 729aca7b64 chore: remove dummy information 2026-06-30 14:28:05 +05:30
Shivam Mishra d04a2c2f56 feat: add coverage banner 2026-06-30 14:27:35 +05:30
Sivin VargheseandGitHub 2767bd434b fix: Show a clear error when message translation fails (#14891) 2026-06-30 14:23:47 +05:30
Aakash BakhleandGitHub ce2e10e89e fix: tighten captain v2 (#14883)
# Pull Request Template

## Description

Tightens v2 prompt and config to match v1

## 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.
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-06-30 14:20:54 +05:30
Sivin VargheseandGitHub 56275b750e fix: respect companies feature flag for auto-association (#14886)
# Pull Request Template

## Description

This PR stops new contacts from getting an auto-assigned company name
when the Companies feature is disabled.

Since #14496, email-domain company auto-association also updates a
contact's `company_name`. However, the callback isn't gated behind the
Companies feature flag, so accounts without the feature enabled still
auto-create companies and overwrite any `company_name` provided via the
SDK/`setUser`.

This PR gates `should_associate_company?` behind
`account.feature_enabled?('companies')`, so auto-association only runs
when the Companies feature is enabled.

Fixes
https://linear.app/chatwoot/issue/CW-7462/setuser-overwrites-contact-company-name-for-accounts-that-dont-use

## Type of change

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


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-29 17:23:31 -07:00
Shivam Mishra dca4c39900 feat: add welcome loader 2026-06-29 22:37:51 +05:30
Shivam Mishra 6d6475232a feat: hide credit usage and response quality sections on overview 2026-06-29 22:34:50 +05:30
Shivam Mishra 4d95956900 feat: use approximate figures in overview summary and cache hourly 2026-06-29 22:31:13 +05:30
Shivam Mishra 33314768f4 refactor: tighten and calibrate captain overview summary prompt 2026-06-29 22:28:27 +05:30
Shivam Mishra 9f5d55ae33 feat: ground captain overview summary in the reporting period dates 2026-06-29 22:16:36 +05:30
Shivam Mishra f0aacbf170 feat: add month-based ranges to captain overview 2026-06-29 22:11:39 +05:30
Shivam Mishra 96d0757041 feat: wire captain overview page to live stats and summary 2026-06-29 22:05:06 +05:30
Shivam Mishra 847bb13aa5 feat: add captain assistant overview stats and summary endpoints 2026-06-29 22:04:53 +05:30
Shivam Mishra ef63752075 feat: add welcome card 2026-06-29 18:11:43 +05:30
Shivam Mishra bb387dbd4a feat: show links for cloud only 2026-06-29 17:53:01 +05:30
Shivam Mishra 4dd630c683 feat: update title 2026-06-29 17:49:05 +05:30
Shivam Mishra 1186aa26dc feat: add inbox banner 2026-06-29 17:48:55 +05:30
Shivam Mishra c86736ff70 feat: add quicklinks 2026-06-29 17:43:41 +05:30
Shivam Mishra 6e31eefd35 feat: add captain docs 2026-06-29 17:43:32 +05:30
Shivam Mishra 3d066333eb chore: use tabular nums 2026-06-29 17:38:52 +05:30
Shivam Mishra 65e5a9dbf1 chore: remove response time 2026-06-29 17:37:08 +05:30
Shivam Mishra 5122536322 feat: move flagged rate 2026-06-29 17:37:01 +05:30
Shivam Mishra bf7a5792b2 feat: add hours saved metric 2026-06-29 17:36:39 +05:30
Shivam Mishra 84eb313f38 feat: add stats 2026-06-29 17:27:00 +05:30
Shivam Mishra 85cd87c410 feat: assistant overview page UI 2026-06-29 17:26:42 +05:30
299bc6c0a4 fix: recover from stale LeadSquared lead ids on activity sync (#14818)
LeadSquared sync now recovers automatically when a contact's cached lead
has been deleted or merged on the LeadSquared side. Previously the stale
lead id was never cleared, so every new conversation or contact update
for that contact failed with "Lead not found"
(`MXInvalidEntityReferenceException`) indefinitely.

## What changed
- Activity sync: on a "Lead not found" error while posting a
conversation/transcript activity, clear the cached `leadsquared_id`,
re-resolve the contact to a fresh lead, and retry the activity once
(guarded against loops and duplicate leads).
- Contact sync: on the same error while updating an existing lead, clear
the cached id and create a fresh lead instead.
- Fix `get_lead_id` to actually return early for unidentifiable contacts
(the guard previously fell through).

## How to reproduce
1. For a LeadSquared-enabled account, point a contact's cached lead id
at a lead that no longer exists in LeadSquared.
2. Update the contact, or create/resolve a conversation for it.
3. Before: the sync fails repeatedly with "Lead not found" and never
self-corrects. After: the stale id is cleared, a fresh lead is
resolved/created, and subsequent syncs reuse the healed id.

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-06-29 16:38:08 +05:30
7522457740 feat: v2 - generations get trace level attributes (#14878)
# Pull Request Template

~~Note: merge only after https://github.com/chatwoot/ai-agents/pull/74
has been merged~~

## Description

Before:
<img width="436" height="617" alt="image"
src="https://github.com/user-attachments/assets/fd5be8dc-abab-4e01-b251-366648b998ea"
/>


After:
<img width="446" height="577" alt="image"
src="https://github.com/user-attachments/assets/8d96d2f2-c126-4cca-b3a2-f2a62b204adf"
/>


<img width="441" height="558" alt="image"
src="https://github.com/user-attachments/assets/89da4157-48a5-4fdc-9c67-9bf987e31638"
/>



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

locally and specs

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-29 13:50:17 +05:30
Sony MathewandGitHub e4ef2de8c8 fix(security): Update crass to 1.0.7 (#14882)
## Description

Updates the transitive `crass` dependency from `1.0.6` to `1.0.7` so the
bundle-audit security check no longer flags the Crass denial-of-service
advisories published on June 25, 2026. `crass` is pulled in through
`rails-html-sanitizer -> loofah`, and this change only updates the
resolved lockfile version.

Fixes # N/A

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle exec rspec spec/mailboxes/mailbox_helper_spec.rb
spec/mailboxes/reply_mailbox_spec.rb
spec/mailboxes/imap/imap_mailbox_spec.rb
spec/models/channel/telegram_spec.rb
spec/lib/integrations/slack/send_on_slack_service_spec.rb
spec/lib/integrations/slack/update_slack_message_service_spec.rb
spec/presenters/html_parser_spec.rb`
- `bundle exec rubocop Gemfile`

## 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-06-29 13:47:57 +05:30
Sojan JoseandGitHub d0b1c055e8 chore: Track cloud plan activation conversions (#14834)
## Summary
- track cloud plan activation conversions when an attributed account
moves from the configured default cloud plan to a paid plan
- use the Stripe webhook event time as the activation timestamp so the
30-day signup attribution window reflects the actual upgrade event
- send the Stripe subscription amount and currency for the conversion
value
- mark the account attribution after enqueueing so later plan updates do
not send duplicate activation conversions

## Notes
- Marketing tracker: https://linear.app/chatwoot/issue/MAR-113
- Cloud implementation: https://linear.app/chatwoot/issue/LEA-34
- Stripe billing stays responsible for subscription state and value
calculation.
- Cloud plan activation conversion tracking is handled by a small
dedicated service that owns the activation rule, duplicate marker, and
conversion enqueue.
- Website attribution cookie capture remains separate in the marketing
attribution service.
- There is no frontend change and no new user-facing configuration.
- Conversion upload still no-ops outside Chatwoot Cloud and when
attribution has no supported click identifier.
2026-06-25 18:41:43 -07:00
Sony MathewandGitHub cf134deb37 fix: Preserve Captain LLM defaults (#14858)
# Pull Request Template

## Description

Adjusts the Captain LLM feature defaults after feature routing so the
defaults stay intentional and avoid unintended high-cost model upgrades.
Assistant, copilot, and onboarding content generation now default to
`gpt-4.1`; audio transcription keeps `gpt-4o-mini-transcribe` as the
default while exposing `whisper-1` as an available account override
option.

Related: https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `ruby -ryaml -e 'yaml = YAML.load_file("config/llm.yml"); features =
yaml.fetch("features"); models = yaml.fetch("models"); features.each {
|name, cfg| missing = Array(cfg["models"]) - models.keys; raise
"#{name}: missing #{missing.join(",")}" if missing.any?; raise "#{name}:
default not in models" unless
Array(cfg["models"]).include?(cfg["default"]) }'`
- `node -e
"JSON.parse(require('fs').readFileSync('app/javascript/dashboard/i18n/locale/en/settings.json',
'utf8'))"`
- `pnpm exec prettier --check
app/javascript/dashboard/i18n/locale/en/settings.json`
- `bundle exec rspec spec/lib/llm/feature_router_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/models/concerns/agentable_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/controllers/super_admin/accounts_controller_spec.rb`
- `bundle exec rspec
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb`
- `RUBOCOP_CACHE_ROOT=/private/tmp/rubocop_cache bundle exec rubocop
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/captain/llm/article_translation_service_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb`
- `git diff --check`

## 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-06-25 21:35:09 +05:30
Sony MathewandGitHub b8b62ad0f1 feat: Harden model override preferences (5/6) (#14846)
## Description

Hardens the Captain model override preferences API so account-level
overrides follow the same feature-router contract used by runtime LLM
calls. The API now permits model and feature keys from `llm.yml`,
removes blank model overrides, rejects invalid saved model combinations,
and returns each feature's effective model, provider, and source for UI
clients.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the account preferences API and account model validation
behavior for valid overrides, invalid model values, unknown feature
keys, blank override removal, and effective model/provider/source
payload metadata.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
app/models/concerns/captain_featurable.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/models/account_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/lib/llm/feature_router_spec.rb`
- `git diff --check`

## 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
- [ ] 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-06-25 17:38:11 +05:30
Sony MathewandGitHub 4e26c5b4bb feat: Route system LLM jobs (4/6) (#14843)
## Description

Routes the remaining system-only and legacy-sensitive LLM jobs through
feature-level model configuration, while preserving system credential
usage and usage-accounting behavior. This adds dedicated defaults for
help center article generation, onboarding content generation, query
translation, transcription, and search embeddings so these flows can be
configured per account without falling back to installation-wide model
settings.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

Verified the feature routing defaults and account overrides for the
touched Captain/system LLM paths, including the legacy OpenAI
transcription and paginated FAQ services.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/lib/captain/base_task_service_spec.rb spec/lib/llm/models_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
lib/captain/base_task_service.rb
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/captain/llm/article_writer_service.rb
enterprise/app/services/captain/llm/embedding_service.rb
enterprise/app/services/captain/llm/help_center_curation_service.rb
enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
enterprise/app/services/captain/llm/translate_query_service.rb
enterprise/app/services/captain/llm/widget_tagline_service.rb
enterprise/app/services/captain/onboarding/website_analyzer_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/lib/captain/base_task_service_spec.rb`
- `ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml');
%w[document_faq_generation help_center_article_generation
onboarding_content_generation help_center_query_translation
audio_transcription help_center_search].each { |feature| abort(%(missing
#{feature})) unless config.dig('features', feature) }; abort('wrong
article default') unless config.dig('features',
'help_center_article_generation', 'default') == 'gpt-5.2'; puts 'llm.yml
ok'"`
- `git diff --check`

## 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-06-25 17:37:45 +05:30
Sony MathewandGitHub 43e0f8a141 feat: Route Enterprise LLM services (3/6) (#14841)
# Pull Request Template

## Description

Routes Enterprise assistant, copilot, FAQ, contact memory,
action-classifier, and false-promise detector LLM paths through
feature-specific model resolution. `Llm::BaseAiService` now accepts
feature/account context and uses `Llm::FeatureRouter` when that context
is present, while retaining the installation-model fallback for
unmigrated callers. This also adds a `document_faq_generation` feature
default for generative FAQ/document content.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14840

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with 112 examples, 0 failures.
- `bundle exec rspec spec/models/concerns/captain_featurable_spec.rb
spec/models/account_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/lib/llm/feature_router_spec.rb` passed with 87 examples, 0
failures.
- `bundle exec rubocop enterprise/app/services/llm/base_ai_service.rb
enterprise/app/services/captain/copilot/chat_service.rb
enterprise/app/services/captain/llm/assistant_chat_service.rb
enterprise/app/services/captain/llm/faq_generator_service.rb
enterprise/app/services/captain/llm/conversation_faq_service.rb
enterprise/app/services/captain/llm/contact_notes_service.rb
enterprise/app/services/captain/llm/contact_attributes_service.rb
enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
enterprise/app/services/captain/llm/assistant_false_promise_service.rb
spec/enterprise/services/llm/base_ai_service_spec.rb
spec/enterprise/services/captain/copilot/chat_service_spec.rb
spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
spec/enterprise/services/captain/llm/faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb
spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
spec/enterprise/services/captain/llm/assistant_false_promise_service_spec.rb
spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb`
passed with no offenses.
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing
document_faq_generation') unless config.dig('features',
'document_faq_generation'); abort('missing default') unless
config.dig('features', 'document_faq_generation', 'default'); puts
'llm.yml ok'"` passed.
- `git diff --check` passed.

## 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-06-25 17:03:07 +05:30
Sony MathewandGitHub 91d8a4e2a3 feat: Route reply box LLM tasks (2/6) (#14840)
# Pull Request Template

## Description

Routes OSS reply-box and small Captain tasks through feature-specific
LLM model resolution. Rewrite, reply suggestion, summary, follow-up, and
CSAT utility analysis now resolve through the `editor` feature; label
suggestion resolves through `label_suggestion`. Existing credentials,
account OpenAI hook behavior, and instrumentation event names remain
unchanged.

Linear: https://linear.app/chatwoot/issue/CW-7425/test-new-models
Depends on #14839

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with 81 examples, 0
failures.
- `bundle exec rubocop lib/captain/base_task_service.rb
lib/captain/rewrite_service.rb lib/captain/reply_suggestion_service.rb
lib/captain/summary_service.rb lib/captain/label_suggestion_service.rb
lib/captain/csat_utility_analysis_service.rb
lib/captain/follow_up_service.rb
enterprise/lib/enterprise/captain/reply_suggestion_service.rb
spec/lib/captain/base_task_service_spec.rb
spec/lib/captain/rewrite_service_spec.rb
spec/lib/captain/reply_suggestion_service_spec.rb
spec/lib/captain/summary_service_spec.rb
spec/lib/captain/label_suggestion_service_spec.rb
spec/lib/captain/csat_utility_analysis_service_spec.rb
spec/lib/captain/follow_up_service_spec.rb` passed with no offenses.
- `git diff --check` passed.

## 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-06-25 17:01:05 +05:30
Sony MathewandGitHub 8b977b35a8 feat: Add LLM feature router (1/6) (#14839)
## Description

Adds the foundation for feature-specific LLM model routing so Captain AI
features can resolve their effective provider/model from code defaults
and account-level overrides. This fixes the provider metadata key in
`config/llm.yml`, adds `Llm::FeatureRouter`, and routes existing
`CaptainFeaturable` model defaults through the shared resolver.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

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

## How Has This Been Tested?

- `bundle exec rspec spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - 23 examples, 0
failures
- `bundle exec rubocop lib/llm/models.rb lib/llm/feature_router.rb
app/models/concerns/captain_featurable.rb spec/lib/llm/models_spec.rb
spec/lib/llm/feature_router_spec.rb
spec/models/concerns/captain_featurable_spec.rb` - no offenses
- `bundle exec ruby -e "require 'yaml'; config =
YAML.load_file('config/llm.yml'); abort('missing providers') unless
config['providers']; abort('missing models') unless config['models'];
abort('missing features') unless config['features']; puts 'llm.yml ok'"`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:00:07 +05:30
62cbeae95f feat: onboarding inboxes UI (#14565)
After entering their account details, new admins land on an **Inbox
setup** screen that shows what we've already set up for them and lets
them connect their conversation channels without leaving onboarding. It
surfaces the auto-created live chat widget (and Help Center on
Enterprise), highlights channels detected from their website, and offers
a **View all** dialog to connect any supported channel inline.

### Channel status

| Channel | How it connects | Status | PR |
|---|---|---|---|
| Live chat (Website) | Auto-created during setup |  Done |
https://github.com/chatwoot/chatwoot/pull/14314 |
| WhatsApp | Meta embedded signup |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Facebook | Login + page picker |  Done |
https://github.com/chatwoot/chatwoot/pull/14619 |
| Instagram | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14568 |
| TikTok | OAuth redirect |  Done |
https://github.com/chatwoot/chatwoot/pull/14569 |
| LINE | Inline credential form |  Done | — |
| Telegram | Inline credential form |  Done | — |
| Gmail / Outlook | OAuth (email) | ⚠️ Disabled — coming in a follow-up
| https://github.com/chatwoot/chatwoot/pull/14567 |
| SMS / API / Voice / Other email | — |  Unavailable | — |

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-06-25 14:54:07 +05:30
7c7459b734 fix(whatsapp): override webhook at phone number level (#13817)
## Description

Move WhatsApp webhook callback override from WABA level to phone number
level, allowing multiple phone numbers on the same WABA to have
independent callback URLs.

## Type of change

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

## How Has This Been Tested?

- Connect a WhatsApp Cloud inbox via embedded signup
- Verify webhook setup succeeds and messages are received
- Connect a second phone number on the same WABA — both should receive
messages independently
- Delete an inbox and verify only that phone number's override is
cleared

## Checklist:

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

---------

Co-authored-by: tds-1 <tds-1@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-25 14:49:09 +05:30
Sojan JoseandGitHub 0849d2e070 chore: Track cloud signup conversions (#14852)
## Summary
- enqueue `cloud_signup` conversion tracking after website attribution
is stored on a new cloud account
- keep authenticated add-workspace requests out of
attribution/conversion tracking
- reuse the existing marketing conversion tracking service and config
already available on `develop`

## Notes
- This PR only wires the signup path.
- Billing/plan activation conversion tracking is intentionally left out
for a separate rollout.
- The conversion service still no-ops outside Chatwoot Cloud and when
stored attribution has no supported click identifier.
2026-06-24 20:04:42 -07:00
Sojan JoseandGitHub cd1a214d98 chore: Add marketing conversion tracking foundation (#14851)
## Summary

Adds the minimal foundation for Cloud marketing conversion tracking:

- locked internal installation config for conversion tracking
credentials and event mappings
- generic background job and service for uploading conversion events
from stored attribution
- focused coverage for Cloud gating, click-id selection, payload shape,
and optional conversion value

This PR intentionally does not wire signup or plan activation yet. The
next step is to validate the service from Rails console against existing
attributed accounts, then add the event hooks in a follow-up PR.

## Notes

The config remains locked and is surfaced under the Internal settings
group next to the existing Cloud plan configuration. The service assumes
the locked config is present and valid; config mistakes should surface
instead of being silently ignored.
2026-06-24 18:27:02 -07:00
f9cc702030 fix(help-center): documentation layout on custom domain root, locale (#14850)
Help centers using the documentation layout now render correctly in two
cases that previously fell back to the wrong output. Opening a portal at
its custom-domain root (e.g. docs.example.com/ ) now shows the full
documentation home
in place instead of the classic layout, and portals whose locale is a
region variant that Chatwoot doesn't ship a
translation for (e.g. th_TH , fr_ML ) now show their categories in the
sidebar on article pages.

Closes
https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain

## How to test

1. Create a portal with the documentation layout and a custom domain
(e.g. example.chat.test ), with at least one
category containing a published article.
2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it
should show the full documentation home
(sidebar, topbar), not the classic layout, with no redirect.
3. Set the portal's locale to a region variant Chatwoot doesn't
translate, e.g. th_TH , with categories/articles
under that locale.
4. Open an article → the sidebar should list the article's category and
sibling articles. Before the fix the sidebar
was empty for these locales.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:12:55 -07:00
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00
Sivin VargheseandGitHub e8edc9ebf5 feat: allow users to set an icon or emoji for teams (#14767) 2026-06-23 20:53:31 +05:30
Vishnu NarayananandGitHub e785620921 feat: read structured X-Chatwoot-* headers for mobile session metadata (#14762)
Phase 2 of [INF-75](https://linear.app/chatwoot/issue/INF-75). Follows
up on [#14753](https://github.com/chatwoot/chatwoot/pull/14753) (Phase 1
UA pattern fallback, already merged).

When the request carries an \`X-Chatwoot-Client-Name\` header,
\`UserSessionTrackingService\` now prefers the five structured
\`X-Chatwoot-*\` headers over the User-Agent for populating the
\`user_sessions\` row:

| Header | Mapped column |
|---|---|
| \`X-Chatwoot-Client-Name\` | \`browser_name\` |
| \`X-Chatwoot-Client-Version\` | \`browser_version\` |
| \`X-Chatwoot-Device-Model\` | \`platform_name\` |
| \`X-Chatwoot-Platform-Version\` | \`platform_version\` |
| \`X-Chatwoot-Platform\` (+ model) | \`device_name\` (\`iPhone\` /
\`iPad\` / \`Android\`) |

When the header is absent or blank, the existing \`Browser.new\` path
runs, with the Phase 1 legacy UA fallback still acting as the floor.
Real browsers are untouched.

A follow-up PR in
[chatwoot-mobile-app](https://github.com/chatwoot/chatwoot-mobile-app)
will start sending these headers from the React Native build. Until that
ships, this PR is a no-op for production traffic, so it can land
independently.

Fixes INF-75.
2026-06-23 20:08:27 +05:30
Aakash BakhleandGitHub 0af9900be2 fix: model for false promise harness (#14824)
fixes model for false promise harness
2026-06-23 18:42:34 +05:30
Aakash BakhleandGitHub 84fda8a323 fix(captain): move false promise harness setting to account (#14823)
# Pull Request Template

## Description

moves the harness to account settings rather than assistant settings

## Type of change
refactor

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

## 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-06-23 16:55:42 +05:30
Aakash BakhleandGitHub 1c7e60880d fix: add harness on false promises (#14672)
# Pull Request Template

## Description


https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard

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

Against negative cases identified by evals

## 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-06-23 16:30:14 +05:30
Aakash BakhleandGitHub 8e27ca4282 fix(firecrawl): get url in case sourceUrl is not available (#14820)
# Pull Request Template

## Description

Fixes inconsistency by adding a fallback to use `url` instead of
`sourceUrl` when Firecrawl returns empty `sourceUrl` in production.

We switched to v2 endpoints of Firecrawl in
https://github.com/chatwoot/chatwoot/pull/14624 and it worked locally,
but seems to fail for some cases in production.

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


## 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-06-23 13:40:31 +05:30
ee5e20551d fix(facebook): handle messenger post attachment type (#14813)
Facebook Messenger messages that share a post no longer break message
ingestion. Following the recent sticker change, Meta now sends a shared
post as a new `post` attachment type. Chatwoot didn't recognise `post`
as a valid attachment file type, so the webhook job crashed and the
message — and any others in the same batch — failed to sync. Shared
posts now appear in the conversation as a fallback link (title + URL),
the same way `share` attachments already do.

This is the same class of bug as #14793 (sticker), just a different
attachment type.

#### Root cause

`post` was neither skipped as unsupported nor mapped to a valid
`file_type`, so `attachment_params` produced `file_type: :post`. The
`Attachment#file_type` enum has no `post` value, so it raised
`ArgumentError: 'post' is not a valid file_type`, crashing
`Webhooks::FacebookEventsJob`.

#### Fix

The Facebook builder already maps `share -> :fallback` because shared
posts point to facebook.com page URLs rather than downloadable media. A
`post` is the same kind of attachment, so map both `share` and `post` to
`fallback` (stores the title/link, no download attempt). Also read the
fallback title from `payload.title`, since the post payload nests it
there rather than at the top level.

## How to reproduce

1. Connect a Facebook Page inbox.
2. Share a Facebook post into Messenger to that page.
3. Before this change: `Webhooks::FacebookEventsJob` raises
`ArgumentError: 'post' is not a valid file_type` and the message is
dropped.
4. After this change: the shared post shows up in the conversation as a
fallback link.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:04 +04:00
df79c0bbde feat: exclude stale conversations via assignment policy age threshold (#14766)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7137/assignment-v2-backlog-flush-overloads-agents

## Description

Assignment policies now skip stale unassigned conversations
automatically. Each policy carries an age threshold (defaults to 7
days), so auto-assignment only picks up recent backlog instead of
draining very old, forgotten conversations. The threshold is
configurable per policy and can be cleared to assign conversations
regardless of age. Previously this control existed only on Enterprise
capacity policies (`exclude_older_than_hours`); it now lives on the
assignment policy itself, so every V2 inbox benefits without needing a
capacity policy.

## Type of change

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

## How Has This Been Tested?

- Local UI flows

## Screenshots? 

<img width="645" height="822" alt="image"
src="https://github.com/user-attachments/assets/ec58db86-c1fa-4f9e-be87-2e24ff02e077"
/>


## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:45 +05:30
647cfc2d83 feat: distinguish agent-declined calls with a rejected status (#14783)
## Linear ticket
-
https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed

## Description

Agent rejections were stored with status: failed, so call reports lumped
deliberate declines together with real technical failure.

Fix: give declines their own terminal rejected status (Twilio + WhatsApp
paths), keeping end_reason: agent_rejected. Genuine provider/network
failures stay failed. Frontend renders declines exactly as before;
existing rows backfilled via migration.

## Type of change

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

## How Has This Been Tested?

- Tested on UI

## Checklist:

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

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-23 12:22:29 +05:30
Sivin VargheseandGitHub 961ec86ba9 chore: support custom variables in auto-resolution message (#14782)
# Pull Request Template

## Description

This PR adds support for custom variables in auto-resolution messages
within Conversation Workflows.

Agents can insert variables (e.g. `{{contact.name}}`) into the custom
auto-resolution message using the variable picker by typing `{{`. These
variables are resolved with actual conversation data when the
auto-resolution message is sent.

The field uses the existing editor with the formatting toolbar hidden,
since auto-resolution messages are delivered across all channels,
including SMS, where formatting isn't supported. Existing message
content is preserved as-is, and typing `{{` opens the variable picker
for quick insertion.

Fixes
https://linear.app/chatwoot/issue/CW-7369/support-custom-variables-in-conversations-workflow-text-fields

## Type of change

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

## How Has This Been Tested?

### Screenshots
<img width="1262" height="764" alt="image"
src="https://github.com/user-attachments/assets/e43be22c-f86e-436a-9fdd-5bcb73c61ca3"
/>



## 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-06-23 11:35:52 +05:30
281 changed files with 8840 additions and 1049 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
+8 -3
View File
@@ -43,13 +43,18 @@
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+1 -1
View File
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.12.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
+4 -4
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ai-agents (0.12.0)
ruby_llm (~> 1.14)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -198,7 +198,7 @@ GEM
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
@@ -570,7 +570,7 @@ GEM
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
msgpack (1.8.3)
multi_json (1.15.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
@@ -1058,7 +1058,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
ai-agents (>= 0.12.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
return :fallback if [:share, :post].include?(type.to_sym)
super
end
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
@@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
end
@@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end
private
@@ -21,11 +21,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@assignable_agents = @inbox.assignable_agents
end
def assignable_owners
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
@agent_bots = AgentBot.accessible_to(Current.account)
end
def campaigns
@campaigns = @inbox.campaigns
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
)
end
@@ -1,17 +1,19 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
render 'api/v1/accounts/update', format: :json
end
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
end
def website
custom_attributes_params[:website]
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
end
def account_params
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign)
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
end
end
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact
@conversation.contact,
@message
)
render_response(response)
end
@@ -0,0 +1,29 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
+5
View File
@@ -1,5 +1,6 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -63,6 +64,10 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale])
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
I18n.with_locale(@locale, &)
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def switch_locale_with_article(&)
@@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
end
def allow_iframe_requests
@@ -1,4 +1,6 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
#
def resource_params
permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params
end
+4 -1
View File
@@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
# Add all_features last so it appears after manually_managed_features
attributes[:all_features] = AccountFeaturesField
attributes[:captain_models] = CaptainModelOverridesField
attributes
else
@@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[custom_attributes limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
attrs = %i[limits]
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
attrs << :all_features
attrs << :captain_models
attrs
else
[]
@@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard
# to prevent an error from being raised (wrong number of arguments)
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action)
attrs = super + [limits: {}]
attrs = super + [limits: {}, captain_models: {}]
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -21,6 +25,18 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
}
export default new CaptainAssistant();
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
-4
View File
@@ -15,10 +15,6 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}
getAssignableOwners(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
}
deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`);
}
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -9,7 +9,6 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
@@ -38,13 +37,6 @@ describe('#InboxesAPI', () => {
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
});
it('#getAssignableOwners', () => {
inboxesAPI.getAssignableOwners(2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/2/assignable_owners'
);
});
it('#deleteInboxAvatar', () => {
inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
@@ -20,6 +20,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -28,13 +29,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<slot name="headerActions" />
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -29,7 +29,6 @@ const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
@@ -131,26 +128,6 @@ watch(
class="z-0"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@@ -0,0 +1,88 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const route = useRoute();
const router = useRouter();
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
const DISMISS_STORE = 'captain_overview_coverage_banner';
const accountId = computed(() => route.params.accountId);
const assistantId = computed(() => route.params.assistantId);
const dismissed = ref(
LocalStorage.getFlag(DISMISS_STORE, accountId.value, assistantId.value)
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
);
const dismiss = () => {
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
dismissed.value = true;
};
const goToPending = () => {
router.push({
name: 'captain_assistants_responses_pending',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
coverage: knowledge.coverage,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
@click="dismiss"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup>
import { computed } from 'vue';
defineProps({
// Selected day-range, used only for the chart's start-axis label.
range: { type: String, default: '30' },
});
// Daily assistant credit usage (sample data) rendered as a lightweight bar
// chart, so we avoid pulling in a chart library for placeholder visuals.
const creditUsage = {
total: '48,210',
trend: '+18.2%',
daily: [
120, 145, 132, 160, 175, 158, 190, 210, 195, 230, 220, 245, 260, 240, 275,
290, 270, 310, 295, 330, 350, 325, 360, 380, 355, 400, 420, 395, 440, 465,
],
};
// Bar heights as a percentage of the peak day, with a small floor so even the
// lowest day stays visible.
const bars = computed(() => {
const max = Math.max(...creditUsage.daily);
return creditUsage.daily.map((value, index) => ({
key: index,
value,
height: Math.max(6, Math.round((value / max) * 100)),
}));
});
</script>
<template>
<section class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.TITLE') }}
</h3>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-semibold tabular-nums text-n-slate-12">
{{ creditUsage.total }}
</span>
<span class="text-sm text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.UNIT') }}
</span>
<span class="text-sm font-medium tabular-nums text-n-slate-11">
{{ creditUsage.trend }}
</span>
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<span class="rounded-full size-2.5 bg-n-brand" />
<span class="text-xs text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.LEGEND') }}
</span>
</div>
</div>
<div class="p-5 border rounded-xl bg-n-solid-1 border-n-weak">
<div class="flex items-end gap-1 h-40">
<div
v-for="bar in bars"
:key="bar.key"
v-tooltip="`${bar.value} ${$t('CAPTAIN.OVERVIEW.CREDITS.UNIT')}`"
class="flex-1 rounded-t transition-colors bg-n-brand/70 hover:bg-n-brand"
:style="{ height: `${bar.height}%` }"
/>
</div>
<div class="flex items-center justify-between mt-3">
<span class="text-xs text-n-slate-10">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_START', { count: range }) }}
</span>
<span class="text-xs text-n-slate-10">
{{ $t('CAPTAIN.OVERVIEW.CREDITS.AXIS_END') }}
</span>
</div>
</div>
</section>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const route = useRoute();
const router = useRouter();
const store = useStore();
const dismissed = ref(false);
const assistantId = computed(() => route.params.assistantId);
const inboxes = useMapGetter('captainInboxes/getRecords');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
// Only surface once we know the assistant has no connected inboxes.
const showBanner = computed(
() =>
!dismissed.value &&
!uiFlags.value.fetchingList &&
inboxes.value.length === 0
);
watch(
assistantId,
id => {
dismissed.value = false;
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
},
{ immediate: true }
);
const goToInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToInboxes"
>
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
@click="dismissed = true"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const { t } = useI18n();
const route = useRoute();
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
const linkTo = routeName => ({
name: routeName,
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
const stats = computed(() => [
{
key: 'approved',
value: props.knowledge.approved,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
},
{
key: 'documents',
value: props.knowledge.documents,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
to: linkTo('captain_assistants_documents_index'),
},
]);
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<RouterLink
v-for="stat in stats"
:key="stat.key"
:to="stat.to"
class="flex flex-col gap-1 group/stat"
>
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
{{ stat.value }}
</span>
<span
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
>
{{ stat.label }}
<span
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
/>
</span>
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
trend: { type: String, default: '' },
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
});
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ value }}
</span>
<span class="text-sm font-medium tabular-nums" :class="trendClass">
{{ trend }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
const { t } = useI18n();
const route = useRoute();
const { isOnChatwootCloud } = useAccount();
const assistantParams = computed(() => ({
accountId: route.params.accountId,
assistantId: route.params.assistantId,
}));
const links = computed(() => [
{
key: 'docs',
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
icon: 'i-lucide-book-open',
href: getHelpUrlForFeature('captain'),
},
{
key: 'playground',
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
icon: 'i-lucide-flask-conical',
to: {
name: 'captain_assistants_playground_index',
params: assistantParams.value,
},
},
{
key: 'billing',
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
icon: 'i-lucide-credit-card',
to: {
name: 'billing_settings_index',
params: { accountId: route.params.accountId },
},
},
]);
</script>
<template>
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<component
:is="link.href ? 'a' : RouterLink"
v-for="link in links"
:key="link.key"
:href="link.href"
:to="link.to"
:target="link.href ? '_blank' : undefined"
:rel="link.href ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
>
<span
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
>
<span :class="link.icon" class="size-4" />
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ link.title }}
</span>
<span class="text-xs truncate text-n-slate-11">
{{ link.description }}
</span>
</div>
<span
:class="
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
"
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
/>
</component>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const modelValue = defineModel({ type: String, default: '30' });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const DAY_RANGES = ['7', '30', '90'];
const decorate = item => ({
...item,
action: 'select',
isSelected: item.value === modelValue.value,
});
const menuSections = computed(() => {
const dayItems = DAY_RANGES.map(value =>
decorate({
value,
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
})
);
const monthItems = [
decorate({
value: 'this_month',
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
}),
decorate({
value: 'last_month',
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
}),
];
return [{ items: dayItems }, { items: monthItems }];
});
const menuItems = computed(() =>
menuSections.value.flatMap(section => section.items)
);
const selectedLabel = computed(
() => menuItems.value.find(item => item.isSelected)?.label || ''
);
const handleAction = ({ value }) => {
toggleDropdown(false);
modelValue.value = value;
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
trailing-icon
icon="i-lucide-chevron-down"
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Flagged responses broken down by report reason, mapped from
// captain_message_reports.report_reason (sample data).
const flagReasons = [
{
key: 'incorrect',
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCORRECT'),
count: 11,
},
{
key: 'incomplete',
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INCOMPLETE'),
count: 7,
},
{
key: 'outdated',
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OUTDATED'),
count: 4,
},
{
key: 'inappropriate',
label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.INAPPROPRIATE'),
count: 2,
},
{ key: 'other', label: t('CAPTAIN.OVERVIEW.FLAG_REASONS.OTHER'), count: 3 },
];
const total = computed(() =>
flagReasons.reduce((sum, reason) => sum + reason.count, 0)
);
// Flagged responses as a share of all Captain replies (sample data).
const rate = '1.3%';
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.FLAGGED.TOTAL', { count: total, rate }) }}
</span>
</div>
<div class="flex flex-col gap-3">
<div
v-for="reason in flagReasons"
:key="reason.key"
class="flex items-center gap-3"
>
<span class="w-28 text-xs truncate text-n-slate-11 shrink-0">
{{ reason.label }}
</span>
<div class="flex-1 h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-amber-9"
:style="{ width: `${Math.round((reason.count / total) * 100)}%` }"
/>
</div>
<span
class="w-6 text-xs font-medium text-right tabular-nums text-n-slate-12"
>
{{ reason.count }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CaptainAssistant from 'dashboard/api/captain/assistant';
const props = defineProps({
range: {
type: String,
default: '30',
},
});
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
// Markdown summary generated by the model from the assistant's stats (served by
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
// **bold** so we can highlight them.
const welcomeMarkdown = ref('');
const isLoading = ref(false);
const fetchSummary = async () => {
isLoading.value = true;
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
});
welcomeMarkdown.value = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Split a line on **bold** runs so we can render emphasised numbers as
// brand-highlighted spans with Tailwind (instead of styling raw HTML).
const parseSegments = text => {
const parts = [];
const regex = /\*\*(.+?)\*\*/g;
let lastIndex = 0;
let match = regex.exec(text);
while (match) {
if (match.index > lastIndex) {
parts.push({ text: text.slice(lastIndex, match.index) });
}
parts.push({ text: match[1], bold: true });
lastIndex = regex.lastIndex;
match = regex.exec(text);
}
if (lastIndex < text.length) {
parts.push({ text: text.slice(lastIndex) });
}
return parts.map((part, index) => ({ ...part, key: index }));
};
// Break the markdown into paragraphs on blank lines so newline breaks survive
// (a single <p> would collapse them), each parsed into highlightable segments.
const paragraphs = computed(() =>
welcomeMarkdown.value
.split(/\n{2,}/)
.map(block => block.trim())
.filter(Boolean)
.map((block, index) => ({ key: index, segments: parseSegments(block) }))
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
<div class="flex items-center gap-1.5 text-n-slate-10">
<span class="i-lucide-sparkles size-3.5" />
<span class="text-xs">
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
</span>
</div>
<div
v-if="isLoading"
class="flex flex-col gap-5"
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
>
<div class="flex flex-col gap-2.5">
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<template v-else>
<p
v-for="paragraph in paragraphs"
:key="paragraph.key"
class="text-lg leading-relaxed text-n-slate-12"
>
<template v-for="segment in paragraph.segments" :key="segment.key">
<span
v-if="segment.bold"
class="font-bold tabular-nums text-n-brand"
>{{ segment.text }}</span
>
<template v-else>{{ segment.text }}</template>
</template>
</p>
</template>
</div>
</template>
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
type="button"
variant="ghost"
color="slate"
size="sm"
@@ -34,6 +34,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
REJECTED: 'rejected',
};
export const VOICE_CALL_DIRECTION = {
@@ -440,6 +440,14 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Overview',
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
activeOn: ['captain_assistants_overview_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_overview_index',
}),
},
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
@@ -31,12 +31,22 @@ export default {
}
},
methods: {
createErrorMessage(error) {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') return responseError;
return (
responseError?.error?.message ||
responseError?.message ||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
);
},
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
useAlert(this.createErrorMessage(error));
} finally {
this.isLoading = false;
}
@@ -21,6 +21,7 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,13 +30,18 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
);
const labelKey = computed(() => {
@@ -31,7 +31,6 @@ const mockUseMapGetter = (overrides = {}) => {
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -76,32 +75,6 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('includes agent bots when includeAgentBots is true', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableOwners': ref(() => [
{ id: 2, name: 'Agent', assignee_type: 'User' },
{
id: 1,
name: 'Bot',
thumbnail: '',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
},
]),
});
const { agentsList } = useAgentsList(true, true);
expect(agentsList.value).toContainEqual(
expect.objectContaining({
id: 1,
name: 'Bot',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
})
);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -10,21 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(
includeNoneAgent = true,
includeAgentBots = false
) {
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const assignableOwners = useMapGetter(
'inboxAssignableAgents/getAssignableOwners'
);
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
@@ -49,22 +42,11 @@ export function useAgentsList(
return inboxId.value ? assignable.value(inboxId.value) : [];
});
const owners = computed(() => {
return includeAgentBots && inboxId.value
? assignableOwners.value(inboxId.value)
: [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = includeAgentBots
? owners.value.filter(owner => owner.assignee_type === 'User')
: assignableAgents.value || [];
const bots = owners.value.filter(
owner => owner.assignee_type === 'AgentBot'
);
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -78,7 +60,6 @@ export function useAgentsList(
return [
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
...filteredAgentsByAvailability,
...bots,
];
});
@@ -163,6 +163,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Context::NoToolbar': {
marks: ['strong', 'em', 'link'],
nodes: ['bulletList', 'orderedList'],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
});
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
+18
View File
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
TIKTOK: 'Channel::Tiktok',
};
// Short channel-type slugs used to identify a channel without leaning on its
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
export const CHANNEL_TYPES = {
WEBSITE: 'website',
WHATSAPP: 'whatsapp',
FACEBOOK: 'facebook',
INSTAGRAM: 'instagram',
TIKTOK: 'tiktok',
TELEGRAM: 'telegram',
LINE: 'line',
GMAIL: 'gmail',
OUTLOOK: 'outlook',
SMS: 'sms',
API: 'api',
VOICE: 'voice',
EMAIL: 'email',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
+1
View File
@@ -7,6 +7,7 @@ export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
@@ -392,6 +392,93 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"OVERVIEW": {
"HEADER": "Overview",
"WELCOME": {
"LABEL": "Captain summary",
"LOADING": "Generating summary…"
},
"INBOX_BANNER": {
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
"ACTION": "Connect inbox",
"DISMISS": "Dismiss"
},
"COVERAGE_BANNER": {
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review FAQs",
"DISMISS": "Dismiss"
},
"RANGES": {
"LAST_DAYS": "Last {count} days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month"
},
"METRICS": {
"HANDLED": {
"LABEL": "Conversations handled",
"HINT": "Distinct conversations this assistant replied in."
},
"AUTO_RESOLUTION": {
"LABEL": "Auto-resolution rate",
"HINT": "Share of handled conversations closed without a human reply."
},
"HANDOFF": {
"LABEL": "Handoff rate",
"HINT": "Share of handled conversations escalated to a human agent."
},
"HOURS_SAVED": {
"LABEL": "Hours saved",
"HINT": "Estimate: Captain replies times the team's average response time. Directional, not measured labor."
},
"REOPEN": {
"LABEL": "Reopen rate",
"HINT": "Auto-resolved conversations that were reopened afterwards."
},
"DEPTH": {
"LABEL": "Messages / conversation",
"HINT": "Average replies the assistant sends per conversation."
}
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
"DESCRIPTION": "Guides and how-tos for Captain"
},
"PLAYGROUND": {
"TITLE": "Playground",
"DESCRIPTION": "Test this assistant's replies"
},
"BILLING": {
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
@@ -494,10 +581,6 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -30,5 +30,59 @@
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
},
"ONBOARDING_INBOX_SETUP": {
"GREETING": "Let's set up a few things",
"SUBTITLE": "This will give you head-start in your workspace",
"CONTINUE": "Continue",
"SKIP": "Skip",
"ERROR": "Something went wrong. Please try again.",
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
"FACEBOOK_CONNECTED": "Facebook connected successfully",
"CREATED_FOR_YOU": {
"TITLE": "We've created the following for you",
"LIVE_CHAT": "Live Chat widget",
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
"LIVE_CHAT_STATUS": "Almost done…",
"LIVE_CHAT_READY": "Ready",
"HELP_CENTER": "Help Center",
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
"HELP_CENTER_GENERATING": "Creating your help center…",
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
},
"CHANNELS": {
"TITLE": "Connect all your conversation channels",
"HEADER": "We found a few channels you can connect",
"CONNECT": "Connect",
"CONNECTED": "Connected",
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
"MORE_CHANNELS_EMAIL": "Email",
"MORE_CHANNELS_VOICE": "Voice",
"VIEW_ALL": "View all",
"GMAIL": "Gmail",
"OUTLOOK": "Outlook",
"OTHER_EMAIL": "Other Email Providers"
},
"CHANNELS_DIALOG": {
"TITLE": "Connect all your channels instantly",
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
"CONNECT_TITLE": "Connect your {name} account",
"CONNECT_SUBTITLE": "Fill out these quick details",
"CONNECT": "Connect",
"BACK": "Back",
"SETUP_LATER": "Setup later in app",
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
"FACEBOOK_LAUNCH": "Continue with Facebook",
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
}
}
}
@@ -324,6 +324,7 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -440,7 +441,9 @@
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
"MODEL_TITLE": "Audio Transcription Model",
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
@@ -798,10 +801,14 @@
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
@@ -6,6 +6,7 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
import {
ACCOUNT_EVENTS,
@@ -119,16 +120,20 @@ export default {
handleClose(e) {
this.$emit('close', e);
},
handleTranslate() {
async handleTranslate() {
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
const agentLocale = this.getUISettings?.locale;
const targetLanguage = agentLocale || accountLocale || 'en';
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
try {
await this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
} catch (error) {
useAlert(parseAPIErrorResponse(error));
}
this.handleClose();
},
handleReplyTo() {
@@ -0,0 +1,147 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
const { t } = useI18n();
const route = useRoute();
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
});
stats.value = data;
} catch {
stats.value = null;
}
};
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
const resolveTrendGood = (trendValue, direction) => {
if (direction === 'neutral' || trendValue === 0) return null;
return direction === 'up' ? trendValue > 0 : trendValue < 0;
};
// Trend units mirror the backend pack mode: a relative percent change ('%') for
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
return {
value: formatValue(data.current),
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
trendGood: resolveTrendGood(data.trend, direction),
};
};
const metrics = computed(() => [
{
key: 'handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', v => `${v}h`, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
...metricFor(
'conversation_depth',
v => v.toFixed(1),
'neutral',
'absolute'
),
},
]);
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
:is-empty="false"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
>
<template #headerActions>
<RangeSelector v-model="selectedRange" />
</template>
<template #body>
<div class="flex flex-col gap-6">
<InboxBanner />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
>
<MetricCard
v-for="metric in metrics"
:key="metric.key"
:label="metric.label"
:value="metric.value"
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
</template>
</PageLayout>
</template>
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
component: AssistantOverviewIndex,
name: 'captain_assistants_overview_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList(true, true);
const { agentsList } = useAgentsList();
return {
agentsList,
};
@@ -81,27 +81,18 @@ export default {
},
assignedAgent: {
get() {
const assignee = this.currentChat.meta.assignee;
if (!assignee) return assignee;
return {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
};
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -167,12 +158,6 @@ export default {
return false;
},
},
mounted() {
this.$store.dispatch(
'inboxAssignableAgents/fetchAssignableOwners',
this.currentChat.inbox_id
);
},
methods: {
onSelfAssign() {
const {
@@ -189,7 +174,6 @@ export default {
account_id,
availability_status,
available_name,
assignee_type: 'User',
email,
id,
name,
@@ -199,14 +183,7 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
const currentAssigneeType = this.assignedAgent?.assignee_type || 'User';
const selectedAssigneeType = selectedItem.assignee_type || 'User';
if (
this.assignedAgent &&
this.assignedAgent.id === selectedItem.id &&
currentAssigneeType === selectedAssigneeType
) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -275,6 +252,7 @@ export default {
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
show-emoji-icon
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
export default {
routes: [
@@ -40,6 +41,14 @@ export default {
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
name: 'onboarding_inbox_setup',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingInboxSetup,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -0,0 +1,158 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
import ChannelRow from './inbox-setup/ChannelRow.vue';
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './inbox-setup/useChannelConnect';
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
import { DIALOG_CHANNELS } from './inbox-setup/constants';
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { isEnterprise } = useConfig();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const helpCenterGenerationId = computed(
() => currentAccount.value?.custom_attributes?.help_center_generation_id
);
const isSubmitting = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
} = useDetectedChannels();
const channelsDialogRef = ref(null);
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
// `inboxes/get` from its own mount — no need to dispatch it here too.
onMounted(() => {
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
});
const completeOnboarding = async event => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
// Declare the step we're completing so the controller only clears it when
// the stored step still matches (idempotent). setUser then refreshes the
// auth store so the router guard sees the cleared step and lets us in.
await finishOnboarding({ onboarding_step: 'inbox_setup' });
useTrack(event);
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleContinue = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
const handleSkip = () =>
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
const openChannelsDialog = () => channelsDialogRef.value?.open();
const refetchInboxes = () => store.dispatch('inboxes/get');
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
// the channels dialog preselected to their in-dialog step; the rest go through
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
// channel type).
const connectChannel = channel => {
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
if (DIALOG_CHANNELS.includes(channel.type)) {
channelsDialogRef.value?.open(channel.type);
return;
}
connectViaOAuth(channel.inbox?.provider || channel.type);
};
</script>
<template>
<div>
<OnboardingLayout
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
:is-loading="isSubmitting"
@continue="handleContinue"
@skip="handleSkip"
>
<template #greeting-icon>
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
</template>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
icon="i-lucide-sparkles"
>
<div class="divide-y divide-n-weak">
<WebWidgetCreationStatus />
<HelpCenterCreationStatus
v-if="isEnterprise && helpCenterGenerationId"
/>
</div>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
icon="i-lucide-inbox"
>
<div
v-if="hasDetectedChannels"
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
>
<Icon
icon="i-lucide-lightbulb"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
</span>
</div>
<ChannelRow
v-for="channel in displayedChannels"
:key="channel.type"
:channel="channel"
:connected-inbox="connectedInbox(channel)"
@connect="connectChannel"
/>
<InboxChannelsFooter
:remaining-channels="remainingChannels"
@view-all="openChannelsDialog"
/>
</OnboardingSection>
</OnboardingLayout>
<InboxChannelsDialog
ref="channelsDialogRef"
:inboxes="inboxes"
@connected="refetchInboxes"
/>
</div>
</template>
@@ -1,21 +1,22 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { ref, computed, nextTick, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
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 { useStore, useMapGetter } from 'dashboard/composables/store';
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 OnboardingLayout from './shared/OnboardingLayout.vue';
import OnboardingSection from './shared/OnboardingSection.vue';
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
@@ -23,12 +24,16 @@ import {
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
} from './shared/constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, finishOnboarding } = useAccount();
// Where each onboarding cursor routes. The backend owns which steps run where;
// the frontend just follows the cursor it advanced us to (no deployment checks).
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -46,7 +51,10 @@ const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
// Website is required: the onboarding web-widget inbox can't be created
// without a URL (Channel::WebWidget validates presence), so a blank value
// would leave the "Live Chat widget" status polling forever.
website: { required },
locale: {},
timezone: {},
companySize: {},
@@ -67,12 +75,15 @@ const v$ = useVuelidate(validationRules, {
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 { isEnriching, getChangedFields } = useAccountEnrichment({
locale,
website,
timezone,
companySize,
industry,
referralSource,
});
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
@@ -98,94 +109,10 @@ const timezoneOptions = computed(() => {
}
});
// 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 = attrs.website || 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());
@@ -204,8 +131,10 @@ const normalizeWebsiteUrl = raw => {
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;
// onboarding_step and persist incomplete data. Also guard against
// re-entry while a submit is in flight (double-click/Enter), which would
// fire parallel requests that can duplicate the auto-created inbox/portal.
if (isEnriching.value || isSubmitting.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
@@ -214,22 +143,15 @@ const handleSubmit = async () => {
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
// The website field is read-only until edited; open it so a required-but-
// empty value is immediately fixable rather than just shaking a locked field.
if (v$.value.website.$error) enableWebsiteEditing();
return;
}
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Capture which enrichable fields the user edited *before* normalizing the
// website, so an untouched auto-filled domain isn't falsely flagged.
const fieldsChanged = getChangedFields();
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
@@ -246,6 +168,7 @@ const handleSubmit = async () => {
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
onboarding_step: 'account_details',
});
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
@@ -260,8 +183,19 @@ const handleSubmit = async () => {
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
// Follow the cursor the backend advanced us to. A next step routes there; no
// next step means onboarding is complete, so refresh the user (so the router
// guard sees the cleared cursor) and head to the dashboard.
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
if (STEP_ROUTES[nextStep]) {
router.push({
name: STEP_ROUTES[nextStep],
params: { accountId: accountId.value },
});
} else {
await store.dispatch('setUser');
router.push({ name: 'home', params: { accountId: accountId.value } });
}
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
@@ -0,0 +1,136 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
const ENRICHMENT_TIMEOUT = 30000;
// Manages the post-signup enrichment lifecycle for the account-details form.
// After signup the account is enriched asynchronously (onboarding_step ===
// 'enrichment'); this fills empty form fields from the enriched
// custom_attributes/brand_info as it arrives — idempotently, so it never
// clobbers a value the user has already typed — waits the step out (with a
// timeout fallback), and tracks which enrichable fields the user edited.
//
// `fields` is the set of form refs to populate, owned by the component so it can
// still wire them to validation and the template.
export function useAccountEnrichment(fields) {
const { currentAccount } = useAccount();
const { enabledLanguages } = useConfig();
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
// Best-effort match browser language to enabled Chatwoot locales: exact match
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the 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 the auto-populated values, used to detect user edits at submit.
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const {
website,
timezone,
company_size: companySize,
industry,
referral_source: referralSource,
brand_info: brandInfo,
} = currentAccount.value?.custom_attributes || {};
const fillIfEmpty = (field, value) => {
if (!field.value) field.value = value || '';
};
fillIfEmpty(fields.locale, detectBestLocale());
fillIfEmpty(fields.website, website || brandInfo?.domain);
fillIfEmpty(
fields.timezone,
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
);
fillIfEmpty(fields.companySize, companySize);
fillIfEmpty(
fields.industry,
industry || brandInfo?.industries?.[0]?.industry
);
fillIfEmpty(fields.referralSource, referralSource);
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, ENRICHMENT_TIMEOUT);
};
onMounted(() => {
populateFormFields();
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, enriching => {
if (enriching) {
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()
);
// Enrichable fields the user actually edited since they were auto-filled.
// Compare against the snapshot *before* the caller normalizes any values —
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
// compares unequal and gets falsely reported as changed.
const getChangedFields = () => {
const init = initialValues.value;
const current = {
website: fields.website.value,
company_size: fields.companySize.value,
industry: fields.industry.value,
};
return Object.entries(current)
.filter(([key, value]) => value !== init[key])
.map(([key]) => key);
};
return { isEnriching, getChangedFields };
}
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
const props = defineProps({
channel: { type: Object, required: true },
connectedInbox: { type: Object, default: null },
});
defineEmits(['connect']);
const { t } = useI18n();
const connected = computed(() => Boolean(props.connectedInbox));
// Prefer the real connected account's name over the detected handle — the user
// may have connected a different account than the one we detected.
const connectedName = computed(
() =>
props.connectedInbox?.name ||
props.channel.handle ||
t(props.channel.labelKey)
);
</script>
<template>
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
<div class="size-4 rounded overflow-hidden flex-shrink-0">
<ChannelIcon
:inbox="channel.inbox"
use-brand-icon
class="size-4"
:class="{ grayscale: !connected }"
/>
</div>
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<div
v-if="connected"
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
>
<span class="whitespace-nowrap">{{ connectedName }}</span>
<span class="w-px h-3 bg-n-weak" />
<span class="whitespace-nowrap">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
</span>
</div>
<button
v-else
type="button"
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
@click="$emit('connect', channel)"
>
<span class="truncate">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
</span>
</button>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// Presentational shell for the "Created for you" rows. The owning component
// drives `ready` from its own lifecycle (widget polling, help-center events);
// row separation is handled by the parent's `divide-y`.
defineProps({
ready: { type: Boolean, default: false },
title: { type: String, required: true },
description: { type: String, default: '' },
status: { type: String, default: '' },
});
</script>
<template>
<div class="flex items-center justify-between gap-3 px-3 py-3">
<div class="flex items-center gap-2 min-w-0">
<Icon
v-if="ready"
icon="i-lucide-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
{{ title }}
</span>
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
</div>
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
</div>
</template>
@@ -0,0 +1,116 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import OnboardingAPI from 'dashboard/api/onboarding';
import CreationStatusRow from './CreationStatusRow.vue';
const { t } = useI18n();
const POLL_INTERVAL = 5000;
const generation = ref({
generation_id: null,
state: null,
articles_count: 0,
categories_count: 0,
});
// Before the first article arrives, advance through phases so the spinner
// label doesn't sit on a single line through the whole curation step.
const generatingPhases = computed(() => [
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
]);
const PHASE_DELAY_BASE = 4000;
const PHASE_DELAY_JITTER = 2000;
const phaseIndex = ref(0);
let phaseTimer = null;
// Advance one phase after a jittered delay; stop once we reach the last line
// (it then holds until the first article arrives).
const scheduleNextPhase = () => {
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
phaseTimer = setTimeout(() => {
phaseIndex.value += 1;
scheduleNextPhase();
}, delay);
};
const status = computed(
() =>
generation.value.state?.status ||
(generation.value.generation_id ? 'generating' : 'not_started')
);
const isCompleted = computed(() => status.value === 'completed');
const isSkipped = computed(() => status.value === 'skipped');
const isNotStarted = computed(() => status.value === 'not_started');
const isTerminal = computed(
() => isCompleted.value || isSkipped.value || isNotStarted.value
);
const articlesCount = computed(() => generation.value.articles_count || 0);
const categoriesCount = computed(() => generation.value.categories_count || 0);
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
// waits for each request to settle before scheduling the next (so requests never
// overlap), fires immediately on mount, and stops on unmount.
const { pause: stopPolling } = useTimeoutPoll(
async () => {
try {
const { data } = await OnboardingAPI.getHelpCenterGeneration();
generation.value = data;
if (isTerminal.value) stopPolling();
} catch {
// Keep polling; transient network failures should not strand onboarding.
}
},
POLL_INTERVAL,
{ immediate: true }
);
onMounted(scheduleNextPhase);
onBeforeUnmount(() => clearTimeout(phaseTimer));
const articlesText = computed(() =>
t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
{ count: articlesCount.value },
articlesCount.value
)
);
const statusText = computed(() => {
if (isCompleted.value) {
const categories = t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
{ count: categoriesCount.value },
categoriesCount.value
);
return t(
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
{ count: articlesCount.value, categories },
articlesCount.value
);
}
if (articlesCount.value > 0) return articlesText.value;
return generatingPhases.value[phaseIndex.value];
});
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<CreationStatusRow
v-if="isVisible"
:ready="isCompleted"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
"
:status="statusText"
/>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
channel: { type: Object, required: true },
});
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
// Per-channel field list + payload shape, mirroring the standalone settings
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
// the existing INBOX_MGMT translations so there's nothing new to localize.
const FORMS = {
line: {
fields: [
{
key: 'lineChannelId',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
),
},
{
key: 'lineChannelSecret',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
),
type: 'password',
},
{
key: 'lineChannelToken',
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
placeholder: t(
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
// Inbox name is mandatory for Line; prefill it from the channel label
// rather than asking, to keep the form to just the credentials.
buildPayload: values => ({
name: t(props.channel.labelKey),
channel: {
type: 'line',
line_channel_id: values.lineChannelId,
line_channel_secret: values.lineChannelSecret,
line_channel_token: values.lineChannelToken,
},
}),
},
telegram: {
fields: [
{
key: 'botToken',
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
type: 'password',
},
],
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
buildPayload: values => ({
channel: { type: 'telegram', bot_token: values.botToken },
}),
},
};
const config = FORMS[props.channel.type];
const values = reactive(
Object.fromEntries(config.fields.map(field => [field.key, '']))
);
const isCreating = ref(false);
const isValid = computed(() =>
config.fields.every(field => values[field.key].trim())
);
const submit = async () => {
if (!isValid.value || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
emit('created');
} catch (error) {
useAlert(error?.message || config.errorMessage);
} finally {
isCreating.value = false;
}
};
</script>
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
channel here instead of bubbling up and completing onboarding. -->
<template>
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
<template v-for="field in config.fields" :key="field.key">
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
{{ field.label }}
</label>
<Input
:id="field.key"
v-model="values[field.key]"
:type="field.type || 'text'"
:placeholder="field.placeholder"
/>
</template>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!isValid"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="submit"
/>
</div>
</div>
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
import { useChannelConnect } from './useChannelConnect';
import { useChannelConfig } from './useChannelConfig';
import { CHANNEL_LIST } from './constants';
import { isChannelConnected } from './channelMatchers';
import InboxChannelForm from './InboxChannelForm.vue';
import InboxFacebookForm from './InboxFacebookForm.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
});
const emit = defineEmits(['connected']);
const { t } = useI18n();
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
const { isConfigured } = useChannelConfig();
// Maps the dialog's display types to the OAuth client key the flow expects.
// Types without an entry (manual-setup channels) are no-ops for now.
const OAUTH_PROVIDERS = {
[CHANNEL_TYPES.GMAIL]: 'google',
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
};
// A card's availability — what the user can do with it right now:
// available — usable now (configured, not deferred)
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
// Channels whose installation OAuth credentials are missing are hidden entirely
// (see channelCards), so they never reach this state.
// `connected` (a real inbox already backs it) is orthogonal and tracked
// separately, since a connected channel can still be in any of these states.
const channelAvailability = channel =>
channel.setupLater ? 'setupLater' : 'available';
const CARD_CLASS = {
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
setupLater: 'bg-n-slate-2 cursor-not-allowed',
};
// Decorate the catalog with per-render state so the template reads plain fields
// rather than calling predicates for each card. Channels needing an absent
// installation credential are dropped so they don't show at all; deferred
// (setupLater) channels stay since they aren't a configuration problem.
const channelCards = computed(() =>
CHANNEL_LIST.filter(
channel => channel.setupLater || isConfigured(channel.type)
).map(channel => {
const connected = isChannelConnected(props.inboxes, channel.inbox);
// Website inboxes are only auto-created during onboarding — there is no
// manual creation path, so an unconnected Website card defers rather than
// offering a click that can't do anything.
const availability =
channel.type === CHANNEL_TYPES.WEBSITE && !connected
? 'setupLater'
: channelAvailability(channel);
return { ...channel, availability, connected };
})
);
const dialogRef = ref(null);
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
// inline view; OAuth channels redirect; the rest are no-ops for now.
const selectedChannel = ref(null);
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
// Facebook page picker); close the dialog (its @close resets the form view) and
// let the parent refetch so the connected state and real channel icons update.
const onCreated = () => {
dialogRef.value?.close();
emit('connected');
};
const onCardClick = channel => {
if (channel.availability !== 'available') return;
if (channel.form) {
selectedChannel.value = channel;
return;
}
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
connectWhatsapp();
return;
}
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
selectedChannel.value = channel;
return;
}
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
};
const dialogTitle = computed(() =>
selectedChannel.value
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
name: t(selectedChannel.value.labelKey),
})
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
);
const dialogDescription = computed(() => {
if (!selectedChannel.value) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
}
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
}
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
});
const open = preselectType => {
const entry = preselectType
? channelCards.value.find(channel => channel.type === preselectType)
: null;
// Only jump straight into a channel's view when it's actually usable;
// otherwise show the grid (with its muted card) rather than launching SDK
// auth with a missing credential.
selectedChannel.value = entry?.availability === 'available' ? entry : null;
dialogRef.value?.open();
};
const close = () => dialogRef.value?.close();
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="dialogTitle"
:description="dialogDescription"
width="lg"
:show-confirm-button="false"
:show-cancel-button="false"
@close="selectedChannel = null"
>
<InboxFacebookForm
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
@back="selectedChannel = null"
@created="onCreated"
/>
<InboxChannelForm
v-else-if="selectedChannel"
:channel="selectedChannel"
@back="selectedChannel = null"
@created="onCreated"
/>
<template v-else>
<div class="grid grid-cols-2 gap-3">
<button
v-for="channel in channelCards"
:key="channel.type"
type="button"
:disabled="channel.availability !== 'available'"
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
:class="CARD_CLASS[channel.availability]"
@click="onCardClick(channel)"
>
<div
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
>
<ChannelIcon
v-if="channel.inbox"
:inbox="channel.inbox"
use-brand-icon
class="size-5 text-n-slate-11"
/>
<Icon
v-else
:icon="channel.fallbackIcon"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<span class="block text-sm font-medium text-n-slate-12">
{{ t(channel.labelKey) }}
</span>
<span
v-if="channel.availability === 'setupLater'"
class="block text-xs text-n-slate-11"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
</span>
</div>
<Icon
v-if="channel.connected"
icon="i-lucide-circle-check"
class="size-5 text-n-teal-11"
/>
<Icon
v-else-if="channel.availability === 'available'"
icon="i-lucide-chevron-right"
class="size-5 text-n-slate-9"
/>
</button>
</div>
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
</p>
</template>
</Dialog>
</template>
@@ -0,0 +1,70 @@
<script setup>
import { computed } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
import { useChannelConfig } from './useChannelConfig';
const props = defineProps({
remainingChannels: { type: Array, default: () => [] },
});
defineEmits(['viewAll']);
const { t } = useI18n();
const { isConfigured } = useChannelConfig();
// Icons shown next to "View all". Defaults to the unconnected socials, but when
// everything detected is already connected we still want a hint of what's
// behind the dialog — fall back to a representative trio. Either way, drop
// channels whose installation credentials are missing so we never preview an
// icon the dialog itself hides.
const previewChannels = computed(() =>
(props.remainingChannels.length
? props.remainingChannels
: FALLBACK_PREVIEW_CHANNELS
).filter(channel => isConfigured(channel.type))
);
</script>
<template>
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
<I18nT
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
tag="span"
class="flex-1 min-w-0 text-body-main text-n-slate-11"
>
<template #email>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
</span>
</template>
<template #voice>
<span class="text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
</span>
</template>
</I18nT>
<div class="flex items-center gap-2 flex-shrink-0">
<ChannelIcon
v-for="channel in previewChannels"
:key="channel.type"
:inbox="channel.inbox"
use-brand-icon
class="size-5"
/>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
<span class="w-px h-3 bg-n-weak" />
<button
type="button"
class="text-button text-n-slate-12 hover:underline"
@click="$emit('viewAll')"
>
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,157 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const emit = defineEmits(['back', 'created']);
const { t } = useI18n();
const store = useStore();
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
useFacebookPageConnect();
// null until the login + page fetch completes.
const pages = ref(null);
const userAccessToken = ref('');
const selectedPageId = ref('');
const errored = ref(false);
const isCreating = ref(false);
const hasFetched = computed(() => pages.value !== null);
// Pages already connected as inboxes come back with `exists: true`.
const pageOptions = computed(() =>
(pages.value || [])
.filter(page => !page.exists)
.map(page => ({ value: page.id, label: page.name }))
);
// Warm the SDK on open so the login click opens its popup within the gesture's
// activation window (see useFacebookPageConnect).
onMounted(preloadSdk);
const connect = async () => {
errored.value = false;
try {
const result = await loginAndFetchPages();
if (!result) return; // cancelled — stay on the connect prompt
userAccessToken.value = result.userAccessToken;
pages.value = result.pages;
} catch {
errored.value = true;
}
};
const createInbox = async () => {
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
if (!page || isCreating.value) return;
isCreating.value = true;
try {
await store.dispatch('inboxes/createFBChannel', {
user_access_token: userAccessToken.value,
page_access_token: page.access_token,
page_id: page.id,
inbox_name: page.name,
});
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
emit('created');
} catch (error) {
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
} finally {
isCreating.value = false;
}
};
</script>
<template>
<div class="flex flex-col gap-4">
<div
v-if="isAuthenticating"
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_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
</span>
</div>
<template v-else-if="hasFetched && pageOptions.length">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
</label>
<ComboBox
v-model="selectedPageId"
:options="pageOptions"
:placeholder="
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
"
/>
</div>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:is-loading="isCreating"
:disabled="!selectedPageId"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
@click="createInbox"
/>
</div>
</template>
<template v-else-if="hasFetched">
<p class="text-sm text-n-slate-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
</p>
<div class="flex">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
</div>
</template>
<template v-else>
<p v-if="errored" class="text-sm text-n-ruby-11">
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
</p>
<div class="flex items-center gap-3">
<NextButton
type="button"
slate
faded
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
@click="emit('back')"
/>
<NextButton
type="button"
blue
class="flex-1 justify-center"
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
@click="connect"
/>
</div>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue';
import { useTimeoutPoll } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import CreationStatusRow from './CreationStatusRow.vue';
const POLL_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
// The web widget inbox is created asynchronously during account setup. We poll
// the inboxes endpoint until it shows up, then stop — much simpler than the
// event-driven help center flow.
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
const isReady = computed(() => websiteInboxes.value.length > 0);
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
// requests never overlap), fires immediately on mount, and stops on unmount.
const { pause } = useTimeoutPoll(
async () => {
await store.dispatch('inboxes/get');
if (isReady.value) pause();
},
POLL_INTERVAL,
{ immediate: true }
);
</script>
<template>
<CreationStatusRow
:ready="isReady"
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
:description="
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
"
:status="
isReady
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
"
/>
</template>
@@ -0,0 +1,18 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
// A detected channel maps to a real inbox when they share a channel_type. Gmail
// and Outlook both use Channel::Email, so for email we also match on provider.
// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
// Returns the matching inbox (not a boolean) so callers can show the connected
// account's real name rather than the detected handle.
export const findConnectedInbox = (inboxes, stub) =>
inboxes.find(
inbox =>
inbox.channel_type === stub?.channel_type &&
(stub?.channel_type !== INBOX_TYPES.EMAIL ||
inbox.provider === stub?.provider)
);
export const isChannelConnected = (inboxes, stub) =>
Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
@@ -0,0 +1,149 @@
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
// Channels whose connect flow opens the channels dialog preselected to their
// in-dialog step — Facebook (page picker) and the credential-form channels
// (Telegram, Line) — rather than redirecting through OAuth.
export const DIALOG_CHANNELS = [
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Suggested channels (in priority order) to offer as rows when nothing is
// detected, so the step isn't empty. The mainstream OAuth channels show on
// configured installs, while credential-free Telegram/LINE keep the list
// non-empty on a bare self-host.
export const DEFAULT_CHANNEL_TYPES = [
CHANNEL_TYPES.WHATSAPP,
CHANNEL_TYPES.FACEBOOK,
CHANNEL_TYPES.INSTAGRAM,
CHANNEL_TYPES.TELEGRAM,
CHANNEL_TYPES.LINE,
];
// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
// exists and falls back to the monochrome glyph otherwise, so no per-channel
// style flag is needed. Entries without a channel type (Voice, Other Email
// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
// inline credential form; `setupLater: true` defers the channel to in-app setup
// for this phase. `labelKey` is an i18n key — most reuse the shared channel
// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
// names translate without duplicating strings; resolve it with `t()` at display.
export const CHANNEL_LIST = [
{
type: 'website',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
inbox: { channel_type: 'Channel::WebWidget' },
},
{
type: 'whatsapp',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
{
type: 'facebook',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
form: true,
},
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
form: true,
},
// Email channels (including Gmail/Outlook OAuth) are set up later in-app for
// this phase; they will be enabled in a future PR.
{
type: 'gmail',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
inbox: { channel_type: 'Channel::Email', provider: 'google' },
setupLater: true,
},
{
type: 'outlook',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
setupLater: true,
},
{
type: 'sms',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
inbox: { channel_type: 'Channel::Sms' },
setupLater: true,
},
{
type: 'api',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
inbox: { channel_type: 'Channel::Api' },
setupLater: true,
},
{
type: 'voice',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
fallbackIcon: 'i-woot-voice',
setupLater: true,
},
{
type: 'email',
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
fallbackIcon: 'i-woot-mail',
setupLater: true,
},
];
const channelByType = type =>
CHANNEL_LIST.find(channel => channel.type === type);
// Icons shown next to "View all" when every detected channel is already
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
// aren't duplicated.
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
channelByType
);
// Social channels that detected brand_info socials map to, keyed by social type
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
// identity (label, channel_type) has a single source. Keys mirror
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
const SOCIAL_PLATFORM_TYPES = [
'whatsapp',
'facebook',
'line',
'instagram',
'telegram',
'tiktok',
];
export const SOCIAL_PLATFORMS = Object.fromEntries(
SOCIAL_PLATFORM_TYPES.map(type => {
const { labelKey, inbox } = channelByType(type);
return [type, { labelKey, channelType: inbox.channel_type }];
})
);
// Mailbox providers inferred from the signup domain's MX records, keyed by
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
export const EMAIL_PROVIDERS = Object.fromEntries(
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
channel.inbox.provider,
{ labelKey: channel.labelKey },
])
);
@@ -0,0 +1,29 @@
import { useMapGetter } from 'dashboard/composables/store';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
// need no installation credential and are always considered configured.
// Mirrors the availability checks in ChannelItem.vue.
export function useChannelConfig() {
const globalConfig = useMapGetter('globalConfig/get');
const installationConfig = window.chatwootConfig || {};
const CHANNEL_CONFIGURED = {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
};
const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
return { isConfigured };
}
@@ -0,0 +1,67 @@
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import googleClient from 'dashboard/api/channel/googleClient';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import instagramClient from 'dashboard/api/channel/instagramClient';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
// Channels that complete via an OAuth redirect. Email channels are keyed by their
// Channel::Email provider, others by channel type. The request is tagged with a
// return hint so the callback brings the user back to onboarding instead of the
// inbox settings page.
const OAUTH_CLIENTS = {
google: googleClient,
microsoft: microsoftClient,
instagram: instagramClient,
tiktok: tiktokClient,
};
export function useChannelConnect() {
const { t } = useI18n();
const store = useStore();
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
const connectViaOAuth = async provider => {
const client = OAUTH_CLIENTS[provider];
if (!client) return;
try {
const {
data: { url },
} = await client.generateAuthorization({ return_to: 'onboarding' });
window.location.href = url;
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
}
};
// WhatsApp connects via Meta's embedded-signup popup instead of the redirect
// OAuth flow above. Collect the signup credentials, exchange them for an
// inbox, and surface the result inline — then refetch so the connected state
// reflects the freshly created inbox (and renders its real channel icon).
const connectWhatsapp = async () => {
let credentials;
try {
credentials = await runEmbeddedSignup();
} catch {
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
return;
}
if (!credentials) return; // user dismissed the popup
try {
await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
await store.dispatch('inboxes/get');
useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
} catch (error) {
useAlert(
parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
);
}
};
return { connectViaOAuth, connectWhatsapp };
}
@@ -0,0 +1,130 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import {
SOCIAL_PLATFORMS,
EMAIL_PROVIDERS,
DEFAULT_CHANNEL_TYPES,
} from './constants';
import { findConnectedInbox } from './channelMatchers';
import { useChannelConfig } from './useChannelConfig';
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
// is config-gated like everything else, then sliced to this limit.
const DISPLAYED_CHANNEL_LIMIT = 3;
// Pull the handle/username out of a detected social URL, formatted per channel.
const extractHandle = ({ type, url }) => {
try {
const { pathname } = new URL(url);
const path = pathname.replace(/^\/+|\/+$/g, '');
if (type === 'whatsapp') {
const digits = path.replace(/\D/g, '');
return digits ? `+${digits}` : '';
}
if (type === 'line') return path;
return path.startsWith('@') ? path : `@${path}`;
} catch {
return '';
}
};
// Derives the channel rows for the inbox-setup step from the account's detected
// brand_info (socials + mailbox provider) and the real connected inboxes,
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
export function useDetectedChannels() {
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const { isConfigured } = useChannelConfig();
const brandSocials = computed(
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
);
const connectedChannels = computed(() =>
brandSocials.value
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
.map(social => ({
type: social.type,
handle: extractHandle(social),
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
}))
);
const detectedEmailChannel = computed(() => {
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
const provider = brandInfo?.email_provider;
if (!EMAIL_PROVIDERS[provider]) return null;
return {
type: 'email',
handle: brandInfo?.email || '',
labelKey: EMAIL_PROVIDERS[provider].labelKey,
inbox: { channel_type: 'Channel::Email', provider },
};
});
// The real inbox backing a channel, if one exists — returned (not just a
// boolean) so the row can show the connected account's real name.
const connectedInbox = channel =>
findConnectedInbox(inboxes.value, channel.inbox);
// A channel row built from a social type, with no detected handle — used for
// the default suggestions when nothing was detected.
const toChannelRow = type => ({
type,
handle: '',
labelKey: SOCIAL_PLATFORMS[type].labelKey,
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
});
const detectedChannels = computed(() =>
[detectedEmailChannel.value, ...connectedChannels.value]
.filter(Boolean)
// Email channels (including Gmail/Outlook OAuth) are disabled for this
// phase; they will be enabled in a future PR.
.filter(channel => channel.type !== 'email')
// Hide channels whose installation OAuth credentials are missing — their
// connect flow would only error.
.filter(channel => isConfigured(channel.type))
);
const defaultChannels = computed(() =>
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
.slice(0, DISPLAYED_CHANNEL_LIMIT)
.map(toChannelRow)
);
// Show the detected channels, or fall back to the default suggestions so the
// step is never an empty list.
const displayedChannels = computed(() =>
detectedChannels.value.length
? detectedChannels.value
: defaultChannels.value
);
const remainingChannels = computed(() => {
// Exclude whatever is already shown as a row (detected or defaulted) so the
// footer preview doesn't duplicate it.
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
return Object.entries(SOCIAL_PLATFORMS)
.filter(([type]) => !shownTypes.has(type))
.filter(([type]) => isConfigured(type))
.slice(0, 3)
.map(([type, { labelKey, channelType }]) => ({
type,
labelKey,
inbox: { channel_type: channelType },
}));
});
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
return {
displayedChannels,
remainingChannels,
connectedInbox,
hasDetectedChannels,
};
}
@@ -5,11 +5,12 @@ defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
skipLabel: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
defineEmits(['continue', 'skip']);
</script>
<template>
@@ -20,7 +21,7 @@ defineEmits(['continue']);
<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 w-full max-w-[40rem]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
@@ -104,16 +105,30 @@ defineEmits(['continue']);
>
<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 class="flex items-center gap-3">
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
:class="
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
<NextButton
v-if="skipLabel"
type="button"
slate
outline
class="flex-1 justify-center"
@click="$emit('skip')"
>
{{ skipLabel }}
</NextButton>
</div>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
bare: { type: Boolean, default: false },
});
</script>
@@ -42,8 +43,12 @@ defineProps({
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<div
v-if="!bare"
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
>
<slot />
</div>
<slot v-else />
</div>
</template>
@@ -0,0 +1,186 @@
import { defineComponent, h, ref } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
vi.mock('vue-router');
const ENABLED_LANGUAGES = [
{ iso_639_1_code: 'en', name: 'English' },
{ iso_639_1_code: 'fr', name: 'French' },
];
// Mounts the composable against a real store and the real useAccount/useConfig
// (only useRoute and the underlying account getter / window config are faked),
// so a change to how those resolve their data is exercised here too. `presets`
// seeds form fields as if the user had already typed them.
const mountComposable = ({
account = {},
enabledLanguages = ENABLED_LANGUAGES,
presets = {},
} = {}) => {
window.chatwootConfig = { enabledLanguages };
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: { getAccount: () => () => account },
},
},
});
const fields = {
locale: ref(presets.locale || ''),
website: ref(presets.website || ''),
timezone: ref(presets.timezone || ''),
companySize: ref(presets.companySize || ''),
industry: ref(presets.industry || ''),
referralSource: ref(presets.referralSource || ''),
};
let api;
const Component = defineComponent({
setup() {
api = useAccountEnrichment(fields);
return () => h('div');
},
});
const wrapper = mount(Component, { global: { plugins: [store] } });
return { ...api, fields, wrapper };
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useAccountEnrichment', () => {
describe('populateFormFields', () => {
it('fills empty fields from the enriched attributes on mount', () => {
const { fields } = mountComposable({
account: {
locale: 'en',
custom_attributes: {
website: 'https://acme.com',
timezone: 'America/New_York',
company_size: '11-50',
industry: 'Technology',
referral_source: 'google',
},
},
});
expect(fields.website.value).toBe('https://acme.com');
expect(fields.timezone.value).toBe('America/New_York');
expect(fields.companySize.value).toBe('11-50');
expect(fields.industry.value).toBe('Technology');
expect(fields.referralSource.value).toBe('google');
});
it('falls back to brand_info for website and industry', () => {
const { fields } = mountComposable({
account: {
custom_attributes: {
brand_info: {
domain: 'acme.com',
industries: [{ industry: 'Retail & E-commerce' }],
},
},
},
});
expect(fields.website.value).toBe('acme.com');
expect(fields.industry.value).toBe('Retail & E-commerce');
});
it('does not clobber fields the user already set', () => {
const { fields } = mountComposable({
presets: { website: 'mysite.com', industry: 'Finance' },
account: {
custom_attributes: {
website: 'https://enriched.com',
industry: 'Technology',
},
},
});
expect(fields.website.value).toBe('mysite.com');
expect(fields.industry.value).toBe('Finance');
});
it('detects the locale from the browser, else the account locale', () => {
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
const { fields } = mountComposable({ account: { locale: 'de' } });
expect(fields.locale.value).toBe('en');
// No enabled language matches the browser -> fall back to account locale.
const { fields: other } = mountComposable({
account: { locale: 'de' },
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
});
expect(other.locale.value).toBe('de');
});
});
describe('isEnriching', () => {
it('is true while the account is on the enrichment step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'enrichment' } },
});
expect(isEnriching.value).toBe(true);
});
it('is false on any other step', () => {
const { isEnriching } = mountComposable({
account: { custom_attributes: { onboarding_step: 'account_details' } },
});
expect(isEnriching.value).toBe(false);
});
it('times out after 30s, flipping to false and populating', () => {
vi.useFakeTimers();
try {
const { isEnriching, fields } = mountComposable({
account: {
custom_attributes: {
onboarding_step: 'enrichment',
company_size: '51-200',
},
},
});
expect(isEnriching.value).toBe(true);
vi.advanceTimersByTime(30000);
expect(isEnriching.value).toBe(false);
expect(fields.companySize.value).toBe('51-200');
} finally {
vi.useRealTimers();
}
});
});
describe('getChangedFields', () => {
it('lists only enrichable fields edited after auto-fill', () => {
const { fields, getChangedFields } = mountComposable({
account: {
custom_attributes: {
website: 'https://acme.com',
company_size: '11-50',
industry: 'Technology',
},
},
});
expect(getChangedFields()).toEqual([]);
fields.industry.value = 'Finance';
expect(getChangedFields()).toEqual(['industry']);
});
});
});
@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils';
import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
import OnboardingAPI from 'dashboard/api/onboarding';
vi.mock('dashboard/api/onboarding', () => ({
default: {
getHelpCenterGeneration: vi.fn(),
},
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key.endsWith('HELP_CENTER_CATEGORIES')) {
return `${params.count} categories`;
}
if (key.endsWith('HELP_CENTER_SUMMARY')) {
return `${params.count} articles across ${params.categories}`;
}
if (key.endsWith('HELP_CENTER_ARTICLES')) {
return `${params.count} articles`;
}
return key;
},
}),
}));
const mountStatus = () =>
mount(HelpCenterCreationStatus, {
global: {
stubs: {
CreationStatusRow: {
props: ['ready', 'title', 'description', 'status'],
template:
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
},
},
},
});
describe('HelpCenterCreationStatus', () => {
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders completed summary from the status endpoint', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 3,
categories_count: 2,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'3 articles across 2 categories'
);
});
it('hides the row when generation is skipped', async () => {
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
data: {
generation_id: 'generation-123',
state: { status: 'skipped' },
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
});
it('polls while generating and stops after completion', async () => {
vi.useFakeTimers();
OnboardingAPI.getHelpCenterGeneration
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'generating' },
articles_count: 1,
categories_count: 0,
},
})
.mockResolvedValueOnce({
data: {
generation_id: 'generation-123',
state: { status: 'completed' },
articles_count: 2,
categories_count: 1,
},
});
const wrapper = mountStatus();
await flushPromises();
expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
'true'
);
expect(wrapper.find('[data-test="row"]').text()).toBe(
'2 articles across 1 categories'
);
vi.advanceTimersByTime(5000);
await flushPromises();
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ({ value: {} }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({
connectViaOAuth: vi.fn(),
connectWhatsapp: vi.fn(),
}),
}));
const mountDialog = () =>
mount(InboxChannelsDialog, {
props: { inboxes: [] },
global: {
stubs: {
Dialog: {
template: '<div><slot /></div>',
methods: { open() {}, close() {} },
},
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
InboxChannelForm: { template: '<div data-test="channel-form" />' },
ChannelIcon: true,
Icon: true,
},
},
});
describe('InboxChannelsDialog Facebook gating', () => {
afterEach(() => {
delete window.chatwootConfig;
});
it('opens the Facebook page picker when fbAppId is configured', async () => {
window.chatwootConfig = { fbAppId: 'fb-app' };
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
});
it('shows the grid (not the picker) when fbAppId is missing', async () => {
window.chatwootConfig = {};
const wrapper = mountDialog();
wrapper.vm.open('facebook');
await nextTick();
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
// The channel grid renders its cards instead.
expect(wrapper.find('button').exists()).toBe(true);
});
});
@@ -0,0 +1,158 @@
import { flushPromises, mount } from '@vue/test-utils';
import { ref, nextTick } from 'vue';
import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('dashboard/store/utils/api', () => ({
parseAPIErrorResponse: vi.fn(),
}));
vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
useFacebookPageConnect: vi.fn(),
}));
const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
}));
const NextButtonStub = {
props: ['label', 'disabled', 'isLoading'],
emits: ['click'],
template: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
};
const ComboBoxStub = {
props: ['modelValue', 'options'],
emits: ['update:modelValue'],
template: '<div data-test="combobox" />',
};
const PAGES = [
{ id: 'p1', name: 'Page One', access_token: 'pt1' },
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
];
const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
let loginAndFetchPages;
let preloadSdk;
const mountForm = () =>
mount(InboxFacebookForm, {
global: {
stubs: {
NextButton: NextButtonStub,
ComboBox: ComboBoxStub,
Spinner: true,
},
},
});
const clickButton = (wrapper, label) =>
wrapper
.findAll('button')
.find(button => button.text() === label)
.trigger('click');
beforeEach(() => {
vi.clearAllMocks();
preloadSdk = vi.fn();
loginAndFetchPages = vi.fn();
useFacebookPageConnect.mockReturnValue({
isAuthenticating: ref(false),
preloadSdk,
loginAndFetchPages,
});
dispatch.mockResolvedValue({ id: 1 });
});
describe('InboxFacebookForm', () => {
it('preloads the SDK on mount', () => {
mountForm();
expect(preloadSdk).toHaveBeenCalled();
});
it('lists only connectable pages and creates an inbox for the selected one', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: PAGES,
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
// p2 is already connected (exists), so only p1 is offered.
const combobox = wrapper.findComponent(ComboBoxStub);
expect(combobox.props('options')).toEqual([
{ value: 'p1', label: 'Page One' },
]);
combobox.vm.$emit('update:modelValue', 'p1');
await nextTick();
await clickButton(wrapper, CONNECT);
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
user_access_token: 'tok',
page_access_token: 'pt1',
page_id: 'p1',
inbox_name: 'Page One',
});
expect(wrapper.emitted('created')).toBeTruthy();
});
it('shows the empty state when every page is already connected', async () => {
loginAndFetchPages.mockResolvedValue({
userAccessToken: 'tok',
pages: [
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
],
});
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
);
expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
});
it('shows an error when the connection fails', async () => {
loginAndFetchPages.mockRejectedValue(new Error('boom'));
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
expect(dispatch).not.toHaveBeenCalled();
});
it('stays on the connect prompt without an error when cancelled', async () => {
loginAndFetchPages.mockResolvedValue(null);
const wrapper = mountForm();
await clickButton(wrapper, LAUNCH);
await flushPromises();
await nextTick();
expect(wrapper.text()).not.toContain(
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
);
// Launch button is still available to retry.
expect(
wrapper.findAll('button').some(button => button.text() === LAUNCH)
).toBe(true);
});
});
@@ -0,0 +1,62 @@
import {
findConnectedInbox,
isChannelConnected,
} from '../../inbox-setup/channelMatchers';
const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
const OUTLOOK = {
id: 3,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
describe('channelMatchers', () => {
describe('findConnectedInbox', () => {
it('returns the inbox sharing the channel type', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(WHATSAPP);
});
it('matches email inboxes on provider', () => {
expect(
findConnectedInbox([OUTLOOK, GMAIL], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBe(GMAIL);
});
it('does not match a different email provider', () => {
expect(
findConnectedInbox([OUTLOOK], {
channel_type: 'Channel::Email',
provider: 'google',
})
).toBeUndefined();
});
it('returns undefined when nothing matches', () => {
expect(
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
).toBeUndefined();
});
});
describe('isChannelConnected', () => {
it('is true when a matching inbox exists', () => {
expect(
isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
).toBe(true);
});
it('is false when no inbox matches', () => {
expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
});
it('is false for a channel without an inbox stub', () => {
expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
});
});
});
@@ -0,0 +1,300 @@
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useRoute } from 'vue-router';
import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
// used, so assertions validate against the actual channel identity (label keys,
// channel_type, social ordering) derived from CHANNEL_LIST.
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
const store = createStore({
modules: {
accounts: {
namespaced: true,
getters: {
getAccount: () => () => ({
id: 1,
custom_attributes: { brand_info: brandInfo },
}),
},
},
inboxes: {
namespaced: true,
getters: { getInboxes: () => inboxes },
},
},
});
let result;
const Component = defineComponent({
setup() {
result = useDetectedChannels();
return () => h('div');
},
});
mount(Component, { global: { plugins: [store] } });
return result;
};
beforeEach(() => {
useRoute.mockReturnValue({ params: { accountId: '1' } });
// Configure the installation OAuth credentials so detected channels aren't
// hidden by the config gate; individual tests clear this to assert hiding.
window.chatwootConfig = {
fbAppId: 'fb',
instagramAppId: 'ig',
tiktokAppId: 'tt',
whatsappAppId: 'wa',
whatsappConfigurationId: 'wa-config',
};
});
afterEach(() => {
delete window.chatwootConfig;
});
describe('useDetectedChannels', () => {
describe('displayedChannels', () => {
it('maps detected socials with a url to channel rows', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
{ type: 'instagram', url: 'https://instagram.com/acme' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '+14155552671',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'instagram',
handle: '@acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('skips socials without a url or with an unknown type', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'telegram' }, // no url
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'tiktok',
]);
});
it('uses the raw path for line and falls back to empty on a bad url', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'line', url: 'https://line.me/acme' },
{ type: 'facebook', url: 'not-a-url' },
],
},
});
expect(displayedChannels.value).toEqual([
{
type: 'line',
handle: 'acme',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
]);
});
it('omits the detected email channel while email is disabled for this phase', () => {
const { displayedChannels } = mountComposable({
brandInfo: {
email_provider: 'google',
email: 'support@acme.com',
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'whatsapp',
]);
});
it('falls back to the default channel suggestions when nothing is detected', () => {
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// The configured mainstream channels, with no detected handle.
expect(displayedChannels.value).toEqual([
{
type: 'whatsapp',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
inbox: { channel_type: 'Channel::Whatsapp' },
},
{
type: 'facebook',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
inbox: { channel_type: 'Channel::FacebookPage' },
},
{
type: 'instagram',
handle: '',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
inbox: { channel_type: 'Channel::Instagram' },
},
]);
});
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
window.chatwootConfig = {}; // no OAuth credentials configured
const { displayedChannels } = mountComposable({ brandInfo: undefined });
// Only the credential-free defaults survive (Telegram, LINE).
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'telegram',
'line',
]);
});
it('hides detected channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { displayedChannels } = mountComposable({
brandInfo: {
socials: [
{ type: 'facebook', url: 'https://facebook.com/acme' },
{ type: 'line', url: 'https://line.me/acme' },
],
},
});
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
'line',
]);
});
});
describe('remainingChannels', () => {
it('returns the platforms not already shown as default rows', () => {
// Nothing detected → displayed falls back to the defaults (WhatsApp,
// Facebook, Instagram), so the footer previews the remaining platforms.
const { remainingChannels } = mountComposable({ brandInfo: {} });
expect(remainingChannels.value).toEqual([
{
type: 'line',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
inbox: { channel_type: 'Channel::Line' },
},
{
type: 'telegram',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
inbox: { channel_type: 'Channel::Telegram' },
},
{
type: 'tiktok',
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
inbox: { channel_type: 'Channel::Tiktok' },
},
]);
});
it('excludes already-detected socials, preserving order', () => {
const { remainingChannels } = mountComposable({
brandInfo: {
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
},
});
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
'facebook',
'line',
'instagram',
]);
});
it('excludes channels whose installation OAuth credentials are missing', () => {
window.chatwootConfig = {}; // nothing configured
const { remainingChannels } = mountComposable({ brandInfo: {} });
// The only configured channels (Telegram, LINE) are shown as default rows,
// and every other platform is gated out — so nothing remains for the footer.
expect(remainingChannels.value).toEqual([]);
});
});
describe('connectedInbox', () => {
it('returns the real inbox sharing the channel type', () => {
const inbox = {
id: 1,
channel_type: 'Channel::Whatsapp',
name: 'WA Biz',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [inbox],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
).toBe(inbox);
});
it('matches email inboxes on provider', () => {
const gmail = {
id: 1,
channel_type: 'Channel::Email',
provider: 'google',
};
const outlook = {
id: 2,
channel_type: 'Channel::Email',
provider: 'microsoft',
};
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [outlook, gmail],
});
expect(
connectedInbox({
inbox: { channel_type: 'Channel::Email', provider: 'google' },
})
).toBe(gmail);
});
it('returns undefined when nothing matches', () => {
const { connectedInbox } = mountComposable({
brandInfo: {},
inboxes: [],
});
expect(
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
).toBeUndefined();
});
});
});
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Editor from 'next/Editor/Editor.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
<Editor
v-model="message"
class="w-full"
channel-type="Context::NoToolbar"
enable-variables
:enable-canned-responses="false"
:show-character-count="false"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Default age threshold for excluding stale unassigned conversations (7 days)
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
@@ -106,6 +106,7 @@ const formData = computed(() => ({
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
}));
const handleDeleteInbox = async inboxId => {
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
@@ -16,6 +18,7 @@ import {
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
@@ -28,6 +31,7 @@ const props = defineProps({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
}),
},
mode: {
@@ -56,7 +60,6 @@ const props = defineProps({
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
@@ -64,6 +67,9 @@ const emit = defineEmits([
'navigateToInbox',
'validationChange',
]);
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
const MIN_EXCLUSION_MINUTES = 60;
const MAX_EXCLUSION_MINUTES = 1438560;
const { t } = useI18n();
const route = useRoute();
@@ -83,12 +89,28 @@ const state = reactive({
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
const validationState = ref({
isValid: false,
});
const exclusionUnit = ref(DURATION_UNITS.DAYS);
// DurationInput works in minutes; the policy stores hours, so bridge the two
const excludeOlderThanMinutes = computed({
get() {
return state.excludeOlderThanHours == null
? null
: state.excludeOlderThanHours * 60;
},
set(minutes) {
state.excludeOlderThanHours =
minutes == null ? null : Math.round(minutes / 60);
},
});
const createOption = (
type,
key,
@@ -170,6 +192,7 @@ const resetForm = () => {
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
});
};
@@ -177,10 +200,17 @@ const handleSubmit = () => {
emit('submit', { ...state });
};
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
const detectExclusionUnit = hours => {
exclusionUnit.value =
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
detectExclusionUnit(newData.excludeOlderThanHours);
},
{ immediate: true, deep: true }
);
@@ -247,6 +277,27 @@ defineExpose({
v-model:window-unit="state.windowUnit"
/>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
</p>
</div>
<div
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<DurationInput
v-model:unit="exclusionUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_EXCLUSION_MINUTES"
:max="MAX_EXCLUSION_MINUTES"
/>
</div>
</div>
</div>
<Button
@@ -144,8 +144,10 @@ const showWebhookSection = computed(
() => props.healthData?.webhook_configuration !== undefined
);
// Phone-level override takes precedence over WABA-level (application), so prefer it.
const webhookUrl = computed(
() =>
props.healthData?.webhook_configuration?.phone_number ||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
props.healthData?.webhook_configuration?.application
);
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const store = useStore();
const { t } = useI18n();
@@ -123,9 +124,16 @@ const confirmPlaceHolderText = computed(() =>
>
<div class="flex items-start gap-4">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1 text-lg"
>
<EmojiIcon
v-if="team.icon"
:value="team.icon"
:color="team.icon_color"
class="size-5"
/>
<Icon
v-else
icon="i-lucide-users-round"
class="size-4 text-n-slate-11"
/>
@@ -1,15 +1,27 @@
<script>
import validations from './helpers/validations';
import FormInput from 'v3/components/Form/Input.vue';
import { reactive } from 'vue';
import { reactive, ref, defineAsyncComponent } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { OnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
export default {
components: {
NextButton,
FormInput,
OnClickOutside,
EmojiIcon,
Icon,
EmojiIconPicker,
},
props: {
onSubmit: {
@@ -35,19 +47,38 @@ export default {
description = '',
name: title = '',
allow_auto_assign: allowAutoAssign = true,
icon = '',
icon_color: iconColor = '',
} = formData;
const state = reactive({
description,
title,
allowAutoAssign,
icon,
iconColor,
});
const isIconPickerOpen = ref(false);
const rules = validations;
const v$ = useVuelidate(rules, state);
return { state, v$ };
return { state, v$, isIconPickerOpen };
},
methods: {
onSelectIcon({ type, value, color }) {
this.state.icon = value;
this.state.iconColor = type === 'icon' ? color : '';
this.isIconPickerOpen = false;
},
onColorChange(color) {
this.state.iconColor = color;
},
onRemoveIcon() {
this.state.icon = '';
this.state.iconColor = '';
this.isIconPickerOpen = false;
},
handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid) {
@@ -57,6 +88,8 @@ export default {
description: this.state.description,
name: this.state.title,
allow_auto_assign: this.state.allowAutoAssign,
icon: this.state.icon,
icon_color: this.state.iconColor,
});
},
},
@@ -66,16 +99,49 @@ export default {
<template>
<div class="flex-shrink-0 w-full">
<form class="mx-0 grid gap-4" @submit.prevent="handleSubmit">
<FormInput
v-model="state.title"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<div class="relative">
<FormInput
v-model="state.title"
class="!ps-12"
name="title"
spacing="compact"
:label="$t('TEAMS_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('TEAMS_SETTINGS.FORM.NAME.PLACEHOLDER')"
:has-error="v$.title.$error"
:error-message="v$.title.$error ? v$.title.$errors[0].$message : ''"
@blur="v$.title.$touch"
/>
<OnClickOutside
class="absolute top-[1.75rem] start-0"
@trigger="isIconPickerOpen = false"
>
<NextButton
type="button"
variant="ghost"
color="slate"
class="text-lg !size-[2.5rem] !p-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isIconPickerOpen = !isIconPickerOpen"
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<Icon v-else icon="i-lucide-smile-plus " class="size-4" />
</NextButton>
<EmojiIconPicker
v-if="isIconPickerOpen"
class="start-0 top-10"
:value="state.icon"
:color="state.iconColor"
show-remove-button
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
/>
</OnClickOutside>
</div>
<FormInput
v-model="state.description"
name="description"
+12 -3
View File
@@ -7,9 +7,12 @@ import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const ONBOARDING_STEPS = ['account_details', 'enrichment', 'inbox_setup'];
const routes = [...dashboard.routes];
const onboardingPath = step =>
step === 'inbox_setup' ? 'onboarding/inbox-setup' : 'onboarding';
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = async (to, next) => {
@@ -39,12 +42,18 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding ? 'onboarding' : 'dashboard';
const target = needsOnboarding
? onboardingPath(userAccount?.onboarding_step)
: 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
return next(
frontendURL(
`accounts/${routeAccountId}/${onboardingPath(userAccount?.onboarding_step)}`
)
);
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
@@ -208,31 +208,23 @@ const actions = {
}
},
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee(
{ commit },
{ conversationId, assignee, assigneeType }
) {
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -2,14 +2,10 @@ import MessageApi from '../../../../api/inbox/message';
export default {
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
try {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
} catch (error) {
// ignore error
}
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
},
};
@@ -108,16 +108,10 @@ export const mutations = {
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
if (assigneeType === 'AgentBot') {
chat.status = 'pending';
} else if (assignee) {
chat.status = 'open';
}
}
},
@@ -1,9 +1,7 @@
import AssignableAgentsAPI from '../../api/assignableAgents';
import InboxesAPI from '../../api/inboxes';
const state = {
records: {},
ownerRecords: {},
uiFlags: {
isFetching: false,
},
@@ -12,7 +10,6 @@ const state = {
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
SET_INBOX_ASSIGNABLE_OWNERS: 'SET_INBOX_ASSIGNABLE_OWNERS',
};
export const getters = {
@@ -21,7 +18,6 @@ export const getters = {
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getAssignableOwners: $state => inboxId => $state.ownerRecords[inboxId] || [],
getUIFlags($state) {
return $state.uiFlags;
},
@@ -44,15 +40,6 @@ export const actions = {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
}
},
async fetchAssignableOwners({ commit }, inboxId) {
const {
data: { payload },
} = await InboxesAPI.getAssignableOwners(inboxId);
commit(types.SET_INBOX_ASSIGNABLE_OWNERS, {
inboxId,
owners: payload,
});
},
};
export const mutations = {
@@ -68,12 +55,6 @@ export const mutations = {
[inboxId]: members,
};
},
[types.SET_INBOX_ASSIGNABLE_OWNERS]: ($state, { inboxId, owners }) => {
$state.ownerRecords = {
...$state.ownerRecords,
[inboxId]: owners,
};
},
};
export default {
@@ -357,12 +357,11 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
{ conversationId: 1, agentId: 1 }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -372,7 +371,6 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -699,11 +699,11 @@ describe('#mutations', () => {
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent bot to the correct conversation by ID', () => {
it('should assign agent to the correct conversation by ID', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [
{ id: 1, meta: {}, status: 'open' },
{ id: 1, meta: {} },
{ id: 2, meta: {} },
],
selectedChatId: 2,
@@ -712,28 +712,10 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[0].status).toEqual('pending');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
it('should open the conversation when assigning a user', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {}, status: 'pending' }],
};
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'User',
});
expect(state.allConversations[0].status).toEqual('open');
});
});
describe('#ASSIGN_PRIORITY', () => {
@@ -1,18 +1,12 @@
import axios from 'axios';
import InboxesAPI from 'dashboard/api/inboxes';
import { actions, types } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
vi.mock('dashboard/api/inboxes');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
@@ -39,20 +33,4 @@ describe('#actions', () => {
]);
});
});
describe('#fetchAssignableOwners', () => {
it('stores assignable owners for the inbox', async () => {
InboxesAPI.getAssignableOwners.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetchAssignableOwners({ commit }, 1);
expect(InboxesAPI.getAssignableOwners).toHaveBeenCalledWith(1);
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_OWNERS, {
inboxId: 1,
owners: agentsData,
});
});
});
});

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