Compare commits

...
Author SHA1 Message Date
Shivam Mishra 22169f0915 feat: use captain key for HC embedding 2026-05-21 20:15:32 +05:30
Tanmay Deep SharmaandGitHub b1db6c3e9b fix: make zadd function optimised to stay in rubocop limits (#14520)
## Description
Fixes rubocop for alfred.rb file on develop 

## Type of change

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

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-21 17:24:51 +05:30
Shivam MishraandGitHub 3d20a7b049 feat: generate Help Center for Onboarding (#14370)
## Manually triggering help center generation

Open a Rails console (`bundle exec rails console`):

```ruby
account = Account.find(<ACCOUNT_ID>)
user    = account.users.first

# Optional: refresh brand info from the customer's website
domain = 'example.com'
result = WebsiteBrandingService.new("noreply@#{domain}").perform
account.update!(
  name: result[:title].presence || account.name,
  custom_attributes: account.custom_attributes.merge('website' => domain, 'brand_info' => result)
)

# Optional: wipe existing portals so a fresh one is created
account.portals.destroy_all

Onboarding::HelpCenterCreationService.new(account, user).perform
```

Sidekiq must be running — articles are written by
`Onboarding::HelpCenterArticleGenerationJob`. Avoid running on
production; generation calls the LLM provider.


### Generation flow (Happy Path) 

```mermaid
sequenceDiagram
    autonumber

    participant Kickoff as HelpCenterCreationService
    participant DB as DB
    participant GenJob as HelpCenterArticleGenerationJob
    participant Curator as HelpCenterCurator
    participant Firecrawl as Firecrawl
    participant CuratorLLM as Curation LLM
    participant Redis as Redis Progress
    participant WriterJob as HelpCenterArticleWriterJob
    participant Builder as HelpCenterArticleBuilder
    participant WriterLLM as Writer LLM
    participant Cable as ActionCable

    Kickoff->>DB: Create portal for account<br/>homepage_link=https://chatwoot.com
    Kickoff->>DB: Attach brand logo if available
    Kickoff->>GenJob: Enqueue generation job<br/>account_id, portal_id, user_id, generation_id

    GenJob->>Curator: Curate help center plan
    Curator->>Firecrawl: map https://chatwoot.com<br/>search: docs help support faq
    Firecrawl-->>Curator: Return discovered links
    Curator->>CuratorLLM: Select categories + article plans<br/>from discovered links only
    CuratorLLM-->>Curator: Return categories, articles, allowed_urls

    GenJob->>DB: Create portal categories
    GenJob->>GenJob: Stamp articles with category_id
    GenJob->>GenJob: Filter article URLs against allowed_urls
    GenJob->>GenJob: Drop articles with no category<br/>or no approved source URLs

    GenJob->>Redis: Start progress<br/>status=generating, total=N, finished=0

    loop For each approved article
      GenJob->>WriterJob: Enqueue writer job<br/>title, category_id, approved URLs
    end

    par Writer jobs run independently
      WriterJob->>Builder: Build article from approved URLs
      Builder->>Firecrawl: batch_scrape approved URLs
      Firecrawl-->>Builder: Return Markdown source pages
      Builder->>WriterLLM: Rewrite sources into one article
      WriterLLM-->>Builder: Return title, description, Markdown content
      Builder->>DB: Create draft portal article<br/>meta.source_urls
      WriterJob->>Redis: Increment finished count
      WriterJob->>Cable: Broadcast help_center.article_generated
    end

    WriterJob->>Redis: If finished >= total<br/>mark status=completed
    WriterJob->>Cable: Broadcast help_center.generation_completed
```

### Redis State Management

```mermaid
 stateDiagram-v2
    [*] --> active_pointer_set
    active_pointer_set --> generating: generation job creates valid plan
    active_pointer_set --> skipped: curation skipped/failed

    generating --> generating: each writer job increments finished
    generating --> completed: finished == total
    generating --> ignored_completion: generation_id superseded

    skipped --> [*]
    completed --> [*]
    ignored_completion --> [*]
```
2026-05-21 16:25:01 +05:30
Tanmay Deep SharmaandGitHub 3cd8cf43ce fix: atomically claim conversation to prevent duplicate assignment (#14495)
## Description

Fixes a bug under Assignment V2 where a single conversation could be
reassigned dozens of times in a row by the system, producing long stacks
of "Assigned to X by Automation System via <policy>" activity messages
alternating between agents. After this change each unassigned
conversation is assigned exactly once, even on busy inboxes.

## Fixes # (issue)


## Type of change

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

## How Has This Been Tested?

## How to reproduce
1. Enable `assignment_v2` on an account with at least 2 online agents in
an inbox.
2. Generate sustained resolve/snooze activity in the inbox (each one
enqueues `AutoAssignment::AssignmentJob` for the whole inbox).
3. Watch any one unassigned conversation while the jobs drain — pre-fix
it picks up multiple back-to-back "Assigned to …" activity rows
alternating between agents.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-21 16:14:28 +05:30
f33e469e9a feat: Unread Count: Frontend changes for showing unread count badges (3/3)[CW-6851] (#14372)
# Pull Request Template

## Description
This is the third and final PR in a series of PRs for Introducing unread
counts in the sidebar for inboxes and labels.

In this PR:
* Added frontend changes to show the badges for unread counts for
Inboxes and Labels
* Added specs for the changes

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts



## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Tested this locally. Cases to test:
* Send a message from the widget and see if the count changes
* Mark a conversation as unread and see the count change for inbox
* Open an unread conversation as agent and see the count go down
* Add a label to an unread conversation from sidebar right click action
without opening the conversation and see the count of un-reads on the
label change

Added the screenshot of how it will look like

<img width="614" height="990" alt="Screenshot 2026-05-05 at 7 00 11 PM"
src="https://github.com/user-attachments/assets/99fbaa9f-bcf2-4d8d-86e2-5727f652a9dd"
/>



## Checklist:

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 19:21:25 +05:30
27f2c2b392 feat: Unread Count: added api, store refresher, invalidation and events (2/3)[CW-6851] (#14369)
# Pull Request Template

## Description

This is the second PR in a series of PRs for Introducing unread counts
in the sidebar for inboxes and labels.

In this PR:

* added api for unread counts 
* Added the store refresher and invalidation with event listeners
* Added action cable event
* Added specs for the changes

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## Checklist:

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 17:36:09 +05:30
40deaef458 feat: Store WhatsApp BSUID identifiers from inbound webhooks (#14436)
Adds storage support for WhatsApp business-scoped user identifiers
received from Meta Cloud API and Twilio WhatsApp webhooks. The change
keeps existing phone-based behavior intact, stores BSUID and parent
BSUID values as additional `contact_inboxes.source_id` rows for the same
contact, and allows BSUID-only inbound messages to create contacts,
conversations, and messages without requiring a phone number.

Related: https://github.com/chatwoot/chatwoot/issues/13837

**What changed**
- Extended WhatsApp source ID validation to accept regular BSUID and
parent BSUID formats.
- For Meta Cloud API, stores phone, `user_id`, and `parent_user_id`
identifiers as contact inbox source IDs when they are present.
- For Twilio WhatsApp, stores phone, `ExternalUserId`, and
`ParentExternalUserId` identifiers as contact inbox source IDs while
preserving the existing `whatsapp:` Twilio source ID shape.
- Supports BSUID-only inbound messages by creating a contact, contact
inbox, conversation, and message even when the phone number is missing.
- Links phone-first and later BSUID-only messages to the same contact
when the first payload contains both phone and BSUID.
- Stores WhatsApp usernames in contact `additional_attributes`, matching
existing social channel patterns.
- Keeps existing phone-based outbound and new-conversation behavior
unchanged for this milestone.

**How to test**
1. Send a Meta Cloud webhook payload with both `wa_id` and `user_id`.
2. Verify Chatwoot creates or finds the phone `contact_inbox` and also
creates a BSUID `contact_inbox` for the same contact.
3. Send a later Meta Cloud payload for the same user with only `user_id`
/ `from_user_id`.
4. Verify Chatwoot finds the BSUID `contact_inbox` and creates the
inbound message without requiring a phone number.
5. Send a Twilio WhatsApp webhook with `From: whatsapp:+E164`,
`ExternalUserId`, and optionally `ParentExternalUserId`.
6. Verify Chatwoot stores the Twilio phone and BSUID identifiers as
`whatsapp:`-prefixed source IDs for the same contact.
7. Send a Twilio WhatsApp webhook where `From` is `whatsapp:<BSUID>` and
there is no phone number.
8. Verify Chatwoot creates the contact, contact inbox, conversation, and
message without a phone number.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-05-20 13:36:43 +04:00
3fae800936 feat: base layer for unread counts (store, counter and builder) (1/3)[CW-6851] (#14368)
## Description

This is the first PR in a series of PRs for Introducing unread counts in
the sidebar for inboxes and labels.

In this PR:

* Added the unread store, counter and builder modules
* Added redis keys for unread count management
* Added specs for all 3 modules, some specs are for testing enterprise
only feature like specific roles and permissions which are added in the
respective enterprise folder itself.

**Note**
None of this changes affect anything else and nothing is wired to
existing modules.

Issue:
https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

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


## Checklist:

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

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-20 14:26:21 +05:30
1913ccadfa fix: [CW-7141] fix gem audit issue for sidekiq-cron and devise (#14497)
# Pull Request Template

## Description
* sidekiq-cron upgraded to 2.4.0
* Sidekiq constrained to stay on 7.3.x
* Devise advisory ignored in .bundler-audit.yml with the reason:
Chatwoot does not enable Timeoutable, so the timeout redirect path is
not reachable


### Details
The sidekiq-cron upgrade is from 1.12.0 to 2.4.0.

What changed that matters for us:

Fixes the reported Sidekiq Web UI reflected XSS in 2.4.0.
Adds namespace handling changes from the 2.x series. Chatwoot does not
use custom cron namespaces in config/schedule.yml, so the migration
guide says no action is needed for our usage.
Drops support for old Sidekiq/Redis versions. We are still on Sidekiq
7.3.1, which is supported.
Adds new dependencies: cronex and unicode.
Keeps the same APIs we use: Sidekiq::Cron::Job.load_from_hash!(schedule,
source: 'schedule'), Sidekiq::Cron::Job.destroy(name), and require
'sidekiq/cron/web' still exist.
Chance of breakage: low, based on the current Chatwoot usage.

The main thing I would watch after deploy is scheduled job registration
in Sidekiq. The one subtle area is namespace behavior: if production has
custom, manually-created cron jobs using non-default namespaces,
load_from_hash! cleanup behavior could matter. For the committed
config/schedule.yml jobs, which do not specify namespaces, they should
continue in the default namespace.

For concerns around Devise, it does not look exploitable in current
Chatwoot, because Chatwoot does not enable Devise :timeoutable.
I checked:
app/models/user.rb (line 59) lists the Devise modules, and :timeoutable
is not included.
config/initializers/devise.rb (line 164) has the timeoutable section,
but config.timeout_in is commented out.
SuperAdmin inherits from User, so it does not add a separate timeoutable
path either.
So from a practical security perspective: the vulnerable redirect path
requires warden_message == :timeout, which is only produced by Devise
Timeoutable. Since Chatwoot does not use Timeoutable, this warning is
not currently reachable.
Is the patch really needed? Strictly for current exploitability: no.

Fixes #CW-7141

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Spec and lints and change-log checks with codex.


## 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: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-19 20:39:40 +05:30
Sivin VargheseandGitHub bca95efb82 feat: add image resize support in articles (#14293) 2026-05-19 19:34:43 +05:30
6560dbb68d feat: Add an option on the dashboard to allow switching help center layout (#14491)
<img width="633" height="431" alt="Screenshot 2026-05-18 at 12 32 55 PM"
src="https://github.com/user-attachments/assets/682d4c5f-4c76-465b-8d2f-92fbc2bb2a40"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-05-19 06:42:48 -07:00
Sivin VargheseandGitHub 497d34c097 fix: render markdown in CSAT survey messages (#14468) 2026-05-19 10:27:14 +05:30
4371793741 fix: captain-v2 cannot see image attachments shared via email (#14449)
## Description

Inbound email attachments are stored with `file_type: 'file'` regardless
of their actual MIME type. As a result, image screenshots shared by
customers via email are not exposed to Captain V2's multimodal pipeline
— `Captain::OpenAiMessageBuilderService#attachment_parts` selects images
via `attachments.where(file_type: :image)` and emits a placeholder
`"User has shared an attachment"` text part instead of an `image_url`
part. The model never gets the image, so Captain keeps asking the
customer to retype information that is already visible in the
screenshot.

This PR makes the email mailbox derive `file_type` from the blob's
`content_type` using the existing shared `FileTypeHelper`, matching how
every other inbound channel (`twilio`, `sms`, `telegram`, `line`,
`tiktok`, `twitter`, `messenger`) and `MessageBuilder` already classify
attachments.

Fixes #14448

## Type of change

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

## How Has This Been Tested?

Reproduced and verified on a self-hosted production instance:

1. Real customer reply via email with a PNG screenshot of an in-app
error.

   Before:
   ```ruby
   a = Message.find(<id>).attachments.first
   a.file_type                # => "file"
   a.file.blob.content_type   # => "image/png"
Captain::OpenAiMessageBuilderService.new(message:
a.message).generate_content
   # => [{type: 'text', text: '...'},
# {type: 'text', text: 'User has shared an attachment'}]  no image_url
   ```
Captain reply: "Please copy and paste the full error text…" (model never
saw the image).

2. After the patch + force-recreate, same conversation:
   ```ruby
   a.file_type                # => "image"
Captain::OpenAiMessageBuilderService.new(message:
a.message).generate_content
   # => [{type: 'text',      text: '...'},
# {type: 'image_url', image_url: {url: 'https://.../<blob>.png'}}] 
   ```
Captain reply now correctly references the on-screen error text from the
screenshot via the multimodal vision path — no more deflection.

3. Regression sanity-check on non-image attachments (PDF / Office docs):
`file_type` falls through to `:file`, behavior unchanged.

## Notes for self-hosted operators

Existing email image attachments in the DB will still have `file_type:
'file'`. A one-shot backfill is straightforward and safe (no data loss,
only metadata):

```ruby
Attachment.joins(message: :conversation)
          .where(messages: { content_type: 'incoming_email' })
          .where(file_type: 'file')
          .find_each do |a|
  next unless a.file.attached?
  ct = a.file.blob.content_type.to_s
  next unless ct.start_with?('image/', 'audio/', 'video/')
  new_type = ct.start_with?('image/') ? :image : (ct.start_with?('video/') ? :video : :audio)
  a.update_columns(file_type: Attachment.file_types[new_type])
end
```

## 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
- [ ] I have added tests that prove my fix is effective — happy to add a
`mailbox_helper_spec` example for `process_regular_attachments` if
maintainers prefer; existing specs in that file focus on inline-image
handling.

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-05-19 10:11:32 +05:30
64585faff0 feat: Add a documentation layout design for public help center portal (#14403)
https://github.com/user-attachments/assets/fc4d15f9-2b54-4627-940f-94772ec739b1

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-05-18 12:30:08 -07:00
Sony Mathew e05008e0a4 Merge branch 'release/4.14.0' into develop 2026-05-18 21:55:01 +05:30
Sony Mathew 92f8c13ce5 Bump version to 4.14.0 2026-05-18 21:53:43 +05:30
c4089f2226 fix(csat): Require confirmation before submitting rating (#14450)
Customers reported that the CSAT survey was recording their rating the
instant they tapped a star — leaving no chance to correct an accidental
pick. This change lets the customer freely change their selection until
they actually submit the comment/feedback. The rating still saves on
click (so we don't lose ratings when a customer never types a comment),
but it stays editable until the comment form is submitted. Once that
happens, the rating locks.

The flow on both surfaces:

- Customer taps a star/emoji → rating is saved.
- Customer taps a different star/emoji → previous save is overwritten
with the new value.
- Customer types a comment and submits → latest rating + comment are
saved together.
- After that submit, the rating is locked and can't be changed.

Two surfaces are updated:

- **Standalone survey page** (`/survey/responses/:uuid`) — the rating
buttons remain re-tappable until the Feedback form is submitted; once
submitted, both rating and feedback lock.
- **In-conversation widget CSAT** — same behavior, the inline
arrow-submit button on the feedback form is the locking action.

In-flight guards prevent a race where the customer changes their pick
mid-network-call (raised by the codex review on the earlier revision):
while a save is in flight, the rating controls are temporarily disabled
so the request and the displayed selection can't diverge.

## Closes

-
https://linear.app/chatwoot/issue/CW-7061/csat-star-rating-submits-on-first-click-needs-confirmation-step

## How to test

**Standalone survey page**
1. Enable CSAT on any inbox (Settings → Inboxes → Configuration → CSAT
survey).
2. Resolve a conversation in that inbox so a CSAT message is generated.
3. Open the survey URL:
`{FRONTEND_URL}/survey/responses/{conversation.uuid}` (easiest: `bundle
exec rails runner 'puts Conversation.joins(:messages).where(messages: {
content_type: "input_csat" }).last.csat_survey_link'`).
4. Tap a star/emoji — confirm the rating saves (Network panel shows a
PUT to `/public/api/v1/csat_survey/{uuid}`).
5. Tap a different star/emoji — confirm a second PUT goes out with the
new value; the latest selection is reflected.
6. Type a comment and hit Submit feedback — confirm rating + feedback
persist; both controls now lock.
7. Reload the page — the locked state is rehydrated correctly.

**Widget CSAT**
1. From an inbox with CSAT enabled, resolve a conversation that has an
active widget session.
2. In the widget, the CSAT card appears with stars/emojis + the inline
comment box.
3. Tap a star/emoji — confirm a PATCH goes out and the selection visibly
updates.
4. Tap different stars/emojis — confirm each overrides the previous
save.
5. Type a comment and click the arrow — rating + comment submit
together; stars lock.

Both display types (emoji and 5-star) should behave consistently.

## What changed

- `app/javascript/survey/views/Response.vue` — `selectRating()` saves on
every tap and short-circuits while a save is in flight (or after
feedback was submitted). Rating components are disabled by
`isFeedbackSubmitted || isUpdating` so the lock follows the feedback
submission, not the first rating tap.
- `app/javascript/survey/components/Rating.vue` — new `isDisabled` prop.
The disabled / hover styling and click guard key off it instead of the
presence of `selectedRating`, so emojis stay re-clickable until the
feedback step locks them.
- `app/javascript/shared/components/CustomerSatisfaction.vue` — same
shape for the widget: rating click auto-submits and re-clicks override
the previous save; controls disabled while a submit is in flight;
emoji-button styling and the inline `StarRating` lock on
`isFeedbackSubmitted || isUpdating`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-18 21:46:30 +05:30
Sivin VargheseandGitHub bcb66cdcc0 chore: Support creating articles from category view (#14406)
# Pull Request Template

## Description

This PR adds support for creating articles directly from the category
view. Previously, articles could only be created from the main articles
page. With this change, users can now create a new article while
browsing a specific category, making the workflow faster and more
convenient.

Fixes
https://linear.app/chatwoot/issue/CW-7050/create-an-article-when-inside-a-category

## Type of change

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

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/e5a72a85-676e-4747-948a-6b1a19d2089f




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-05-18 18:09:53 +05:30
7f0d5caca4 chore: Update translations (#14276)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-18 17:31:05 +05:30
1d2f3e86dd feat(companies): track company last activity (#14435)
Tracks company recency from linked contact activity so the Companies
list and detail page can show/sort by real customer engagement instead
of generic record updates.

## Closes

None.

## Why

Company recency should reflect activity from people associated with the
company. This keeps the signal tied to persisted contact activity,
without treating passive online presence or widget heartbeat pings as
company activity.

## What Changed

- Adds a company helper to record `last_activity_at` from linked contact
activity.
- Rolls up `Contact#last_activity_at` changes to the associated company.
- Initializes company activity when an already-active contact is
associated with a company, including the business-email auto-association
path.
- Throttles company activity rollups to once every 5 minutes per company
to avoid unnecessary writes during active conversations.
- Treats company activity as monotonic: unlinking, moving, or deleting
contacts does not move a company's activity timestamp backwards.
- Leaves historical backfill, online presence tracking, widget visit
tracking, and richer activity attribution out of scope.

## How to Test

1. Open an account with Companies enabled and visit the Companies list.
2. Trigger activity for a contact that belongs to a company, for example
by receiving or sending a message in that contact's conversation.
3. Confirm the linked company shows a recent activity timestamp in the
Companies list/detail page after the contact activity updates.
4. Associate an already-active contact with a company and confirm the
company receives that contact's existing activity timestamp.
5. Confirm repeated contact activity within a short window does not
continuously rewrite the company timestamp.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-05-18 15:01:05 +05:30
3253e863ed fix: validate OpenAI hook credentials (#14068)
# Pull Request Template

## Description

- Validates openai key while configuring hooks
- added backfill logic

Fixes # (issue)

## Type of change

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


## How Has This Been Tested?

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

<img width="1710" height="1234" alt="CleanShot 2026-04-15 at 16 15
02@2x"
src="https://github.com/user-attachments/assets/3d319fe0-19f9-4fd0-9308-74987daac2e1"
/>

<img width="2884" height="1136" alt="CleanShot 2026-05-11 at 19 22
53@2x"
src="https://github.com/user-attachments/assets/5eae8650-985b-4c4a-af42-35f7175ff52d"
/>



## Checklist:

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

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-05-18 14:08:57 +05:30
Aakash BakhleandGitHub 059d840272 feat: Refresh llm settings when superadmin configs change [AI-151] (#14388)
# Pull Request Template

## Description

fixes:
https://linear.app/chatwoot/issue/AI-151/captains-super-admin-config-dont-get-applied-into-rails-without

## Type of change

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


## How Has This Been Tested?

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

To test locally: 
go to super admin -> settings -> captain -> Change endpoint to something
incorrect
go to local app -> captain -> playground -> try chatting (should fail
due to incorrect endpoint)

now in super admin captain settings, set the correct endpoint then chat
in playground. Now it should work.

Current develop code doesn't reflect the changes in installation config
for captain instantly, needs a server restart.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-05-18 14:08:26 +05:30
b8deb89613 fix: make SAML callback session independent (#14467)
This PR makes SAML login independent of Rails session cookies

## Problem

The normal SAML login flow should be straightforward:

- User opens Chatwoot.
- Chatwoot creates `_chatwoot_session`.
- User starts SSO.
- Chatwoot redirects the browser to the SAML provider.
- The provider authenticates the user.
- The provider sends the browser back to Chatwoot's ACS URL.
- Chatwoot reads the SAML response, finds or creates the user, and logs
them in.

The fragile step is the ACS callback. Most SSO flows return to the app
through browser redirects where cookies usually pass through as
expected. **ADFS commonly returns the SAML response with a cross-site
POST**. With Chatwoot's session cookie using `SameSite=Lax`, browsers
may not send `_chatwoot_session` on that POST.

SAML validation itself does not need the old Rails session cookie. The
problem was our callback handoff after validation. DeviseTokenAuth
stores the verified OmniAuth payload in Rails session, then redirects to
a second callback route. If the browser does not preserve that session,
Chatwoot has already received a valid SAML response but can no longer
finish login.

## Solution

This PR removes the session-backed handoff for SAML only:

- The SAML callback completes login in the same request where OmniAuth
validates the SAML response.
- Chatwoot reads the verified auth payload directly from
`request.env['omniauth.auth']`.
- Account context and RelayState come from callback params or OmniAuth
env data, not Rails session.
- Other OmniAuth providers continue using the existing DeviseTokenAuth
flow.
- Mobile SAML still works when the IdP returns `RelayState=mobile`; the
callback redirects to the mobile deep link with the generated SSO token.

The previous SAML override used `303 See Other` to avoid replaying the
SAML POST into the second callback route. This change keeps that intent,
but removes the second callback route for SAML entirely.

## Screen recording

### SP Initiated

https://github.com/user-attachments/assets/b0735e93-3864-4cc3-b6fc-419fff4b549e

### IDP Initiated

https://github.com/user-attachments/assets/3ded0246-933c-4c85-9b7c-fa15fdc34883

## Testing

Manual validation:

- Complete a SAML login.
- In the browser network trace, find the IdP POST to
`/omniauth/saml/callback?account_id=<account-id>`.
- Confirm it redirects directly to `/app/login?...sso_auth_token=...`
for web login.
- For mobile, confirm `RelayState=mobile` redirects to the configured
mobile deep link.
- Confirm there is no intermediate `/auth/saml/callback` request.

Testing with mocksaml.com:

- Configure Chatwoot with a public `FRONTEND_URL`.
- Set the mocksaml ACS URL to:

```text
https://<chatwoot-host>/omniauth/saml/callback?account_id=<account-id>
```

- Set the mocksaml audience/SP entity ID to the value shown in Chatwoot
SAML settings, usually:

```text
https://<chatwoot-host>/saml/sp/<account-id>
```

- Use an email returned by mocksaml that exists in the SAML-enabled
account.
- Start login from Chatwoot's SSO login page.
- Confirm the callback redirects directly to the app login URL with an
SSO token.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-05-18 12:52:45 +05:30
Shivam MishraandGitHub ef27e571f7 feat: enable quoted reply for everyone (#14469)
Quoted email replies is now available to every account by default.
Previously this was gated behind the `quoted_email_reply` account-level
feature flag, so accounts needed it toggled on (via Super Admin) before
agents saw the toggle in the reply box.

## How to test

1. Open any conversation on an email inbox.
2. Confirm the **Quote previous email** toggle is visible in the reply
box (and is **not** visible on private notes or non-email channels).
3. Toggle it on, type a reply, and send — the outbound email should
include the quoted prior email below your message.
4. Toggle it off and send another reply — the quoted block should not
appear.
5. The toggle preference should persist per channel type (UI setting),
as before.
6. Verify the toggle works on a brand new account with no feature flags
flipped on (previously it would have been hidden).

## What changed

- Removed all `isFeatureEnabledonAccount(..., QUOTED_EMAIL_REPLY)` gates
from `ReplyBox.vue`, so the toggle and quoted-content behavior are
unconditional on email channels.
- Removed the `QUOTED_EMAIL_REPLY` constant from
`dashboard/featureFlags.js`.
- Marked the flag as `deprecated: true` in `config/features.yml` (kept
the entry in place to preserve FlagShihTzu bit positions on existing
accounts; `deprecated: true` hides it from the Super Admin UI).
- Dropped the now-unnecessary
`account.enable_features('quoted_email_reply')` setup from the message
builder spec.
2026-05-15 10:59:48 -07:00
1087 changed files with 25757 additions and 1189 deletions
+3
View File
@@ -2,6 +2,9 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
+4 -2
View File
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.1'
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
gem 'sidekiq-cron', '>= 2.4.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+12 -5
View File
@@ -196,6 +196,9 @@ GEM
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -336,6 +339,7 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -902,10 +906,11 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6)
sidekiq (>= 6.5.0)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -979,6 +984,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1074,6 +1080,7 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -1155,8 +1162,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
+1 -1
View File
@@ -1 +1 @@
4.13.0
4.14.0
@@ -0,0 +1,16 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -162,6 +162,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end
def should_update_last_seen?
@@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
)
end
@@ -1,9 +1,11 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
before_action :set_article, only: [:show, :show_markdown]
layout 'portal'
def index
@@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
@parsed_content = render_article_content(@article.content.to_s)
end
def show_markdown
return head :not_found unless @article&.published?
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
end
def tracking_pixel
@@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale
after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private
def show_plain_layout
@@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def set_portal_layout
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
@@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
article_locale = if article.category.present?
article.category.locale
else
article.portal.default_locale
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@@ -1,12 +1,18 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def index
@categories = @portal.categories.order(position: :asc)
respond_to do |format|
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
format.json { @categories = @portal.categories.order(position: :asc) }
end
end
def show
@@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end
@@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
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' }
layout 'portal'
def show
@@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
redirect_to super_admin_settings_path, flash: success_flash
end
end
@@ -58,6 +58,21 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,6 +25,29 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -42,6 +65,20 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
@@ -31,7 +31,11 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
)
end
end
+1
View File
@@ -17,6 +17,7 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
+1
View File
@@ -17,6 +17,7 @@ module FileTypeHelper
def image_file?(content_type)
[
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
+11
View File
@@ -96,4 +96,15 @@ module PortalHelper
colors[username.length % colors.size]
end
def format_authors_label(authors)
return if authors.blank?
names = authors.map(&:available_name)
return names.to_sentence if names.size <= 3
I18n.t('public_portal.sidebar.authors_others',
names: names.first(2).join(', '),
count: authors.size - 2)
end
end
@@ -13,6 +13,10 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
getUnreadCounts() {
return axios.get(`${this.url}/unread_counts`);
}
}
export default new ConversationApi();
@@ -11,6 +11,7 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -47,5 +48,12 @@ describe('#ConversationApi', () => {
}
);
});
it('#getUnreadCounts', () => {
conversationsAPI.getUnreadCounts();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
});
});
});
@@ -84,28 +84,20 @@ const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (selectedCategoryId.value) {
return (
categories.value?.find(c => c.id === selectedCategoryId.value) || null
);
}
if (categorySlugFromRoute.value) {
const categoryFromSlug = assignCategoryFromSlug(
const categoryFromSlug = findCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
return categories.value?.[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
@@ -168,7 +168,9 @@ const handlePageChange = page => emit('pageChange', page);
const navigateToNewArticlePage = () => {
const { categorySlug, locale } = route.params;
router.push({
name: 'portals_articles_new',
name: props.isCategoryArticles
? 'portals_categories_articles_new'
: 'portals_articles_new',
params: { categorySlug, locale },
});
};
@@ -274,6 +276,7 @@ watch(
:categories="categories"
:allowed-locales="allowedLocales"
:has-selected-category="isCategoryArticles"
@new-article="navigateToNewArticlePage"
/>
</div>
</template>
@@ -25,7 +25,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['localeChange']);
const emit = defineEmits(['localeChange', 'newArticle']);
const route = useRoute();
const router = useRouter();
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
/>
</OnClickOutside>
</div>
<div v-else class="relative">
<div v-else class="relative flex items-center gap-2">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
@@ -196,6 +196,12 @@ const handleBreadcrumbClick = () => {
@close="isEditCategoryDialogOpen = false"
/>
</OnClickOutside>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="emit('newArticle')"
/>
</div>
</div>
</template>
@@ -0,0 +1,290 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import classicLayoutPreview from './classic-layout-preview.svg?raw';
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const PORTAL_LAYOUTS = {
CLASSIC: 'classic',
DOCUMENTATION: 'documentation',
};
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
const SOCIAL_PLATFORMS = [
{
key: 'facebook',
label: 'Facebook',
icon: 'i-ri-facebook-circle-fill',
prefix: 'facebook.com/',
},
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
{
key: 'instagram',
label: 'Instagram',
icon: 'i-ri-instagram-fill',
prefix: 'instagram.com/',
},
{
key: 'linkedin',
label: 'LinkedIn',
icon: 'i-ri-linkedin-box-fill',
prefix: 'linkedin.com/',
},
{
key: 'youtube',
label: 'YouTube',
icon: 'i-ri-youtube-fill',
prefix: 'youtube.com/',
},
{
key: 'tiktok',
label: 'TikTok',
icon: 'i-ri-tiktok-fill',
prefix: 'tiktok.com/',
},
{
key: 'github',
label: 'GitHub',
icon: 'i-ri-github-fill',
prefix: 'github.com/',
},
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'i-ri-whatsapp-fill',
prefix: 'wa.me/',
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const state = reactive({
layout: PORTAL_LAYOUTS.CLASSIC,
socialProfiles: {},
});
const visiblePlatforms = ref([]);
const showAddMenu = ref(false);
let originalSnapshot = '';
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
const buildSocialProfiles = () =>
visiblePlatforms.value.reduce((acc, key) => {
const handle = trimmedHandle(key);
if (handle) acc[key] = handle;
return acc;
}, {});
const snapshot = () =>
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
const resetFromPortal = () => {
const savedProfiles = portalConfig.value.social_profiles || {};
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
acc[key] = savedProfiles[key] || '';
return acc;
}, {});
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
(savedProfiles[key] || '').trim()
);
originalSnapshot = snapshot();
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const hasChanges = computed(() => snapshot() !== originalSnapshot);
const visiblePlatformDetails = computed(() =>
visiblePlatforms.value.map(platformByKey)
);
const addablePlatforms = computed(() =>
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
)
);
const addPlatform = ({ value }) => {
if (!visiblePlatforms.value.includes(value)) {
visiblePlatforms.value.push(value);
}
showAddMenu.value = false;
};
const removePlatform = key => {
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
state.socialProfiles[key] = '';
};
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: {
layout: state.layout,
social_profiles: buildSocialProfiles(),
},
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
<RadioCard
:id="PORTAL_LAYOUTS.CLASSIC"
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="classicLayoutPreview" />
</div>
</RadioCard>
<RadioCard
:id="PORTAL_LAYOUTS.DOCUMENTATION"
beta
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="documentationLayoutPreview" />
</div>
</RadioCard>
</div>
</section>
<section
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
)
}}
</span>
</div>
<div
v-for="platform in visiblePlatformDetails"
:key="platform.key"
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
>
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
<span class="text-sm shrink-0 text-n-slate-10">{{
platform.prefix
}}</span>
<input
v-model="state.socialProfiles[platform.key]"
type="text"
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
)
"
/>
<Button
icon="i-lucide-x"
color="slate"
variant="ghost"
size="xs"
:aria-label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
"
@click="removePlatform(platform.key)"
/>
</div>
<div
v-if="addablePlatforms.length"
v-on-clickaway="() => (showAddMenu = false)"
class="relative"
>
<Button
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
"
icon="i-lucide-plus"
color="slate"
variant="faded"
size="sm"
@click="showAddMenu = !showAddMenu"
/>
<DropdownMenu
v-if="showAddMenu"
:menu-items="addablePlatforms"
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
@action="addPlatform"
/>
</div>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
:disabled="!hasChanges || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -102,6 +103,12 @@ const handleDeletePortal = () => {
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<PortalLayoutContentSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -19,6 +19,7 @@ const DEFAULT_ROUTE = 'portals_articles_index';
const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_new',
'portals_categories_articles_edit',
];
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
@@ -41,7 +42,8 @@ const starRatingValue = computed(() => {
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<FormattedContent v-if="content" :content="content" />
<h4 v-else>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
@@ -30,10 +31,16 @@ const props = defineProps({
type: String,
default: '',
},
beta: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
@@ -42,14 +49,14 @@ const handleChange = () => {
</script>
<template>
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
<label
:for="id"
class="rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6 focus-within:has-[:focus-visible]:ring-2 focus-within:has-[:focus-visible]:ring-n-strong"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
@@ -58,6 +65,7 @@ const handleChange = () => {
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
</div>
<input
:id="`${id}`"
@@ -66,7 +74,7 @@ const handleChange = () => {
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
@change="handleChange"
/>
</div>
@@ -75,5 +83,5 @@ const handleChange = () => {
</p>
<slot />
</div>
</div>
</label>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: {
@@ -17,6 +18,10 @@ const props = defineProps({
type: Object,
required: true,
},
badgeCount: {
type: [Number, String],
default: 0,
},
});
const reauthorizationRequired = computed(() => {
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -1,5 +1,5 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
const getLabelUnreadCount = useMapGetter(
'conversationUnreadCounts/getLabelUnreadCount'
);
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -173,6 +200,10 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
@@ -270,6 +301,7 @@ const menuItems = computed(() => {
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -281,6 +313,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -288,6 +321,7 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -299,6 +333,7 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
@@ -5,6 +5,7 @@ import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -166,6 +167,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -10,6 +11,7 @@ const props = defineProps({
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
<component
:is="component"
v-if="shouldRenderComponent"
:label
:icon
:active
v-bind="{ label, icon, active, badgeCount }"
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
</template>
</component>
</Policy>
@@ -0,0 +1,27 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
});
const normalizedCount = computed(() => {
const count = Number(props.count);
return Number.isFinite(count) && count > 0 ? count : 0;
});
const displayCount = computed(() =>
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
);
</script>
<template>
<span
v-if="normalizedCount > 0"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-brand px-1 text-xxs font-medium leading-3 text-white flex-shrink-0"
>
{{ displayCount }}
</span>
<span v-else class="hidden" />
</template>
@@ -0,0 +1,38 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
props: {
label: 'Website',
inbox: { reauthorization_required: false },
...props,
},
global: {
mocks: {
$t: key => key,
},
stubs: {
ChannelIcon: true,
Icon: true,
},
},
});
describe('ChannelLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountChannelLeaf({ badgeCount: 3 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('3');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
});
@@ -0,0 +1,76 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
resolvePermissions: () => [],
resolveFeatureFlag: () => '',
}),
}));
const PolicyStub = {
props: ['as', 'permissions', 'featureFlag'],
template: '<li><slot /></li>',
};
const RouterLinkStub = {
props: ['to'],
template: '<a><slot /></a>',
};
const mountLeaf = props =>
mount(SidebarGroupLeaf, {
props: {
label: 'Support',
to: '/support',
...props,
},
global: {
stubs: {
Icon: true,
Policy: PolicyStub,
RouterLink: RouterLinkStub,
},
},
});
describe('SidebarGroupLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountLeaf({ badgeCount: 7 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('7');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
it('caps large unread counts', () => {
const wrapper = mountLeaf({ badgeCount: 120 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
'99+'
);
});
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
leafProps.badgeCount
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});
@@ -7,6 +7,7 @@ import {
ArticleMarkdownTransformer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -235,6 +236,7 @@ export default {
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
imageUpload: () => this.openFileBrowser(),
};
const command = commandMap[actionKey];
@@ -332,6 +334,9 @@ export default {
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -60,6 +60,12 @@ const EDITOR_ACTIONS = [
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'imageUpload',
labelKey: 'SLASH_COMMANDS.IMAGE',
icon: 'i-lucide-image',
menuKey: 'imageUpload',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
@@ -5,7 +5,6 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ReplyToMessage from './ReplyToMessage.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
@@ -144,8 +143,6 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -384,14 +381,8 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
if (!this.isAnEmailChannel) {
return false;
}
@@ -416,11 +407,7 @@ export default {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
return this.isAnEmailChannel && !this.isOnPrivateNote;
},
shouldShowQuotedPreview() {
return (
@@ -577,7 +564,6 @@ export default {
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
+1 -1
View File
@@ -43,10 +43,10 @@ export const FEATURE_FLAGS = {
CAPTAIN_TASKS: 'captain_tasks',
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
@@ -4,14 +4,18 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
this.lastUnreadCountsFetchAt = null;
this.unreadCountsFetchTimer = null;
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
@@ -32,6 +36,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'conversation.unread_count_changed':
this.onConversationUnreadCountChanged,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
@@ -120,6 +126,56 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationUnreadCountChanged = () => {
this.throttledFetchConversationUnreadCounts();
};
throttledFetchConversationUnreadCounts = () => {
const now = Date.now();
const elapsedTime = now - this.lastUnreadCountsFetchAt;
if (
this.lastUnreadCountsFetchAt === null ||
elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS
) {
this.clearUnreadCountsFetchTimer();
this.fetchConversationUnreadCounts();
return;
}
if (this.unreadCountsFetchTimer) return;
this.unreadCountsFetchTimer = setTimeout(() => {
this.unreadCountsFetchTimer = null;
this.fetchConversationUnreadCounts();
}, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime);
};
clearUnreadCountsFetchTimer = () => {
if (!this.unreadCountsFetchTimer) return;
clearTimeout(this.unreadCountsFetchTimer);
this.unreadCountsFetchTimer = null;
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get');
};
isConversationUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return isFeatureEnabled?.(
accountId,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
@@ -30,12 +30,17 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
afterEach(() => {
vi.useRealTimers();
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
@@ -64,4 +69,95 @@ describe('ActionCableConnector - Copilot Tests', () => {
);
});
});
describe('conversation unread count event handlers', () => {
it('should register the conversation.unread_count_changed event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'conversation.unread_count_changed'
);
expect(actionCable.events['conversation.unread_count_changed']).toBe(
actionCable.onConversationUnreadCountChanged
);
});
it('should refetch unread counts when unread count changes', () => {
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(4999);
expect(mockDispatch).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenLastCalledWith(
'conversationUnreadCounts/get'
);
});
it('clears pending unread count refetch before immediate refetch', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.advanceTimersByTime(1000);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(4000);
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
});
});
+2
View File
@@ -7,6 +7,7 @@ import de from './locale/de';
import el from './locale/el';
import en from './locale/en';
import es from './locale/es';
import et from './locale/et';
import fa from './locale/fa';
import fi from './locale/fi';
import fr from './locale/fr';
@@ -49,6 +50,7 @@ export default {
el,
en,
es,
et,
fa,
fi,
fr,
@@ -10,7 +10,8 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "ኩባንያ"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -108,7 +109,8 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "ኩባንያ"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,6 +130,7 @@
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
},
"NONE_OPTION": "None",
"LAST_RESPONDING_AGENT": "Last Responding Agent",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
@@ -181,6 +182,7 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "ኩባንያ",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -6,7 +6,12 @@
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -38,6 +43,8 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
}
}
@@ -7,6 +7,7 @@
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at",
"LAST_ACTIVITY_AT": "Last activity",
"CONTACTS_COUNT": "Contacts count"
}
},
@@ -21,6 +22,121 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "ባህሪያት",
"CONTACTS": "እውቂያዎች",
"HISTORY": "ታሪክ",
"NOTES": "ማስታወሻዎች"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search attributes...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "Loading contacts...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "እውቂያ አክል",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "አንድም እውቂያ አልተገኘም.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "ኩባንያ",
"CONTACT_LABEL": "እውቂያ",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "Cancel"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "ተፈጥሯል {date}",
"LAST_ACTIVE": "መጨረሻ እንቅስቃሴ {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "Name",
"DOMAIN": "ዶሜይን"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -386,6 +386,7 @@
"IDENTIFIER": "መለያ",
"COUNTRY": "አገር",
"CITY": "ከተማ",
"COMPANY": "ኩባንያ",
"CREATED_AT": "ተፈጥሯል በ",
"LAST_ACTIVITY": "መጨረሻ እንቅስቃሴ",
"REFERER_LINK": "የመግቢያ አገናኝ አገናኝ",
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -82,7 +83,9 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
},
"HEADER": {
"RESOLVE_ACTION": "ተፈትኗል",
@@ -92,6 +95,7 @@
"OPEN": "ተጨማሪ",
"CLOSE": "ዝጋ",
"DETAILS": "ዝርዝሮች",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
@@ -362,7 +366,19 @@
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "የተገናኙ የLinear ጉዳዮች",
"SHOPIFY_ORDERS": "Shopify Orders"
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -525,6 +525,7 @@
"PUBLISH": "ለህትመት አድርግ",
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate",
"DELETE": "ሰርዝ"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "በዚህ ምድብ ምንም ጽሑፎች የሉም",
"SUBTITLE": "በዚህ ምድብ ያሉ ጽሑፎች እዚህ ይታያሉ"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "ሁሉንም ይምረጡ ({count})",
"SELECTED_COUNT": "{count} ተመረጡ",
"CLEAR_SELECTION": "ምርጫ አጽዳ",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "አስተዋውቅ",
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "የዊጅት አሰራር መሣሪያ",
"BOT_CONFIGURATION": "የቦት ቅንብሮች",
"ACCOUNT_HEALTH": "የመለያ ጤና",
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)"
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)",
"VOICE": "ድምጽ"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች",
"WIDGET_FEATURES": "የዊጅት ባህሪያት",
@@ -745,6 +755,7 @@
"SENDER_NAME_SECTION_TEXT": "በኢሜይል ውስጥ የAgent ስም እንዲታይ/እንዳይታይ አርግ፣ ካልተከናወነ የንግድ ስም ይታያል",
"ENABLE_CONTINUITY_VIA_EMAIL": "በኢሜል የውይይት ቀጥታነት አንቀሳቅስ",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "ከኮንታክት ኢሜይል አድራሻ ካለ ውይይቶች በኢሜይል ይቀጥላሉ።",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "የውይይት መላኪያ",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "ለአሁን ያሉ እውቂያዎች ውይይት ፍጠራ ያስተካክሉ",
"INBOX_UPDATE_TITLE": "የኢንቦክስ ቅንብሮች",
@@ -1000,7 +1011,8 @@
"LABEL": "የይለፍ ቃል",
"PLACE_HOLDER": "የይለፍ ቃል"
},
"ENABLE_SSL": "SSL አንቀሳቅስ"
"ENABLE_SSL": "SSL አንቀሳቅስ",
"AUTH_MECHANISM": "ማረጋገጫ"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -8,6 +8,7 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -26,6 +27,8 @@ import integrations from './integrations.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
@@ -47,6 +50,7 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -65,6 +69,8 @@ export default {
...labelsMgmt,
...login,
...macros,
...mfa,
...onboarding,
...report,
...resetPassword,
...search,
@@ -46,6 +46,7 @@
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Create",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "Cancel"
},
"API": {
@@ -742,6 +742,7 @@
"SELECT_ALL": "ሁሉንም ይምረጡ ({count})",
"UNSELECT_ALL": "ሁሉንም አልምረጥም ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_SYNC_BUTTON": "Refresh",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -749,6 +750,51 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "አልተሳካም"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "updating...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Page not found",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "ተዛማጅ የFAQ ጥያቄዎች",
"DESCRIPTION": "እነዚህ የFAQ ጥያቄዎች ቀጥተኛ ከሰነዱ ተፈጥረዋል።"
@@ -792,11 +838,15 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "ተዛማጅ ምላሾችን እይ",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "ሰነድ ሰርዝ"
},
"EMPTY_STATE": {
"TITLE": "ሰነዶች አልተገኙም",
"SUBTITLE": "ሰነዶች በእርስዎ አገልጋይ በተጠቃሚ ጥያቄዎች ላይ የሚሰጥ የተደጋጋሚ ጥያቄዎችን ለመፍጠር ይጠቀማሉ። ሰነዶችን ለአገልጋይዎ እውነተኛ እይታ ለማቅረብ ማስገባት ይችላሉ።",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain ሰነድ",
"NOTE": "በCaptain ውስጥ ሰነድ እንደ ለማድረግ ምንጭ እንደሚሰራ አገልግሎት አገልግሎት ነው። የእርዳታ ማዕከላችሁን ወይም መምሪያዎችን በመገናኘት፣ Captain ይዘቱን ማብራሪያ ማድረግ እና ለደንበኞች ጥያቄዎች ትክክለኛ ምላሾችን ማቅረብ ይችላል።"
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account."
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "Private",
@@ -55,6 +55,10 @@
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -0,0 +1,34 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "የሰዓት ክልል ይምረጡ",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "ቀጥል",
"SAVING": "እየተቀማጭ ነው...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -10,7 +10,8 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال"
"CONTACT": "جهات الاتصال",
"COMPANY": "المنشأة"
},
"ATTRIBUTE_TYPES": {
"TEXT": "النص",
@@ -108,7 +109,8 @@
"TABS": {
"HEADER": "صفات مخصصة",
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال"
"CONTACT": "جهات الاتصال",
"COMPANY": "المنشأة"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,6 +130,7 @@
"ATLEAST_ONE_ACTION_REQUIRED": "إجراء واحد على الأقل مطلوب"
},
"NONE_OPTION": "لا شيء",
"LAST_RESPONDING_AGENT": "آخر وكيل قام بالرد",
"EVENTS": {
"CONVERSATION_CREATED": "تم إنشاء المحادثة",
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
@@ -181,6 +182,7 @@
"BROWSER_LANGUAGE": "لغة المتصفح",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "الدولة",
"COMPANY_NAME": "المنشأة",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "المكلَّف",
"TEAM_NAME": "الفريق",
@@ -6,7 +6,12 @@
"UNASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من إلغاء تعيين {conversationCount} {conversationLabel}؟",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"NONE": "لا شيء",
"CLEAR_SELECTION": "مسح",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "نعم",
"CANCEL": "إلغاء",
"SEARCH_INPUT_PLACEHOLDER": "بحث",
"ASSIGN_AGENT_TOOLTIP": "تعيين وكيل",
"ASSIGN_TEAM_TOOLTIP": "تعيين فريق",
@@ -38,6 +43,8 @@
"NONE": "لا شيء",
"NO_TEAMS_AVAILABLE": "لا توجد فرق مضافة إلى هذا الحساب حتى الآن.",
"ASSIGN_SELECTED_TEAMS": "تعيين فريق محدد.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "تم تعيين الفرق بنجاح.",
"ASSIGN_FAILED": "فشل تعيين الفريق، الرجاء المحاولة مرة أخرى."
}
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "إخفاء النص المقتبس",
"SHOW_QUOTED_TEXT": "إظهار النص المقتبس",
"MESSAGE_READ": "قراءة",
"SENDING": "Sending"
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
}
}
@@ -7,6 +7,7 @@
"NAME": "الاسم",
"DOMAIN": "النطاق",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY_AT": "آخر نشاط",
"CONTACTS_COUNT": "عدد جهات الاتصال"
}
},
@@ -21,6 +22,121 @@
"LOADING": "جاري تحميل الشركات...",
"UNNAMED": "شركة بلا اسم",
"CONTACTS_COUNT": "جهة اتصال {n} | {n} جهات الاتصال",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "السمات",
"CONTACTS": "جهات الاتصال",
"HISTORY": "History",
"NOTES": "ملاحظات"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "البحث عن صفات...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "جاري جلب جهات الاتصال...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "Add contact",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "Search contacts...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "No contacts found.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "المنشأة",
"CONTACT_LABEL": "جهات الاتصال",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "إلغاء"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "Created {date}",
"LAST_ACTIVE": "Last active {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "الاسم",
"DOMAIN": "النطاق"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "لم يتم العثور على شركات"
}
@@ -386,6 +386,7 @@
"IDENTIFIER": "المعرف",
"COUNTRY": "الدولة",
"CITY": "المدينة",
"COMPANY": "المنشأة",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط",
"REFERER_LINK": "رابط المرجع",
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "الرسالة غير متوفرة",
"CARD": {
"SHOW_LABELS": "إظهار السمات",
"HIDE_LABELS": "إخفاء السمات"
"HIDE_LABELS": "إخفاء السمات",
"LABELS_COUNT": "{count} علامة"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -82,7 +83,9 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "انضم إلى المكالمة"
},
"HEADER": {
"RESOLVE_ACTION": "حل المحادثة",
@@ -92,6 +95,7 @@
"OPEN": "المزيد",
"CLOSE": "أغلق",
"DETAILS": "التفاصيل",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "غفوة حتى",
"SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد",
"SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم",
@@ -362,7 +366,19 @@
"PREVIOUS_CONVERSATION": "المحادثات السابقة",
"MACROS": "ماكروس",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "عرض الكل",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -525,6 +525,7 @@
"PUBLISH": "نشر",
"DRAFT": "مسودة",
"ARCHIVE": "Archive",
"TRANSLATE": "ترجم",
"DELETE": "حذف"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "ترجمة المقالة | ترجمة {count} مقالة",
"DESCRIPTION": "ترجمة المقالة المحددة إلى لغة أخرى. | ترجمة المقالات المحددة إلى لغة أخرى.",
"LOCALE_LABEL": "اللغة المستهدفة",
"LOCALE_PLACEHOLDER": "اختر لغة",
"CATEGORY_LABEL": "الفئة المستهدفة",
"CATEGORY_PLACEHOLDER": "اختر الفئة",
"OPTIONAL": "(اختياري)",
"CONFIRM": "ترجم",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "ترجم",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "نشر",
"DRAFT": "مسودة",
"ARCHIVE": "Archive",
"TRANSLATE": "ترجم",
"DELETE": "حذف",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "حذف",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "منشئ اللايف شات",
"BOT_CONFIGURATION": "اعدادات البوت",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "تقييم رضاء العملاء"
"CSAT": "تقييم رضاء العملاء",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -745,6 +755,7 @@
"SENDER_NAME_SECTION_TEXT": "تمكين/تعطيل إظهار اسم الوكيل في البريد الإلكتروني، إذا تم تعطيله فسيظهر اسم المنشأة",
"ENABLE_CONTINUITY_VIA_EMAIL": "تمكين استمرارية المحادثة عبر البريد الإلكتروني",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "المحادثات ستستمر عبر البريد الإلكتروني إذا كان عنوان البريد الإلكتروني لجهة الاتصال متاحاً.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "هذه الميزة متوفرة في الخطة المدفوعة. قم بالترقية لتفعيل استمرارية المحادثة عبر البريد الإلكتروني.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "إعدادات قناة التواصل",
@@ -1000,7 +1011,8 @@
"LABEL": "كلمة المرور",
"PLACE_HOLDER": "كلمة المرور"
},
"ENABLE_SSL": "تمكين SSL"
"ENABLE_SSL": "تمكين SSL",
"AUTH_MECHANISM": "المصادقة"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -8,6 +8,7 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -26,6 +27,8 @@ import integrations from './integrations.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
@@ -47,6 +50,7 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -65,6 +69,8 @@ export default {
...labelsMgmt,
...login,
...macros,
...mfa,
...onboarding,
...report,
...resetPassword,
...search,
@@ -46,6 +46,7 @@
"PLACEHOLDER": "اختر صندوق الوارد"
},
"SUBMIT": "إنشاء",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "إلغاء"
},
"API": {
@@ -742,6 +742,7 @@
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "حذف",
"BULK_SYNC_BUTTON": "تحديث",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -749,6 +750,51 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "جاري التحديث...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "لم يتم العثور على الصفحة",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -792,11 +838,15 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "حدث خطأ أثناء حذف الماكرو. الرجاء المحاولة مرة أخرى في وقت لاحق"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "تعديل الماكرو",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "الرؤية الخاصة بالماكرو",
"GLOBAL": {
"LABEL": "عامة",
"DESCRIPTION": "هذا الماكرو متاح بشكل عام لجميع الوكلاء في هذا الحساب."
"DESCRIPTION": "هذا الماكرو متاح بشكل عام لجميع الوكلاء في هذا الحساب.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "خاص",
@@ -55,6 +55,10 @@
"PASSWORD": "كلمة المرور",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "إلغاء",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -0,0 +1,34 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "البريد الإلكتروني",
"YOUR_ROLE": "Your Role",
"WEBSITE": "الموقع الإلكتروني",
"LANGUAGE": "اللغة",
"TIMEZONE": "منطقة زمنية",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "اختر المنطقة الزمنية",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "جاري الحفظ...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -10,7 +10,8 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Şirkət"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -108,7 +109,8 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Şirkət"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,6 +130,7 @@
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
},
"NONE_OPTION": "None",
"LAST_RESPONDING_AGENT": "Last Responding Agent",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
@@ -181,6 +182,7 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "Şirkət",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -6,7 +6,12 @@
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "Heç biri",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -38,6 +43,8 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
}
}
@@ -7,6 +7,7 @@
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at",
"LAST_ACTIVITY_AT": "Last activity",
"CONTACTS_COUNT": "Contacts count"
}
},
@@ -21,6 +22,121 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Xüsusiyyətlər",
"CONTACTS": "Əlaqələr",
"HISTORY": "Tarix",
"NOTES": "Qeydlər"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search attributes...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "Loading contacts...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "Əlaqə əlavə et",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "Əlaqələrdə axtarış...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "No contacts found.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "Şirkət",
"CONTACT_LABEL": "Əlaqə",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "Cancel"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "Yaradılıb {date}",
"LAST_ACTIVE": "Son fəaliyyət {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "Name",
"DOMAIN": "Domen"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -386,6 +386,7 @@
"IDENTIFIER": "Identifier",
"COUNTRY": "Ölkə",
"CITY": "Şəhər",
"COMPANY": "Şirkət",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Son fəaliyyət",
"REFERER_LINK": "Referer link",
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Mesaj mövcud deyil",
"CARD": {
"SHOW_LABELS": "Etiketləri göstər",
"HIDE_LABELS": "Etiketləri gizlədin"
"HIDE_LABELS": "Etiketləri gizlədin",
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Gələn zəng",
@@ -82,7 +83,9 @@
"CALL_ENDED": "Zəng bitdi",
"NOT_ANSWERED_YET": "Hələ cavab verilməyib",
"THEY_ANSWERED": "Onlar cavab verdi",
"YOU_ANSWERED": "Siz cavab verdiniz"
"YOU_ANSWERED": "Siz cavab verdiniz",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Zəngə qoşul"
},
"HEADER": {
"RESOLVE_ACTION": "Həll et",
@@ -92,6 +95,7 @@
"OPEN": "Daha çox",
"CLOSE": "Bağla",
"DETAILS": "təfərrüatlar",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "Gecikdirilib",
"SNOOZED_UNTIL_TOMORROW": "Sabaha qədər təxirə salındı",
"SNOOZED_UNTIL_NEXT_WEEK": "Gələn həftəyə qədər təxirə salındı",
@@ -362,7 +366,19 @@
"PREVIOUS_CONVERSATION": "Əvvəlki Söhbətlər",
"MACROS": "Makrolar",
"LINEAR_ISSUES": "Əlaqəli Linear məsələlər",
"SHOPIFY_ORDERS": "Shopify Sifarişləri"
"SHOPIFY_ORDERS": "Shopify Sifarişləri",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Sifariş #{id}",
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Tərcümə et",
"DELETE": "Delete"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Tərcümə et",
"SELECT_ALL": "Hamısını seç ({count})",
"SELECTED_COUNT": "{count} seçildi",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Tərcümə et",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Yayımla",
"DRAFT": "Qaralama",
"ARCHIVE": "Archive",
"TRANSLATE": "Tərcümə et",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Qurucusu",
"BOT_CONFIGURATION": "Bot Konfiqurasiyası",
"ACCOUNT_HEALTH": "Hesabın sağlamlığı",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Səs"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Kanal üstünlükləri",
"WIDGET_FEATURES": "Widget xüsusiyyətləri",
@@ -745,6 +755,7 @@
"SENDER_NAME_SECTION_TEXT": "E-poçtda Agentin adının göstərilməsini aktivləşdirin/deaktivləşdirin, deaktiv edilsə biznes adı göstəriləcək",
"ENABLE_CONTINUITY_VIA_EMAIL": "E-poçt vasitəsilə söhbət davamlılığını aktiv edin",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Əlaqə e-poçt ünvanı mövcuddursa, söhbətlər e-poçt vasitəsilə davam edəcək.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "Söhbət yönləndirilməsi",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Mövcud əlaqələr üçün söhbət yaradılmasını qurun",
"INBOX_UPDATE_TITLE": "Gələn Qutu Parametrləri",
@@ -1000,7 +1011,8 @@
"LABEL": "Şifrə",
"PLACE_HOLDER": "Şifrə"
},
"ENABLE_SSL": "SSL-i aktiv et"
"ENABLE_SSL": "SSL-i aktiv et",
"AUTH_MECHANISM": "Avtorizasiya"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -8,6 +8,7 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -26,6 +27,8 @@ import integrations from './integrations.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
@@ -47,6 +50,7 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -65,6 +69,8 @@ export default {
...labelsMgmt,
...login,
...macros,
...mfa,
...onboarding,
...report,
...resetPassword,
...search,
@@ -46,6 +46,7 @@
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Create",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "Cancel"
},
"API": {
@@ -742,6 +742,7 @@
"SELECT_ALL": "Hamısını seç ({count})",
"UNSELECT_ALL": "Hamısını seçmə ({count})",
"BULK_DELETE_BUTTON": "Sil",
"BULK_SYNC_BUTTON": "Refresh",
"BULK_DELETE": {
"TITLE": "Sənədlər silinsin?",
"DESCRIPTION": "Seçilmiş sənədləri silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
@@ -749,6 +750,51 @@
"SUCCESS_MESSAGE": "Sənədlər uğurla silindi",
"ERROR_MESSAGE": "Sənədlər silinərkən xəta baş verdi, yenidən cəhd edin."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "updating...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Page not found",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Əlaqəli FAQ-lar",
"DESCRIPTION": "Bu FAQ-lar birbaşa sənəddən yaradılıb."
@@ -792,11 +838,15 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "Əlaqəli cavablara bax",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Sənədi sil"
},
"EMPTY_STATE": {
"TITLE": "Sənəd yoxdur",
"SUBTITLE": "Sənədlər assistentiniz tərəfindən FAQ-lar yaratmaq üçün istifadə olunur. Assistentinizə kontekst vermək üçün sənədləri əlavə edə bilərsiniz.",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Sənəd",
"NOTE": "Captain-da sənəd assistent üçün bilik mənbəyi rolunu oynayır. Kömək mərkəzinizi və ya təlimatları bağlayaraq, Captain məzmunu analiz edə və müştəri sorğuları üçün dəqiq cavablar verə bilər."
@@ -49,6 +49,9 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -66,7 +69,9 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account."
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
},
"PERSONAL": {
"LABEL": "Private",
@@ -55,6 +55,10 @@
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -0,0 +1,34 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "Zaman zonası seçin",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Davam et",
"SAVING": "Yadda saxlanılır...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -10,7 +10,8 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Разговор",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Фирма"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -108,7 +109,8 @@
"TABS": {
"HEADER": "Персонализирани атрибути",
"CONVERSATION": "Разговор",
"CONTACT": "Контакт"
"CONTACT": "Контакт",
"COMPANY": "Фирма"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,6 +130,7 @@
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
},
"NONE_OPTION": "Нито един",
"LAST_RESPONDING_AGENT": "Last Responding Agent",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
@@ -181,6 +182,7 @@
"BROWSER_LANGUAGE": "Език на браузъра",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Държава",
"COMPANY_NAME": "Фирма",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -6,7 +6,12 @@
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Отмени",
"SEARCH_INPUT_PLACEHOLDER": "Търсене",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -38,6 +43,8 @@
"NONE": "Нито един",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -140,6 +140,7 @@
"HIDE_QUOTED_TEXT": "Скриване на цитирания текст",
"SHOW_QUOTED_TEXT": "Показване на цитирания текст",
"MESSAGE_READ": "Read",
"SENDING": "Sending"
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
}
}
@@ -7,6 +7,7 @@
"NAME": "Име",
"DOMAIN": "Domain",
"CREATED_AT": "Създаден в",
"LAST_ACTIVITY_AT": "Last activity",
"CONTACTS_COUNT": "Contacts count"
}
},
@@ -21,6 +22,121 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
"CONTACTS": "Контакти",
"HISTORY": "History",
"NOTES": "Бележки"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Търсене на атрибути...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "Зареждане на контактите...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "Add contact",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "Search contacts...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "No contacts found.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "Фирма",
"CONTACT_LABEL": "Contact",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "Отмени"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "Created {date}",
"LAST_ACTIVE": "Last active {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "Име",
"DOMAIN": "Domain"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -386,6 +386,7 @@
"IDENTIFIER": "Идентификатор",
"COUNTRY": "Държава",
"CITY": "Град",
"COMPANY": "Фирма",
"CREATED_AT": "Създаден в",
"LAST_ACTIVITY": "Last activity",
"REFERER_LINK": "Референтна връзка",
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -82,7 +83,9 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -92,6 +95,7 @@
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
@@ -362,7 +366,19 @@
"PREVIOUS_CONVERSATION": "Предишни разговори",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -525,6 +525,7 @@
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Изтрий"
},
"STATUS": {
@@ -579,6 +580,41 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "Publish",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"TRANSLATE": "Translate",
"DELETE": "Изтрий",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Изтрий",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -745,6 +755,7 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
@@ -1000,7 +1011,8 @@
"LABEL": "Password",
"PLACE_HOLDER": "Password"
},
"ENABLE_SSL": "Enable SSL"
"ENABLE_SSL": "Enable SSL",
"AUTH_MECHANISM": "Authentication"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -8,6 +8,7 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -26,6 +27,8 @@ import integrations from './integrations.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
@@ -47,6 +50,7 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -65,6 +69,8 @@ export default {
...labelsMgmt,
...login,
...macros,
...mfa,
...onboarding,
...report,
...resetPassword,
...search,
@@ -46,6 +46,7 @@
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Създаване",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "Отмени"
},
"API": {
@@ -742,6 +742,7 @@
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Изтрий",
"BULK_SYNC_BUTTON": "Refresh",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -749,6 +750,51 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "updating...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Page not found",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -792,11 +838,15 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."

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