Compare commits

...
Author SHA1 Message Date
Sony MathewandGitHub a4eff3d200 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-22 16:54:30 +05:30
Sony MathewandGitHub 565a26be21 Merge branch 'develop' into feature/cw-7513 2026-07-22 16:54:12 +05:30
Sivin VargheseandGitHub 1e52d23d7a fix: guard agent sort against null names in assignment dropdown (#15125) 2026-07-22 15:27:04 +05:30
Sivin VargheseandGitHub fbb3479263 fix: guard agent sort against null names in assignment dropdown (#15125)
# Pull Request Template

## Description

This PR fixes a crash where opening a conversation threw `TypeError:
Cannot read properties of null (reading 'localeCompare')` and prevented
the agent assignment dropdown from rendering.

Since #14866, agent bots are included in the assignable agents list.
`AgentBot#name` is not presence-validated, so system bots (account-less,
global) can have a `null` name. Those nameless bots flowed into
name-based operations that assumed a string, causing crashes and
warnings across multiple surfaces:

* **Assignment dropdown sort:** `getAgentsByAvailability` called
`a.name.localeCompare(b.name)`, causing a `localeCompare` `TypeError`.
* **Dropdown search:** `MultiselectDropdownItems` called
`option.name.toLowerCase()`, causing a `toLowerCase` `TypeError`.
* **Agent Bots settings:** `Avatar` received `name=null` for a `String`
prop, triggering a Vue prop validation warning.

### What changed

* Keep nameless agent bots in the assignment dropdown and render a `-`
fallback label in `useAgentsList`. These are still valid,
assignable-by-ID records: the assignable agents API includes accessible
bots, and `Conversations::AssignmentService` assigns them by ID.
Preserving them avoids hiding valid assignment targets. Bots are still
included only when `includeAgentBots` is enabled.
* Make the sort in `getAgentsByAvailability` null-safe by coercing
missing names to an empty string (defense in depth).
* Make the search filter in `MultiselectDropdownItems` null-safe
(defense in depth).
* Pass a null-safe `name` prop to `Avatar` in the Agent Bots settings
list to eliminate the Vue prop validation warning.

Fixes
https://linear.app/chatwoot/issue/CW-7670/agent-assignment-dropdown-crashes-with-cannot-read-properties-of-null

## Type of change

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

## How Has This Been Tested?


1. Have a system agent bot (`name: null`) that is assignable to an
inbox.
2. Open any conversation in that inbox.
   * The agent assignment dropdown renders without console errors.
   * The nameless bot is listed with a `-` label and can be assigned.
3. Go to **Settings → Agent Bots**.
   * The page renders without the `Avatar` prop validation warning.


## 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-07-22 15:23:19 +05:30
166a41c31c fix(whatsapp): prevent invalid automation sends outside reply window (#15113)
WhatsApp automations now fail locally when they attempt to send a
free-form message after the 24-hour customer service window has closed.
This avoids sending an invalid template request to Meta and gives users
a clear, actionable error instead of “Template not found or invalid
template name.”

Template messages continue to be sent whenever template parameters are
present. Free-form messages continue to be sent normally while the
conversation is replyable.

Fixes
https://linear.app/chatwoot/issue/PLA-183/prevent-whatsapp-automations-outside-the-24-hour-window-from-producing

### How to reproduce

1. Create a WhatsApp automation that sends a message without template
parameters.
2. Trigger it on a conversation whose 24-hour customer service window is
closed.
3. Observe that the message previously reached the template send path
and failed with a misleading provider error.

### How to test

1. Trigger an automation with template parameters and confirm it sends
as a template message.
2. Trigger an automation without template parameters inside the 24-hour
window and confirm it sends as a free-form message.
3. Trigger an automation without template parameters outside the 24-hour
window and confirm it fails locally with a clear error and makes no
request to Meta.

### Things to know

This changes only the invalid closed-window, no-template path. Existing
template and in-window message behavior remains unchanged.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 12:40:28 +04:00
e65e18e9c5 fix: normalize phone numbers during whatsapp channel lookup (#13709)
Fixes
https://linear.app/chatwoot/issue/PLA-99/whatsapp-messages-dropped-for-brazilargentina-numbers-due-to-phone
Fixes https://github.com/chatwoot/chatwoot/issues/14492

Meta's WhatsApp Cloud API includes `display_phone_number` in webhook
payloads, but its format can differ from the number stored in Chatwoot's
channel record.
In Brazil, Meta omits the mobile 9 prefix. For example, it sends
55419XXXXXXX (12 digits) instead of 554199XXXXXXX (13 digits). In
Argentina, Meta adds an extra 9 after the country code. For example, it
sends 549XXXXXXXXXX instead of 54XXXXXXXXXX.
The whatsapp event job uses `display_phone_number` for an exact-match
channel lookup. When the formats do not match, the lookup returns nil
and the incoming message is silently dropped, logging:

`Inactive WhatsApp channel: unknown - <phone_number>.`

The fix extends `get_channel_from_wb_payload` to fall back to normalized
phone number matching using the existing PhoneNumberNormalizationService
normalizers (Brazil, Argentina), which were previously only used for
contact-level lookups.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-22 10:33:40 +04:00
Shivam MishraandGitHub 89b83c65c8 fix: close message generation popover when its trigger scrolls away (#15114) 2026-07-21 19:34:57 +05:30
ed30ff9c22 fix(whatsapp): allow calling a contact with no existing conversation (#15014)
## Description

Agents can now place a WhatsApp call to a contact straight from the
contacts screen, even if that contact has never messaged in. Previously
the call only worked once a conversation already existed, so a freshly
added contact would fail with "Unable to start the call. Please try
again." — the only workaround was to get the contact to message the
channel first.


## Type of change

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

## How Has This Been Tested?

- Manually via 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 18:08:32 +05:30
7d2f01e402 feat(whatsapp): unify embedded signup feature gating (#15106)
WhatsApp embedded signup now uses
`whatsapp_embedded_signup_inbox_creation` as the single Chatwoot Cloud
rollout gate for inbox creation, proactive reconfiguration, and
disconnected inbox reauthorization. The authorization endpoint enforces
the same gate, so the UI and backend remain consistent.

Self-hosted installations keep their existing behavior.

## Things to know

- This reuses the existing feature flag; there is no migration or schema
change.
- The feature is shown as “WhatsApp Embedded Signup Flow” in feature
management.
- `whatsapp_reconfigure` remains visible and honored for self-hosted
proactive reconfiguration to preserve existing accounts. It can be
deprecated after the self-hosted dependency is removed or migrated.

## How to test

1. On Chatwoot Cloud, enable `whatsapp_embedded_signup_inbox_creation`
for an account.
2. Confirm that new WhatsApp inbox creation, proactive reconfiguration,
and disconnected inbox reauthorization are available.
3. Disable the flag and confirm those entry points are hidden and
authorization requests are rejected.
4. On self-hosted, confirm proactive reconfiguration remains controlled
by the existing `whatsapp_reconfigure` account setting.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 15:05:11 +04:00
Shivam MishraandGitHub 8c013415b8 fix: localize the captain overview summary greeting (#15108) 2026-07-21 16:29:08 +05:30
Tanmay Deep SharmaandGitHub 2144de92f2 fix(whatsapp): reopen conversation across a contact's coexistence identities (#15098)
WhatsApp contacts using coexistence are identified by more than one
source ID (a phone `wa_id` and a `BR.`/BSUID identity), so a single
contact ends up owning multiple `contact_inbox` records. The "reopen the
same conversation" feature scoped conversation reuse to a single
`contact_inbox`, so messages arriving under a different identity of the
same contact started a brand-new conversation — even with reopen enabled
— producing duplicate conversations.

This scopes reuse to the contact across all of its `contact_inbox`
records in the inbox instead of a single `contact_inbox`.

## Closes
- [CW-7651
](https://linear.app/chatwoot/issue/CW-7651/duplicate-conversations)

## How to reproduce
1. On a WhatsApp Cloud inbox with "reopen the same conversation" (lock
to single conversation) enabled.
2. Have a coexistence contact whose webhooks alternate between carrying
the phone `wa_id` and only the BSUID identity.
3. Before: each identity opens its own conversation → duplicates. After:
incoming messages reopen the contact's existing conversation regardless
of which identity the webhook carried.

## What changed
- `Whatsapp::IncomingMessageBaseService#set_conversation` now looks up
reusable conversations via `@contact.conversations.where(inbox_id:
@inbox.id)` instead of `@contact_inbox.conversations`.
- Updated existing specs to wire the conversation's `contact` to the
contact_inbox's contact, mirroring production data.
2026-07-21 16:21:40 +05:30
0e376f4fe2 feat(whatsapp-call): support BSUID callers for inbound voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling

## Description

Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.

## Type of change

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

## How Has This Been Tested?

-  Locally via 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 16:19:53 +05:30
Shivam MishraandGitHub 67cab7171d feat: show Captain generation path on conversation messages [CW-7484] (#15078) 2026-07-21 15:15:13 +05:30
Shivam MishraandGitHub 7a5385cc32 feat: improve captain overview loading and reuse stats for summary [CW-7610] (#15105) 2026-07-21 15:14:18 +05:30
Sivin VargheseandGitHub 920a98ccf4 fix: calls dashboard load race (#15094) 2026-07-21 15:00:08 +05:30
Aakash BakhleandGitHub ae49af354d fix: serialize multimodal Captain session content (#15096)
Captain now saves agent session records when a user message includes an
image. The saved record keeps the image URL and excludes downloaded
image bytes, so image replies no longer report a JSON serialization
error after delivery.

Fixes:
https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue&notification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945

## Root cause

RubyLLM downloads image attachments and caches the binary bytes inside
`RubyLLM::Content`. `SessionCaptureService` passed the live object to
the `run_context` JSON column. Rails then tried to encode the cached
JPEG bytes as UTF-8 and raised `JSON::GeneratorError`.

The error did not block replies, handoffs, or credit updates because
session capture rescues its own failures. The failed write meant that
Chatwoot lost the agent session record for the response.

## How to reproduce

1. Send an image to a Captain V2 assistant.
2. Let RubyLLM load the image during the model request.
3. Save the resulting conversation history in an agent session.
4. Observe the JSON encoding error when Rails reaches the cached image
bytes.

## What changed

`SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe
hash before saving the current turn. The hash contains the message text
and attachment URL without the cached bytes. Other message content is
unchanged.

The focused service spec covers a cached JPEG byte payload and passes
with 12 examples. RuboCop reports no offenses in the changed service and
spec.
2026-07-21 13:04:12 +05:30
d1fa8d8c2f refactor: share whatsapp/twilio template logic via @chatwoot/utils (#15001)
# Pull Request Template

## Description
Moves the WhatsApp & Twilio content-template logic to the shared
[`@chatwoot/utils`](https://github.com/chatwoot/utils)
([PR](https://github.com/chatwoot/utils/pull/62)) package so web and
mobile share one implementation. The neutral core takes the raw template
and returns `processed_params`, the same shape the web parsers already
use, so it's a drop-in with no behavior change.

- `templateHelper.js` / `URLHelper.js` → source `MEDIA_FORMATS`,
`findComponentByType`, `processVariable`, `buildTemplateParameters`,
`extractFilenameFromUrl` from the package
- `inboxes.js` → filters with shared `isSendableTemplate`
- `WhatsAppTemplateParser.vue` / `ContentTemplateParser.vue` →
`isFormInvalid` and Twilio media helpers now use the shared
`isWhatsAppComplete` / `isTwilioComplete` / `applyTwilioMediaFilename`

> Depends on the `@chatwoot/utils` release adding the shared template
API, bump `package.json` from `^0.0.55` to the published version before
merge.

Fixes
[CW-7540](https://linear.app/chatwoot/issue/CW-7540/web-templates-integration-with-utils)

## Type of change

- [x] Breaking change (Refactor)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-21 10:08:23 +04:00
71fffdd2b9 fix: rate limit widget conversation transcript API (#15085)
## Description

The widget conversation transcript endpoint (`POST
/api/v1/widget/conversations/transcript`) has no rate limit. Every other
comparable endpoint does: the agent-facing transcript API and the widget
conversation-create and contact-update endpoints are all throttled. This
gap lets a single client trigger a large burst of transcript emails from
one conversation.

This adds an IP-based throttle (5 requests/hour) for the endpoint,
placed inside the existing widget-API throttle block so it inherits the
`ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients.
The limit is generous for legitimate use (a visitor emailing themselves
a transcript) while stopping abusive loops. Throttled requests get the
standard 429 the widget already handles.

## Type of change

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

## How Has This Been Tested?

`config/initializers/rack_attack.rb` throttles have no existing specs in
this file, so this follows the established convention (no new spec).
Verified `ruby -c` and `rubocop` pass on the file. The new throttle
mirrors the sibling widget throttles directly above it (same IP key,
path guard, and structure).

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-20 16:09:29 -07:00
Sojan JoseandGitHub eae9841eb4 fix: restore token access to account APIs (#15088)
Token-authenticated requests to Agent Bots, Labels, and affected Captain
endpoints return normal responses again. The regression was caused by
duplicate `current_account` callbacks in subclasses moving account
resolution behind the API entitlement check, leaving `Current.account`
unset.

## Closes

- https://linear.app/chatwoot/issue/CW-7641/5xx-errors-in-agent-bot-apis

## How to reproduce

1. Send `GET /api/v1/accounts/:account_id/agent_bots` with a valid
administrator API access token.
2. Observe a `500` from `validate_token_api_access` because
`Current.account` is `nil`.
3. With this change, account resolution runs in the base-controller
order and the request succeeds.

## What changed

- Removed redundant `current_account` callbacks from account-scoped
controllers that already inherit the callback from
`Api::V1::Accounts::BaseController`.
- Kept the standalone direct-upload controller callback unchanged.
- Added regression coverage for administrator API-token access to Agent
Bots.
2026-07-20 15:28:33 -07:00
160732c07d fix: rate limit agent management APIs (#15081)
# Pull Request Template


Bring  agent create and delete requests under rack attack throttling

Related to https://linear.app/chatwoot/issue/CW-7637


Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-07-20 20:41:39 +05:30
Sivin VargheseandGitHub 7a299307b8 fix: apply installation name to sender name preview (#15076) 2026-07-20 19:28:58 +05:30
Sivin VargheseandGitHub 08f49f5896 fix: prevent channel list crash on hard reload (#15074) 2026-07-20 19:28:48 +05:30
Tanmay Deep Sharma b324a147e6 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 18:32:46 +05:30
Tanmay Deep Sharma 428b07157c chore(automations): run delayed sweep on the 5-minute scheduled items job 2026-07-20 18:32:19 +05:30
Tanmay Deep Sharma 9e281bd655 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 18:27:05 +05:30
Tanmay Deep Sharma 63837ebef3 feat(automations): trigger-based delayed builder with standard dropdowns 2026-07-20 18:26:56 +05:30
Tanmay Deep Sharma bdee98d965 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 17:48:34 +05:30
Tanmay Deep Sharma 174630bec1 refactor(automations): keep delayed-rule curation in the frontend, leave backend validation as-is 2026-07-20 17:48:27 +05:30
Tanmay Deep Sharma 927e6573c9 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 17:42:41 +05:30
Tanmay Deep Sharma a363f22d82 feat(automations): scope delayed rules to meaningful events/conditions and group the list 2026-07-20 17:42:33 +05:30
Sivin VargheseandGitHub bf0a10c780 feat: introduce voice call dashboard (#14954) 2026-07-20 14:54:05 +05:30
Tanmay Deep Sharma a1c32ca96c test(automations): re-arm tracks newest incoming message on episode collision 2026-07-20 14:06:41 +05:30
Tanmay Deep Sharma 7deda0f82a Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 14:06:01 +05:30
Tanmay Deep Sharma a450d6437d fix(automations): track newest incoming message on episode re-arm 2026-07-20 14:05:59 +05:30
Tanmay Deep Sharma a9149ed211 test(automations): re-anchor stuck processing reply-chase rows on a newer reply 2026-07-20 13:02:07 +05:30
Tanmay Deep Sharma f52f988b6f Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 13:01:05 +05:30
Tanmay Deep Sharma a33ee3a1d0 fix(automations): re-anchor stuck processing reply-chase rows on a newer reply 2026-07-20 13:01:03 +05:30
Tanmay Deep Sharma 3c813a6d15 test(automations): re-arm message waits after a condition-only skip 2026-07-20 12:47:52 +05:30
Tanmay Deep Sharma 77d37fb889 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 12:47:03 +05:30
Tanmay Deep Sharma 1a73682c9a fix(automations): re-arm message waits after a condition-only skip 2026-07-20 12:47:00 +05:30
Tanmay Deep Sharma 10ae8218d2 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 12:26:18 +05:30
Tanmay Deep Sharma 5f92426e85 fix(automations): sweep delayed rules on a dedicated 1-minute cron 2026-07-20 12:26:16 +05:30
Tanmay Deep Sharma cfb6643ed5 test(automations): reply-chase clock does not move backwards on late older reply 2026-07-20 12:06:06 +05:30
Tanmay Deep Sharma 7929fbd8d9 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 12:05:26 +05:30
Tanmay Deep Sharma 68feaf99f6 fix(automations): guard reply-chase reschedule and re-validate delayed conditions on event change 2026-07-20 12:05:12 +05:30
Tanmay Deep Sharma 906263ee20 test(automations): anchor delayed due_at to the triggering event time 2026-07-20 11:49:03 +05:30
Tanmay Deep Sharma f298b00477 Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 11:46:56 +05:30
Tanmay Deep Sharma 7ef91bee53 fix(automations): anchor delayed due_at to the triggering event time 2026-07-20 11:46:06 +05:30
Tanmay Deep Sharma 0d2c0a5656 Merge branch 'develop' into feature/cw-7513 2026-07-20 11:44:18 +05:30
Tanmay Deep Sharma 89df93e1dd Merge feature/cw-7513 into feature/cw-7513-specs 2026-07-20 11:42:48 +05:30
Tanmay Deep Sharma 0e54bd17e5 fix(automations): reset delayed condition to a supported attribute per event 2026-07-20 11:42:40 +05:30
Tanmay Deep Sharma ee3600be46 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-20 11:28:50 +05:30
Tanmay Deep Sharma 6830c3273f feat(automations): show what ends the wait in the builder
Make the wait's interrupt explicit (as Intercom does) so users can predict when the
rule runs. An "Ends the wait if …" line adapts to the rule's episode: status change
for conversation rules, customer reply for outgoing message rules, agent reply for
incoming ones — plus "or the conditions no longer match" universally. Behaviour is
unchanged; this only surfaces it.
2026-07-20 11:28:41 +05:30
Tanmay Deep Sharma 9530cca197 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-20 11:22:22 +05:30
Tanmay Deep Sharma 2c728341e0 refactor(automations): rename "Delayed execution" to "Wait" in the builder
Adopt the simpler Wait concept (as Intercom et al. use): the toggle is labelled
"Wait", the duration reads "Wait for [4] [Hours]", and the block moves to just
before Actions (it applies to the whole rule; if we add ordered steps later it can
become a proper Wait action). The "Runs after 4h" list badge is unchanged, and the
engineering layer (execution_delay column, delayed_automations flag) keeps its name.
2026-07-20 11:22:12 +05:30
Sojan Jose a752e56765 Merge branch 'release/4.16.0' into develop 2026-07-18 03:59:29 -07:00
Sojan Jose ff6c068743 Bump version to 4.16.0 2026-07-18 03:54:55 -07:00
Sojan JoseandGitHub 465763f256 feat: enable Uzbek language (#15056)
Enables Uzbek as a selectable Chatwoot language and wires the `uz`
locale into the dashboard, widget, and survey translation loaders.

## Closes

Closes https://github.com/chatwoot/chatwoot/issues/15030

## Why

Uzbek is now available in the Chatwoot Crowdin project. The widget
translation has been proofread and is 100% translated and approved.

## What changed

- Adds Uzbek to the supported language registry
- Registers the `uz` locale with the dashboard, widget, and survey i18n
loaders

## Validation

- Proofread the Uzbek widget translation in Crowdin
- Verified placeholder preservation and corrected wording where needed
- Confirmed the widget file is 100% translated and 100% approved

This draft intentionally remains blocked until the Crowdin sync adds the
generated Uzbek locale files. It should only be marked ready and merged
after that sync lands.
2026-07-18 03:40:53 -07:00
e70bbfaaa5 chore: Update translations (#15059)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-07-18 02:59:24 -07:00
Sojan JoseandGitHub a61a8bfbc6 chore(deps): address bundle audit vulnerabilities (#15055)
Updates the vulnerable Ruby dependencies reported by bundle audit so the
application resolves to patched versions.

## Why

The latest Ruby advisory database reports known vulnerabilities in
Datadog, Loofah, and Rails HTML Sanitizer on `develop`, including a
high-severity denial-of-service advisory in Datadog.

## What changed

- Updates `datadog` from 2.19.0 to 2.38.0
- Updates `loofah` from 2.23.1 to 2.25.2
- Updates `rails-html-sanitizer` from 1.6.1 to 1.7.1
- Refreshes Datadog's required native packages

## Validation

Verified the resolved bundle is satisfied, bundle audit reports no known
vulnerabilities, and Datadog 2.38.0 loads successfully.
2026-07-17 16:04:56 -07:00
PranavandGitHub 4cb89d0de1 chore: Update brand assets (#15054)
Refresh favicons and app icons from the official brand kit and align the
PWA theme colors with the current brand blue.
2026-07-17 13:56:39 -07:00
Sony MathewandGitHub 259187e1fd docs: add branded email layout API reference (#15023)
## Description

Adds Swagger API documentation for branded email layouts, including the
account-level layout endpoint, Email inbox update field, and generated
API schemas. This stacked PR keeps the implementation review in #14936
focused on the product change.

Related:
[CW-7514](https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand)
Depends on #14936

## Type of change

- [x] This change requires a documentation update

## How to test

1. Run `bundle exec rake swagger:build`.
2. Start Chatwoot locally and open `http://localhost:3000/swagger`.
3. Verify the branded email layout account endpoint and Email inbox
`branded_email_layout` field appear in the API reference.

## Checklist

- [x] I have performed a self-review of my changes
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
2026-07-17 16:20:00 +05:30
90861f8809 feat(whatsapp): gate embedded signup inbox creation (#15046)
WhatsApp inbox creation now shows Embedded Signup for Chatwoot Cloud
accounts only when the new `whatsapp_embedded_signup_inbox_creation`
feature flag is enabled. Cloud accounts without the flag go directly to
manual WhatsApp Cloud API setup, while self-hosted installations with a
configured WhatsApp App ID retain their existing Embedded Signup flow.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 14:09:36 +04:00
Sony MathewandGitHub c420edce58 feat: branded email layouts for email inboxes (#14936)
## Description

Adds an API-only branded email layout feature for Email inbox replies.
Administrators can configure an account-level fallback layout and
per-email-inbox overrides with Liquid HTML using `{{ content_for_layout
}}`, and eligible outbound email replies/transcripts render through the
scoped layout when the account feature flag `branded_email_templates` is
enabled.

The feature is disabled by default and is manually controlled through
the normal account feature flag mechanism.

Fixes
https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How to test

1. Start Chatwoot locally and sign in as an administrator.
2. Enable the account feature flag for the account you are testing:

   ```ruby
   account = Account.find(<account_id>)
   account.enable_features!(:branded_email_templates)
   ```

3. Create or pick an Email inbox, then note the `account_id` and
`inbox_id`.
4. Configure an account-level fallback layout through the API using
authenticated admin headers:

   ```http
   PATCH /api/v1/accounts/:account_id/branded_email_layout
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Account Brand</header>{{
content_for_layout }}<footer>Account footer</footer></body></html>"
   }
   ```

5. Confirm `GET /api/v1/accounts/:account_id/branded_email_layout`
returns the saved account layout.
6. Configure an inbox-level override for the Email inbox:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Inbox Brand</header>{{
content_for_layout }}<footer>Inbox footer</footer></body></html>"
   }
   ```

7. Confirm `GET /api/v1/accounts/:account_id/inboxes/:inbox_id` returns
the inbox `branded_email_layout`.
8. Send an Email inbox reply and verify the outbound email body is
wrapped with the inbox layout around the generated reply content.
9. Clear the inbox layout by sending a blank value, then send another
reply and verify it falls back to the account layout:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
     "branded_email_layout": ""
   }
   ```

10. Clear the account layout with a blank value and verify Email replies
return to the existing no-layout behavior.
11. Verify validation behavior:
- Updating either API with a layout that omits `{{ content_for_layout
}}` returns `422`.
    - Updating either API with invalid Liquid returns `422`.
- Updating a non-Email inbox with `branded_email_layout` returns `422`.
- Disabling `branded_email_templates` and updating a layout returns
`422`.

## How Has This Been Tested?

Validation:

- `bundle exec rspec spec/models/email_template_spec.rb
spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/lib/email_templates/db_resolver_service_spec.rb
spec/mailers/conversation_reply_mailer_spec.rb`
- `bundle exec rspec
spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb`
- `bundle exec rubocop` on changed Ruby files, excluding generated
`db/schema.rb`
- `git diff --check` and `git diff --cached --check`
- YAML parsing for changed config/Swagger files
- `bundle exec rails routes -g branded_email_layout`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [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
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-17 15:25:55 +05:30
Sivin VargheseandGitHub 331875cdaa feat: Add popular content per locale (#14939) 2026-07-17 14:43:42 +05:30
Sivin VargheseandGitHub 5af26e45a9 chore: Cleanup help center layouts (#14812) 2026-07-17 14:07:52 +05:30
f948b0b8d9 test(onboarding): fix inbox channel dialog spec (#15048)
Restores the Inbox Channels dialog’s Facebook-gating test coverage by
isolating account feature dependencies that are unrelated to these
cases. This keeps the onboarding checks focused on whether Facebook is
configured and avoids initializing router and account-store state.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 13:57:34 +05:30
081e08c7c6 fix(whatsapp): skip template sync for suspended accounts (#15047)
Suspended accounts no longer participate in scheduled WhatsApp template
syncs. This avoids unnecessary external API calls while keeping the
existing refresh behavior unchanged for active accounts.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 11:57:07 +04:00
Sivin VargheseandGitHub dc3a4814aa fix: resolve {{conversation.id}} to display_id (#15041) 2026-07-17 12:55:48 +05:30
5e811eab99 chore(inbox): re-enable Instagram inbox creation (#15042)
Instagram inbox creation is available again on Chatwoot Cloud. Users can
discover and connect Instagram during onboarding or from Add Inbox,
while WhatsApp restrictions and existing-inbox Instagram advisories
remain unchanged.

Closes https://linear.app/chatwoot/issue/CW-7549/enable-instagram

## How to test

1. On Chatwoot Cloud, open Add Inbox and confirm Instagram can be
selected.
2. Confirm **Continue with Instagram** is enabled and starts the OAuth
flow.
3. In onboarding, confirm Instagram is displayed and can start OAuth.
4. Confirm WhatsApp embedded signup remains restricted.

## What changed

- Removed the Cloud-only Instagram filter and OAuth guard from
onboarding.
- Re-enabled the regular Instagram inbox creation action on Cloud.
- Removed the obsolete “Instagram inbox creation is temporarily
unavailable” copy.
- Updated the onboarding expectation for Chatwoot Cloud.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 20:28:49 +04:00
Shivam MishraandGitHub 9749a3dc96 feat: capture captain sessions for v2 assistant responses [CW-7485] (#14971)
Records a `Captain::Session` row for every Captain V2 assistant response
delivered in a conversation, so we can show how a response was generated
and report on credit, FAQ, and document usage. Stacked on #14970 (the
`captain_sessions` model).

## What changed

- `FaqLookupTool` now records the retrieved FAQ ids (and their backing
document ids) into the shared run state, accumulated across tool calls.
- `AgentRunnerService` exposes the raw ai-agents run result via
`last_run_result`; the `generate_response` return shape is unchanged, so
the playground path is unaffected.
- New `Captain::Assistant::SessionCaptureService` builds the session:
scenario resolved from the answering agent name, model from
`assistant.agent_model`, token usage plus the trimmed current-turn
conversation history stored in `run_context`.
- `ResponseBuilderJob` captures after delivery: `credits_consumed`
mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs,
where the session points at the customer-facing handoff message).
Capture runs outside the delivery transaction and swallows its own
failures, so a logging bug can never block or roll back a customer
reply.

V1 responses and copilot are out of scope; copilot capture comes next.

## How to test

On an account with `captain_integration_v2` enabled and an inbox
connected to an assistant with approved FAQs, send a customer message on
a pending conversation. After the assistant replies, a
`Captain::Session` row should exist with the conversation as subject,
the reply message as result, the FAQs/documents used, and the run
context for that turn. Asking for a human agent should produce a
zero-credit session pointing at the handoff message.

<img width="2428" height="1058" alt="CleanShot 2026-07-15 at 17 25
40@2x"
src="https://github.com/user-attachments/assets/d8e44923-c17b-494f-8c33-c8fa4219438c"
/>
2026-07-16 18:20:44 +05:30
Shivam MishraandGitHub 8dd0d08322 refactor: align conversation direct uploads with standard account auth (#15039)
Conversation attachment uploads now go through the same authentication
that every other account-scoped API endpoint uses. Agents continue to
attach files exactly as before, and the upload request is now tied to
the agent's dashboard session instead of a separately serialized access
token.

Because the upload request is now authenticated, the dashboard proves
the agent's session directly instead of passing
`currentUser.access_token`. This keeps uploads working alongside the
profile access-token changes in #14973, including on accounts where that
token is serialized as empty.

## What changed

- `Api::V1::Accounts::Conversations::DirectUploadsController` now runs
the standard account auth stack: API access token when the
`api_access_token` header is present, dashboard session
(devise-token-auth) otherwise, with agent-bot tokens rejected.
Previously it inherited `ActiveStorage::DirectUploadsController`
directly and did not run any authentication.
- `EnsureCurrentAccountHelper#ensure_current_account` now returns `401`
when a request has neither an authenticated user nor a bot resource,
instead of continuing. This closes the same gap for any controller that
relies on the helper.
- The dashboard direct-upload paths (`useFileUpload.js` and the legacy
`fileUploadMixin.js`) now attach the agent's session headers to the
upload request via a new `directUploadsHelper.js`, instead of sending
`currentUser.access_token`.

## How to test

1. As a logged-in agent, open a conversation and attach a file. Upload
should succeed as before, on installs with direct uploads enabled.
2. Confirm attachments still work for an agent on an account whose
profile access token is not serialized (e.g. a Cloud plan without
`api_and_webhooks`).
3. Send a `POST` to
`/api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads`
with no credentials, an empty `api_access_token`, or an invalid token,
and confirm it returns `401`.
4. Confirm a valid agent of the account (via API token or session) gets
`200`, while an agent of a different account gets `401`.
2026-07-16 18:17:29 +05:30
7e88d44fd9 fix(whatsapp): update manual migration guide link (#15040)
Updates the WhatsApp manual migration guide links in the inbox banner
and migration dialog to use `https://chwt.app/migrate-whatsapp`.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 15:57:37 +04:00
Tanmay Deep SharmaandGitHub 2891a72cb9 feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) 2026-07-16 16:18:58 +05:30
522e3c4d3f feat: enforce api_and_webhooks feature for token API and account webhooks (#14973)
This gates API-token access and outgoing account webhooks behind the
`api_and_webhooks` account feature introduced in #14972. On Chatwoot
Cloud, Hacker accounts lose token-authenticated account API access and
account webhook delivery, while paid accounts retain them through the
billing-plan feature reconcile. Community and self-hosted installations
continue to work without any upgrade-time interruption.

## What changed

- Added `Account#api_and_webhooks_enabled?` as the single backend kill
switch. Core returns enabled; the Enterprise override consults the
account flag on Chatwoot Cloud and remains enabled off-Cloud.
- Account-scoped v1 and v2 requests authenticated with a user or
agent-bot API token now return `403 Forbidden` when the feature is
disabled. Invalid tokens still return 401, and dashboard session
requests are unaffected.
- Profile responses return an empty access token when none of the user's
accounts has access. The stored token is preserved, and the profile UI
disables its token controls with paid-plan copy on Cloud.
- Account webhook delivery stops when the feature is disabled. Webhook
CRUD remains available to session-authenticated dashboard requests,
API-inbox webhooks continue to be delivered, and the Cloud dashboard
shows a webhook paywall instead of the webhook list.
- Removed the database backfill migration. Existing paid Cloud accounts
should be enabled with the one-off script below before enforcement is
deployed.

## Existing paid-account rollout

Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It
intentionally targets only the Startups, Business, and Enterprise plans
and does not add `api_and_webhooks` to `manually_managed_features`, so
future billing reconciles remain authoritative.

```rb
paid_plan_names = %w[Startups Business Enterprise]
accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names)

total = accounts.count
enabled = 0
skipped = 0

puts "Enabling api_and_webhooks for #{total} paid account(s)..."

accounts.find_each(batch_size: 500).with_index(1) do |account, processed|
  if account.feature_enabled?('api_and_webhooks')
    skipped += 1
  else
    account.enable_features!('api_and_webhooks')
    enabled += 1
  end

  puts "Processed #{processed}/#{total}..." if (processed % 1000).zero?
end

puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}"
```

For example, save the snippet outside the repository as
`enable_api_and_webhooks.rb`, then run:

```sh
bundle exec rails runner /path/to/enable_api_and_webhooks.rb
```

## How to test

- On Cloud, use a Hacker account and confirm token-authenticated
requests to account-scoped v1 and v2 endpoints return 403, while the
same dashboard actions continue to work through session authentication.
- Confirm profile access-token controls are disabled with paid-plan copy
when all accounts are ineligible, and remain available when at least one
account has the feature.
- Confirm the Webhooks settings page shows the billing paywall for a
Cloud account without the feature; admins get the billing action and
agents get the existing ask-an-admin message.
- Confirm outgoing account webhooks stop for an ineligible Cloud account
while API-inbox webhooks still deliver.
- Confirm community and self-hosted installations retain API and webhook
behavior after upgrading, even when an existing account does not have
the stored feature bit.


### Screenshots

## Cloud

<img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x"
src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b"
/>

<img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x"
src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 13:43:49 +04:00
0a25a0ef66 fix(whatsapp): log manual transfer only for embedded signup migrations (#15032)
The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to
track embedded signup → manual migrations was firing on any WhatsApp
credential change — including routine `api_key` rotations on inboxes
that were already manually configured — inflating the migration count.
The log now fires only for the actual migration, and uses a new tag so
log searches don't match the older over-counted entries.

## How to reproduce

1. On a manually configured WhatsApp Cloud inbox, update the API key
from inbox settings → Configuration.
2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER]
success` line even though no migration happened; after this change it
stays silent.
3. Switching an embedded signup inbox to manual setup still logs the
migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`).

## What changed

- `Channel::Whatsapp#log_credentials_transfer` now keys off the
migration's unique signal — `provider_config['source']` changing from
`embedded_signup` to absent — instead of diffing credential keys.
- Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to
`WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the
corrected entries are searchable without matching pre-fix false
positives.

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

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-16 13:35:20 +04:00
Sivin VargheseandGitHub 0fccb7dacd feat: stage edits to published articles as drafts (#14842) 2026-07-16 12:44:10 +05:30
PranavandGitHub f1a07657e9 fix(whatsapp): restore embedded signup reauthorization banner (#15035)
Show the reauthorization banner whenever an embedded signup inbox has a
reauthorization error instead of suppressing it when the manual
migration flow is enabled. Reword the manual migration banner and dialog
as a recommendation to reconnect with your own Meta app, and switch
their styling from amber warning to blue info.
2026-07-16 12:28:30 +05:30
9cb6c501b5 fix: html/body background not applied in appearance mode (#13989)
# Pull Request Template

## Description

This PR fixes the white background bleed visible in the widget, article
viewer, and Help Center when dark mode is active.
This change was previously merged but later reverted due to a background
being added for the unread bubble.

Reverted PR: https://github.com/chatwoot/chatwoot/pull/13955,
https://github.com/chatwoot/chatwoot/pull/13981

**What was happening**

While scrolling, the `<body>` element retained a white background in
dark mode. This occurred because dark mode classes were only applied to
inner container elements, not the root.

**What changed**

* **Widget:** Updated the `useDarkMode` composable to sync the `dark`
class to `<html>` using `watchEffect`, allowing `<body>` to inherit dark
theme variables. Also added background styles to `html`, `body`, and
`#app` in `woot.scss`.
* **Help center portal:** Moved `bg-white dark:bg-slate-900` from
`<main>` to `<body>` in the portal layout so the entire page background
responds correctly to dark mode, including within the widget iframe.
* **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1`
to ensure better theming.


Fixes
https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented

## Type of change

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

## How Has This Been Tested?

### Screencasts

### Before

**Widget**


https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728

**Help center**


https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845



### After

**Widget**


https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c

<img width="347" height="265" alt="image"
src="https://github.com/user-attachments/assets/89460b36-c8ed-4579-9737-c258c6fe5da5"
/>


**Help center**


https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3




## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-16 12:21:01 +05:30
Vishnu NarayananandGitHub ab01ab7853 fix(perf): back off conversation meta polling when the count is unknown (#15031)
## Description

The conversation sidebar counts (`GET /conversations/meta`) are polled
on a debounced cadence selected from the last known conversation count
(`allCount` in the `conversationStats` store).

`allCount` starts at `0` and is only set after a **successful** meta
response. When the meta query is slow or failing under load, `allCount`
stays `0`, which selected the fastest cadence, so every client polled as
aggressively as possible exactly when the endpoint was already
struggling. On large accounts with many concurrent agents this
multiplies into sustained load that keeps the query slow, a
self-reinforcing loop.

This PR makes two changes:

1. **Treat an unknown count (`0`) as a large account** and poll at the
slowest cadence instead of the fastest (`getMetaDebounceKey`, extracted
as a pure, testable function). This breaks the failure loop.
2. **Raise the debounce intervals across all tiers** so counts are
polled less frequently under sustained activity:
   - fast: max 1 poll / 2s -> 1 / 5s
   - mid: 1 / 10s -> 1 / 20s
   - slow (large/unknown accounts): 1 / 20s -> 1 / 30s

First-load counts still render immediately (the debounce fires on the
leading edge for the very first call), so the higher intervals only
affect the sustained poll rate, not initial render.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2026-07-16 11:41:28 +05:30
Aakash BakhleandGitHub 6d74ff9477 fix(captain): make assistant switcher scrollable (#15027) 2026-07-16 10:44:22 +05:30
Tanmay Deep Sharma 1f713d6dae Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 23:26:53 +05:30
Tanmay Deep Sharma 2b8d1a67b3 fix(automations): preserve actions when a delay resets invalid conditions
Enabling a delay on a rule with delay-unsafe conditions called onEventChange, which
resets conditions AND actions to defaults, silently replacing the user's configured
actions with assign_agent. Reset only the conditions (getDefaultConditions) and leave
the actions intact.
2026-07-15 23:26:44 +05:30
Tanmay Deep Sharma f62b5e51d9 test(automations): claim due_at guard, paused-row resume, factory episode key
- process-job specs make rows due before running (the sweep only enqueues due rows).
- claim! guard: a row pushed into the future isn't claimed.
- reschedule_paused resets overdue rows; re-enabling the account flag enqueues the
  resume job.
- factory derives a current episode key so rows are episode_current.
2026-07-15 23:14:32 +05:30
Tanmay Deep Sharma 9488b7d005 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 23:11:09 +05:30
Tanmay Deep Sharma f4e0481b00 fix(automations): dont fire rescheduled rows early; resume paused rows past expiry
- claimable? now requires the row to still be due, so a reply-chase reschedule that
  pushes due_at forward after the sweep enqueued the row no longer fires early.
- Re-enabling delayed_automations on an account reschedules its overdue pending rows
  (past DUE_WINDOW) via a job enqueued ahead of the next sweep, so a pause longer than
  the expiry window resumes those rows instead of expiring them.
2026-07-15 23:10:55 +05:30
Tanmay Deep Sharma b355443642 test(automations): delayed conversation_created rule is valid 2026-07-15 20:15:02 +05:30
Tanmay Deep Sharma 12a53d5a1e Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 20:14:34 +05:30
Tanmay Deep Sharma 67dbcca638 fix(automations): keep all events available for delayed rules
Event-narrowing wrongly hid valid delayed triggers like conversation_created
(new conversation idle 10m -> assign), forcing the event to conversation_updated.
The real guardrail is the Status/Inbox condition narrowing, which stays; the event
list no longer needs restricting since every event supports a delayed rule with
the right conditions.
2026-07-15 20:14:26 +05:30
Tanmay Deep Sharma 8097e329d0 test(automations): delayed conversation rule allows status+inbox, rejects mutable attrs 2026-07-15 18:56:59 +05:30
Tanmay Deep Sharma 8df903b008 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:56:26 +05:30
Tanmay Deep Sharma 1957cd15dc feat(automations): allow inbox filter on delayed conversation rules
inbox_id never changes after a conversation is created, so filtering a delayed
conversation-level rule by inbox is safe: the status episode key still tracks the
only mutable dimension, and inbox rides along as a static fire-time re-check. Allow
status + inbox_id in the validation and the narrowed condition dropdown.
2026-07-15 18:56:15 +05:30
Tanmay Deep Sharma abaf4d9ccb Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:42:13 +05:30
Tanmay Deep Sharma 88d3876c2b chore(automations): remove unused IMMEDIATELY i18n string 2026-07-15 18:42:10 +05:30
Tanmay Deep Sharma 55e7cafbac Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:41:30 +05:30
Tanmay Deep Sharma 29eecfafcb chore(automations): label the delay toggle 'Delayed execution' 2026-07-15 18:41:21 +05:30
Tanmay Deep Sharma 1fafa7d13d Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:36:09 +05:30
Tanmay Deep Sharma dd2f3d80cc feat(automations): narrow condition options for delayed rules instead of warning
When a delay is on, the condition attribute dropdown for conversation-level events
offers only Status, and attribute_changed is dropped from the operator list for any
delayed rule. Toggling the delay on resets conditions the delayed rule can't use.
This makes unsupported delayed rules unconstructable, so the inline restriction
warning (and its i18n strings) are removed.
2026-07-15 18:35:55 +05:30
Tanmay Deep Sharma 852a219e0b feat(automations): narrow condition options for delayed rules instead of warning
When a delay is on, the condition attribute dropdown for conversation-level events
offers only Status, and attribute_changed is dropped from the operator list for any
delayed rule. Toggling the delay on resets conditions the delayed rule can't use.
This makes unsupported delayed rules unconstructable, so the inline restriction
warning (and its i18n strings) are removed.
2026-07-15 18:35:11 +05:30
Tanmay Deep Sharma be381b940d Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 18:12:30 +05:30
Tanmay Deep Sharma 3728d71176 feat(automations): make delayed execution a toggle that narrows the event list
Delayed execution is now a toggle chosen first (above Event). Turning it on limits
the Event dropdown to the events where a delayed rule is meaningful (Conversation
Updated for status waits, Message Created for awaiting-agent/reply-chase) and snaps
an unsupported event to a supported one, so users can't build a delayed rule on an
event the engine can't handle.
2026-07-15 18:12:21 +05:30
Tanmay Deep Sharma 9660b48c27 test(automations): sweep excludes rows for accounts with delayed automations disabled 2026-07-15 16:45:12 +05:30
Tanmay Deep Sharma a44645a7de Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 16:44:10 +05:30
Tanmay Deep Sharma 1e57cff70a fix(automations): exclude paused accounts from the sweep so they can't starve others
Now that a flag-off account's due rows stay pending (paused), order(:due_at).limit
would keep re-selecting that backlog every sweep, starving enabled accounts with
later due_at. Filter the sweep to accounts with delayed_automations enabled via a
for_enabled_accounts scope, so paused rows sit out of the limit until re-enabled.
2026-07-15 16:44:01 +05:30
Tanmay Deep Sharma 4c398b3c85 test(automations): account flag off pauses the row and resumes on re-enable 2026-07-15 16:19:31 +05:30
Tanmay Deep Sharma a521175422 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 16:19:03 +05:30
Tanmay Deep Sharma a921a38db9 fix(automations): pause (not skip) armed rows when the account flag is off
Marking a due row skipped/flag_disabled was terminal, so re-enabling the account
flag could never resume it (sweepable only sees pending/processing) — the delayed
action was lost, contradicting the banner that says rules resume when re-enabled.
Check the flag before claiming and return, leaving the row pending like the
instance kill switch does, so it fires once the feature is turned back on.
2026-07-15 16:18:55 +05:30
Tanmay Deep Sharma 7c5d6f8d72 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 15:59:23 +05:30
Tanmay Deep Sharma 7bb90d84cd fix(automations): hydrate delay controls from the edited rule on first open
The edit dialog's delay radios/value were synced once in open() from the automation
model, whose prop only settles a tick later — so the first edit click showed the
previous rule's delay (or immediate). Thread the clicked rule's execution_delay
directly into open() so the delay controls hydrate correctly on the first click.
2026-07-15 15:59:11 +05:30
Tanmay Deep Sharma 466763054c Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 15:31:04 +05:30
Tanmay Deep Sharma 21ea2ae08d revert(search): drop unnecessary reindex_for_search guard; trim automation_rule comments
The guard was dead code: should_index? is only true when searchkick is loaded
(both gated on advanced_search_allowed?), so #reindex is always defined when the
callback fires. It only masked a flaky develop-owned message_spec and contradicts
the fail-loudly-on-impossible-state guideline. Also trim verbose comments in
automation_rule.rb.
2026-07-15 15:30:46 +05:30
Tanmay Deep Sharma fd91ab84af test(automations): status episode keys use microsecond stamp and survive DB reload 2026-07-15 14:59:23 +05:30
Tanmay Deep Sharma e6ae59b8b2 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:58:49 +05:30
Tanmay Deep Sharma 35f14c7e02 fix(automations): use microsecond-integer stamp for status episode keys too
Same float64 imprecision as the awaiting-agent key: a status episode armed from an
in-memory status_changed_at (Time.current, nanosecond) could recompute to a slightly
different float once the worker reloads the DB-rounded value, skipping the row as
episode_moved. Use the shared microsecond_stamp helper so both sides agree.
2026-07-15 14:58:39 +05:30
Tanmay Deep Sharma e2c449a0c2 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:50:52 +05:30
Tanmay Deep Sharma 40b77ca001 fix(search): guard reindex_for_search when searchkick is not loaded
searchkick (which defines #reindex) is only mixed into Message at class-load time
when advanced_search_allowed? (enterprise + OPENSEARCH_URL) is true at boot. A spec
that stubs advanced_search_allowed? true without a loaded index makes should_index?
true and fires the reindex callback, raising NoMethodError. Guard on respond_to?
so it no-ops when no index is available (real deployments without search index
already have should_index? false, so behavior is unchanged).
2026-07-15 14:50:39 +05:30
Tanmay Deep Sharma 40e529fe4d test(automations): fix awaiting-agent key format and tighten job assertions
- Match the microsecond-integer (strftime %s%6N) awaiting-agent key format,
  fixing the CI failure from float epoch imprecision.
- Assert exactly one ProcessPendingExecutionJob is enqueued and the future row
  is not, so an early-fire regression is caught.
- Count the reply-chase follow-up as exactly one so a double-send regression fails.
- Stub the ActionService instance's perform (not new) to raise, exercising the
  job's error/retry path rather than only constructor failures.
2026-07-15 14:42:59 +05:30
Tanmay Deep Sharma d7b25d7db0 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:40:12 +05:30
Tanmay Deep Sharma e2cd372d2d fix(automations): use microsecond-integer stamp for awaiting-agent keys
Float epoch seconds carry ~16 significant digits, exceeding float64 precision,
and the arm path compares an in-memory created_at against the DB-stored
waiting_since. A float rounds differently on each side, so the armed key never
matched at fire time and the awaiting-agent automation was always skipped.
Use strftime('%s%6N') (integer microseconds) on both paths.
2026-07-15 14:40:05 +05:30
Tanmay Deep Sharma 17da6ee3ec test(automations): clone strips execution_delay when feature is disabled 2026-07-15 14:16:54 +05:30
Tanmay Deep Sharma b81f916002 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 14:16:20 +05:30
Tanmay Deep Sharma a1fdba2783 fix(automations): strip execution_delay when cloning a rule with the feature off
clone duplicates the rule via dup (including execution_delay) with no param for
ensure_execution_delay_allowed to inspect, so it could create new delayed rules
while the feature is disabled. Drop the delay on the clone unless the account has
delayed_automations enabled, matching create/update.
2026-07-15 14:16:10 +05:30
Tanmay Deep Sharma dea4d47602 test(automations): sub-second awaiting-agent keys and stale processing discard 2026-07-15 13:27:43 +05:30
Tanmay Deep Sharma 2728a90a2f Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 13:26:50 +05:30
Tanmay Deep Sharma d55c5660a3 fix(automations): sub-second awaiting-agent keys and discard stale processing rows on edit
- awaiting_agent episode keys use sub-second (.to_f) precision like status keys,
  so a reply then re-wait within the same second is a distinct episode and the
  original armed row no longer matches the later waiting period.
- discard_stale_pending_executions now deletes armed rows (pending and stale
  processing), since the sweep reclaims stale processing rows and would otherwise
  fire them against the edited rule definition.
2026-07-15 13:26:37 +05:30
Tanmay Deep Sharma 92bcd2f214 test(automations): cover event restriction, stale-edit discard, and awaiting-agent arm race 2026-07-15 13:08:29 +05:30
Tanmay Deep Sharma cc23867774 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 13:07:44 +05:30
Tanmay Deep Sharma 7a8903051d fix(automations): make awaiting-agent race fix asymmetric to preserve reply cancellation
The earlier waiting_since fallback in episode_key_for also matched at fire time,
so an agent reply (which clears waiting_since) no longer ended the episode and the
rule could fire anyway. Apply the created_at fallback only at arm time
(arm_episode_key_for); keep episode_key_for strict so a nil waiting_since at fire
time still means the agent replied and the episode is over.
2026-07-15 13:07:37 +05:30
Tanmay Deep Sharma d1d464dfb0 Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 13:00:22 +05:30
Tanmay Deep Sharma 1e6291616d fix(automations): harden delayed rules against races, stale edits, and unsupported configs
- episode_key_for falls back to the incoming message's created_at when
  waiting_since is still nil (it is written after MESSAGE_CREATED dispatches),
  so awaiting-agent episodes no longer arm as awaiting_agent:0 and get skipped
  as episode_moved on the first customer message after an agent reply.
- Editing a rule's trigger, conditions, or actions (not just the delay) now
  discards its armed pending rows so they can't fire against a definition they
  were never armed under; deleting rather than skipping frees the episode slot
  so the new definition re-arms on the next matching event.
- The rule form only offers a delay when the config supports it: the delayed
  option is disabled with an explanation for attribute_changed conditions and
  for non-status conditions on conversation events, and the backend's specific
  error is surfaced on save instead of a generic message.
2026-07-15 13:00:03 +05:30
Tanmay Deep Sharma 2e2fcdec47 test(automations): update feature flag bit-mapping spec for delayed_automations 2026-07-15 12:43:43 +05:30
Tanmay Deep Sharma 3436632a3b Merge branch 'feature/cw-7513' into feature/cw-7513-specs 2026-07-15 12:42:54 +05:30
Tanmay Deep Sharma 7a73a68753 fix(automations): cancel stale pending runs on delay edit; restrict conversation-level delayed rules to status conditions
- Editing a rule's execution_delay (removing or changing it) now cancels any
  pending executions armed under the old configuration instead of leaving
  them to fire on a stale schedule.
- conversation_created/updated/opened/resolved delayed rules key their episode
  on status_changed_at alone, so a delayed condition on any other attribute
  (assignee, team, priority, ...) could collapse distinct qualifying periods
  into one episode. Restricted to status conditions until episodes track
  per-attribute change times.
2026-07-15 12:36:58 +05:30
Tanmay Deep Sharma 55d68d930d Merge remote-tracking branch 'origin/develop' into feature/cw-7513
# Conflicts:
#	app/javascript/dashboard/featureFlags.js
#	config/features.yml
2026-07-15 12:31:43 +05:30
Tanmay Deep Sharma e328492463 refactor(automations): fold expiry check into skip_reason_for guard chain 2026-07-15 12:30:46 +05:30
Tanmay Deep Sharma ac9b055ada test(automations): specs for delayed automation rules 2026-07-15 10:57:41 +05:30
Tanmay Deep Sharma 82f28aa7fb chore(automations): move specs to a follow-up branch 2026-07-15 10:57:23 +05:30
Tanmay Deep Sharma 5c0cbcbaaa chore(automations): remove design docs from branch 2026-07-15 10:56:07 +05:30
Tanmay Deep Sharma 5d5fa0c21a refactor(automations): job-side claim for delayed executions, remove rubocop disables 2026-07-15 10:50:54 +05:30
Tanmay Deep Sharma e12e686244 Merge branch 'develop' into feature/cw-7513 2026-07-13 14:36:26 +05:30
Tanmay Deep Sharma cdccbb39aa Merge branch 'develop' into feature/cw-7513 2026-07-13 14:35:03 +05:30
Tanmay Deep Sharma 30ee4a61fc feat(automations): add delayed execution for automation rules 2026-07-10 10:00:07 +05:30
1271 changed files with 56356 additions and 2436 deletions
+2 -2
View File
@@ -85,8 +85,8 @@
## Project-Specific
- **Translations**:
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
+16 -14
View File
@@ -211,13 +211,14 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.19.0)
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
libdatadog (~> 18.1.0.1.0)
libddwaf (~> 1.24.1.0.3)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
logger
msgpack
datadog-ruby_core_source (3.4.1)
datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
@@ -522,15 +523,16 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (18.1.0.1.0)
libdatadog (18.1.0.1.0-x86_64-linux)
libddwaf (1.24.1.0.3)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-arm64-darwin)
libddwaf (1.30.0.0.2-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-darwin)
libddwaf (1.30.0.0.2-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.24.1.0.3-x86_64-linux)
libddwaf (1.30.0.0.2-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
@@ -547,7 +549,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.23.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -740,8 +742,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10)
i18n (>= 0.7, < 2)
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2024 Chatwoot Inc.
Copyright (c) 2017-2026 Chatwoot Inc.
Portions of this software are licensed as follows:
+1 -1
View File
@@ -1 +1 @@
4.15.1
4.16.0
@@ -1,5 +1,4 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def update
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
persist_article_changes if params[:article].present?
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
end
def destroy
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
# Draft-only autosaves must not bump the public-facing updated_at, so write
# them with update_columns (which skips the timestamp). update_columns also
# skips validations, so assign and validate first to avoid persisting content
# that exceeds the column length limit.
def persist_article_changes
keys = article_params.to_h.keys
if keys.any? && (keys - %w[draft_title draft_content]).empty?
@article.assign_attributes(article_params)
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
else
@article.update!(article_params)
end
end
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
:locale, :draft_title, :draft_content, meta: [:title,
:description,
{ tags: [] }]
)
end
@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
before_action :ensure_execution_delay_allowed, only: [:create, :update]
def index
@automation_rules = Current.account.automation_rules
@@ -48,6 +49,9 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
# dup copies execution_delay; drop it when the feature is off so clone can't create new
# delayed rules that create/update would reject.
new_rule.execution_delay = nil unless delayed_automations_enabled?
new_rule.save!
@automation_rule = new_rule
end
@@ -55,13 +59,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private
def automation_rules_permit
permitted_attributes = [:name, :description, :event_name, :active]
permitted_attributes << :execution_delay if delayed_automations_enabled?
params.permit(
:name, :description, :event_name, :active,
*permitted_attributes,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
def ensure_execution_delay_allowed
return if delayed_automations_enabled?
return if params[:execution_delay].blank?
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
end
def delayed_automations_enabled?
Current.account.feature_enabled?('delayed_automations')
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
end
@@ -0,0 +1,28 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
@@ -1,6 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler
include AccessTokenAuthHelper
include EnsureCurrentAccountHelper
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
around_action :handle_with_exception
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
before_action :conversation
def create
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
private
def authenticate_by_access_token?
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def validate_token_api_access
return if Current.account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
continue_update = false
ActiveRecord::Base.transaction do
continue_update = update_branded_email_layout
raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
end
def agent_bot
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present?
end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
@@ -1,5 +1,4 @@
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :fetch_label, except: [:index, :create]
before_action :check_authorization
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def popular_content_keys
params.dig(:portal, :config, :popular_content)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -1,4 +1,7 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :ensure_embedded_signup_enabled
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
# POST /api/v1/accounts/:account_id/whatsapp/authorization
@@ -16,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
private
def ensure_embedded_signup_enabled
return unless ChatwootApp.chatwoot_cloud?
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
raise Pundit::NotAuthorizedError
end
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
@@ -31,7 +41,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
end
def validate_reauthorization_required
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
render json: {
success: false,
@@ -39,9 +49,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
}, status: :unprocessable_entity
end
def can_upgrade_to_embedded_signup?
def can_reconfigure_channel?
channel = @inbox.channel
return false unless channel.provider == 'whatsapp_cloud'
return true if ChatwootApp.chatwoot_cloud?
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
true
end
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def validate_token_api_access
return if @account.api_and_webhooks_enabled?
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
end
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
else
render_unauthorized(I18n.t('errors.account.not_authorized'))
end
account
end
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
end
def account_accessible_for_bot?(account)
+38 -2
View File
@@ -4,17 +4,53 @@ module PortalHomeData
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
load_recommended_content
# The classic hero only needs the recommendations above; the rest is
# documentation-layout home data (also used on custom-domain home pages).
return unless @portal.layout == 'documentation'
@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)
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def load_recommended_content
@recommended_categories = recommended_categories
@recommended_articles = recommended_articles
end
def base_articles
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
end
# Admin-recommended categories for the locale, in the chosen order. Unlike the
# position-based fallback, published articles aren't required: the admin's pick wins.
def recommended_categories
ids = @portal.popular_category_ids(@locale)
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
end
# Admin-recommended articles for the locale, in the chosen order, limited to
# published articles that still exist.
def recommended_articles
ids = @portal.popular_article_ids(@locale)
ordered_by_ids(base_articles.where(id: ids), ids)
end
# Loads the scope and returns its records ordered to match `ids`, dropping any
# that no longer exist. Skips the query entirely when `ids` is blank.
def ordered_by_ids(scope, ids)
return [] if ids.blank?
by_id = scope.index_by(&:id)
ids.filter_map { |id| by_id[id] }
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
+2 -4
View File
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
request.variant = :documentation if @portal.layout == 'documentation'
load_home_data
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
request.variant = if @is_plain_layout_enabled
:plain
elsif @portal_layout == 'documentation'
:documentation
end
end
def portal
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
layout 'portal'
def show
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
Whatsapp::WebhookChannelFinderService.new(
display_phone_number: metadata[:display_phone_number],
phone_number_id: metadata[:phone_number_id]
).perform
end
def inactive_whatsapp_number?
+4
View File
@@ -1,6 +1,10 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
def id
@obj.try(:display_id)
end
def display_id
@obj.try(:display_id)
end
+14
View File
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
def business_name
@obj.try(:sanitized_business_name)
end
def avatar_url
@obj.try(:avatar_url)
end
def email
return unless @obj.try(:email?)
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
end
end
+14
View File
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class CallsAPI extends ApiClient {
constructor() {
super('calls', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
}
export default new CallsAPI();
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainAgentSessions extends ApiClient {
constructor() {
super('captain/agent_sessions', { accountScoped: true });
}
}
export default new CaptainAgentSessions();
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
getStats({ assistantId, range, signal }) {
const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
});
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
getSummary({ assistantId, range }) {
getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
contact_id: contactId,
inbox_id: inboxId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
categorySlug,
sort,
query,
signal,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
host: this.url,
});
return axios.get(url);
return axios.get(url, { signal });
}
searchArticles({ portalSlug, query }) {
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
{ signal: undefined }
);
});
});
@@ -0,0 +1,240 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_STATUS,
} from 'dashboard/components-next/message/constants';
import CallStatusBadge from './CallStatusBadge.vue';
import { CALL_KIND, getCallKind } from './constants';
const props = defineProps({
call: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const route = useRoute();
const kind = computed(() => getCallKind(props.call));
const contactName = computed(() =>
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
/^\+/,
''
)
);
const agentActionLabel = computed(() => {
if (!props.call.agent) return '';
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
if (kind.value === CALL_KIND.ONGOING) {
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
? t('CALLS_PAGE.ROW.DIALED_BY')
: t('CALLS_PAGE.ROW.PICKED_BY');
}
return '';
});
const resultLabel = computed(() => {
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
if (kind.value === CALL_KIND.NO_REPLY) {
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
}
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
if (kind.value === CALL_KIND.ONGOING) {
return props.call.status === VOICE_CALL_STATUS.RINGING
? t('CALLS_PAGE.ROW.RINGING')
: t('CALLS_PAGE.ROW.IN_PROGRESS');
}
return t('CALLS_PAGE.ROW.ANSWERED');
});
const providerIcon = computed(() =>
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
);
const createdAtLabel = computed(() =>
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
);
const conversationRoute = computed(() => ({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: props.call.conversation.displayId,
},
query: { messageId: props.call.messageId },
}));
</script>
<template>
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
<div class="flex items-center gap-2 min-w-0">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
>
{{ contactName }}
</span>
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
</div>
<div class="flex items-center gap-1.5 min-w-0">
<template v-if="agentActionLabel">
<span class="text-label-small text-n-slate-10 shrink-0">
{{ agentActionLabel }}
</span>
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span class="text-body-main truncate text-n-slate-12 min-w-0">
{{ call.agent.name }}
</span>
</template>
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
{{ resultLabel }}
</span>
<span class="w-px h-3 bg-n-strong shrink-0" />
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11 min-w-0">
{{ call.inbox.name }}
</span>
<span
v-if="!call.recordingUrl"
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
>
{{ createdAtLabel }}
</span>
</div>
<div
v-if="call.recordingUrl"
class="flex items-center gap-2 min-w-0 justify-between"
>
<AudioPlayer
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="flex-1 sm:flex-[0.7] min-w-0"
/>
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
{{ createdAtLabel }}
</span>
</div>
</div>
<div
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
>
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
<Avatar
:src="call.contact.avatar"
:name="contactName"
:size="24"
rounded-full
/>
<span
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
class="text-heading-3 font-medium truncate text-n-slate-12"
>
{{ contactName }}
</span>
</div>
<div
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
>
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
<CallStatusBadge :kind="kind" class="shrink-0" />
<template v-if="agentActionLabel">
<span
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
>
{{ agentActionLabel }}
</span>
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
<Avatar
:src="call.agent.avatar"
:name="call.agent.name"
:size="20"
rounded-full
/>
<span
v-tooltip.top="{
content: call.agent.name,
delay: { show: 500, hide: 0 },
}"
class="text-body-main truncate text-n-slate-12 min-w-0"
>
{{ call.agent.name }}
</span>
</span>
</template>
<span
v-else-if="resultLabel"
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
>
{{ resultLabel }}
</span>
</div>
<AudioPlayer
v-if="call.recordingUrl"
:src="call.recordingUrl"
:fallback-duration="call.durationSeconds || 0"
class="w-auto min-w-44 shrink mx-auto"
/>
</div>
<div
v-tooltip.top="{
content: call.inbox.name,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
>
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
<span class="text-body-main truncate text-n-slate-11">
{{ call.inbox.name }}
</span>
</div>
<RouterLink
:to="conversationRoute"
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
>
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
{{ call.conversation.displayId }}
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
</RouterLink>
<span
v-tooltip.top="{
content: createdAtLabel,
delay: { show: 500, hide: 0 },
}"
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
>
{{ createdAtLabel }}
</span>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { CALL_KIND } from './constants';
const props = defineProps({
kind: {
type: String,
required: true,
},
});
const { t } = useI18n();
const KIND_CONFIG = {
[CALL_KIND.ONGOING]: {
icon: 'i-lucide-phone-call',
class: 'bg-n-teal-3 text-n-teal-11',
},
[CALL_KIND.INCOMING]: {
icon: 'i-lucide-phone-incoming',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.OUTGOING]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-slate-3 text-n-slate-11',
},
[CALL_KIND.MISSED]: {
icon: 'i-lucide-phone-missed',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
[CALL_KIND.NO_REPLY]: {
icon: 'i-lucide-phone-outgoing',
class: 'bg-n-amber-3 text-n-amber-11',
},
[CALL_KIND.FAILED]: {
icon: 'i-lucide-phone-off',
class: 'bg-n-ruby-3 text-n-ruby-11',
},
};
const config = computed(() => KIND_CONFIG[props.kind]);
</script>
<template>
<span
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
:class="config.class"
>
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
<span class="truncate">{{
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
}}</span>
</span>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const setupVoiceChannel = () => {
router.push({
name: 'settings_inbox_new',
params: { accountId: route.params.accountId },
});
};
</script>
<template>
<EmptyStateLayout
:title="t('CALLS_PAGE.SETUP.TITLE')"
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
:show-backdrop="false"
:action-perms="['administrator']"
>
<template #actions>
<Button
:label="t('CALLS_PAGE.SETUP.ACTION')"
icon="i-lucide-plus"
@click="setupVoiceChannel"
/>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,250 @@
<script setup>
import { computed, ref } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
// Null while a fetch is in flight so stale counts are never shown.
totalCount: {
type: Number,
default: null,
},
agents: {
type: Array,
default: () => [],
},
inboxes: {
type: Array,
default: () => [],
},
// Self-scoped viewers only ever see their own calls, so the assignee filter
// is meaningless for them — only admins get it.
showAssignee: {
type: Boolean,
default: false,
},
});
const activity = defineModel('activity', { type: String, default: null });
const assigneeId = defineModel('assigneeId', { type: Number, default: null });
const inboxId = defineModel('inboxId', { type: Number, default: null });
const { t } = useI18n();
const ACTIVITY_ICONS = {
missed: 'i-lucide-phone-missed',
no_reply: 'i-lucide-phone-outgoing',
incoming: 'i-lucide-phone-incoming',
outgoing: 'i-lucide-phone-outgoing',
in_progress: 'i-lucide-phone-call',
};
const BASE_ACTIVITIES = ['missed', 'no_reply'];
const OTHER_ACTIVITIES = ['incoming', 'outgoing', 'in_progress'];
// A single open-menu identifier keeps the three dropdowns mutually exclusive:
// opening one closes the others without any cross-wiring.
const openMenu = ref(null); // 'activity' | 'assignee' | 'more' | null
const toggleMenu = name => {
openMenu.value = openMenu.value === name ? null : name;
};
// Each dropdown wrapper closes itself on outside clicks. The guard keeps the
// other two wrappers (which the click is also outside of) from closing a
// menu the user is interacting with.
const closeOnOutside = name => {
if (openMenu.value === name) openMenu.value = null;
};
const activityLabel = value => t(`CALLS_PAGE.FILTERS.${value.toUpperCase()}`);
const activeChipLabel = computed(() => {
const label = activityLabel(activity.value);
return props.totalCount === null ? label : `${label} (${props.totalCount})`;
});
const inactiveChips = computed(() =>
BASE_ACTIVITIES.filter(value => value !== activity.value)
);
const otherActivityItems = computed(() =>
OTHER_ACTIVITIES.map(value => ({
label: activityLabel(value),
value,
action: 'filter',
icon: ACTIVITY_ICONS[value],
isSelected: activity.value === value,
}))
);
const assigneeItems = computed(() => [
{
label: t('CALLS_PAGE.FILTERS.ALL_ASSIGNEES'),
value: null,
action: 'filter',
isSelected: !assigneeId.value,
},
...props.agents.map(agent => ({
label: agent.name,
value: agent.id,
action: 'filter',
thumbnail: { name: agent.name, src: agent.thumbnail },
isSelected: assigneeId.value === agent.id,
})),
]);
const moreFiltersSections = computed(() => [
{
title: t('CALLS_PAGE.FILTERS.INBOX'),
items: [
{
label: t('CALLS_PAGE.FILTERS.ALL_INBOXES'),
value: null,
action: 'inbox',
isSelected: !inboxId.value,
},
...props.inboxes.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'inbox',
isSelected: inboxId.value === inbox.id,
})),
],
},
]);
const selectedAssigneeLabel = computed(
() =>
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
);
const hasMoreFilters = computed(() => Boolean(inboxId.value));
const setActivity = value => {
openMenu.value = null;
activity.value = value;
};
const setAssignee = ({ value }) => {
openMenu.value = null;
assigneeId.value = value;
};
const applyMoreFilter = ({ action, value }) => {
openMenu.value = null;
if (action === 'inbox') inboxId.value = value;
};
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
{{
totalCount === null
? t('CALLS_PAGE.ALL_CALLS')
: t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
}}
</span>
<Button
v-else
variant="outline"
color="blue"
size="sm"
:icon="ACTIVITY_ICONS[activity]"
class="shrink-0"
@click="setActivity(null)"
>
{{ activeChipLabel }}
<Icon icon="i-lucide-x" />
</Button>
<div class="w-px h-4 bg-n-strong shrink-0" />
<Button
v-for="chip in inactiveChips"
:key="chip"
variant="outline"
color="slate"
size="sm"
:icon="ACTIVITY_ICONS[chip]"
:label="activityLabel(chip)"
class="shrink-0 text-n-slate-12"
@click="setActivity(chip)"
/>
<OnClickOutside
class="relative shrink-0"
@trigger="closeOnOutside('activity')"
>
<Button
variant="outline"
color="slate"
size="sm"
icon="i-lucide-phone"
class="text-n-slate-12"
@click="toggleMenu('activity')"
>
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11" />
</Button>
<DropdownMenu
v-if="openMenu === 'activity'"
:menu-items="otherActivityItems"
class="mt-1 start-0 top-full w-44"
@action="setActivity($event.value)"
/>
</OnClickOutside>
</div>
<div class="flex items-center gap-2 shrink-0">
<OnClickOutside
v-if="showAssignee"
class="relative"
@trigger="closeOnOutside('assignee')"
>
<Button
variant="outline"
color="slate"
size="sm"
icon="i-lucide-user-round-cog"
class="max-w-52 text-n-slate-12"
@click="toggleMenu('assignee')"
>
<span class="truncate">{{ selectedAssigneeLabel }}</span>
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
</Button>
<DropdownMenu
v-if="openMenu === 'assignee'"
:menu-items="assigneeItems"
show-search
class="mt-1 end-0 top-full w-56 max-h-72"
@action="setAssignee"
/>
</OnClickOutside>
<OnClickOutside class="relative" @trigger="closeOnOutside('more')">
<Button
variant="outline"
size="sm"
icon="i-lucide-list-filter"
:color="hasMoreFilters ? 'blue' : 'slate'"
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
@click="toggleMenu('more')"
>
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
<Icon
icon="i-lucide-chevron-down"
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
/>
</Button>
<DropdownMenu
v-if="openMenu === 'more'"
:menu-sections="moreFiltersSections"
class="mt-1 end-0 top-full w-56 max-h-80"
@action="applyMoreFilter"
/>
</OnClickOutside>
</div>
</div>
</template>
@@ -0,0 +1,51 @@
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
export const CALL_KIND = {
ONGOING: 'ongoing',
INCOMING: 'incoming',
OUTGOING: 'outgoing',
MISSED: 'missed',
NO_REPLY: 'no_reply',
FAILED: 'failed',
};
// The API returns display values: status (ringing/in-progress/completed/
// no-answer/failed) and direction (inbound/outbound). The list UI presents
// them as a single "kind" per row.
export const getCallKind = call => {
if (
[VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
call.status
)
) {
return CALL_KIND.ONGOING;
}
if (
[VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
) {
return CALL_KIND.FAILED;
}
const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
}
return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
};
// Filter chips map to the status/direction params supported by CallFinder.
export const CALL_ACTIVITY_PARAMS = {
missed: {
status: VOICE_CALL_STATUS.NO_ANSWER,
direction: VOICE_CALL_DIRECTION.INBOUND,
},
no_reply: {
status: VOICE_CALL_STATUS.NO_ANSWER,
direction: VOICE_CALL_DIRECTION.OUTBOUND,
},
incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
};
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
const response = await whatsappCallSession.initiateOutboundCall(
conversationIdHint
? { conversationId: conversationIdHint }
: { contactId: props.contactId, inboxId }
);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
const conversationId = response?.conversation_id || conversationIdHint;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
@@ -7,7 +7,9 @@ import {
ARTICLE_MENU_ITEMS,
ARTICLE_MENU_OPTIONS,
ARTICLE_STATUSES,
getArticleStatus,
} from 'dashboard/helper/portalHelper';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
@@ -53,6 +55,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
hasPendingChanges: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
@@ -68,12 +74,16 @@ const emit = defineEmits([
'articleAction',
'toggleSelect',
'hover',
'draftResolved',
'draftFailed',
]);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
@@ -105,7 +115,18 @@ const articleMenuItems = computed(() => {
.filter(key => key !== 'translate' || isTranslationAvailable.value)
.map(key => commonItems[key]);
return [...statusItems, commonItems.delete];
const draftItems = props.hasPendingChanges
? [
{
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
},
]
: [];
return [...statusItems, ...draftItems, commonItems.delete];
});
const statusTextColor = computed(() => {
@@ -153,6 +174,12 @@ const lastUpdatedAt = computed(() => {
const handleArticleAction = ({ action, value }) => {
toggleDropdown(false);
// Un-publishing an article with staged edits — confirm apply/discard first;
// the popover applies the chosen status itself.
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
emit('articleAction', { action, value, id: props.id });
};
@@ -184,6 +211,18 @@ const handleClick = id => {
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="hasPendingChanges"
:title="
t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
)
"
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
@@ -204,9 +243,15 @@ const handleClick = id => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
class="mt-1 end-0 top-full w-40"
@action="handleArticleAction($event)"
/>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="id"
@resolved="emit('draftResolved', $event)"
@failed="emit('draftFailed', $event)"
/>
</div>
</div>
</div>
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
'select-popular-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -0,0 +1,173 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
renderInlineDiff,
buildDiffBlocks,
} from 'dashboard/helper/articleDiffHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
article: {
type: Object,
default: () => ({}),
},
});
const isOpen = defineModel({ type: Boolean, default: false });
const { t } = useI18n();
const liveTitle = computed(() => props.article?.title ?? '');
const liveContent = computed(() => props.article?.content ?? '');
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
const draftContent = computed(
() => props.article?.draftContent ?? liveContent.value
);
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
const titleDiff = computed(() =>
renderInlineDiff(liveTitle.value, draftTitle.value)
);
const contentBlocks = computed(() =>
buildDiffBlocks(liveContent.value, draftContent.value)
);
const contentChanged = computed(() =>
contentBlocks.value.some(block => block.type !== 'equal')
);
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
// unsized columns so they don't collapse.
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
const DEFAULT_COL_WIDTH = 50;
const applyColumnWidths = (html, widths) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.body.querySelector('table');
if (!table) return html;
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
const colgroup = doc.createElement('colgroup');
sized.forEach(width => {
const col = doc.createElement('col');
col.style.width = `${width}px`;
colgroup.appendChild(col);
});
table.insertBefore(colgroup, table.firstChild);
table.style.tableLayout = 'fixed';
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
return doc.body.innerHTML;
};
const renderMarkdown = markdown => {
if (!markdown) return '';
const html = new MessageFormatter(markdown).formattedMessage;
const match = markdown.match(COLWIDTHS_RE);
return match
? applyColumnWidths(html, match[1].split(',').map(Number))
: html;
};
const blockClass = type => {
if (type === 'added') {
return 'border-n-teal-9 bg-n-teal-2';
}
if (type === 'removed') {
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
}
return 'border-transparent';
};
const close = () => {
isOpen.value = false;
};
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
</script>
<template>
<TeleportWithDirection to="body">
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<aside
v-if="isOpen"
v-on-click-outside="dismissOnClickOutside"
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
>
<header
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
</h3>
</div>
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="sm"
class="shrink-0 hover:text-n-slate-11"
@click="close"
/>
</header>
<div
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="titleChanged"
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
>
<span
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
>
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
</span>
<h1
class="text-lg font-semibold leading-snug text-n-slate-12"
v-html="titleDiff"
/>
</div>
<div
v-if="contentChanged"
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
>
<div
v-for="(block, index) in contentBlocks"
:key="index"
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
:class="blockClass(block.type)"
v-html="renderMarkdown(block.md)"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</div>
</aside>
</Transition>
</TeleportWithDirection>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useTimeoutFn } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -9,6 +9,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
const props = defineProps({
article: {
@@ -38,32 +39,75 @@ const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
// falling back to the live value.
const effectiveTitle = () =>
props.article?.draftTitle ?? props.article?.title ?? '';
const effectiveContent = () =>
props.article?.draftContent ?? props.article?.content ?? '';
// Sync local state when navigating to a different article or on initial fetch
const hasPendingChanges = computed(
() => props.article?.draftTitle != null || props.article?.draftContent != null
);
const localTitle = ref(effectiveTitle());
const localContent = ref(effectiveContent());
const isDiffPanelOpen = ref(false);
// Autosave 500ms after the last edit. It sends both title and content so an
// edit to one never drops a recent edit to the other. `stop` cancels a queued
// save; `isPending` tells the header to wait before allowing a publish.
const {
isPending: isSaving,
start: debouncedSave,
stop: cancelSave,
} = useTimeoutFn(
() =>
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
}),
500,
{ immediate: false }
);
const syncLocalState = () => {
cancelSave();
localTitle.value = effectiveTitle();
localContent.value = effectiveContent();
};
// Reseed on article switch or once a draft is published/discarded; close the
// diff panel in the latter case since there's nothing left to compare.
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
[() => props.article?.id, hasPendingChanges],
([id, pending], [prevId, prevPending]) => {
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
if (prevPending && !pending) isDiffPanelOpen.value = false;
}
);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
const handleSave = value => {
const scheduleSave = () => {
if (isNewArticle.value) return;
debouncedSave(value);
debouncedSave();
};
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
onBeforeUnmount(() => {
if (isNewArticle.value || !isSaving.value) return;
cancelSave();
emit('saveArticle', {
title: localTitle.value,
content: localContent.value,
});
});
const articleTitle = computed({
get: () => localTitle.value,
set: value => {
localTitle.value = value;
handleSave({ title: value });
scheduleSave();
},
});
@@ -71,7 +115,7 @@ const articleContent = computed({
get: () => localContent.value,
set: content => {
localContent.value = content;
handleSave({ content });
scheduleSave();
},
});
@@ -108,9 +152,13 @@ const handleCreateArticle = event => {
:is-saved="isSaved"
:status="article.status"
:article-id="article.id"
:pending-changes="hasPendingChanges"
:is-saving="isSaving"
@go-back="onClickGoBack"
@preview-article="previewArticle"
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
/>
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
</template>
<template #content>
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
@@ -1,8 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store.js';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { OnClickOutside } from '@vueuse/components';
@@ -17,6 +17,7 @@ import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
const props = defineProps({
isUpdating: {
@@ -35,9 +36,17 @@ const props = defineProps({
type: Number,
default: 0,
},
pendingChanges: {
type: Boolean,
default: false,
},
isSaving: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goBack', 'previewArticle']);
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
const { t } = useI18n();
const store = useStore();
@@ -49,9 +58,30 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
const showArticleActionMenu = ref(false);
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
// Per-article update flag the store already maintains.
const articleUiFlags = useMapGetter('articles/uiFlags');
const isUpdatingArticle = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
const blockedWhileSaving = () => {
if (!props.isSaving && !isUpdatingArticle.value) return false;
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
return true;
};
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
const hasPendingChanges = computed(
() => isPublished.value && props.pendingChanges
);
const articleMenuItems = computed(() => {
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
return statusOptions.map(option => {
const items = statusOptions.map(option => {
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
return {
label: t(label),
@@ -60,6 +90,17 @@ const articleMenuItems = computed(() => {
icon,
};
});
if (hasPendingChanges.value) {
items.push({
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
value: 'discard-draft',
action: 'discard-draft',
icon: 'i-lucide-undo-2',
});
}
return items;
});
const statusText = computed(() =>
@@ -85,8 +126,9 @@ const getStatusMessage = (status, isSuccess) => {
: '';
};
const updateArticleStatus = async ({ value }) => {
showArticleActionMenu.value = false;
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
// update; omit it for a plain status change.
const performStatusUpdate = async (value, draftAction) => {
const status = getArticleStatus(value);
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
isArticlePublishing.value = true;
@@ -94,7 +136,7 @@ const updateArticleStatus = async ({ value }) => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/update', {
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
portalSlug,
articleId: props.articleId,
status,
@@ -107,12 +149,100 @@ const updateArticleStatus = async ({ value }) => {
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
isArticlePublishing.value = false;
} catch (error) {
useAlert(error?.message ?? getStatusMessage(status, false));
} finally {
isArticlePublishing.value = false;
}
};
const updateArticleStatus = ({ value }) => {
showArticleActionMenu.value = false;
// Leaving published with unsaved draft edits — ask whether to apply or discard
// first; the popover applies the status itself once resolved.
if (hasPendingChanges.value) {
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
return;
}
performStatusUpdate(value);
};
const publishDraftChanges = async () => {
isArticlePublishing.value = true;
const { portalSlug } = route.params;
try {
await store.dispatch('articles/publishDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
} finally {
isArticlePublishing.value = false;
}
};
const discardDraftChanges = async () => {
const { portalSlug } = route.params;
try {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: props.articleId,
});
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
} catch (error) {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
);
}
};
const onPrimaryAction = () => {
if (blockedWhileSaving()) return;
if (hasPendingChanges.value) {
publishDraftChanges();
} else if (props.pendingChanges) {
// Promote leftover draft edits on publish instead of republishing stale content.
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
} else {
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
}
};
const onMenuAction = event => {
showArticleActionMenu.value = false;
// Don't resolve a draft while an autosave is still in flight — it could land
// after and recreate the draft we just discarded/applied.
if (blockedWhileSaving()) return;
if (event.action === 'discard-draft') {
discardDraftChanges();
} else {
updateArticleStatus(event);
}
};
// The popover applies the draft + status itself; we just surface the outcome.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
};
const onDraftFailed = error => {
useAlert(
error?.message ??
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
</script>
<template>
@@ -127,13 +257,24 @@ const updateArticleStatus = async ({ value }) => {
@click="onClickGoBack"
/>
<div class="flex items-center gap-4">
<button
v-if="hasPendingChanges"
type="button"
data-diff-toggle
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
@click="emit('showDiff')"
>
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
</button>
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
<div class="flex items-center gap-2">
<div class="relative flex items-center gap-2">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
color="slate"
@@ -143,17 +284,21 @@ const updateArticleStatus = async ({ value }) => {
/>
<ButtonGroup class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
:label="
hasPendingChanges
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
!articleId ||
isArticlePublishing
isArticlePublishing ||
(isPublished && !hasPendingChanges)
"
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
@click="onPrimaryAction"
/>
<div class="relative">
<OnClickOutside @trigger="showArticleActionMenu = false">
@@ -169,11 +314,17 @@ const updateArticleStatus = async ({ value }) => {
v-if="showArticleActionMenu"
:menu-items="articleMenuItems"
class="mt-2 ltr:right-0 rtl:left-0 top-full"
@action="updateArticleStatus($event)"
@action="onMenuAction($event)"
/>
</OnClickOutside>
</div>
</ButtonGroup>
<ArticlePendingChangesPopover
ref="pendingChangesPopoverRef"
:article-id="articleId"
@resolved="onDraftResolved"
@failed="onDraftFailed"
/>
</div>
</div>
</div>
@@ -0,0 +1,132 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { onKeyStroke } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
articleId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['resolved', 'failed']);
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const isOpen = ref(false);
const requestedStatus = ref(null);
// Which button is in flight, so only that one shows the spinner.
const activeAction = ref(null);
const articleUiFlags = useMapGetter('articles/uiFlags');
const isLoading = computed(
() => articleUiFlags.value(props.articleId).isUpdating
);
// Open the confirmation for a target status; resolving it also applies that status.
const open = status => {
requestedStatus.value = status;
activeAction.value = null;
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
// Don't let a click-outside or Escape dismiss the popover mid-action.
const dismiss = () => {
if (!isLoading.value) close();
};
const resolve = async draftAction => {
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
try {
await store.dispatch(`articles/${draftAction}`, {
portalSlug: route.params.portalSlug,
articleId: props.articleId,
status: requestedStatus.value,
});
emit('resolved', requestedStatus.value);
close();
} catch (error) {
emit('failed', error);
}
};
const onApply = () => resolve('publishDraft');
const onDiscard = () => resolve('discardDraft');
onKeyStroke('Escape', () => {
if (isOpen.value) dismiss();
});
defineExpose({ open, close });
</script>
<template>
<div
v-show="isOpen"
v-on-click-outside="dismiss"
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
>
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
)
}}
</p>
</div>
<Button
icon="i-lucide-x"
variant="ghost"
color="slate"
size="xs"
class="shrink-0 -me-1 -mt-1"
:disabled="isLoading"
@click="close"
/>
</div>
<div class="flex items-center justify-between gap-2">
<Button
type="button"
variant="faded"
color="ruby"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'discard'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
"
@click="onDiscard"
/>
<Button
type="button"
color="blue"
size="sm"
class="flex-1"
:is-loading="isLoading && activeAction === 'apply'"
:disabled="isLoading"
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
"
@click="onApply"
/>
</div>
</div>
</template>
@@ -5,8 +5,12 @@ import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import {
getArticleStatus,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper.js';
import wootConstants from 'dashboard/constants/globals';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
@@ -145,6 +149,24 @@ const updateArticlesMeta = () => {
});
};
const refreshArticleMeta = async () => {
await updateArticlesMeta();
await updatePortalMeta();
};
// The card's pending-changes popover applies the status itself; surface the result.
const onDraftResolved = status => {
useAlert(getStatusMessage(status, true));
refreshArticleMeta();
};
const onDraftFailed = error => {
useAlert(
error?.message ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
);
};
const handleArticleAction = async (action, { status, id }) => {
const { portalSlug } = route.params;
try {
@@ -154,6 +176,14 @@ const handleArticleAction = async (action, { status, id }) => {
articleId: id,
});
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
} else if (action === 'discard-draft') {
await store.dispatch('articles/discardDraft', {
portalSlug,
articleId: id,
});
useAlert(
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
);
} else {
await store.dispatch('articles/update', {
portalSlug,
@@ -168,15 +198,16 @@ const handleArticleAction = async (action, { status, id }) => {
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
}
}
await updateArticlesMeta();
await updatePortalMeta();
await refreshArticleMeta();
} catch (error) {
const errorMessage =
error?.message ||
(action === 'delete'
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
: getStatusMessage(status, false));
useAlert(errorMessage);
const fallbackMessage =
{
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
'discard-draft': t(
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
),
}[action] ?? getStatusMessage(status, false);
useAlert(error?.message || fallbackMessage);
}
};
@@ -210,10 +241,15 @@ const updateArticle = ({ action, value, id }) => {
:views="item.views || 0"
:updated-at="item.updatedAt"
:is-selected="selectedArticleIds.has(item.id)"
:has-pending-changes="
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
"
selectable
:show-selection-control="shouldShowSelectionControl(item.id)"
@open-article="openArticle"
@article-action="updateArticle"
@draft-resolved="onDraftResolved"
@draft-failed="onDraftFailed"
@toggle-select="emit('toggleSelect', $event)"
@hover="isHovered => handleCardHover(isHovered, item.id)"
/>
@@ -3,10 +3,15 @@ import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import {
ARTICLE_TABS,
CATEGORY_ALL,
ARTICLE_STATUSES,
} from 'dashboard/helper/portalHelper';
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
import articlesAPI from 'dashboard/api/helpCenter/articles';
@@ -60,6 +65,7 @@ const emit = defineEmits([
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -227,15 +233,46 @@ const onBulkActionSuccess = message => {
};
const bulkUpdateStatus = async status => {
const selectedIds = [...selectedArticleIds.value];
const { portalSlug } = route.params;
const pendingIds = props.articles
.filter(
article =>
selectedIds.includes(article.id) &&
article.status === ARTICLE_STATUSES.PUBLISHED &&
hasPendingChanges(article)
)
.map(article => article.id);
// Publish promotes each pending draft; other status changes skip them.
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
const draftIds = isPublishing ? pendingIds : [];
const skippedCount = isPublishing ? 0 : pendingIds.length;
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
if (!articleIds.length && !draftIds.length) {
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
return;
}
try {
await articlesAPI.bulkUpdateStatus({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
status,
});
if (articleIds.length) {
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
}
await Promise.all(
draftIds.map(articleId =>
store.dispatch('articles/publishDraft', { portalSlug, articleId })
)
);
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
);
if (skippedCount) {
useAlert(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
);
}
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -26,6 +27,7 @@ const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const popularContentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'select-popular-content') {
popularContentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
</ul>
</template>
@@ -0,0 +1,235 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import categoriesAPI from 'dashboard/api/helpCenter/categories';
import articlesAPI from 'dashboard/api/helpCenter/articles';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const MAX_CATEGORIES = 3;
const MAX_ARTICLES = 6;
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
// One flag per picker so each shows its skeleton until its own data loads.
const categoriesLoading = ref(false);
const { run: runArticleRequest, isPending: articlesLoading } =
useAbortableRequest();
const categoryOptions = ref([]);
const selectedCategoryIds = ref([]);
// Articles are searched server-side, so cache options for selected items that
// fall outside the current results.
const articleResults = ref([]);
const articleOptionById = ref({});
const selectedArticleIds = ref([]);
const popularContent = computed(
() => props.portal?.config?.popular_content || {}
);
const articleOptions = computed(() => {
const options = new Map();
selectedArticleIds.value.forEach(id => {
if (articleOptionById.value[id])
options.set(id, articleOptionById.value[id]);
});
articleResults.value.forEach(option => options.set(option.value, option));
return [...options.values()];
});
const toArticleOption = article => ({
value: article.id,
label: article.title,
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
}),
});
const fetchCategories = async localeCode => {
const {
data: { payload },
} = await categoriesAPI.get({
portalSlug: props.portal?.slug,
locale: localeCode,
});
return payload.map(category => ({
value: category.id,
label: category.name,
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
count: category.meta?.articles_count || 0,
}),
icon: category.icon,
iconColor: category.icon_color,
}));
};
const requestArticles = async (query, signal) => {
const { data } = await articlesAPI.getArticles({
pageNumber: 1,
portalSlug: props.portal?.slug,
locale: activeLocale.value,
status: 'published',
query,
signal,
});
articleResults.value = data.payload.map(article => {
const option = toArticleOption(article);
articleOptionById.value[article.id] = option;
return option;
});
};
const searchArticles = (query = '') =>
runArticleRequest(signal => requestArticles(query, signal));
const onArticleSearch = useDebounceFn(searchArticles, 300);
// Resolve options for pre-selected articles that aren't in the current results.
const cacheSelectedArticleOptions = async () => {
const unknownIds = selectedArticleIds.value.filter(
id => !articleOptionById.value[id]
);
await Promise.all(
unknownIds.map(async id => {
try {
const { data } = await articlesAPI.getArticle({
id,
portalSlug: props.portal?.slug,
});
articleOptionById.value[id] = toArticleOption(data.payload);
} catch {
// Deleted since it was picked; leave it for the id fallback.
}
})
);
};
const loadCategories = async localeCode => {
categoriesLoading.value = true;
try {
const options = await fetchCategories(localeCode);
// Reopened for another locale mid-flight; drop the stale response.
if (localeCode !== activeLocale.value) return;
categoryOptions.value = options;
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
} finally {
if (localeCode === activeLocale.value) categoriesLoading.value = false;
}
};
const loadArticles = async () => {
try {
await runArticleRequest(signal =>
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
);
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
}
};
const openForLocale = localeCode => {
const existing = popularContent.value[localeCode] || {};
activeLocale.value = localeCode;
selectedCategoryIds.value = [...(existing.category_ids || [])];
selectedArticleIds.value = [...(existing.article_ids || [])];
categoryOptions.value = [];
articleResults.value = [];
articleOptionById.value = {};
dialogRef.value?.open();
loadCategories(localeCode);
loadArticles();
};
const onConfirm = async () => {
const updated = { ...popularContent.value };
const entry = {
category_ids: selectedCategoryIds.value,
article_ids: selectedArticleIds.value,
};
if (entry.category_ids.length || entry.article_ids.length) {
updated[activeLocale.value] = entry;
} else {
delete updated[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { popular_content: updated },
});
dialogRef.value?.close();
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t(`${KEY}.TITLE`)"
:description="t(`${KEY}.DESCRIPTION`)"
:confirm-button-label="t(`${KEY}.CONFIRM`)"
@confirm="onConfirm"
>
<div class="flex flex-col gap-5">
<ReorderableMultiSelect
v-model="selectedCategoryIds"
:options="categoryOptions"
:max="MAX_CATEGORIES"
:label="t(`${KEY}.CATEGORIES.LABEL`)"
:add-label="t(`${KEY}.ADD_ANOTHER`)"
:search-placeholder="t(`${KEY}.SEARCH`)"
:empty-state="t(`${KEY}.EMPTY`)"
fallback-icon="i-lucide-folder"
:loading="categoriesLoading"
>
<template #counter="{ remaining }">
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
</template>
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
</ReorderableMultiSelect>
<ReorderableMultiSelect
v-model="selectedArticleIds"
server-search
:options="articleOptions"
:max="MAX_ARTICLES"
:label="t(`${KEY}.ARTICLES.LABEL`)"
:add-label="t(`${KEY}.ADD_ANOTHER`)"
:search-placeholder="t(`${KEY}.SEARCH`)"
:empty-state="t(`${KEY}.EMPTY`)"
:loading="articlesLoading"
@search="onArticleSearch"
>
<template #counter="{ remaining }">
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
</template>
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
</ReorderableMultiSelect>
</div>
</Dialog>
</template>
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
ref="popoverRef"
:align="align"
:show-content-border="false"
:close-on-scroll="false"
@show="onPopoverShow"
@hide="onPopoverHide"
>
@@ -0,0 +1,158 @@
<script setup>
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
import { downloadFile } from '@chatwoot/utils';
import { useEmitter } from 'dashboard/composables/emitter';
import { emitter } from 'shared/helpers/mitt';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
src: {
type: String,
required: true,
},
fallbackDuration: {
type: Number,
default: 0,
},
});
const PLAYBACK_SPEEDS = [1, 1.5, 2];
const audioPlayer = useTemplateRef('audioPlayer');
const { uid } = getCurrentInstance();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(props.fallbackDuration);
const playbackSpeed = ref(1);
const onLoadedMetadata = () => {
const loadedDuration = audioPlayer.value?.duration;
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
};
const formatTime = time => {
if (!time || Number.isNaN(time)) return '00:00';
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
const displayedTime = computed(() =>
formatTime(
isPlaying.value || currentTime.value ? currentTime.value : duration.value
)
);
// Only one recording should play at a time across the list.
useEmitter('pause_playing_audio', currentPlayingId => {
if (currentPlayingId !== uid && isPlaying.value) {
audioPlayer.value?.pause();
isPlaying.value = false;
}
});
const playOrPause = () => {
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
emitter.emit('pause_playing_audio', uid);
audioPlayer.value.play();
isPlaying.value = true;
}
};
const onTimeUpdate = () => {
currentTime.value = audioPlayer.value?.currentTime;
};
const seek = event => {
const time = Number(event.target.value);
audioPlayer.value.currentTime = time;
currentTime.value = time;
};
const onEnd = () => {
isPlaying.value = false;
currentTime.value = 0;
};
const changePlaybackSpeed = () => {
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
playbackSpeed.value =
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
audioPlayer.value.playbackRate = playbackSpeed.value;
};
const downloadRecording = () => {
downloadFile({ url: props.src, type: 'audio' });
};
</script>
<template>
<div
class="flex items-center justify-center h-8 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
@click.stop
>
<audio
ref="audioPlayer"
class="hidden"
playsinline
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
>
<source :src="src" />
</audio>
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-12"
@click="playOrPause"
>
<template #icon>
<Icon
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
class="size-4 flex-shrink-0"
/>
</template>
</Button>
<input
type="range"
min="0"
:max="duration || 0"
:value="currentTime"
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
@input="seek"
/>
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
{{ displayedTime }}
</span>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
:label="playbackSpeedLabel"
class="!px-1 min-w-6 !text-n-slate-11"
@click="changePlaybackSpeed"
/>
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
<Button
variant="ghost"
color="slate"
size="xs"
class="!w-6 !p-0 text-n-slate-11"
@click="downloadRecording"
>
<template #icon>
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
</template>
</Button>
</div>
</template>
@@ -14,6 +14,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -61,6 +62,7 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
:disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
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">
<div v-if="loading" class="flex items-end justify-between gap-2">
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-else class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
stats: {
type: Object,
default: null,
},
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
// Increments on every fetch so a slow response for a superseded
// range/stats/assistant can't overwrite the latest request's state.
let fetchToken = 0;
const fetchSummary = async () => {
fetchToken += 1;
const token = fetchToken;
if (!props.stats) {
welcomeMarkdown.value = '';
isLoading.value = false;
return;
}
isLoading.value = true;
let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
stats: props.stats,
});
welcomeMarkdown.value = data.message ?? '';
message = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
message = '';
}
if (token !== fetchToken) return;
welcomeMarkdown.value = message;
isLoading.value = false;
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
immediate: true,
});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
@@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] max-h-96 rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -114,7 +114,10 @@ const openCreateAssistantDialog = () => {
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<div
v-if="assistants.length > 0"
class="flex flex-col flex-1 min-h-0 gap-2 px-4 pb-3 overflow-y-auto overscroll-contain"
>
<Button
v-for="assistant in assistants"
:key="assistant.id"
@@ -1,6 +1,8 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
open: {
@@ -27,6 +29,10 @@ const props = defineProps({
type: [String, Number, Array],
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'search']);
@@ -63,13 +69,22 @@ defineExpose({
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<Spinner
v-if="loading"
:size="16"
class="absolute top-2.5 start-3 text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-search"
class="absolute top-2.5 size-4 start-3"
/>
<input
ref="searchInput"
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
class="reset-base w-full py-2 !ps-10 !pe-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
@input="onInputSearch"
/>
</div>
@@ -0,0 +1,297 @@
<script setup>
import { ref, computed, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// Drag-reorderable multi-select capped at `max`. The model is the ordered list
// of selected values.
const props = defineProps({
// { value, label, subtitle?, icon?, iconColor? } — icon is an emoji or an
// icon-picker value, and falls back to `fallbackIcon`.
options: {
type: Array,
default: () => [],
},
max: {
type: Number,
default: 3,
},
label: {
type: String,
default: '',
},
addLabel: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
fallbackIcon: {
type: String,
default: 'i-lucide-file-text',
},
// Show skeleton rows while options load, so the row height stays stable.
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
// Let the parent filter `options` (via the `search` event) instead of locally.
serverSearch: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['search']);
const selectedIds = defineModel({ type: Array, default: () => [] });
const isOpen = ref(false);
const searchQuery = ref('');
const dragIndex = ref(null);
const dropdownRef = ref(null);
const optionsByValue = computed(
() => new Map(props.options.map(option => [option.value, option]))
);
const selectedRows = computed(() =>
selectedIds.value.map(
id => optionsByValue.value.get(id) || { value: id, label: String(id) }
)
);
const remaining = computed(() => props.max - selectedIds.value.length);
const canAddMore = computed(() => selectedIds.value.length < props.max);
const dropdownOptions = computed(() => {
const available = props.options.filter(
option => !selectedIds.value.includes(option.value)
);
if (props.serverSearch) return available;
const query = searchQuery.value.toLowerCase();
return available.filter(option =>
option.label?.toLowerCase().includes(query)
);
});
const onSearch = value => {
searchQuery.value = value;
emit('search', value);
};
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
onSearch('');
nextTick(() => dropdownRef.value?.focus());
}
};
const onSelect = option => {
if (!canAddMore.value) return;
selectedIds.value = [...selectedIds.value, option.value];
if (!canAddMore.value) isOpen.value = false;
};
const removeItem = id => {
selectedIds.value = selectedIds.value.filter(value => value !== id);
};
const onDragStart = index => {
dragIndex.value = index;
};
const onDragOver = index => {
if (dragIndex.value === null || dragIndex.value === index) return;
const ids = [...selectedIds.value];
const [moved] = ids.splice(dragIndex.value, 1);
ids.splice(index, 0, moved);
dragIndex.value = index;
selectedIds.value = ids;
};
const onDragEnd = () => {
dragIndex.value = null;
};
</script>
<template>
<div>
<div
v-if="label || $slots.counter"
class="flex items-center justify-between mb-1.5"
>
<label class="text-sm font-medium text-n-slate-12">{{ label }}</label>
<div class="flex items-center gap-2">
<span class="text-xs text-n-slate-10">
<slot name="counter" :remaining="remaining" :max="max" />
</span>
<div class="flex items-center gap-1">
<span
v-for="slot in max"
:key="slot"
class="w-4 h-1 rounded-full"
:class="slot <= selectedIds.length ? 'bg-n-brand' : 'bg-n-slate-4'"
/>
</div>
</div>
</div>
<OnClickOutside @trigger="isOpen = false">
<div
class="flex flex-col gap-1 p-1 border rounded-xl border-n-weak bg-n-background"
:class="{ 'opacity-50 pointer-events-none': disabled }"
>
<div
v-if="loading && selectedIds.length && !isOpen"
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
aria-busy="true"
>
<div
v-for="n in selectedIds.length"
:key="n"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-n-alpha-2"
>
<span
class="flex-shrink-0 opacity-40 i-lucide-grip-vertical size-4 text-n-slate-9"
/>
<div class="flex-shrink-0 rounded-md size-6 bg-n-alpha-3" />
<div class="flex-grow min-w-0">
<p class="mb-0 text-sm">
<span
class="inline-block w-32 h-2.5 align-middle rounded bg-n-alpha-3 animate-pulse"
/>
</p>
<p class="mb-0 text-xs">
<span
class="inline-block w-20 h-2 align-middle rounded bg-n-alpha-3 animate-pulse"
/>
</p>
</div>
<span class="flex-shrink-0 size-6" />
</div>
</div>
<div
v-else-if="selectedRows.length"
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
>
<div
v-for="(row, index) in selectedRows"
:key="row.value"
draggable="true"
class="flex items-center gap-2 px-2 py-1.5 transition-colors rounded-lg cursor-grab group/row"
:class="
index === dragIndex
? 'opacity-40 bg-n-alpha-3 ring-1 ring-inset ring-n-brand'
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
"
@dragstart="onDragStart(index)"
@dragover.prevent="onDragOver(index)"
@dragend="onDragEnd"
>
<span
class="flex-shrink-0 transition-colors i-lucide-grip-vertical size-4 text-n-slate-9 group-hover/row:text-n-slate-11"
/>
<div
class="flex items-center justify-center flex-shrink-0 text-sm rounded-md size-6 bg-n-alpha-3 text-n-slate-11"
>
<EmojiIcon
v-if="row.icon"
:value="row.icon"
:color="row.iconColor"
class="shrink-0 size-4"
/>
<span v-else class="size-4" :class="fallbackIcon" />
</div>
<div class="flex-grow min-w-0">
<p class="mb-0 text-sm truncate text-n-slate-12">
{{ row.label }}
</p>
<p
v-if="row.subtitle"
class="mb-0 text-xs truncate text-n-slate-10"
>
{{ row.subtitle }}
</p>
</div>
<Button
type="button"
ghost
slate
xs
no-animation
icon="i-lucide-x"
class="flex-shrink-0"
@click="removeItem(row.value)"
/>
</div>
</div>
<div v-if="canAddMore" class="relative">
<Button
type="button"
ghost
slate
sm
no-animation
justify="start"
:label="addLabel"
:disabled="loading && !isOpen"
class="w-full"
@click="toggleDropdown"
>
<template #icon>
<Spinner
v-if="loading && !isOpen"
:size="16"
class="text-n-slate-11"
/>
<Icon
v-else
icon="i-lucide-search"
class="flex-shrink-0 size-4"
/>
</template>
</Button>
<ComboBoxDropdown
ref="dropdownRef"
:open="isOpen"
:options="dropdownOptions"
:search-value="searchQuery"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:loading="loading"
@update:search-value="onSearch"
@select="onSelect"
/>
</div>
</div>
</OnClickOutside>
<p
v-if="selectedIds.length && $slots.note"
class="flex items-center gap-1.5 mt-1.5 mb-0 text-xs text-n-slate-11"
>
<span class="rounded-full size-1.5 bg-n-teal-9" />
<slot name="note" />
</p>
</div>
</template>
@@ -0,0 +1,224 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
const OPTIONS = [
{ value: 1, label: 'Getting started', subtitle: 'Guides' },
{ value: 2, label: 'Billing', subtitle: 'Payments' },
{ value: 3, label: 'Security' },
{ value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
];
// A findable dropdown stub that exposes the `focus()` the component calls on open.
const ComboBoxDropdownStub = {
name: 'ComboBoxDropdown',
props: [
'open',
'options',
'searchValue',
'searchPlaceholder',
'emptyState',
'loading',
],
emits: ['select', 'update:searchValue'],
methods: { focus() {} },
template: '<div class="combo-dropdown" />',
};
// Renders a real <button> so clicks reach the parent handlers; `data-icon`
// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
const ButtonStub = {
name: 'Button',
props: ['label', 'icon', 'disabled'],
emits: ['click'],
template:
'<button :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" />{{ label }}</button>',
};
const mountSelect = (props = {}, slots = {}) =>
mount(ReorderableMultiSelect, {
props: { options: OPTIONS, max: 3, ...props },
slots,
global: {
stubs: {
Button: ButtonStub,
ComboBoxDropdown: ComboBoxDropdownStub,
Spinner: true,
Icon: true,
EmojiIcon: true,
OnClickOutside: { template: '<div><slot /></div>' },
},
},
});
const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
const addTrigger = wrapper =>
wrapper.findAll('button').find(button => !button.attributes('data-icon'));
const removeButtons = wrapper =>
wrapper.findAll('button[data-icon="i-lucide-x"]');
const rows = wrapper => wrapper.findAll('[draggable="true"]');
const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
describe('ReorderableMultiSelect', () => {
describe('rendering selected rows', () => {
it('renders rows in model order with labels resolved from options', () => {
const wrapper = mountSelect({ modelValue: [2, 1] });
const labels = rows(wrapper).map(row => row.find('p').text());
expect(labels).toEqual(['Billing', 'Getting started']);
});
it('falls back to the stringified id when an option is unknown', () => {
const wrapper = mountSelect({ modelValue: [99] });
expect(rows(wrapper)[0].find('p').text()).toBe('99');
});
it('renders the progress dots filled up to the selection count', () => {
const wrapper = mountSelect({
modelValue: [1, 2],
max: 3,
label: 'Tags',
});
const filled = wrapper.findAll('.bg-n-brand').length;
expect(filled).toBe(2);
});
it('exposes remaining and max to the counter slot', () => {
const wrapper = mountSelect(
{ modelValue: [1], max: 3 },
{ counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
);
expect(wrapper.text()).toContain('2/3');
});
});
describe('adding options', () => {
it('appends the chosen option to the model', () => {
const wrapper = mountSelect({ modelValue: [1] });
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
expect(lastModel(wrapper)).toEqual([1, 2]);
});
it('hides the add trigger once the model reaches max', () => {
const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
expect(addTrigger(wrapper)).toBeUndefined();
expect(dropdown(wrapper).exists()).toBe(false);
});
it('closes the dropdown when the last slot is filled', async () => {
const wrapper = mountSelect({ modelValue: [1], max: 2 });
await addTrigger(wrapper).trigger('click');
expect(dropdown(wrapper).props('open')).toBe(true);
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
await wrapper.vm.$nextTick();
// Reaching max removes the trigger (and its dropdown) entirely.
expect(dropdown(wrapper).exists()).toBe(false);
});
it('excludes already-selected options from the dropdown', () => {
const wrapper = mountSelect({ modelValue: [1] });
const values = dropdown(wrapper)
.props('options')
.map(option => option.value);
expect(values).toEqual([2, 3, 4]);
});
});
describe('removing options', () => {
it('removes the clicked item from the model', async () => {
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
await removeButtons(wrapper)[1].trigger('click');
expect(lastModel(wrapper)).toEqual([1, 3]);
});
});
describe('searching', () => {
it('filters options locally by label', async () => {
const wrapper = mountSelect({ modelValue: [] });
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
await wrapper.vm.$nextTick();
const values = dropdown(wrapper)
.props('options')
.map(option => option.value);
expect(values).toEqual([2]);
});
it('emits search and skips local filtering when serverSearch is set', async () => {
const wrapper = mountSelect({ modelValue: [], serverSearch: true });
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
// All unselected options remain; the parent owns filtering.
expect(dropdown(wrapper).props('options')).toHaveLength(4);
});
it('emits an empty search when the trigger opens', async () => {
const wrapper = mountSelect({ modelValue: [1] });
await addTrigger(wrapper).trigger('click');
expect(wrapper.emitted('search').at(-1)).toEqual(['']);
expect(dropdown(wrapper).props('open')).toBe(true);
});
});
describe('reordering', () => {
it('moves a row to the dropped position within the model', async () => {
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
await rows(wrapper)[0].trigger('dragstart');
await rows(wrapper)[2].trigger('dragover');
expect(lastModel(wrapper)).toEqual([2, 3, 1]);
});
});
describe('loading state', () => {
it('shows skeleton rows when loading a non-empty, closed selection', () => {
const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
const skeleton = wrapper.find('[aria-busy="true"]');
expect(skeleton.exists()).toBe(true);
expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
});
it('does not show skeletons when the selection is empty', () => {
const wrapper = mountSelect({ modelValue: [], loading: true });
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
});
it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
// Open first (trigger is enabled), then a live search turns loading on.
const wrapper = mountSelect({ modelValue: [1, 2] });
await addTrigger(wrapper).trigger('click');
await wrapper.setProps({ loading: true });
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
expect(rows(wrapper)).toHaveLength(2);
});
it('forwards loading to the dropdown and disables the closed trigger', () => {
const wrapper = mountSelect({ modelValue: [1], loading: true });
expect(dropdown(wrapper).props('loading')).toBe(true);
expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
});
});
});
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
import {
isTwilioComplete,
isTwilioMediaTemplate,
getTwilioMediaVariableKey,
getTwilioMediaUrl,
applyTwilioMediaFilename,
} from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
return props.template.body || '';
});
const hasMediaTemplate = computed(() => {
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
});
// Media-template detection and variable extraction are shared with the mobile
// app via @chatwoot/utils.
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
const hasVariables = computed(() => {
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
});
const mediaVariableKey = computed(() => {
if (!hasMediaTemplate.value) return null;
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
if (!mediaUrl) return null;
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
});
const mediaVariableKey = computed(() =>
getTwilioMediaVariableKey(props.template)
);
const hasMediaVariable = computed(() => {
return hasMediaTemplate.value && mediaVariableKey.value !== null;
});
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
const templateMediaUrl = computed(() => {
if (!hasMediaTemplate.value) return '';
return props.template?.types?.['twilio/media']?.media?.[0] || '';
});
const templateMediaUrl = computed(() =>
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
);
const variablePattern = computed(() => {
if (!hasVariables.value) return [];
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
return rendered;
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaVariable.value) return false;
if (hasVariables.value) {
const hasEmptyVariable = variablePattern.value.some(
variable => !processedParams.value[variable]
);
if (hasEmptyVariable) return true;
}
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
!processedParams.value[mediaVariableKey.value]
) {
return true;
}
return false;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isTwilioComplete(props.template, processedParams.value)
);
const v$ = useVuelidate(
{
@@ -135,19 +117,11 @@ const sendMessage = () => {
const { friendly_name, language } = props.template;
// Process parameters and extract filename from media URL if needed
const processedParameters = { ...processedParams.value };
// For media templates, extract filename from full URL
if (
hasMediaVariable.value &&
mediaVariableKey.value &&
processedParameters[mediaVariableKey.value]
) {
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
processedParameters[mediaVariableKey.value]
);
}
// For media templates, reduce the media URL to a filename before sending.
const processedParameters = applyTwilioMediaFilename(
props.template,
processedParams.value
);
const payload = {
message: renderedTemplate.value,
@@ -0,0 +1,342 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Popover from 'dashboard/components-next/popover/Popover.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMessageContext } from './provider.js';
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
const props = defineProps({
messageId: { type: Number, required: true },
});
const { t } = useI18n();
const { orientation, variant, createdAt } = useMessageContext();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const isOpen = ref(false);
const showSparkle = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const session = computed(() =>
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
);
const hasFetched = computed(() =>
store.getters['captainAgentSessions/hasFetched'](props.messageId)
);
const isLoading = computed(
() =>
!hasFetched.value ||
store.getters['captainAgentSessions/isFetching'](props.messageId)
);
const citations = computed(() => session.value?.citations || []);
const scenarioTitles = computed(() =>
(session.value?.scenarios || []).reduce((map, scenario) => {
map[scenario.id] = scenario.title;
return map;
}, {})
);
// Fallback for agents without a matching scenario title:
// "chatwoot_assistant" → "Chatwoot assistant",
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
const humanizeAgentName = agentName => {
const label = agentName
.replace(/^scenario_\d+_/, '')
.replace(/_agent$/, '')
.replaceAll('_', ' ')
.trim();
return label.charAt(0).toUpperCase() + label.slice(1);
};
const handoffLabel = agentName => {
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
};
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
// Tool names arrive as RubyLLM identifiers like
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
// show "FAQ Lookup" / "Get Status Page Overview" instead.
const humanizeToolName = name => {
return (name || '')
.split('--')
.pop()
.replace(/^custom_/, '')
.split('_')
.filter(Boolean)
.map(word =>
ACRONYMS.includes(word)
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join(' ');
};
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
const humanizeArgumentKey = key =>
key
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const formatArguments = args => {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
.join(', ');
};
// Timeline of what Captain did during the run: tool calls (with their
// arguments) and scenario/agent handoffs. Message bodies and raw tool
// results are intentionally not echoed here.
const steps = computed(() => {
const runContext = session.value?.runContext;
const result = [];
let currentAgent = null;
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
if (entry?.role !== 'assistant') return;
const agentName = entry.agentName;
if (agentName && agentName !== currentAgent) {
if (currentAgent !== null) {
result.push({ type: 'handoff', name: handoffLabel(agentName) });
}
currentAgent = agentName;
}
(entry.toolCalls || []).forEach(call => {
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
// the agent_name change above already yields a handoff step for them.
if (call.name?.startsWith('handoff_to_')) return;
result.push({
type: 'tool',
name: humanizeToolName(call.name),
detail: formatArguments(call.arguments),
});
});
});
return result;
});
// The final assistant entry stores structured content ({response, reasoning});
// surface the model's reasoning for the reply it produced.
const reasoning = computed(() => {
const runContext = session.value?.runContext;
if (!Array.isArray(runContext)) return '';
const entry = [...runContext]
.reverse()
.find(item => item?.role === 'assistant' && item.content?.reasoning);
return entry?.content?.reasoning || '';
});
const STEP_ICONS = {
tool: 'i-ph-wrench',
handoff: 'i-ph-user-switch',
};
const STEP_KEYPATHS = {
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
};
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
// Model and credits are only surfaced to super admins and in development.
const devDetails = computed(() => {
if (!session.value) return null;
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
model: session.value.llmModel,
});
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
credits: session.value.creditsConsumed,
});
return `${model} · ${credits}`;
});
// With the sparkle at the row start, the meta gets pushed to the opposite end;
// without it, fall back to the message orientation.
const rowLayoutClass = computed(() => {
if (showSparkle.value) return 'justify-between';
return orientation.value === ORIENTATION.LEFT
? 'justify-start'
: 'justify-end';
});
// Blend the sparkle with the bubble background: amber on private notes,
// slate everywhere else. Tokens adapt to dark mode on their own.
const sparkleColorClass = computed(() => {
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
return isOpen.value
? 'text-n-amber-12/80'
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
}
return isOpen.value
? 'text-n-slate-12'
: 'text-n-slate-11/60 hover:text-n-slate-12';
});
const popoverAlign = computed(() =>
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
);
const prefetch = () => {
store.dispatch('captainAgentSessions/fetch', {
messageId: props.messageId,
createdAt: createdAt.value,
});
};
const onPopoverShow = () => {
isOpen.value = true;
prefetch();
};
const onPopoverHide = () => {
isOpen.value = false;
};
</script>
<template>
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
<Popover
v-if="showSparkle"
:align="popoverAlign"
@show="onPopoverShow"
@hide="onPopoverHide"
>
<button
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
type="button"
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
:class="sparkleColorClass"
@mouseenter="prefetch"
@focus="prefetch"
>
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
<span class="text-xs">
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
</span>
</button>
<template #content>
<div class="flex flex-col gap-4 p-4 w-80">
<span v-if="isLoading" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
</span>
<span v-else-if="!session" class="text-xs text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
</span>
<template v-else>
<div v-if="steps.length" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
</span>
<div class="flex flex-col">
<div
v-for="(step, index) in steps"
:key="index"
class="flex gap-2.5"
>
<div class="flex flex-col items-center">
<span
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
>
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
</span>
<span
v-if="index < steps.length - 1"
class="flex-1 w-px min-h-2 bg-n-weak"
/>
</div>
<div
class="flex flex-col min-w-0 gap-0.5"
:class="index < steps.length - 1 ? 'pb-3' : ''"
>
<I18nT
:keypath="STEP_KEYPATHS[step.type]"
tag="span"
class="text-xs leading-5 text-n-slate-11"
>
<template #name>
<span class="font-medium text-n-slate-12">
{{ step.name }}
</span>
</template>
</I18nT>
<span
v-if="step.detail"
class="text-xs text-n-slate-11 break-words"
>
{{ step.detail }}
</span>
</div>
</div>
</div>
</div>
<div v-if="citations.length" class="flex flex-col gap-2">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
</span>
<span class="text-xs text-n-slate-10">
{{
t(
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
citations.length
)
}}
</span>
</div>
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
<li
v-for="citation in citations"
:key="citation.id"
class="text-xs text-n-slate-12"
>
<a
v-if="citation.link"
:href="citation.link"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-n-blue-11 hover:underline"
>
{{ citation.title || citation.link }}
</a>
<span v-else>{{ citation.title }}</span>
</li>
</ul>
</div>
<div v-if="reasoning" class="flex flex-col gap-2">
<span class="text-xs font-medium text-n-slate-11">
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
</span>
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
{{ reasoning }}
</p>
</div>
<span v-if="devDetails" class="text-xs text-n-slate-11">
{{ devDetails }}
</span>
</template>
</div>
</template>
</Popover>
<slot name="meta" />
</div>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import MessageMeta from '../MessageMeta.vue';
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
const props = defineProps({
hideMeta: { type: Boolean, default: false },
});
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
useMessageContext();
const {
variant,
orientation,
inReplyTo,
shouldGroupWithNext,
id,
sender,
senderType,
} = useMessageContext();
const { t } = useI18n();
const isCaptainMessage = computed(
() =>
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
);
const metaColorClass = computed(() =>
variant.value === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11'
);
const emailMetaClass = computed(() =>
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
);
const varaintBaseMap = {
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
[MESSAGE_VARIANTS.PRIVATE]:
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
/>
</div>
<slot />
<MessageMeta
v-if="shouldShowMeta"
:class="[
flexOrientationClass,
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
variant === MESSAGE_VARIANTS.PRIVATE
? 'text-n-amber-12/50'
: 'text-n-slate-11',
]"
class="mt-2"
/>
<template v-if="shouldShowMeta">
<CaptainGenerationDetails
v-if="isCaptainMessage"
:message-id="id"
class="mt-2"
>
<template #meta>
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
</template>
</CaptainGenerationDetails>
<MessageMeta
v-else
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
class="mt-2"
/>
</template>
</div>
</template>
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
if (!canCallBack.value || isInitiatingCall.value) return;
try {
if (isWhatsapp.value) {
const response = await whatsappCallSession.initiateOutboundCall(
conversationId.value
);
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: conversationId.value,
});
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
@@ -1,7 +1,11 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
import {
useBreakpoints,
breakpointsTailwind,
useEventListener,
} from '@vueuse/core';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -16,6 +20,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
closeOnScroll: {
type: Boolean,
default: true,
},
showContentBorder: {
type: Boolean,
default: true,
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
{ align: props.align }
);
const SCROLL_CLOSE_THRESHOLD = 24;
const triggerTopAtOpen = ref(0);
const show = async () => {
isActive.value = true;
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (!isMobile.value) {
await nextTick();
updatePosition();
@@ -56,6 +68,22 @@ const hide = () => {
emit('hide');
};
// The teleported popover tracks its trigger while ancestors scroll; allow
// small drift (trackpad inertia), but close once the trigger moves further.
useEventListener(
window,
'scroll',
event => {
if (!props.closeOnScroll || !showPopover.value) return;
if (popoverRef.value?.contains(event.target)) return;
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
hide();
}
},
{ capture: true, passive: true }
);
const toggle = async () => {
if (isActive.value) hide();
else await show();
@@ -2,6 +2,7 @@
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
@@ -43,7 +44,14 @@ const emit = defineEmits([
]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
const { isEnterprise } = useConfig();
const store = useStore();
// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
const isCallsAvailable = computed(
() => isOnChatwootCloud.value || isEnterprise
);
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
},
],
},
...(isCallsAvailable.value
? [
{
name: 'Calls',
label: t('SIDEBAR.CALLS'),
icon: 'i-lucide-phone',
to: accountScopedRoute('calls_dashboard_index'),
activeOn: ['calls_dashboard_index'],
},
]
: []),
{
name: 'Contacts',
label: t('SIDEBAR.CONTACTS'),
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import { isWhatsAppComplete } from '@chatwoot/utils';
import Input from 'dashboard/components-next/input/Input.vue';
import {
buildTemplateParameters,
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
// Completeness validation is shared with the mobile app via @chatwoot/utils.
const isFormInvalid = computed(
() => !isWhatsAppComplete(props.template, processedParams.value)
);
const v$ = useVuelidate(
{
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
props.chat.id
);
const response = await whatsappCallSession.initiateOutboundCall({
conversationId: props.chat.id,
});
// Composable returns LOCKED when init is already in flight or a call is
// active; soft no-op so a parallel click doesn't trigger a banner.
@@ -0,0 +1,120 @@
import { effectScope } from 'vue';
import { useAbortableRequest } from '../useAbortableRequest';
// Resolves when the request "completes", rejects like axios does when the
// signal is aborted mid-flight.
const abortableRunner =
(value, { fail = false } = {}) =>
signal =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
const error = new Error('canceled');
error.name = 'CanceledError';
reject(error);
});
// Defer so a follow-up `run`/`abort` can supersede this one first.
Promise.resolve().then(() => {
if (signal.aborted) return;
if (fail) {
reject(new Error('boom'));
return;
}
resolve(value);
});
});
describe('useAbortableRequest', () => {
it('passes a fresh signal to the runner and returns its result', async () => {
const { run } = useAbortableRequest();
let received = null;
const result = await run(signal => {
received = signal;
return Promise.resolve('ok');
});
expect(received).toBeInstanceOf(AbortSignal);
expect(received.aborted).toBe(false);
expect(result).toBe('ok');
});
it('toggles isPending around the request', async () => {
const { run, isPending } = useAbortableRequest();
expect(isPending.value).toBe(false);
const pending = run(() => Promise.resolve('done'));
expect(isPending.value).toBe(true);
await pending;
expect(isPending.value).toBe(false);
});
it('aborts the previous request when a new one starts', async () => {
const { run } = useAbortableRequest();
const first = run(abortableRunner('first'));
const second = run(abortableRunner('second'));
await expect(first).resolves.toBeUndefined();
await expect(second).resolves.toBe('second');
});
it('returns the onAbort value when a request is superseded', async () => {
const { run } = useAbortableRequest();
const first = run(abortableRunner('first'), { onAbort: null });
const second = run(abortableRunner('second'));
await expect(first).resolves.toBeNull();
await expect(second).resolves.toBe('second');
});
it('abort cancels the in-flight request and clears isPending', async () => {
const { run, abort, isPending } = useAbortableRequest();
const pending = run(abortableRunner('value'));
expect(isPending.value).toBe(true);
abort();
await expect(pending).resolves.toBeUndefined();
expect(isPending.value).toBe(false);
});
it('rethrows non-abort errors and clears isPending', async () => {
const { run, isPending } = useAbortableRequest();
await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow(
'boom'
);
expect(isPending.value).toBe(false);
});
it('aborts the in-flight request when its scope is disposed', async () => {
const scope = effectScope();
let request;
scope.run(() => {
request = useAbortableRequest();
});
const pending = request.run(abortableRunner('value'));
expect(request.isPending.value).toBe(true);
scope.stop();
await expect(pending).resolves.toBeUndefined();
expect(request.isPending.value).toBe(false);
});
it('keeps separate controllers per instance', async () => {
const a = useAbortableRequest();
const b = useAbortableRequest();
const first = a.run(abortableRunner('a'));
// Starting b's request must not abort a's.
const second = b.run(abortableRunner('b'));
await expect(first).resolves.toBe('a');
await expect(second).resolves.toBe('b');
});
});
@@ -1,9 +1,9 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useAgentsList } from '../useAgentsList';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('keeps nameless agent bots and applies a fallback label', () => {
const namelessBot = {
id: 91,
name: null,
assignee_type: 'AgentBot',
availability_status: 'offline',
};
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => [
...allAgentsData,
namelessBot,
]),
});
const { agentsList } = useAgentsList();
// access the computed to trigger evaluation
expect(agentsList.value).toBeDefined();
const passedAgents =
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
expect(passedAgents).toContainEqual({
...namelessBot,
name: '-',
});
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -0,0 +1,62 @@
import { getCurrentScope, onScopeDispose, ref } from 'vue';
export const isAbortError = error =>
error?.name === 'AbortError' ||
error?.name === 'CanceledError' ||
error?.code === 'ERR_CANCELED';
/**
* Keeps only the latest request alive. Starting a new `run` (or calling
* `abort`) cancels the previous request through its `AbortSignal`, so
* out-of-order responses can never overwrite fresher data.
*
* @example
* const { run, abort, isPending } = useAbortableRequest();
* const results = await run(signal => api.search(query, { signal }));
*
* @returns {{
* run: (runner: (signal: AbortSignal) => Promise<any>, options?: { onAbort?: any }) => Promise<any>,
* abort: () => void,
* isPending: import('vue').Ref<boolean>,
* }}
* `run` resolves with the runner's value, or `options.onAbort` (default
* `undefined`) when the request was superseded. Non-abort errors are rethrown.
*/
export function useAbortableRequest() {
const isPending = ref(false);
let controller = null;
const abort = () => {
controller?.abort();
controller = null;
isPending.value = false;
};
const run = async (runner, { onAbort } = {}) => {
controller?.abort();
const currentController = new AbortController();
controller = currentController;
isPending.value = true;
try {
return await runner(currentController.signal);
} catch (error) {
if (currentController.signal.aborted || isAbortError(error))
return onAbort;
throw error;
} finally {
// Only the latest run owns the shared state; a superseded run leaves it
// for the run that replaced it.
if (controller === currentController) {
controller = null;
isPending.value = false;
}
}
};
// Cancel any in-flight request when the owning scope is disposed.
// Guarded so the composable can also be used outside an effect scope.
if (getCurrentScope()) onScopeDispose(abort);
return { run, abort, isPending };
}
@@ -1,10 +1,10 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
/**
* A composable function that provides a list of agents for assignment.
@@ -53,7 +53,11 @@ export function useAgentsList(
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agents = (assignableAgents.value || []).map(agent =>
!agent.name && agent.assignee_type === 'AgentBot'
? { ...agent, name: '-' }
: agent
);
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -2,6 +2,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { setDirectUploadAuthHeaders } from 'dashboard/helper/directUploadsHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
import {
@@ -21,7 +22,6 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
@@ -78,10 +78,7 @@ export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
setDirectUploadAuthHeaders(xhr);
},
}
);
@@ -308,7 +308,8 @@ export function useWhatsappCallSession() {
}
};
const initiateOutboundCall = async conversationId => {
// target: { conversationId } or { contactId, inboxId }
const initiateOutboundCall = async target => {
// Module-scoped lock + active-session guard so a second click — from the
// same composable instance OR a different one (header vs contact panel)
// OR while a call is already live — can't tear down the in-flight setup
@@ -320,10 +321,7 @@ export function useWhatsappCallSession() {
isInitiatingOutbound.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
if (response?.id) {
activeCallId = response.id;
// A connect webhook that raced ahead of this response was buffered;
@@ -354,7 +352,7 @@ export function useWhatsappCallSession() {
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
) {
return { status: data.status };
return { status: data.status, conversation_id: data.conversation_id };
}
throw e;
} finally {
+4
View File
@@ -7,11 +7,15 @@ export const FEATURE_FLAGS = {
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
DELAYED_AUTOMATIONS: 'delayed_automations',
API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
+2 -19
View File
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
}
};
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
// Shared with the mobile app via @chatwoot/utils.
export { extractFilenameFromUrl } from '@chatwoot/utils';
/**
* Normalizes a comma/newline separated list of domains
@@ -7,7 +7,7 @@
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
};
/**
@@ -0,0 +1,172 @@
// Powers the "unpublished changes" preview: marks what changed between the live
// article and the draft — word by word in the title, block by block in the body.
import MarkdownIt from 'markdown-it';
// Matches the public renderer (CommonMark, no typographer). True when two
// markdown strings render the same — so blank-line/spacing-only edits don't count,
// but real changes (code indentation, smart quotes, width markers) do.
const commonmark = MarkdownIt('commonmark');
export const rendersIdentically = (a, b) =>
commonmark.render(a ?? '') === commonmark.render(b ?? '');
const INS_CLASS = '!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5';
const DEL_CLASS = '!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5';
// Detailed compare gets slow on huge texts; past this, show all old as removed
// and all new as added.
const MAX_DIFF_TOKENS = 2000;
const tokenizeWords = value => (value || '').match(/\S+/g) || [];
const escapeHtml = value =>
value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Compares two lists in order and reports what's the same (`equal`), removed
// (`del`) or added (`ins`), keeping as much unchanged as possible. `keyOf` says
// how to compare items (title passes words, body passes blocks).
const diffSequence = (a, b, keyOf = item => item) => {
const n = a.length;
const m = b.length;
if (n > MAX_DIFF_TOKENS || m > MAX_DIFF_TOKENS) {
return [
...a.map(item => ({ type: 'del', item })),
...b.map(item => ({ type: 'ins', item })),
];
}
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] =
keyOf(a[i]) === keyOf(b[j])
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const ops = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (keyOf(a[i]) === keyOf(b[j])) {
ops.push({ type: 'equal', item: a[i] });
i += 1;
j += 1;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ type: 'del', item: a[i] });
i += 1;
} else {
ops.push({ type: 'ins', item: b[j] });
j += 1;
}
}
while (i < n) {
ops.push({ type: 'del', item: a[i] });
i += 1;
}
while (j < m) {
ops.push({ type: 'ins', item: b[j] });
j += 1;
}
return ops;
};
const wrapDiff = {
ins: text => `<ins class="${INS_CLASS}">${text}</ins>`,
del: text => `<del class="${DEL_CLASS}">${text}</del>`,
};
// Builds the highlighted title. Compares whole words (not single spaces) so
// repeated words/spaces don't make the highlights jump around, then rejoins
// with single spaces — a run of added/removed words shares one <ins>/<del> tag.
export const renderInlineDiff = (oldValue, newValue) => {
const ops = diffSequence(tokenizeWords(oldValue), tokenizeWords(newValue));
const segments = [];
let run = [];
let runType = null;
const flushRun = () => {
if (!run.length) return;
const text = run.map(escapeHtml).join(' ');
segments.push(wrapDiff[runType] ? wrapDiff[runType](text) : text);
run = [];
};
ops.forEach(({ type, item }) => {
if (type !== runType) flushRun();
runType = type;
run.push(item);
});
flushRun();
return segments.join(' ');
};
// A fenced code block opener: ``` or ~~~, indented up to 3 spaces (CommonMark).
const FENCE_RE = /^ {0,3}(```|~~~)/;
// A list item marker: -, *, + or "1." / "1)", indented up to 3 spaces.
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
// Split on blank lines so each paragraph, heading or list compares as one piece.
// Blank lines inside a fenced code block, or between items of the same list, are
// content — splitting there would tear a code block or list apart and render it
// with broken structure (orphaned <li>/<p>), so we keep those together.
const splitBlocks = text => {
const lines = (text || '').split('\n');
const blocks = [];
let buffer = [];
let fence = null;
let inList = false;
const flush = () => {
const block = buffer.join('\n');
if (block.trim()) blocks.push(block);
buffer = [];
inList = false;
};
lines.forEach((line, index) => {
const marker = line.match(FENCE_RE)?.[1];
if (marker && !fence) fence = marker;
else if (fence && line.trimStart().startsWith(fence)) fence = null;
if (fence) {
buffer.push(line);
return;
}
if (LIST_ITEM_RE.test(line)) inList = true;
if (line.trim() !== '') {
buffer.push(line);
return;
}
// Blank line: keep it when the current list continues on the next non-blank
// line (another item or an indented continuation); otherwise end the block.
const next = lines.slice(index + 1).find(other => other.trim() !== '');
if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
buffer.push(line);
} else {
flush();
}
});
flush();
return blocks;
};
const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
// Diffs the body block by block. Blocks match when they render to the same HTML
// (the check staging uses), so only edits that change the page show as a diff.
export const buildDiffBlocks = (oldText, newText) => {
const toBlocks = text =>
splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
};
export const hasPendingChanges = article =>
article?.draftTitle != null || article?.draftContent != null;
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
return automation;
};
export const formatDelay = minutes => {
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
if (minutes % 60 === 0) return `${minutes / 60}h`;
return `${minutes}m`;
};
export const isCustomAttribute = (attrs, key) => {
return attrs.find(attr => attr.key === key);
};
@@ -0,0 +1,19 @@
import Auth from 'dashboard/api/auth';
export const setDirectUploadAuthHeaders = xhr => {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData() || {};
if (!accessToken) return;
xhr.setRequestHeader('access-token', accessToken);
xhr.setRequestHeader('token-type', tokenType);
xhr.setRequestHeader('client', client);
xhr.setRequestHeader('expiry', expiry);
xhr.setRequestHeader('uid', uid);
};
@@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = {
value: 'customize-content',
icon: 'i-lucide-pencil',
},
selectPopularContent: {
label:
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT',
action: 'select-popular-content',
value: 'select-popular-content',
icon: 'i-lucide-sparkles',
},
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
@@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
LOCALE_MENU_ITEMS.moveToDraft,
]),
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.selectPopularContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
}
@@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
return [
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.selectPopularContent,
LOCALE_MENU_ITEMS.delete,
];
}
@@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.selectPopularContent,
LOCALE_MENU_ITEMS.delete,
];
};
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
offlineAgentsData
);
});
it('does not throw when an agent has a null name', () => {
const agents = [
{ id: 1, name: null, availability_status: 'offline' },
{ id: 2, name: 'Zoe', availability_status: 'offline' },
];
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
expect(
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
).toEqual([1, 2]);
});
});
describe('getSortedAgentsByAvailability', () => {
@@ -0,0 +1,170 @@
import {
renderInlineDiff,
buildDiffBlocks,
hasPendingChanges,
rendersIdentically,
} from '../articleDiffHelper';
describe('articleDiffHelper', () => {
describe('renderInlineDiff', () => {
it('returns the text unchanged when there is no difference', () => {
const result = renderInlineDiff('hello world', 'hello world');
expect(result).toBe('hello world');
expect(result).not.toContain('<ins');
expect(result).not.toContain('<del');
});
it('wraps inserted words in <ins>', () => {
const result = renderInlineDiff('hello', 'hello there');
expect(result).toContain('hello');
expect(result).toContain('<ins');
expect(result).toContain('there');
});
it('wraps removed words in <del>', () => {
const result = renderInlineDiff('hello there', 'hello');
expect(result).toContain('<del');
expect(result).toContain('there');
});
it('keeps a single removal contiguous when a word repeats', () => {
const result = renderInlineDiff(
'How to use Agent bots?',
'How How to Agent bots?'
);
expect(result).toBe(
'How <ins class="!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5">How</ins> to <del class="!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5">use</del> Agent bots?'
);
});
it('escapes markup when diffing plain text', () => {
const result = renderInlineDiff('a', 'a <b>');
expect(result).toContain('&lt;b&gt;');
expect(result).not.toContain('<b>');
});
it('treats a cleared empty string as a full deletion', () => {
const result = renderInlineDiff('gone', '');
expect(result).toContain('<del');
expect(result).toContain('gone');
});
});
describe('buildDiffBlocks', () => {
it('passes an unchanged block through as equal', () => {
const blocks = buildDiffBlocks('same para', 'same para');
expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
});
it('marks an appended block as added', () => {
const blocks = buildDiffBlocks('a', 'a\n\nb');
expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
expect(blocks).toContainEqual({ type: 'added', md: 'b' });
});
it('marks a deleted block as removed', () => {
const blocks = buildDiffBlocks('a\n\nb', 'a');
expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
});
it('emits the old block then the new block for a reworded section', () => {
const blocks = buildDiffBlocks('hello world', 'hello there');
expect(blocks).toEqual([
{ type: 'removed', md: 'hello world' },
{ type: 'added', md: 'hello there' },
]);
});
it('keeps a fenced code block whole when it contains blank lines', () => {
const code = '```\nline one\n\nline two\n```';
const blocks = buildDiffBlocks(code, code);
expect(blocks).toEqual([{ type: 'equal', md: code }]);
});
it('diffs an edited code block as one whole removed + added block', () => {
const live = '```\ncode line\n```';
const draft = '```\ncode line\n\nsd\n```';
const blocks = buildDiffBlocks(live, draft);
expect(blocks).toContainEqual({ type: 'removed', md: live });
expect(blocks).toContainEqual({ type: 'added', md: draft });
});
it('surfaces whitespace edits that change the rendered output', () => {
expect(
buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
block => block.type !== 'equal'
)
).toBe(true);
expect(
buildDiffBlocks('line one\nline two', 'line one \nline two').some(
block => block.type !== 'equal'
)
).toBe(true);
});
it('surfaces an indented code block turning into a paragraph', () => {
const blocks = buildDiffBlocks(
' curl example.com',
'curl example.com'
);
expect(blocks).toContainEqual({
type: 'removed',
md: ' curl example.com',
});
expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
});
it('keeps spacing the renderer ignores as equal', () => {
const blocks = buildDiffBlocks('a\nb', 'a \nb');
expect(blocks.every(block => block.type === 'equal')).toBe(true);
});
it('keeps a loose list with item descriptions as one block', () => {
const list =
'1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
const blocks = buildDiffBlocks(list, list);
expect(blocks).toEqual([{ type: 'equal', md: list }]);
});
});
describe('rendersIdentically', () => {
it('ignores blank-line / empty-paragraph differences', () => {
expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
});
it('counts code-block indentation changes', () => {
expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
});
it('counts smart vs straight quotes (no typographer)', () => {
expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
});
it('counts real text changes', () => {
expect(rendersIdentically('hello world', 'hello there')).toBe(false);
});
it('treats nullish input as empty', () => {
expect(rendersIdentically(null, '')).toBe(true);
expect(rendersIdentically(undefined, 'x')).toBe(false);
});
});
describe('hasPendingChanges', () => {
it('is true when a draft title or content is staged', () => {
expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
});
it('treats a cleared empty-string draft as a pending change', () => {
expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
});
it('is false with no draft columns', () => {
expect(hasPendingChanges({ title: 'live' })).toBe(false);
expect(hasPendingChanges({})).toBe(false);
expect(hasPendingChanges(null)).toBe(false);
});
});
});
@@ -0,0 +1,61 @@
import { setDirectUploadAuthHeaders } from '../directUploadsHelper';
import Auth from 'dashboard/api/auth';
vi.mock('dashboard/api/auth', () => ({
default: { getAuthData: vi.fn() },
}));
describe('setDirectUploadAuthHeaders', () => {
const buildXhr = () => ({ setRequestHeader: vi.fn() });
afterEach(() => {
vi.clearAllMocks();
});
it('sets the five session auth headers from the auth cookie', () => {
Auth.getAuthData.mockReturnValue({
'access-token': 'token-123',
'token-type': 'Bearer',
client: 'client-123',
expiry: '9999',
uid: 'agent@example.com',
});
const xhr = buildXhr();
setDirectUploadAuthHeaders(xhr);
expect(xhr.setRequestHeader).toHaveBeenCalledTimes(5);
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
'access-token',
'token-123'
);
expect(xhr.setRequestHeader).toHaveBeenCalledWith('token-type', 'Bearer');
expect(xhr.setRequestHeader).toHaveBeenCalledWith('client', 'client-123');
expect(xhr.setRequestHeader).toHaveBeenCalledWith('expiry', '9999');
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
'uid',
'agent@example.com'
);
});
it('does not set any header when there is no auth data', () => {
Auth.getAuthData.mockReturnValue(false);
const xhr = buildXhr();
setDirectUploadAuthHeaders(xhr);
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
});
it('does not set any header when the access token is missing', () => {
Auth.getAuthData.mockReturnValue({
client: 'client-123',
uid: 'agent@example.com',
});
const xhr = buildXhr();
setDirectUploadAuthHeaders(xhr);
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
});
});
@@ -74,26 +74,34 @@ describe('PortalHelper', () => {
});
describe('buildLocaleMenuItems', () => {
it('disables other actions but keeps customize enabled for the default locale', () => {
it('disables other actions but keeps content actions enabled for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
const customize = items.find(item => item.action === 'customize-content');
const enabledActions = ['customize-content', 'select-popular-content'];
expect(customize).toBeTruthy();
expect(customize.disabled).toBeFalsy();
enabledActions.forEach(action => {
expect(
items.find(item => item.action === action)?.disabled
).toBeFalsy();
});
expect(
items
.filter(item => item.action !== 'customize-content')
.filter(item => !enabledActions.includes(item.action))
.every(item => item.disabled)
).toBe(true);
});
it('returns publish, customize, and delete actions for draft locales', () => {
it('returns publish, customize, popular content, and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
).toEqual(['publish-locale', 'customize-content', 'delete']);
).toEqual([
'publish-locale',
'customize-content',
'select-popular-content',
'delete',
]);
});
it('returns default, draft, customize, and delete actions for live locales', () => {
@@ -106,6 +114,7 @@ describe('PortalHelper', () => {
'change-default',
'move-to-draft',
'customize-content',
'select-popular-content',
'delete',
]);
});
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
]);
});
it('should handle templates with no variables', () => {
it('should handle templates with no variables but a media header', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
const result = buildTemplateParameters(emptyTemplate);
// hasMediaHeader is derived from the template, so the document header is kept.
expect(result.body).toBeUndefined();
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
});
it('should build parameters for templates with multiple component types', () => {
@@ -1,19 +1,16 @@
// Constants
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
// Constants and pure template helpers are shared with the mobile app via
// @chatwoot/utils so the logic lives in one place.
export {
MEDIA_FORMATS,
COMPONENT_TYPES,
findComponentByType,
processVariable,
} from '@chatwoot/utils';
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
});
};
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
// The media-header flag is derived from the template inside the shared helper;
// the second argument is kept for backwards-compatible call sites.
export const buildTemplateParameters = template =>
buildWhatsAppProcessedParams(template);
+2
View File
@@ -34,6 +34,7 @@ import ta from './locale/ta';
import th from './locale/th';
import tr from './locale/tr';
import uk from './locale/uk';
import uz from './locale/uz';
import vi from './locale/vi';
import zh_CN from './locale/zh_CN';
import zh_TW from './locale/zh_TW';
@@ -77,6 +78,7 @@ export default {
th,
tr,
uk,
uz,
vi,
zh_CN,
zh_TW,
@@ -19,6 +19,8 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -49,6 +51,7 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "እውቂያ",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -79,6 +79,9 @@
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
},
"unread": {
"TEXT": "Unread Count: Highest first"
}
},
"ATTACHMENTS": {
@@ -25,6 +25,10 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -510,6 +510,7 @@
"ATTRIBUTES": "ባህሪያት",
"HISTORY": "ታሪክ",
"NOTES": "ማስታወሻዎች",
"MEDIA": "Media",
"MERGE": "አንድነት አድርግ"
},
"HISTORY": {
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "ለዚህ ትመልሳለህ፦",
"REMOVE_SELECTION": "ምርጫ አስወግድ",
"DOWNLOAD": "አውርድ",
@@ -233,6 +235,7 @@
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"IMAGE_UPLOAD_SUCCESS": "ምስል በተሳካ ሁኔታ ተሰብስቧል",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
@@ -303,6 +306,25 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"REPORT_MESSAGE": {
"LABEL": "Report message",
"TITLE": "Report Captain message",
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
"PROBLEM_TYPE": "Problem type",
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
"DESCRIPTION_LABEL": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
"SUBMIT": "Report",
"SUCCESS": "Thanks for reporting. Our team will take a look.",
"ERROR": "Could not report this message. Please try again.",
"REASONS": {
"incorrect_information": "Incorrect information",
"inappropriate_response": "Inappropriate response",
"incomplete_response": "Incomplete response",
"outdated_information": "Outdated information",
"other": "Other"
}
}
},
"SIDEBAR": {
@@ -400,7 +422,8 @@
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
"UNTITLED_FILE": "Untitled file",
"JUMP_TO_MESSAGE": "Jump to message"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -3,5 +3,33 @@
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
},
"EMOJI_ICON_PICKER": {
"TABS": {
"ICONS": "Icons",
"EMOJIS": "Emojis"
},
"SEARCH_EMOJI": "Search emoji…",
"SEARCH_ICON": "Search icons…",
"FREQUENTLY_USED": "Frequently used",
"NO_EMOJI": "No emoji match your search",
"NO_ICON": "No icons match your search",
"REMOVE": "አስወግድ",
"STYLE": {
"OUTLINE": "Outline icons",
"FILLED": "Filled icons"
},
"COLORS": {
"SLATE": "Slate",
"RED": "Red",
"ORANGE": "Orange",
"AMBER": "Amber",
"GREEN": "Green",
"TEAL": "Teal",
"BLUE": "Blue",
"INDIGO": "Indigo",
"VIOLET": "Violet",
"PINK": "Pink"
}
}
}
@@ -533,6 +533,8 @@
"PUBLISHED": "ተለቀቀ",
"ARCHIVED": "ተቀምጧል"
},
"PENDING_EDITS": "Unpublished edits",
"PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
"CATEGORY": {
"UNCATEGORISED": "ያልተደራጀ"
}
@@ -552,6 +554,7 @@
"LOCALE": {
"ALL": "ሁሉም ቋንቋዎች"
},
"SEARCH_PLACEHOLDER": "ጽሑፎችን ፈልግ...",
"NEW_ARTICLE": "አዲስ ጽሑፍ"
},
"EMPTY_STATE": {
@@ -579,6 +582,10 @@
"CATEGORY": {
"TITLE": "በዚህ ምድብ ምንም ጽሑፎች የሉም",
"SUBTITLE": "በዚህ ምድብ ያሉ ጽሑፎች እዚህ ይታያሉ"
},
"SEARCH": {
"TITLE": "No matching articles",
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
}
},
"BULK_TRANSLATE": {
@@ -611,6 +618,8 @@
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
"STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
@@ -624,6 +633,7 @@
"CATEGORY_HEADER": {
"NEW_CATEGORY": "አዲስ ምድብ",
"EDIT_CATEGORY": "ምድብ አርትዕ",
"SEARCH_PLACEHOLDER": "Search categories...",
"CATEGORIES_COUNT": "{n} ምድብ | {n} ምድቦች",
"BREADCRUMB": {
"CATEGORY_LOCALE": "ምድቦች ({localeCode})",
@@ -634,6 +644,10 @@
"TITLE": "ምድቦች አልተገኙም",
"SUBTITLE": "ካተጎሪዎች እዚህ ይታያሉ። በ'አዲስ ካተጎሪ' አዝራር ቁልፍ በመጫን ካተጎሪ ማክሰኞ ይችላሉ።."
},
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching categories",
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
},
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} ጽሑፍ | {count} ጽሑፎች"
},
@@ -691,6 +705,11 @@
"LOCALES_PAGE": {
"LOCALES_COUNT": "ምንም ቋንቋ አልተገኘም | {n} ቋንቋ | {n} ቋንቋዎች",
"NEW_LOCALE_BUTTON_TEXT": "አዲስ ቋንቋ",
"SEARCH_PLACEHOLDER": "Search locales...",
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching locales",
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
},
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} ጽሑፍ | {count} ጽሑፎች",
"CATEGORIES_COUNT": "{count} ምድብ | {count} ምድቦች",
@@ -700,9 +719,51 @@
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"SELECT_POPULAR_CONTENT": "Select recommended content",
"DELETE": "ሰርዝ"
}
},
"POPULAR_CONTENT_DIALOG": {
"TITLE": "Recommended content",
"DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.",
"SEARCH": "Search...",
"EMPTY": "No matching results",
"ADD_ANOTHER": "Add another...",
"SLOTS_LEFT": "{count} slots left",
"OVERRIDING_DEFAULTS": "Overriding defaults for this locale",
"CONFIRM": "Save recommendations",
"CATEGORIES": {
"LABEL": "Recommended categories",
"ARTICLES_COUNT": "No articles | {count} article | {count} articles"
},
"ARTICLES": {
"LABEL": "Recommended articles",
"IN_CATEGORY": "in {category}",
"UNCATEGORIZED": "ያልተደራጀ"
},
"API": {
"SUCCESS_MESSAGE": "Recommended content updated successfully",
"ERROR_MESSAGE": "Unable to update recommended content. Try again."
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "የገፅ ርዕስ"
},
"HEADER_TEXT": {
"LABEL": "የራስጌ ጽሑፍ"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "አዲስ ቋንቋ አክል",
"DESCRIPTION": "ይህ ሰነድ በሚጻፍበት ቋንቋ ይምረጡ። ይህ ወደ የትርጉም ዝርዝርዎ ይጨምራል እና በኋላ ተጨማሪ ማክሰኞ ይችላሉ።.",
@@ -730,10 +791,30 @@
},
"PREVIEW": "ቅድመ እይታ",
"PUBLISH": "ለማስታወቂያ",
"PUBLISH_CHANGES": "Publish changes",
"PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
"PUBLISH_CHANGES_ERROR": "Could not publish changes",
"SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
"DISCARD_CHANGES": "Discard changes",
"DISCARD_CHANGES_SUCCESS": "Changes discarded",
"DISCARD_CHANGES_ERROR": "Could not discard changes",
"PENDING_CHANGES": "Pending changes",
"VIEW_CHANGES": "View unpublished changes",
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"BACK_TO_ARTICLES": "ወደ ጽሑፎች ተመለስ"
},
"PENDING_CHANGES_POPOVER": {
"TITLE": "Unpublished changes",
"DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
"APPLY": "Apply changes",
"DISCARD": "Discard changes"
},
"DIFF_DIALOG": {
"TITLE": "Unpublished changes",
"DESCRIPTION": "Compare your draft against the version that's currently live.",
"TITLE_LABEL": "Title"
},
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "ተጨማሪ ባህሪያት",
"UNCATEGORIZED": "ያልተመደበ",
@@ -58,14 +58,17 @@
"ERROR_MESSAGE": "Instagram ጋር ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
"ERROR_AUTH": "Instagram ጋር ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
"NEW_INBOX_SUGGESTION": "ይህ የInstagram መለያ ቀድሞ ወደ ሌላ ኢንቦክስ ተገናኝቷል እና አሁን ወደዚህ ተለዋዋጭ ሆኗል። አዲስ መልእክቶች ሁሉ እዚህ ይታያሉ። አሮጌው ኢንቦክስ ለዚህ መለያ መልእክት ማስተላለፍ እና መቀበል አይችልም።.",
"DUPLICATE_INBOX_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ኢንቦክስ ተለዋዋጭ ሆኗል። ከዚህ ኢንቦክስ የInstagram መልእክቶችን መላክ/መቀበል አትችሉም።."
"DUPLICATE_INBOX_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ኢንቦክስ ተለዋዋጭ ሆኗል። ከዚህ ኢንቦክስ የInstagram መልእክቶችን መላክ/መቀበል አትችሉም።.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "ከTikTok ጋር ቀጥሉ",
"CONNECT_YOUR_TIKTOK_PROFILE": "TikTok መገለጫዎን ያገናኙ",
"HELP": "TikTok መገለጫዎን እንደ ቻናል ለመጨመር፣ “Continue with TikTok” በመጫን መለያዎን መረጋገጥ አለብዎት ",
"ERROR_MESSAGE": "TikTok ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
"ERROR_AUTH": "TikTok ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ"
"ERROR_AUTH": "TikTok ለመገናኘት ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ",
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
},
"TWITTER": {
"HELP": "የTwitter መገለጫዎን እንደ ቻናል ለመጨመር በ\"Sign in with Twitter\" ላይ በመጫን የTwitter መገለጫዎን መረጋገጥ አለብዎት። ",
@@ -653,6 +656,10 @@
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
},
"INBOUND": {
"LABEL": "Allow incoming calls",
"DESCRIPTION": "Let customers call this number. When turned off, incoming calls are declined automatically — agents aren't notified and no conversation is created. Agents can still place outgoing calls."
}
},
"WHATSAPP_CALLING": {
@@ -810,6 +817,15 @@
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "ይህ ኢንቦክስ በWhatsApp ተዋሰነ መመዝገቢያ ተገናኝቷል።.",
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "ይህን ኢንቦክስ ለWhatsApp ንግድ ቅንብሮች ለማዘመን መቀየር ይችላሉ።.",
"WHATSAPP_RECONFIGURE_BUTTON": "እንደገና ያቀናብሩ",
"WHATSAPP_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
"WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
"WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
"WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "የንግድ አካውንት መለያ",
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API ቁልፍ",
"WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
"WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
"WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
"WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
"WHATSAPP_CONNECT_TITLE": "ወደ WhatsApp ቢዝነስ ይገናኙ",
"WHATSAPP_CONNECT_SUBHEADER": "ለቀላል አስተዳደር ወደ WhatsApp ተዋሰነ መመዝገቢያ ይዘምኑ።.",
"WHATSAPP_CONNECT_DESCRIPTION": "ይህን ኢንቦክስ ወደ WhatsApp ንግድ ያገናኙ ለተጨማሪ ባህሪያትና ቀላል አስተዳደር።.",
@@ -827,6 +843,70 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "ከWhatsApp መልእክት አብነቶች እጅግ በእጅ ማስተካከል ለእንደገና የሚገኙ አብነቶችን ያዘምኑ።.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "አብነቶችን ያዘምኑ",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "የአብነት ማስተካከያ በተሳካ ሁኔታ ተጀምሯል። ለማዘመን ጥቂት ደቂቃዎች ሊወስድ ይችላል።.",
"WHATSAPP_MANUAL_MIGRATION": {
"BANNER": {
"TITLE": "Manual setup recommended",
"DESCRIPTION": "This inbox connects through the shared Meta app used for embedded signup, which recent Meta restrictions have affected. To avoid similar issues in the future, we recommend reconnecting it with your own Meta app.",
"START": "Start manual migration",
"GUIDE": "View guide"
},
"DIALOG": {
"EYEBROW": "WhatsApp manual migration",
"TITLE": "Reconnect WhatsApp inbox",
"CLOSE": "ዝጋ",
"ACTION_REQUIRED_TITLE": "Reconnect with your own Meta app",
"ACTION_REQUIRED_DESCRIPTION": "Inboxes connected through your own Meta app are not affected by restrictions on the shared embedded signup app. This guided flow updates the WhatsApp API connection without creating a new inbox.",
"GUIDE_LINK": "Open the manual setup guide",
"PRESERVED_TITLE": "Preserved",
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
"UPDATED_TITLE": "Updated",
"UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
"WABA_ID": "WABA ID",
"WABA_PLACEHOLDER": "Enter WABA ID",
"WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
"PHONE_NUMBER_ID": "Phone Number ID",
"PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
"PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
"DISPLAY_PHONE_NUMBER": "የስልክ ቁጥር አሳይ",
"DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
"DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
"ACCESS_TOKEN": "Permanent access token or system user token",
"ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
"TOKEN_HELP_PREFIX": "The token must include",
"TOKEN_HELP_MIDDLE": "Add",
"TOKEN_HELP_SUFFIX": "for template sync and template management.",
"MESSAGING_PERMISSION": "whatsapp_business_messaging.",
"MANAGEMENT_PERMISSION": "whatsapp_business_management",
"REVIEW_TITLE": "Review before reconnecting",
"INBOX": "Inbox",
"PHONE_NUMBER": "ስልክ ቁጥር",
"NOT_ENTERED": "Not entered",
"VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
"BACK": "Back",
"CANCEL": "Cancel",
"CONTINUE": "ቀጥል",
"REVIEW_MIGRATION": "Review migration",
"RECONNECT": "Reconnect WhatsApp inbox"
},
"STEPS": {
"BEFORE_YOU_START": {
"TITLE": "Before you start",
"DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
},
"BUSINESS_DETAILS": {
"TITLE": "Business details",
"DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
},
"ACCESS_TOKEN": {
"TITLE": "Access token",
"DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
},
"REVIEW_MIGRATION": {
"TITLE": "Review migration",
"DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
}
}
},
"WHATSAPP_CALLING_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
@@ -33,6 +33,7 @@ import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
import setNewPassword from './setNewPassword.json';
import sessionLimit from './sessionLimit.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
@@ -75,6 +76,7 @@ export default {
...resetPassword,
...search,
...setNewPassword,
...sessionLimit,
...settings,
...signup,
...sla,
@@ -31,6 +31,13 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "ተመዝግቧል የሆኑ ክስተቶች",
"LEARN_MORE": "ስለ webhooks ተጨማሪ ያውቁ",
"PAYWALL": {
"TITLE": "Webhooks are available on paid plans",
"AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
"UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "Change or cancel your plan anytime."
},
"SECRET": {
"LABEL": "ምስጢር",
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
@@ -392,6 +399,82 @@
"CAPTAIN": {
"NAME": "ካፕቴን",
"HEADER_KNOW_MORE": "ተጨማሪ ያውቁ",
"OVERVIEW": {
"HEADER": "አጠቃላይ እይታ",
"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": "Time saved",
"HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. 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."
}
},
"DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "በመጠባበቂያ ላይ ያሉ የተደጋጋሚ ጥያቄዎች",
"DOCUMENTS": "ሰነዶች"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
"DESCRIPTION": "Guides and how-tos for Captain"
},
"PLAYGROUND": {
"TITLE": "መጫወቻ ቦታ",
"DESCRIPTION": "Test this assistant's replies"
},
"BILLING": {
"TITLE": "ክፍያ",
"DESCRIPTION": "Manage credits and plan"
}
}
},
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "አገልጋዮች",
"SWITCH_ASSISTANT": "በአገልጋዮች መካከል ቀይር",
@@ -494,10 +577,6 @@
"PLACEHOLDER": "የእርዳታ ስም ያስገቡ",
"ERROR": "ስም አስፈላጊ ነው"
},
"TEMPERATURE": {
"LABEL": "የምላሽ ሙቀት",
"DESCRIPTION": "እንዴት ፈጠራዊ ወይም ገደብ ያለ መልስ እንደሚሰጥ ያስተካክሉ። ዝቅተኛ እሴቶች ትክክለኛና ተወላጅ መልሶችን ያመነታሉ፣ ከፍተኛ እሴቶች ግን በተለያዩና ፈጠራዊ መልሶች ላይ ያስተዋውቃሉ።"
},
"DESCRIPTION": {
"LABEL": "መግለጫ",
"PLACEHOLDER": "እርዳታ መግለጫ ያስገቡ",
@@ -739,6 +818,7 @@
"DOCUMENTS": {
"HEADER": "ሰነዶች",
"ADD_NEW": "አዲስ ሰነድ ፍጠር",
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
"SELECTED": "{count} ተመረጡ",
"SELECT_ALL": "ሁሉንም ይምረጡ ({count})",
"UNSELECT_ALL": "ሁሉንም አልምረጥም ({count})",
@@ -798,7 +878,27 @@
},
"RELATED_RESPONSES": {
"TITLE": "ተዛማጅ የFAQ ጥያቄዎች",
"DESCRIPTION": "እነዚህ የFAQ ጥያቄዎች ቀጥተኛ ከሰነዱ ተፈጥረዋል።"
"EMPTY": "No FAQs have been generated from this document yet."
},
"DETAILS": {
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
"SOURCE": "Source",
"GENERATED_FAQS": "Generated FAQs",
"LAST_UPDATED": "Last updated",
"NOT_AVAILABLE": "Not available",
"CONTENT_TAB": "Crawled content",
"PDF_TAB": "PDF details",
"CONTENT_TITLE": "Crawled content",
"PDF_TITLE": "PDF file",
"PDF_DESCRIPTION": "Review the original PDF source.",
"CHARACTER_COUNT": "{count} characters extracted",
"COPY_CONTENT": "ቅዳ",
"COPY_SUCCESS": "Crawled content copied to clipboard",
"COPY_ERROR": "Could not copy crawled content",
"VIEW_RAW": "View raw",
"VIEW_PREVIEW": "View preview",
"UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
"EMPTY_CONTENT": "No crawled content is available for this document yet."
},
"FORM_DESCRIPTION": "ሰነዱን እንደ የእውቀት ምንጭ ለማከማቸት የሰነዱን URL ያስገቡ እና ከዚያ ጋር ለማገናኘት እገዛ አገልጋይን ይምረጡ።",
"CREATE": {
@@ -838,7 +938,7 @@
"ERROR_MESSAGE": "ሰነዱን ለማስወገድ ስህተት አጋጥሟል፣ እባክዎን እንደገና ይሞክሩ።"
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "ተዛማጅ ምላሾችን እይ",
"VIEW_DETAILS": "ዝርዝሮችን እይ",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "ሰነድ ሰርዝ"
@@ -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": "ቀጥል",
"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": "ዝግጅተዋል",
"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} ምድብ | {count} ምድቦች",
"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": "አገናኝ",
"CONNECTED": "Connected",
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
"MORE_CHANNELS_EMAIL": "Email",
"MORE_CHANNELS_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": "አገናኝ",
"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."
}
}
}
@@ -121,6 +121,26 @@
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No results found"
},
"DRILLDOWN": {
"TITLE": "{metric} details",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations",
"RESULT_COUNT_MESSAGE": "{count} message | {count} messages",
"EMPTY": "No records found for this bar.",
"ERROR": "Could not load records. Please try again.",
"ADMIN_ONLY": "Only administrators can drill down into report records.",
"LOAD_MORE": "Load more",
"CLOSE": "Close details",
"PREVIOUS_BUCKET": "Previous bar",
"NEXT_BUCKET": "Next bar",
"UNKNOWN_CONTACT": "Unknown contact",
"UNKNOWN_INBOX": "Unknown inbox",
"UNASSIGNED_AGENT": "Unassigned",
"NO_MESSAGE_CONTENT": "No message content",
"MESSAGE_CREATED_AT": "Message created at {time}",
"EVENT_OCCURRED_AT": "Event occurred at {time}",
"INCOMING_MESSAGE": "Incoming message",
"OUTGOING_MESSAGE": "Outgoing message"
},
"PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results",
"PER_PAGE_TEMPLATE": "{size} / page"
@@ -0,0 +1,12 @@
{
"SESSION_LIMIT": {
"TITLE": "Active session limit reached",
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
"END": "End",
"END_ALL": "End all sessions",
"LOG_IN": "Log in",
"CANCEL": "Back to login",
"UNKNOWN_DEVICE": "Unknown device",
"STARTED": "Started"
}
}
@@ -86,9 +86,21 @@
"NOTE": "ለመለያዎ ተጨማሪ የደህንነት ባለስልጣናት ያስተዳድሩ።.",
"MFA_BUTTON": "ሁለት-አምስት ማረጋገጫ ያስተካክሉ"
},
"SESSIONS_SECTION": {
"TITLE": "Active Sessions",
"NOTE": "These are the devices currently logged in to your account.",
"CURRENT": "Current session",
"REVOKE": "Revoke",
"REVOKE_SUCCESS": "Session revoked successfully",
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
"LAST_ACTIVE": "Last active",
"UNKNOWN_DEVICE": "Unknown device"
},
"ACCESS_TOKEN": {
"TITLE": "የመዳረሻ ቶክን",
"NOTE": "ይህ ቶክን ከAPI በመሠረት የተደረገ አንደኛ አገናኝ ሲሆን ሊጠቀምበት ይችላል",
"PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "ቅዳ",
"RESET": "እንደገና ማስጀመር",
"CONFIRM_RESET": "እርግጠኛ ነህ?",
@@ -313,6 +325,7 @@
"ALL_COMPANIES": "ሁሉም ኩባንያዎች",
"CAPTAIN": "ካፕቴን",
"CAPTAIN_ASSISTANTS": "እርዳታ አገልጋዮች",
"CAPTAIN_OVERVIEW": "አጠቃላይ እይታ",
"CAPTAIN_DOCUMENTS": "ሰነዶች",
"CAPTAIN_RESPONSES": "ተደጋጋሚ ጥያቄዎች",
"CAPTAIN_TOOLS": "መሣሪያዎች",
@@ -328,6 +341,7 @@
"NOTIFICATIONS": "ማስታወቂያዎች",
"CANNED_RESPONSES": "ተዘጋጅቷ የሆነ መልስ",
"INTEGRATIONS": "አካባቢዎች",
"DATA": "Data",
"PROFILE_SETTINGS": "የመገለጫ ቅንብሮች",
"ACCOUNT_SETTINGS": "የመለያ ቅንብሮች",
"APPLICATIONS": "መተግበሪያዎች",
@@ -379,11 +393,124 @@
"INFO_TEXT": "ሲስተሙ እርስዎን በመተግበሪያው ወይም በዳሽቦርድ ሳትጠቀሙ ራስሰር እንዲሆኑ ያደርጉ።.",
"INFO_SHORT": "ሲስተሙ ሲሳለቅ ራስሰር እንዲሆኑ ያደርጉ።."
},
"SORT_TOOLTIP": "Sort",
"SORT_BY": "በይፋ ያደርጉ",
"SORT_GROUPS": {
"CREATED": "Sort by date of creation",
"ALPHABETICAL": "Sort in alphabetical order",
"UNREAD_COUNT": "Sort by unread count"
},
"SORT_OPTIONS": {
"CREATED_DESC": "Newest first",
"CREATED_ASC": "Oldest first",
"ALPHABETICAL_ASC": "Ascending (A - Z)",
"ALPHABETICAL_DESC": "Descending (Z - A)",
"UNREAD_COUNT_DESC": "Highest first",
"UNREAD_COUNT_ASC": "Lowest first"
},
"DOCS": "ሰነዶችን አንብቡ",
"SECURITY": "ደህንነት",
"CAPTAIN_AI": "ካፕቴን",
"CONVERSATION_WORKFLOW": "የውይይት ስርዓት"
},
"DATA_IMPORTS": {
"HEADER": "Data",
"DESCRIPTION": "Bring your existing contacts and past conversations into this account from another support tool. Each import runs in the background, so you can keep working while it finishes, track its progress, and review anything that was skipped along the way.",
"LOADING": "Fetching imports",
"DEFAULT_IMPORT_NAME": "Intercom import",
"TABS": {
"IMPORT": "አስመጣ",
"EXPORT": "ውጣ"
},
"TYPES": {
"CONTACTS": "እውቂያዎች",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"DRAWER": {
"TITLE": "New import",
"SOURCE": "Source",
"NAME": "Import name",
"NAME_PLACEHOLDER": "July Intercom migration",
"ACCESS_KEY": "Intercom access key",
"ACCESS_KEY_PLACEHOLDER": "Paste your Intercom access key",
"DATA_TYPES": "Data to import",
"VALIDATING": "Validating access key...",
"VALID_KEY": "Access key validated.",
"INVALID_KEY": "Could not validate this access key.",
"ACTIVE_IMPORT": "Wait for the active import to finish before starting another one.",
"CANCEL": "Cancel",
"IMPORT": "አስመጣ"
},
"EXPORT": {
"TITLE": "Exports are on the way",
"DESCRIPTION": "Export your contacts and conversations out of this account. This workflow is coming soon.",
"COMING_SOON": "በቅርብ ይመጣል"
},
"TABLE": {
"TITLE": "Recent imports",
"EMPTY": "No imports yet",
"EMPTY_DESCRIPTION": "Start an import to bring your existing customer history into this account.",
"NEW_IMPORT": "አስመጣ",
"COUNT": "{count} imports",
"UNNAMED": "Untitled import",
"IMPORTED_COUNT": "{count} imported",
"VIEW": "View import",
"NAME": "Name",
"TYPE": "Type",
"STATUS": "Status",
"IMPORTED": "Imported",
"CREATED": "የተፈጠረበት",
"ABANDON": "Abandon"
},
"DETAIL": {
"BACK": "Back to imports",
"ERRORS": "Errors",
"SKIP_LOGS": "Skip logs",
"SOURCE": "Source",
"IMPORT_TYPES": "Import types",
"CREATED": "የተፈጠረበት",
"DURATION": "Duration",
"INITIATED_BY": "Started by",
"PROGRESS": "Import progress",
"PROGRESS_WITH_TOTAL": "{imported} of {total} imported",
"PROGRESS_WITHOUT_TOTAL": "{imported} imported",
"PROGRESS_OF_TOTAL": "of {total} imported",
"PROGRESS_IMPORTED": "imported",
"LAST_UPDATED_TOOLTIP": "Last updated {time}",
"NO_SKIP_LOGS": "No skipped or failed records recorded.",
"DOWNLOAD_SKIP_LOGS": "Download CSV",
"DOWNLOAD_ERROR_LOGS": "Download CSV",
"ALL_SKIP_LOGS": "All",
"KIND": "Kind",
"NO_ERRORS": "No errors recorded.",
"ERROR_CODE": "Code",
"SOURCE_OBJECT": "Source object",
"MESSAGE": "Message"
},
"MONITOR": {
"LIVE": "Live updates every {seconds}s",
"LAST_UPDATED": "Last updated {time}",
"REFRESH": "Refresh",
"REFRESHING": "Refreshing",
"STAGES": {
"unknown": "Waiting for update",
"queued": "Queued",
"contacts": "Importing contacts",
"conversations": "Importing conversations",
"finalizing": "Finalizing import",
"completed": "Completed",
"completed_with_errors": "Completed with errors",
"failed": "አልተሳካም",
"abandoned": "Abandoned"
}
},
"ALERTS": {
"IMPORT_STARTED": "Intercom import has started.",
"IMPORT_ABANDONED": "Intercom import has been abandoned.",
"IMPORT_FAILED": "Could not start the Intercom import."
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "የCaptain ቅንብሮች",
"DESCRIPTION": "ለCaptain የAI ሞዴሎችና ባለስልጣናት ያስተካክሉ። Captain በክሬዲት መሠረት ክፍያ ያደርጋል፣ ለእያንዳንዱ እርምጃ በሞዴሉ መሠረት ክሬዲቶች ይከፈላሉ።.",
@@ -414,7 +541,9 @@
"DESCRIPTION": "የAI ኃይል ያላቸውን ባለስልጣናት አብራሪዎችን አንቀሳቅሱ ወይም ዝጋ።.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "የድምጽ ትርጉም",
"DESCRIPTION": "የድምፅ መልእክቶችና የጥሪ መዝገቦችን በራስሰር ወደ ሊቀ መለኪያ ጽሑፍ ይቀይሩ።."
"DESCRIPTION": "የድምፅ መልእክቶችና የጥሪ መዝገቦችን በራስሰር ወደ ሊቀ መለኪያ ጽሑፍ ይቀይሩ።.",
"MODEL_TITLE": "Audio Transcription Model",
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
},
"HELP_CENTER_SEARCH": {
"TITLE": "የእርዳታ ማዕከል ፍለጋ መደበኛ እና መረጃ ማውጫ",
@@ -439,7 +568,18 @@
"TITLE": "የአሁኑ እቅድ",
"PLAN_NOTE": "አሁን በ**{plan}** እቅድ እና በ**{quantity}** ፈቃዶች ተመዝግበዋል",
"SEAT_COUNT": "የቦታ ብዛት",
"RENEWS_ON": "በዚህ ቀን ይደገፋል"
"RENEWS_ON": "በዚህ ቀን ይደገፋል",
"CURRENCY": "Currency"
},
"CURRENCY": {
"SELECT": {
"TITLE": "Choose your billing currency",
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
},
"OPTIONS": {
"USD": "US Dollar (USD)",
"BRL": "Brazilian Real (BRL)"
}
},
"VIEW_PRICING": "ዋጋዎችን እይ",
"MANAGE_SUBSCRIPTION": {
@@ -475,6 +615,7 @@
"PURCHASE": "ክሬዲቶችን ግዛ",
"LOADING": "አማራጮች እየጫኑ ነው...",
"FETCH_ERROR": "የክሬዲት አማራጮችን ማስገባት አልተሳካም። እባክዎ እንደገና ይሞክሩ።.",
"RETRY": "Retry",
"PURCHASE_ERROR": "ግዢውን ማካሄድ አልተሳካም። እባክዎ እንደገና ይሞክሩ።.",
"PURCHASE_SUCCESS": "በተሳካ ሁኔታ {credits} ክሬዲቶች ወደ አካውንትዎ ተጨምሯል",
"CONFIRM": {
@@ -776,6 +917,10 @@
"INPUT_MAX": "ከፍተኛ ያድርጉ",
"DURATION": "በእያንዳንዱ የወኪል ውይይቶች በ"
},
"EXCLUDE_OLDER_THAN": {
"LABEL": "Skip stale conversations",
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
},
"INBOXES": {
"LABEL": "ተጨማሪ የተላከ ሳጥኖች",
"DESCRIPTION": "ለዚህ ፖሊሲ የሚሰሩ ኢንቦክሶችን ያክሉ።.",
@@ -19,6 +19,8 @@
"OR": "أو"
},
"INPUT_PLACEHOLDER": "أدخل القيمة",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "يساوي",
"not_equal_to": "لا يساوي",
@@ -49,6 +51,7 @@
"ASSIGNEE_NAME": "اسم المكلَّف",
"INBOX_NAME": "اسم صندوق الوارد",
"TEAM_NAME": "اسم الفريق",
"CONTACT": "جهات الاتصال",
"CONVERSATION_IDENTIFIER": "معرف المحادثة",
"CAMPAIGN_NAME": "اسم الحملة",
"LABELS": "الوسوم",
@@ -79,6 +79,9 @@
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
},
"unread": {
"TEXT": "Unread Count: Highest first"
}
},
"ATTACHMENTS": {

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