Compare commits

..
Author SHA1 Message Date
Sojan JoseandClaude 602c56d1c5 feat: Add account-level SSO configuration
This implements Single Sign-On (SSO) functionality at the account level, allowing individual accounts to configure their own SSO settings.

Key changes:
- Added SSO configuration to account model with JSONB storage
- Implemented SSO settings UI in account settings panel
- Added feature flag to control SSO access (premium feature)
- Updated authentication flow to support account-level SSO
- Added proper validation and error handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-03 18:41:41 -08:00
647a65d481 fix: Fetch all facebook pages during inbox creation (#11956)
## Summary
- fetch all Facebook pages during setup instead of only the first 25

fixes:  #3082

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-17 18:32:12 +05:30
Muhsin KelothandGitHub 99ec18c95b feat: Add support for multiple attachments in Slack (#11958) 2025-07-17 09:38:46 +05:30
Sivin VargheseandGitHub 64ba23688b feat: New GuardRails and Response Guidelines edit page (#11932) 2025-07-16 21:52:25 +05:30
Sojan Jose 2fec2e5993 Merge branch 'release/4.4.0' into develop 2025-07-16 00:10:53 -07:00
Sojan Jose 090316a078 Bump version to 4.4.0 2025-07-16 00:09:26 -07:00
Shivam MishraandGitHub fcd604dcde chore: Make captain_integration_v2 an internal feature (#11953) 2025-07-16 10:09:46 +05:30
13b4fdb34c chore: Add submenu for super admin settings (#11860)
- Improve how settings are rendered in Chatwoot Super admin panel
- Add google settings support 
- show setting for community edition

##  Settings page - community edition
<img width="1702" alt="Screenshot 2025-07-08 at 9 08 03 PM"
src="https://github.com/user-attachments/assets/0434f56f-ea74-44a8-a7b0-8e26fab88093"
/>

## Expanded settings
<img width="1675" alt="Screenshot 2025-07-03 at 2 17 16 AM"
src="https://github.com/user-attachments/assets/3aa1f888-c54a-4b58-896a-0d3e828fa176"
/>

---------

Co-authored-by: Sojan Jose <sojan@Sojans-MacBook-Pro.local>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-15 21:28:39 -07:00
45d4d3660c feat: Add private note action to automations (#11926)
## Summary
- allow AutomationRule to accept `add_private_note` action
- support `add_private_note` in automation action service
- expose private note action in frontend constants and i18n
- test new automation rule action

## Testing
- `pnpm eslint
app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`
- `bundle exec rubocop app/services/automation_rules/action_service.rb
app/models/automation_rule.rb
spec/services/automation_rules/action_service_spec.rb`
- `bundle exec rspec
spec/services/automation_rules/action_service_spec.rb`


------
https://chatgpt.com/codex/tasks/task_e_6870c5f7b8b88326a9bd60b2ba710ccd

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-16 09:57:35 +05:30
0ea616a6ea feat: WhatsApp campaigns (#11910)
# Pull Request Template

## Description

This PR adds support for WhatsApp campaigns to Chatwoot, allowing
businesses to reach their customers through WhatsApp. The implementation
includes backend support for WhatsApp template messages, frontend UI
components, and integration with the existing campaign system.

Fixes #8465

Fixes https://linear.app/chatwoot/issue/CW-3390/whatsapp-campaigns

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] 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 WhatsApp campaign creation UI flow
- Verified backend API endpoints for campaign creation
- Tested campaign service integration with WhatsApp templates
- Validated proper filtering of WhatsApp campaigns in the store

## Checklist:

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

## What we have changed:

We have added support for WhatsApp campaigns as requested in the
discussion.
Ref: https://github.com/orgs/chatwoot/discussions/8465

**Note:** This implementation doesn't exactly match the maintainer's
specification and variable support is missing. This is an initial
implementation that provides the core WhatsApp campaign functionality.

### Changes included:

**Backend:**
- Added `template_params` column to campaigns table (migration + schema)
- Created `Whatsapp::OneoffCampaignService` for WhatsApp campaign
execution
- Updated campaign model to support WhatsApp inbox types
- Added template_params support to campaign controller and API

**Frontend:**
- Added WhatsApp campaign page, dialog, and form components
- Updated campaign store to filter WhatsApp campaigns separately
- Added WhatsApp-specific routes and empty state
- Updated i18n translations for WhatsApp campaigns
- Modified sidebar to include WhatsApp campaigns navigation

This provides a foundation for WhatsApp campaigns that can be extended
with variable support and other enhancements in future iterations.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-16 09:04:02 +05:30
6b8dd3c86a chore: move UpdateMessageStatus to deferred queue (#11943)
-  move `UpdateMessageStatus` to `deferred` queue below `scheduled_jobs`

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-16 08:19:00 +05:30
Tanmay Deep SharmaGitHubMuhsin KelothCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>iamsivinSivin VargheseSojan Jose
61d10044a0 feat: Whatsapp embedded signup (#11612)
## Description

This PR introduces WhatsApp Embedded Signup functionality, enabling
users to connect their WhatsApp Business accounts through Meta's
streamlined OAuth flow without manual webhook configuration. This
significantly improves the user experience by automating the entire
setup process.

**Key Features:**

- Embedded signup flow using Facebook SDK and Meta's OAuth 2.0
- Automatic webhook registration and phone number configuration
- Enhanced provider selection UI with card-based design
- Real-time progress tracking during signup process
- Comprehensive error handling and user feedback


## Required Configuration

The following environment variables must be configured by administrators
before this feature can be used:
Super Admin Configuration (via
super_admin/app_config?config=whatsapp_embedded)

- `WHATSAPP_APP_ID`: The Facebook App ID for WhatsApp Business API
integration
- `WHATSAPP_CONFIGURATION_ID`: The Configuration ID for WhatsApp
Embedded Signup flow (obtained from Meta Developer Portal)
- `WHATSAPP_APP_SECRET`: The App Secret for WhatsApp Embedded Signup
flow (required for token exchange)
![Screenshot 2025-06-09 at 11 21
08 AM](https://github.com/user-attachments/assets/1615fb0d-27fc-4d9e-b193-9be7894ea93a)


## How Has This Been Tested?

#### Backend Tests (RSpec):

- Authentication validation for embedded signup endpoints
- Authorization code validation and error handling
- Missing business parameter validation
- Proper response format for configuration endpoint
- Unauthorized access prevention

#### Manual Test Cases:

- Complete embedded signup flow (happy path)
- Provider selection UI navigation
- Facebook authentication popup handling
- Error scenarios (cancelled auth, invalid business data, API failures)
- Configuration presence/absence behavior

## Related Screenshots:

![Screenshot 2025-06-09 at 7 48
18 PM](https://github.com/user-attachments/assets/34001425-df11-4d78-9424-334461e3178f)
![Screenshot 2025-06-09 at 7 48
22 PM](https://github.com/user-attachments/assets/c09f4964-3aba-4c39-9285-d1e8e37d0e33)
![Screenshot 2025-06-09 at 7 48
32 PM](https://github.com/user-attachments/assets/a34d5382-7a91-4e1c-906e-dc2d570c864a)
![Screenshot 2025-06-09 at 10 43
05 AM](https://github.com/user-attachments/assets/a15840d8-8223-4513-82e4-b08f23c95927)
![Screenshot 2025-06-09 at 10 42
56 AM](https://github.com/user-attachments/assets/8c345022-38b5-44c4-aba2-0cda81389c69)


Fixes
https://linear.app/chatwoot/issue/CW-2131/spec-for-whatsapp-cloud-channels-sign-in-with-facebook

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-07-14 21:37:06 -07:00
4378506a35 feat: add response guidelines and guardrails field (#11911)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-15 08:56:03 +05:30
Shivam MishraandGitHub 1132556512 feat: setup qlty [CW-4614] (#11918) 2025-07-14 17:12:28 +05:30
93f18315cc feat: add Captain::Scenario Model and API [CW-4597] (#11907)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-14 16:12:38 +05:30
d89fa09359 chore: Update translations (#11903)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-14 14:52:57 +05:30
Sivin VargheseandGitHub 07dd6448f4 fix: fast scrolling in canned responses list on mouse hover (#11933)
# Pull Request Template

## Description

This PR fixes a fast scrolling issue in the canned responses list that
occurred when hovering near the top or bottom edge.

Fixes https://github.com/chatwoot/chatwoot/issues/11923,
[CW-4624](https://linear.app/chatwoot/issue/CW-4624/canned-responses-menu-scrolls-too-fast-when-mouse-near-top-or-bottom)

## Type of change

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

## How Has This Been Tested?

### Screencast

**Before**


https://github.com/user-attachments/assets/1c39ad33-c5c9-49ce-a252-542428b7f7e3

**After**


https://github.com/user-attachments/assets/19c73713-0ffe-461a-9c3d-486e53e21abf




## 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
2025-07-14 11:02:51 +05:30
Sivin VargheseandGitHub 661326ae51 fix: Contact name editing did not allow spaces (#11931)
# Pull Request Template

## Description

This PR fixes an issue where editing a contact name from the “Contacts”
tab would not allow spaces in the First or Last Name fields — the space
would disappear immediately after typing.
This issue did not occur in the sidebar editor or when adding a new
contact.



**Cause:** The form had a deep watcher on `contactData`, which triggered
`prepareStateBasedOnProps()` on every nested change. This caused the
form state to reset.

**Solution:** Replaced the deep watcher with a shallow watcher that only
watches `contactData.id`.
This fires once on mount and whenever a new contact is selected,
avoiding unnecessary re-hydration while the user is typing.


Fixes https://github.com/chatwoot/chatwoot/issues/11922 ,
CW-[4623](https://linear.app/chatwoot/issue/CW-4623/inconsistent-name-field-validation-when-editing-contacts-from-contacts)

## Type of change

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

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/e088f627-d7b1-4d67-85eb-58911ac0c012



## 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
2025-07-14 10:27:50 +05:30
18eb53acf4 feat: New Assistants Edit Page (#11920)
# Pull Request Template

## Description

Fixes https://linear.app/chatwoot/issue/CW-4602/assistants-edit-page

### Screenshots
<img width="1650" height="890" alt="image"
src="https://github.com/user-attachments/assets/f9834cd3-9cf3-4173-8d33-1aad04d8991e"
/>
<img width="1650" height="890" alt="image"
src="https://github.com/user-attachments/assets/a628328a-fdfd-4ee7-86b5-2a1945e0c114"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-12 17:02:10 +05:30
1b02ebec68 fix: Approved FAQ not disappearing from pending list after filtering (#11909)
# Pull Request Template

## Description

This PR fixes an issue where approved FAQs were not removed from the
list when filtered by `pending` status.

**Fix:** Implemented real-time client-side filtering using a
`filteredResponses` computed property in `Index.vue`, updated all list
and selection logic to use it, and also hide the card toggle dropdown
button when the bulk action checkbox is selected.


## Type of change

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

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/216221a4910c44ebb1d49ab9e34c820c?sid=98c9c239-54eb-4bd9-b04d-aeccc55bfb3a

## Checklist:

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

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-12 10:06:30 +05:30
Tanmay Deep SharmaandGitHub 5b9f997fa0 feat: Add the support for images in Captain (#11850) 2025-07-11 23:28:46 +07:00
Sivin VargheseandGitHub 802f0694ed chore: Alphabetically sort inbox list on settings page (#11921)
# Pull Request Template

## Description

This PR updates the inbox list on the settings page to be sorted
alphabetically.

## Type of change

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

## How Has This Been Tested?

### Screenshot
<img width="222" height="463" alt="image"
src="https://github.com/user-attachments/assets/0caabeb1-b2bd-4072-a44f-e0ac5a52404d"
/>



## 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
2025-07-11 11:46:15 +05:30
433 changed files with 10705 additions and 1004 deletions
-62
View File
@@ -1,62 +0,0 @@
version: '2'
plugins:
rubocop:
enabled: false
channel: rubocop-0-73
eslint:
enabled: false
csslint:
enabled: true
scss-lint:
enabled: true
brakeman:
enabled: false
checks:
similar-code:
enabled: false
method-count:
enabled: true
config:
threshold: 32
file-lines:
enabled: true
config:
threshold: 300
method-lines:
config:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/**/**'
- '**/spec/**/**'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
- 'config/**/*'
- 'public/**/*'
- 'vendor/**/*'
- 'node_modules/**/*'
- 'lib/tasks/auto_annotate_models.rake'
- 'app/test-matchers.js'
- 'docs/*'
- '**/*.md'
- '**/*.yml'
- 'app/javascript/dashboard/i18n/locale'
- '**/*.stories.js'
- 'stories/'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
- 'app/javascript/shared/constants/countries.js'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+7
View File
@@ -0,0 +1,7 @@
*
!configs
!configs/**
!hooks
!hooks/**
!qlty.toml
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
ignored:
- DL3008
+1
View File
@@ -0,0 +1 @@
source-path=SCRIPTDIR
+8
View File
@@ -0,0 +1,8 @@
rules:
document-start: disable
quoted-strings:
required: only-when-needed
extra-allowed: ["{|}"]
key-duplicates: {}
octal-values:
forbid-implicit-octal: true
+84
View File
@@ -0,0 +1,84 @@
# This file was automatically generated by `qlty init`.
# You can modify it to suit your needs.
# We recommend you to commit this file to your repository.
#
# This configuration is used by both Qlty CLI and Qlty Cloud.
#
# Qlty CLI -- Code quality toolkit for developers
# Qlty Cloud -- Fully automated Code Health Platform
#
# Try Qlty Cloud: https://qlty.sh
#
# For a guide to configuration, visit https://qlty.sh/d/config
# Or for a full reference, visit https://qlty.sh/d/qlty-toml
config_version = "0"
exclude_patterns = [
"*_min.*",
"*-min.*",
"*.min.*",
"**/.yarn/**",
"**/*.d.ts",
"**/assets/**",
"**/bower_components/**",
"**/build/**",
"**/cache/**",
"**/config/**",
"**/db/**",
"**/deps/**",
"**/dist/**",
"**/extern/**",
"**/external/**",
"**/generated/**",
"**/Godeps/**",
"**/gradlew/**",
"**/mvnw/**",
"**/node_modules/**",
"**/protos/**",
"**/seed/**",
"**/target/**",
"**/templates/**",
"**/testdata/**",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
]
test_patterns = [
"**/test/**",
"**/spec/**",
"**/*.test.*",
"**/*.spec.*",
"**/*_test.*",
"**/*_spec.*",
"**/test_*.*",
"**/spec_*.*",
]
[smells]
mode = "comment"
[smells.boolean_logic]
threshold = 4
[smells.file_complexity]
threshold = 66
enabled = true
[smells.return_statements]
threshold = 4
[smells.nested_control_flow]
threshold = 4
[smells.function_parameters]
threshold = 4
[smells.function_complexity]
threshold = 5
[smells.duplication]
enabled = true
threshold = 20
[[source]]
name = "default"
default = true
+1 -1
View File
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Enabled: true
Style/SafeNavigationChainLength:
Enabled: false
@@ -30,7 +30,14 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end
def facebook_pages
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
pages = []
fb_pages = fb_object.get_connections('me', 'accounts')
pages.concat(fb_pages)
while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page)
fb_pages = next_page
pages.concat(fb_pages)
end
@page_details = mark_already_existing_facebook_pages(pages)
end
def set_instagram_id(page_access_token, facebook_channel)
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
end
end
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
end
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
render json: { success: false }, status: :unprocessable_entity
end
end
end
end
@@ -0,0 +1,64 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles the embedded signup callback data from the Facebook SDK
def create
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
render_error_response(e)
end
private
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
)
service.perform
end
def render_success_response(inbox)
render json: {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
end
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
success: false,
error: error.message
}, status: :unprocessable_entity
end
def validate_feature_enabled!
return if Current.account.feature_whatsapp_embedded_signup?
render json: {
success: false,
error: 'WhatsApp embedded signup is not enabled for this account'
}, status: :forbidden
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
missing_params << 'business_id' if params[:business_id].blank?
missing_params << 'waba_id' if params[:waba_id].blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end
@@ -47,6 +47,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.sso_config.merge!(sso_config_params) if sso_config_params.present? && sso_feature_enabled?
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -95,6 +96,14 @@ class Api::V1::AccountsController < Api::BaseController
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def sso_config_params
params.permit(sso_config: [:enabled, :provider_name, :login_url, :logout_url, :secret_key, :token_expiry])[:sso_config]
end
def sso_feature_enabled?
@account.feature_enabled?('sso')
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
+2
View File
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
end
@@ -39,8 +39,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -7,8 +7,9 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper
include ActionView::Context
include SuperAdmin::NavigationHelper
helper_method :render_vue_component
helper_method :render_vue_component, :settings_open?, :settings_pages
# authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin!
@@ -1,5 +1,7 @@
# TODO: Move this values to features.yml itself
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
@@ -32,6 +34,15 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
# ------- Product Features ------- #
help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
# ------- Communication Channels ------- #
live_chat:
name: 'Live Chat'
description: 'Improve your customer experience using a live chat on your website.'
@@ -42,6 +53,12 @@ email:
description: 'Manage your email customer interactions from Chatwoot.'
enabled: true
icon: 'icon-mail-send-fill'
config_key: 'email'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'icon-message-line'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
@@ -69,22 +86,22 @@ line:
description: 'Manage your Line customer interactions from Chatwoot.'
enabled: true
icon: 'icon-line-line'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
# ------- OAuth & Authentication ------- #
google:
name: 'Google'
description: 'Configuration for setting up Google OAuth Integration'
enabled: true
icon: 'icon-message-line'
help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
icon: 'icon-google'
config_key: 'google'
microsoft:
name: 'Microsoft'
description: 'Configuration for setting up Microsoft Email'
enabled: true
icon: 'icon-microsoft'
config_key: 'microsoft'
# ------- Third-party Integrations ------- #
linear:
name: 'Linear'
description: 'Configuration for setting up Linear Integration'
@@ -103,6 +120,12 @@ slack:
enabled: true
icon: 'icon-slack'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'icon-whatsapp-line'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
@@ -1,6 +1,6 @@
module SuperAdmin::FeaturesHelper
def self.available_features
YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access
YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access
end
def self.plan_details
@@ -0,0 +1,16 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def settings_pages
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
general_feature + features.to_a
end
end
@@ -15,14 +15,6 @@ class CaptainDocument extends ApiClient {
},
});
}
uploadPdf(formData) {
return axios.post(`${this.url}/upload_pdf`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new CaptainDocument();
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class WhatsappChannel extends ApiClient {
constructor() {
super('whatsapp', { accountScoped: true });
}
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
}
export default new WhatsappChannel();
@@ -0,0 +1,37 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -0,0 +1,357 @@
<script setup>
import { reactive, computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
// Extract template content - this should be the template message body
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Preview -->
<div
v-if="selectedTemplate"
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -218,10 +218,13 @@ const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
watch(
() => props.contactData?.id,
id => {
if (id) prepareStateBasedOnProps();
},
{ immediate: true }
);
// Expose state to parent component for avatar upload
defineExpose({
@@ -15,8 +15,8 @@ const emit = defineEmits(['click']);
const { t } = useI18n();
const onClick = event => {
emit('click', event);
const onClick = (item, index) => {
emit('click', item, index);
};
</script>
@@ -24,22 +24,25 @@ const onClick = event => {
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<!-- Render as button for all except the last item -->
<button
v-if="index === 0"
v-if="index !== items.length - 1"
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
@click="onClick"
@click="onClick(item, index)"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
</button>
<template v-else>
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<span class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</template>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
</ol>
</nav>
@@ -0,0 +1,91 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -0,0 +1,21 @@
<script setup>
import AddNewRulesDialog from './AddNewRulesDialog.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesDialog"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-4 py-4 bg-n-background h-[200px]">
<AddNewRulesDialog
button-label="Add a guardrail"
placeholder="Type in another guardrail..."
confirm-label="Create"
cancel-label="Cancel"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,77 @@
<script setup>
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
confirmLabel: {
type: String,
default: '',
},
cancelLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const [showPopover, togglePopover] = useToggle();
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div class="inline-flex relative">
<Button
:label="buttonLabel"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
>
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<div class="flex gap-2 justify-between">
<Button
:label="cancelLabel"
sm
link
slate
class="h-10 hover:!no-underline"
@click="onClickCancel"
/>
<Button :label="confirmLabel" sm @click="onClickAdd" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import AddNewRulesInput from './AddNewRulesInput.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesInput"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<AddNewRulesInput
placeholder="Type in another response guideline..."
label="Add and save (↵)"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,51 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
};
</script>
<template>
<div
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
>
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<Button
:label="label"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickAdd"
/>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
allItems: {
type: Array,
required: true,
},
selectAllLabel: {
type: String,
default: '',
},
selectedCountLabel: {
type: String,
default: '',
},
deleteLabel: {
type: String,
default: 'Delete',
},
});
const emit = defineEmits(['bulkDelete']);
const modelValue = defineModel({
type: Set,
default: () => new Set(),
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
);
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
},
});
</script>
<template>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{ selectedCountLabel }}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
<div v-else class="flex items-center gap-3">
<slot name="default-actions" />
</div>
</transition>
</template>
@@ -8,7 +8,6 @@ import { usePolicy } from 'dashboard/composables/usePolicy';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -27,10 +26,6 @@ const props = defineProps({
type: String,
required: true,
},
file: {
type: Object,
default: null,
},
createdAt: {
type: Number,
required: true,
@@ -68,22 +63,6 @@ const menuItems = computed(() => {
const createdAt = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => {
// For PDF uploads, show the filename instead of the generated external_link
if (props.file?.filename) {
return props.file.filename;
}
return props.externalLink;
});
const actualLink = computed(() => {
// For PDF uploads, return the actual file URL; for URLs, return the external_link
if (props.file?.url) {
return props.file.url;
}
return props.externalLink;
});
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
@@ -124,15 +103,12 @@ const handleAction = ({ action, value }) => {
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<a
:href="actualLink"
target="_blank"
rel="noopener noreferrer"
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1 hover:text-n-slate-12 transition-colors"
<span
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
>
<Icon icon="i-lucide-link" class="shrink-0 size-4" />
<span class="truncate">{{ displayLink }}</span>
</a>
<i class="i-ph-link-simple shrink-0" />
<span class="truncate">{{ externalLink }}</span>
</span>
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
@@ -55,6 +55,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -130,7 +134,7 @@ const handleDocumentableClick = () => {
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
<div v-if="!compact" class="flex items-center gap-2">
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
@@ -0,0 +1,37 @@
<script setup>
import RuleCard from './RuleCard.vue';
const sampleRules = [
{ id: 1, content: 'Block sensitive personal information', selectable: true },
{ id: 2, content: 'Reject offensive language', selectable: true },
{ id: 3, content: 'Deflect legal or medical advice', selectable: true },
];
</script>
<template>
<Story
title="Captain/Assistant/RuleCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Selectable List">
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
<RuleCard
v-for="rule in sampleRules"
:id="rule.id"
:key="rule.id"
:content="rule.content"
:selectable="rule.selectable"
@select="id => console.log('Selected rule', id)"
@edit="id => console.log('Edit', id)"
@delete="id => console.log('Delete', id)"
/>
</div>
</Variant>
<Variant title="Non-Selectable">
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
<RuleCard id="4" content="Replies should be friendly and clear." />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { computed, ref, watch } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
content: {
type: String,
required: true,
},
selectable: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'hover', 'edit', 'delete']);
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const isEditing = ref(false);
const editedContent = ref(props.content);
// Local content to display to avoid flicker until parent prop updates on inline edit
const localContent = ref(props.content);
// Keeps localContent in sync when parent updates content prop
watch(
() => props.content,
newVal => {
localContent.value = newVal;
}
);
const startEdit = () => {
isEditing.value = true;
editedContent.value = props.content;
};
const saveEdit = () => {
isEditing.value = false;
// Update local content
localContent.value = editedContent.value;
emit('edit', { id: props.id, content: editedContent.value });
};
</script>
<template>
<CardLayout
selectable
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
layout="row"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-6 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<InlineInput
v-if="isEditing"
v-model="editedContent"
focus-on-mount
custom-input-class="flex items-center gap-2 text-sm text-n-slate-12"
@keyup.enter="saveEdit"
/>
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
{{ localContent }}
</span>
<div class="flex items-center gap-2">
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
<span class="w-px h-4 bg-n-weak" />
<Button
icon="i-lucide-trash"
slate
xs
ghost
@click="emit('delete', id)"
/>
</div>
</CardLayout>
</template>
@@ -0,0 +1,46 @@
<script setup>
import SuggestedRules from './SuggestedRules.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const guidelinesExample = [
{
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
</script>
<template>
<Story
title="Captain/Assistant/SuggestedRules"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Suggested Rules List">
<div class="px-20 py-4 bg-n-background">
<SuggestedRules
title="Example response guidelines"
:items="guidelinesExample"
>
<template #default="{ item }">
<span class="text-sm text-n-slate-12">{{ item.content }}</span>
<Button
label="Add this"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
/>
</template>
</SuggestedRules>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add', 'close']);
const { t } = useI18n();
const onAddClick = () => {
emit('add');
};
const onClickClose = () => {
emit('close');
};
</script>
<template>
<div
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
>
<div class="flex items-center justify-between w-full gap-3 px-4 pb-1 pt-4">
<div class="flex items-center gap-3">
<h5 class="text-sm font-medium text-n-slate-11">{{ title }}</h5>
<span class="h-3 w-px bg-n-weak" />
<Button
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD')"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onAddClick"
/>
</div>
<Button
ghost
xs
slate
icon="i-lucide-x"
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickClose"
/>
</div>
<div
class="flex flex-col items-start divide-y divide-n-strong divide-dashed w-full"
>
<div v-for="item in items" :key="item.content" class="w-full px-4 py-4">
<slot :item="item" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const initialState = {
name: '',
description: '',
productName: '',
features: {
conversationFaqs: false,
memories: false,
},
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) updateStateFromAssistant(newAssistant);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
</label>
<label class="flex items-center gap-2">
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
</div>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleBasicInfoUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { useRouter } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
controlItem: {
type: Object,
default: () => ({}),
},
});
const router = useRouter();
const onClick = name => {
router.push({ name });
};
</script>
<template>
<div
:key="controlItem.name"
class="pt-3 ltr:pl-4 rtl:pr-4 ltr:pr-2 rtl:pl-2 pb-5 gap-2 flex flex-col w-full shadow outline-1 outline outline-n-container rounded-2xl bg-n-solid-2 cursor-pointer"
@click="onClick(controlItem.routeName)"
>
<div class="flex items-center justify-between w-full gap-1 h-8">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ controlItem.name }}
</span>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-chevron-right"
slate
ghost
xs
@click="onClick(controlItem.routeName)"
/>
</div>
</div>
<span class="text-n-slate-11 text-sm leading-[21px] line-clamp-5">
{{ controlItem.description }}
</span>
</div>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const initialState = {
handoffMessage: '',
resolutionMessage: '',
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.temperature = config.temperature || 1;
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
temperature: state.temperature || 1,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (newAssistant) updateStateFromAssistant(newAssistant);
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-6">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div>
<Button
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</template>
@@ -3,40 +3,27 @@ import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
const emit = defineEmits(['close', 'success']);
// Constants
const DOCUMENT_TYPE = {
PDF: 'pdf',
URL: 'url',
};
const store = useStore();
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/createDocument', newDocument);
// Show appropriate success message based on document type
const successMessage =
newDocument.type === DOCUMENT_TYPE.PDF
? t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.SUCCESS_MESSAGE')
: t('CAPTAIN.DOCUMENTS.CREATE.SUCCESS_MESSAGE');
useAlert(successMessage);
emit('success');
await store.dispatch('captainDocuments/create', newDocument);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.data?.message ||
t('CAPTAIN.DOCUMENTS.CREATE.ERROR_MESSAGE');
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
@@ -55,8 +42,8 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="$t('CAPTAIN.DOCUMENTS.CREATE.TITLE')"
:description="$t('CAPTAIN.DOCUMENTS.CREATE.DESCRIPTION')"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
@@ -1,70 +1,34 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { formatBytes } from 'shared/helpers/FileHelper';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import FileUpload from 'dashboard/components-next/fileUpload/FileUpload.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
// Constants
const SOURCE_TYPE = {
URL: 'url',
PDF: 'pdf',
};
const MESSAGE_TYPE = {
ERROR: 'error',
INFO: 'info',
};
const DOCUMENT_TYPE = {
URL: 'url',
PDF: 'pdf',
};
const FIELD_NAME = {
URL: 'url',
ASSISTANT_ID: 'assistantId',
};
const FILE_ACCEPT_TYPE = 'application/pdf';
const MAX_FILE_SIZE_MB = 25;
const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
url: '',
name: '',
assistantId: null,
selectedFile: null,
sourceType: SOURCE_TYPE.URL,
};
const state = reactive({ ...initialState });
const uploadError = ref('');
const validationRules = computed(() => {
const rules = {
assistantId: { required },
};
if (state.sourceType === SOURCE_TYPE.URL) {
rules.url = { required, url, minLength: minLength(1) };
}
return rules;
});
const validationRules = {
url: { required, url, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
@@ -77,227 +41,70 @@ const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const formattedFileSize = computed(() => {
if (!state.selectedFile) return '';
return formatBytes(state.selectedFile.size);
});
const getErrorMessage = (field, errorMessage) => {
return v$.value[field]?.$error ? errorMessage : '';
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
url: getErrorMessage(
FIELD_NAME.URL,
t('CAPTAIN.DOCUMENTS.CREATE.FORM.URL.ERROR')
),
assistantId: getErrorMessage(
FIELD_NAME.ASSISTANT_ID,
t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.ERROR')
),
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => {
if (state.sourceType === SOURCE_TYPE.PDF) {
return {
type: DOCUMENT_TYPE.PDF,
pdf_document: state.selectedFile,
assistant_id: state.assistantId,
};
}
return {
type: DOCUMENT_TYPE.URL,
external_link: state.url,
assistant_id: state.assistantId,
};
};
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
uploadError.value = '';
// Validate form
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
// For PDF uploads, check if file is selected
if (state.sourceType === SOURCE_TYPE.PDF && !state.selectedFile) {
uploadError.value = t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.ERROR');
return;
}
emit('submit', prepareDocumentDetails());
};
// PDF Upload handlers
const handleFileSelected = file => {
state.selectedFile = file;
uploadError.value = '';
};
const handleFileError = error => {
uploadError.value = error;
state.selectedFile = null;
};
const clearSelectedFile = () => {
state.selectedFile = null;
uploadError.value = '';
};
const handleSourceTypeChange = type => {
state.sourceType = type;
uploadError.value = '';
state.selectedFile = null;
state.url = '';
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Source Type Selection -->
<div class="flex flex-col gap-2">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.LABEL') }}
</label>
<div class="flex gap-2">
<button
type="button"
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
:class="{
'ring-2 ring-n-brand text-n-brand border-0':
state.sourceType === SOURCE_TYPE.URL,
'border-2 border-n-slate-7 text-n-slate-11':
state.sourceType !== SOURCE_TYPE.URL,
}"
@click="handleSourceTypeChange(SOURCE_TYPE.URL)"
>
<div class="flex items-center justify-center gap-2">
<Icon icon="i-lucide-link" class="size-5" />
<span class="text-sm font-medium">{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.WEBSITE_URL')
}}</span>
</div>
</button>
<button
type="button"
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
:class="{
'ring-2 ring-n-brand text-n-brand border-0':
state.sourceType === SOURCE_TYPE.PDF,
'border-2 border-n-slate-7 text-n-slate-11':
state.sourceType !== SOURCE_TYPE.PDF,
}"
@click="handleSourceTypeChange(SOURCE_TYPE.PDF)"
>
<div class="flex items-center justify-center gap-2">
<Icon icon="i-lucide-file" class="size-5" />
<span class="text-sm font-medium">{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.PDF_UPLOAD')
}}</span>
</div>
</button>
</div>
</div>
<!-- URL Input (when URL is selected) -->
<div v-if="state.sourceType === SOURCE_TYPE.URL">
<Input
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.CREATE.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.CREATE.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? MESSAGE_TYPE.ERROR : MESSAGE_TYPE.INFO"
/>
</div>
<!-- PDF Upload (when PDF is selected) -->
<div
v-if="state.sourceType === SOURCE_TYPE.PDF"
class="flex flex-col gap-2"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.LABEL') }}
</label>
<!-- File upload area -->
<div v-if="!state.selectedFile">
<FileUpload
:accept="FILE_ACCEPT_TYPE"
:max-size-m-b="MAX_FILE_SIZE_MB"
:placeholder="
t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.PLACEHOLDER')
"
:upload-text="
t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.UPLOAD_TEXT')
"
:drag-text="t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.DROP_TEXT')"
@file-selected="handleFileSelected"
@file-error="handleFileError"
/>
</div>
<!-- Selected file display -->
<div v-else class="p-3 bg-n-alpha-2 rounded-lg border border-n-slate-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Icon icon="i-lucide-file" class="size-5 text-n-ruby-9" />
<div>
<p class="text-sm font-medium text-n-slate-12">
{{ state.selectedFile.name }}
</p>
<p class="text-xs text-n-slate-8">
{{ formattedFileSize }}
</p>
</div>
</div>
<button
type="button"
class="p-1 rounded hover:bg-n-alpha-3 text-n-slate-8 hover:text-n-slate-10"
@click="clearSelectedFile"
>
<Icon icon="i-lucide-x" class="size-4" />
</button>
</div>
</div>
<!-- Upload error -->
<p v-if="uploadError" class="text-sm text-n-ruby-9">{{ uploadError }}</p>
</div>
<!-- Assistant Selection -->
<Input
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.LABEL') }}
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.PLACEHOLDER')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.DOCUMENTS.CREATE.FORM.ACTIONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t('CAPTAIN.DOCUMENTS.CREATE.FORM.ACTIONS.CREATE')"
:label="t('CAPTAIN.FORM.CREATE')"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
@@ -1,174 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
accept: {
type: String,
default: '*',
},
multiple: {
type: Boolean,
default: false,
},
maxSizeMB: {
type: Number,
default: 10,
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
uploadText: {
type: String,
default: '',
},
dragText: {
type: String,
default: '',
},
});
const emit = defineEmits(['fileSelected', 'fileError']);
const { t } = useI18n();
const fileInput = ref(null);
const isDragOver = ref(false);
const acceptTypes = computed(() => props.accept);
const maxSize = computed(() => props.maxSizeMB * 1024 * 1024);
const handleFileClick = () => {
if (!props.disabled) {
fileInput.value?.click();
}
};
const validateFile = file => {
// Check file size
if (file.size > maxSize.value) {
const errorMsg = t(
'CAPTAIN.DOCUMENTS.CREATE.FORM.FILE_UPLOAD.ERROR_TOO_LARGE',
{
fileName: file.name,
maxSize: props.maxSizeMB,
}
);
emit('fileError', errorMsg);
return false;
}
// Check file type if specified
if (
props.accept !== '*' &&
!file.type.match(new RegExp(props.accept.replace(/\*/g, '.*')))
) {
const errorMsg = t(
'CAPTAIN.DOCUMENTS.CREATE.FORM.FILE_UPLOAD.ERROR_TYPE_NOT_SUPPORTED',
{
acceptedTypes: props.accept,
}
);
emit('fileError', errorMsg);
return false;
}
return true;
};
const handleFileChange = event => {
const files = event.target.files;
if (files && files.length > 0) {
const file = files[0];
if (validateFile(file)) {
emit('fileSelected', file);
}
}
// Reset input value to allow selecting the same file again
event.target.value = '';
};
const handleDragOver = event => {
event.preventDefault();
if (!props.disabled) {
isDragOver.value = true;
}
};
const handleDragLeave = event => {
event.preventDefault();
// Only set to false if we're leaving the drop zone completely
if (!event.currentTarget.contains(event.relatedTarget)) {
isDragOver.value = false;
}
};
const handleDrop = event => {
event.preventDefault();
isDragOver.value = false;
if (props.disabled) return;
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (validateFile(file)) {
emit('fileSelected', file);
}
}
};
const displayText = computed(() => {
if (isDragOver.value && props.dragText) {
return props.dragText;
}
return props.uploadText || props.placeholder;
});
</script>
<template>
<div
class="border-2 border-dashed border-n-slate-6 rounded-lg p-6 bg-n-alpha-2 hover:bg-n-alpha-3 cursor-pointer transition-all duration-200 ease-in-out flex flex-col items-center justify-center text-center min-h-[120px] hover:border-n-slate-7"
:class="{
'border-n-blue-9 bg-n-blue-2/50': isDragOver,
'opacity-50 cursor-not-allowed hover:bg-n-alpha-2 hover:border-n-slate-6':
disabled,
}"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="handleFileClick"
>
<input
ref="fileInput"
type="file"
:accept="acceptTypes"
:multiple="multiple"
:disabled="disabled"
class="hidden"
@change="handleFileChange"
/>
<div class="flex flex-col items-center gap-3">
<div class="p-2 rounded-full bg-n-alpha-3">
<Icon icon="i-lucide-file-up" class="size-6 text-n-slate-8" />
</div>
<div>
<p class="text-sm font-medium text-n-slate-11">
{{ displayText }}
</p>
<p class="text-xs text-n-slate-8 mt-1">
{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.FILE_UPLOAD.MAX_SIZE', {
size: maxSizeMB,
})
}}
</p>
</div>
</div>
</div>
</template>
@@ -331,6 +331,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
{
name: 'WhatsApp',
label: t('SIDEBAR.WHATSAPP'),
to: accountScopedRoute('campaigns_whatsapp_index'),
},
],
},
{
@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
const props = defineProps({
@@ -19,19 +19,16 @@ const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
const adjustScroll = () => {
const container = mentionsListContainerRef.value;
const item = container.querySelector(`#mention-item-${selectedIndex.value}`);
if (item) {
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.offsetHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom + 34 > containerBottom) {
container.scrollTop = itemBottom - container.offsetHeight + 34;
nextTick(() => {
const container = mentionsListContainerRef.value;
if (!container) return;
const selectedElement = container.querySelector(
`#mention-item-${selectedIndex.value}`
);
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
}
});
};
const onSelect = () => {
+6
View File
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
@@ -36,6 +37,9 @@ export const FEATURE_FLAGS = {
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
SSO: 'sso',
};
export const PREMIUM_FEATURES = [
@@ -44,4 +48,6 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SSO,
];
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "جلب الصفات المخصصة",
"DESCRIPTION": "سمة مخصصة تتتبع تفاصيل إضافية حول جهات الاتصال أو المحادثات الخاصة بك - مثل خطة الاشتراك أو تاريخ الشراء الأول. يمكنك إضافة أنواع مختلفة من السمات المخصصة، مثل النص أو القوائم أو الأرقام، لالتقاط المعلومات المحددة التي تحتاجها.",
"LEARN_MORE": "تعرف على المزيد حول السمات المخصصة",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال"
},
"ATTRIBUTE_TYPES": {
"TEXT": "النص",
"NUMBER": "العدد",
"LINK": "الرابط",
"DATE": "Date",
"LIST": "القائمة",
"CHECKBOX": "خانة الاختيار"
},
"ADD": {
"TITLE": "إضافة صفة خاصة",
"SUBMIT": "إنشاء",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "فتح المحادثة"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "لا شيء",
"LOW": "منخفضة",
"MEDIUM": "متوسطة",
"HIGH": "عالية",
"URGENT": "عاجل"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "اضغط على زر الإدخال للاختيار",
"ENTER_TO_REMOVE": "اضغط على زر الإدخال للحذف",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "اختر واحدا",
"SELECT": "اختر"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "تغيير الأولوية",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "لا شيء",
"LOW": "منخفضة",
"MEDIUM": "متوسطة",
"HIGH": "عالية",
"URGENT": "عاجل"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Разговор",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Създаване",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Press enter to select",
"ENTER_TO_REMOVE": "Press enter to remove",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Select one",
"SELECT": "Select"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "None",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "S'estan recollint atributs personalitzats",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversa",
"CONTACT": "Contacte"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Llista",
"NUMBER": "Número",
"LINK": "Enllaç",
"DATE": "Date",
"LIST": "Llista",
"CHECKBOX": "Casella de selecció"
},
"ADD": {
"TITLE": "Afegir atribut personalitzat",
"SUBMIT": "Crear",
@@ -150,6 +150,17 @@
"ADD_SLA": "Afegeix SLA",
"OPEN_CONVERSATION": "Obrir conversa"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Ningú",
"LOW": "Baixa",
"MEDIUM": "Mitjana",
"HIGH": "Alta",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Presiona retorn (tecla enter) per seleccionar",
"ENTER_TO_REMOVE": "Presiona retorn (tecla enter) per eliminar",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Selecciona un",
"SELECT": "Selecciona"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Canvia la prioritat",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "Ningú",
"LOW": "Baixa",
"MEDIUM": "Mitjana",
"HIGH": "Alta",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Number",
"LINK": "Link",
"DATE": "Date",
"LIST": "List",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Nic",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Typ zprávy",
"MESSAGE_CONTAINS": "Zpráva obsahuje",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Stiskněte Enter pro vybrání",
"ENTER_TO_REMOVE": "Stiskněte Enter pro odebrání",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Vyberte jeden",
"SELECT": "Select"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Přidat soukromou poznámku",
"SEND_WEBHOOK_EVENT": "Poslat událost webhook"
},
"PRIORITY_TYPES": {
"NONE": "Nic",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Henter brugerdefinerede attributter",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Samtale",
"CONTACT": "Kontakt"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Tekst",
"NUMBER": "Nummer",
"LINK": "Link",
"DATE": "Date",
"LIST": "Liste",
"CHECKBOX": "Afkrydsningsfelt"
},
"ADD": {
"TITLE": "Tilføj Tilpasset Attribut",
"SUBMIT": "Opret",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Åbn samtale"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Ingen",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Tryk enter for at vælge",
"ENTER_TO_REMOVE": "Tryk enter for at fjerne",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Vælg en",
"SELECT": "Vælg"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "Ingen",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Benutzerdefinierte Attribute abrufen",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Konversation",
"CONTACT": "Kontakt"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
"NUMBER": "Nummer",
"LINK": "Link",
"DATE": "Date",
"LIST": "Liste",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Benutzerdefiniertes Attribut hinzufügen",
"SUBMIT": "Erstellen",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Unterhaltung öffnen"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Keine",
"LOW": "Niedrig",
"MEDIUM": "Mittel",
"HIGH": "Hoch",
"URGENT": "Dringend"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Drücken Sie zur Auswahl die Eingabetaste",
"ENTER_TO_REMOVE": "Drücken Sie zum Entfernen die Eingabetaste",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Eines wählen",
"SELECT": "Auswählen"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Priorität ändern",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "Keine",
"LOW": "Niedrig",
"MEDIUM": "Mittel",
"HIGH": "Hoch",
"URGENT": "Dringend"
}
}
}
@@ -5,6 +5,18 @@
"LOADING": "Λήψη προσαρμοσμένων ιδιοτήτων",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Συνομιλία",
"CONTACT": "Επαφές"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Κείμενο",
"NUMBER": "Αριθμός",
"LINK": "Σύνδεσμος",
"DATE": "Date",
"LIST": "Λίστα",
"CHECKBOX": "Checkbox"
},
"ADD": {
"TITLE": "Προσθήκη προσαρμοσμένης ιδιότητας",
"SUBMIT": "Δημιουργία",
@@ -150,6 +150,17 @@
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Άνοιγμα συνομιλίας"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
"OUTGOING": "Outgoing Message"
},
"PRIORITY_TYPES": {
"NONE": "Κανένα",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"MESSAGE_CONTAINS": "Message Contains",
@@ -134,6 +134,7 @@
"MULTISELECT": {
"ENTER_TO_SELECT": "Πάτησε enter για επιλογή",
"ENTER_TO_REMOVE": "Πάτησε enter για αφαίρεση",
"NO_OPTIONS": "List is empty",
"SELECT_ONE": "Επιλέξτε ένα",
"SELECT": "Επιλογή"
}
@@ -481,6 +481,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -99,6 +99,13 @@
"CHANGE_PRIORITY": "Change Priority",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
},
"PRIORITY_TYPES": {
"NONE": "Κανένα",
"LOW": "Low",
"MEDIUM": "Medium",
"HIGH": "High",
"URGENT": "Urgent"
}
}
}
@@ -146,6 +146,7 @@
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
@@ -137,6 +137,70 @@
}
}
},
"WHATSAPP": {
"HEADER_TITLE": "WhatsApp campaigns",
"NEW_CAMPAIGN": "Create campaign",
"EMPTY_STATE": {
"TITLE": "No WhatsApp campaigns are available",
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
},
"CARD": {
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
"CAMPAIGN_DETAILS": {
"SENT_FROM": "Sent from",
"ON": "on"
}
},
"CREATE": {
"TITLE": "Create WhatsApp campaign",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"TEMPLATE": {
"LABEL": "WhatsApp Template",
"PLACEHOLDER": "Select a template",
"INFO": "Select a template to use for this campaign.",
"ERROR": "Template is required",
"PREVIEW_TITLE": "Process {templateName}",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"ERROR": "Scheduled time is required"
},
"BUTTONS": {
"CREATE": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
}
},
"CONFIRM_DELETE": {
"TITLE": "Are you sure to delete?",
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
@@ -5,9 +5,6 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close",
"FILE_SIZE": {
"MB": "MB"
}
"CLOSE": "Close"
}
}
@@ -123,6 +123,50 @@
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."
}
},
"SSO": {
"TITLE": "Single Sign-On",
"DESCRIPTION": "Configure SSO authentication for your organization",
"FORM": {
"ENABLE_SSO": {
"LABEL": "Enable SSO",
"HELP": "Allow users to log in using Single Sign-On"
},
"PROVIDER_NAME": {
"LABEL": "Provider Name",
"PLACEHOLDER": "e.g., 'Okta', 'Azure AD', 'Google Workspace'",
"ERROR": "Please enter a provider name"
},
"LOGIN_URL": {
"LABEL": "SSO Login URL",
"PLACEHOLDER": "https://your-sso-provider.com/login",
"ERROR": "Please enter a valid SSO login URL",
"HELP": "Users will be redirected to this URL for authentication"
},
"LOGOUT_URL": {
"LABEL": "SSO Logout URL (Optional)",
"PLACEHOLDER": "https://your-sso-provider.com/logout",
"HELP": "Users will be redirected to this URL after logout"
},
"SECRET_KEY": {
"LABEL": "Secret Key",
"PLACEHOLDER": "Enter your SSO secret key",
"ERROR": "Please enter a secret key",
"HELP": "Secret key used to verify SSO tokens"
},
"TOKEN_EXPIRY": {
"LABEL": "Token Expiry (minutes)",
"PLACEHOLDER": "5",
"ERROR": "Please enter a valid expiry time",
"HELP": "How long SSO tokens remain valid"
},
"ERROR": "Please fix the form errors"
},
"SUBMIT": "Update SSO Settings",
"UPDATE": {
"SUCCESS": "SSO settings updated successfully",
"ERROR": "Failed to update SSO settings"
}
},
"UPDATE_CHATWOOT": "An update {latestChatwootVersion} for Chatwoot is available. Please update your instance.",
"LEARN_MORE": "Learn more",
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
@@ -222,10 +222,17 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -264,6 +271,28 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@@ -478,6 +478,37 @@
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
},
"SETTINGS": {
"BREADCRUMB": {
"ASSISTANT": "Assistant"
},
"BASIC_SETTINGS": {
"TITLE": "Basic settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
},
"CONTROL_ITEMS": {
"TITLE": "The Fun Stuff",
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
"OPTIONS": {
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic."
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”"
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?"
}
}
}
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
@@ -490,6 +521,100 @@
"TITLE": "Captain Assistant",
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
}
},
"GUARDRAILS": {
"TITLE": "Guardrails",
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
"BREADCRUMB": {
"TITLE": "Guardrails"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example guardrails",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another guardrail..."
},
"NEW": {
"TITLE": "Add a guardrail",
"CREATE": "Create",
"CANCEL": "Cancel",
"PLACEHOLDER": "Type in another guardrail...",
"TEST_ALL": "Test all"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
"ERROR": "There was an error adding guardrails, please try again."
},
"UPDATE": {
"SUCCESS": "Guardrails updated successfully",
"ERROR": "There was an error updating guardrails, please try again."
},
"DELETE": {
"SUCCESS": "Guardrails deleted successfully",
"ERROR": "There was an error deleting guardrails, please try again."
}
}
},
"RESPONSE_GUIDELINES": {
"TITLE": "Response Guidelines",
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
"BREADCRUMB": {
"TITLE": "Response Guidelines"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example response guidelines",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"SAVE": "Add and save (↵)",
"PLACEHOLDER": "Type in another response guideline..."
},
"NEW": {
"TITLE": "Add a response guideline",
"CREATE": "Create",
"CANCEL": "Cancel",
"PLACEHOLDER": "Type in another response guideline...",
"TEST_ALL": "Test all"
}
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
"ERROR": "There was an error adding response guidelines, please try again."
},
"UPDATE": {
"SUCCESS": "Response Guidelines updated successfully",
"ERROR": "There was an error updating response guidelines, please try again."
},
"DELETE": {
"SUCCESS": "Response Guidelines deleted successfully",
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -501,44 +626,9 @@
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
"TITLE": "Create New Document",
"DESCRIPTION": "Add a new document from a website URL or by uploading a PDF file",
"TITLE": "Add a document",
"SUCCESS_MESSAGE": "The document has been successfully created",
"ERROR_MESSAGE": "There was an error creating the document, please try again.",
"FORM": {
"SOURCE_TYPE": {
"LABEL": "Choose Source Type",
"WEBSITE_URL": "Website URL",
"PDF_UPLOAD": "PDF Upload"
},
"PDF_UPLOAD": {
"LABEL": "Upload PDF Document",
"SUCCESS_MESSAGE": "PDF uploaded successfully! Processing will begin shortly.",
"ERROR": "Please select a PDF file to upload.",
"PLACEHOLDER": "Select a PDF file",
"UPLOAD_TEXT": "Click to select PDF or drag and drop",
"DROP_TEXT": "Drop PDF file here"
},
"ASSISTANT": {
"LABEL": "Select Assistant",
"PLACEHOLDER": "Choose an assistant",
"ERROR": "Please select an assistant"
},
"URL": {
"LABEL": "Website URL",
"PLACEHOLDER": "Enter the website URL to crawl",
"ERROR": "Please enter a valid URL"
},
"FILE_UPLOAD": {
"MAX_SIZE": "Maximum file size: {size}MB",
"ERROR_TOO_LARGE": "File \"{fileName}\" is too large. Maximum size is {maxSize}MB.",
"ERROR_TYPE_NOT_SUPPORTED": "File type not supported. Only {acceptedTypes} files are allowed."
},
"ACTIONS": {
"CANCEL": "Cancel",
"CREATE": "Create Document"
}
}
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"URL": {
@@ -20,6 +20,9 @@
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
},
"SSO": {
"BUTTON_TEXT": "Login with {provider}"
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"

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