Merge branch 'release/3.9.0'

This commit is contained in:
Sojan
2024-05-15 22:32:12 -07:00
825 changed files with 13079 additions and 6567 deletions

View File

@@ -23,6 +23,9 @@ FORCE_SSL=false
ENABLE_ACCOUNT_SIGNUP=false
# Redis config
# specify the configs via single URL or individual variables
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
# You can also use the following format for the URL: redis://:password@host:port/db_number
REDIS_URL=redis://redis:6379
# If you are using docker-compose, set this variable's value to be any string,
# which will be the password for the redis service running inside the docker-compose
@@ -180,8 +183,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Sentry
# SENTRY_DSN=
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
@@ -251,9 +252,6 @@ AZURE_APP_SECRET=
## OpenAI key
# OPENAI_API_KEY=
# Sentiment analysis model file path
SENTIMENT_FILE_PATH=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed

13
Gemfile
View File

@@ -71,13 +71,13 @@ gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth'
gem 'devise_token_auth', '>= 1.2.3'
# authorization
gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-active_storage', '>= 1.0.2'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
@@ -113,10 +113,10 @@ gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.2.1'
gem 'sidekiq', '>= 7.2.4'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
@@ -166,7 +166,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
# adds cosine similarity to postgres using vector extension
@@ -175,9 +175,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
# Sentiment analysis
gem 'informers'
### Gems required only in specific deployment environments ###
##############################################################

View File

@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.1)
administrate-field-active_storage (1.0.2)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -148,10 +148,10 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.1.1)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.7)
bindex (0.8.1)
blingfire (0.1.8)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
@@ -200,10 +200,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_token_auth (1.2.1)
devise_token_auth (1.2.3)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.1)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.0)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
@@ -316,15 +316,15 @@ GEM
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.2)
google-protobuf (3.25.2-arm64-darwin)
google-protobuf (3.25.2-x86_64-darwin)
google-protobuf (3.25.2-x86_64-linux)
google-protobuf (3.25.3)
google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.11.0)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
@@ -335,14 +335,17 @@ GEM
signet (>= 0.16, < 2.a)
groupdate (6.2.1)
activesupport (>= 5.2)
grpc (1.54.3)
google-protobuf (~> 3.21)
grpc (1.62.0)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-darwin)
google-protobuf (~> 3.21)
grpc (1.62.0-arm64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.54.3-x86_64-linux)
google-protobuf (~> 3.21)
grpc (1.62.0-x86_64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-linux)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (1.0.0)
@@ -366,15 +369,11 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.4)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
informers (0.2.0)
blingfire (>= 0.1.7)
numo-narray
onnxruntime (>= 0.5.1)
io-console (0.6.0)
irb (1.7.2)
reline (>= 0.3.6)
@@ -449,13 +448,13 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.2)
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.0.0)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
@@ -475,7 +474,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.9)
net-imap (0.4.11)
date
net-protocol
net-pop (0.1.2)
@@ -490,17 +489,16 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.0)
nokogiri (1.16.4)
nio4r (2.7.3)
nokogiri (1.16.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.4-arm64-darwin)
nokogiri (1.16.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-darwin)
nokogiri (1.16.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
@@ -526,17 +524,9 @@ GEM
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
onnxruntime (0.7.6)
ffi
onnxruntime (0.7.6-arm64-darwin)
ffi
onnxruntime (0.7.6-x86_64-darwin)
ffi
onnxruntime (0.7.6-x86_64-linux)
ffi
openssl (3.1.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -614,7 +604,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.19.1)
redis-client (0.22.1)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -713,18 +703,19 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.14.0)
sentry-rails (5.17.3)
railties (>= 5.0)
sentry-ruby (~> 5.14.0)
sentry-ruby (5.14.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.14.0)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.2.1)
sidekiq (7.2.4)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
@@ -828,7 +819,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.13)
zeitwerk (2.6.14)
PLATFORMS
arm64-darwin-20
@@ -846,7 +837,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.1)
administrate-field-active_storage (>= 1.0.2)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -869,7 +860,7 @@ DEPENDENCIES
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
devise_token_auth
devise_token_auth (>= 1.2.3)
dotenv-rails
down
elastic-apm
@@ -892,7 +883,6 @@ DEPENDENCIES
hashie
html2text!
image_processing
informers
jbuilder
json_refs
json_schemer
@@ -914,7 +904,7 @@ DEPENDENCIES
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
pg_search
pgvector
@@ -943,9 +933,9 @@ DEPENDENCIES
seed_dump
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.14.0)
sentry-sidekiq (>= 5.15.0)
shoulda-matchers
sidekiq (>= 7.2.1)
sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)

View File

@@ -55,7 +55,7 @@
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:mini"
"plan": "heroku-postgresql:essential-0"
}
],
"stack": "heroku-20",

View File

@@ -19,9 +19,9 @@ class ContactInboxWithContactBuilder
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
private

View File

@@ -21,6 +21,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
end
def permitted_params
params.permit(:type, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end
end

View File

@@ -46,7 +46,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] }
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
head :ok, message: I18n.t('errors.contacts.export.success')
end
@@ -61,7 +62,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end
def filter
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts(contacts)

View File

@@ -0,0 +1,28 @@
class Api::V1::Accounts::Conversations::DraftMessagesController < Api::V1::Accounts::Conversations::BaseController
def show
render json: { has_draft: false } and return unless Redis::Alfred.exists?(draft_redis_key)
draft_message = Redis::Alfred.get(draft_redis_key)
render json: { has_draft: true, message: draft_message }
end
def update
Redis::Alfred.set(draft_redis_key, draft_message_params)
head :ok
end
def destroy
Redis::Alfred.delete(draft_redis_key)
head :ok
end
private
def draft_redis_key
format(Redis::Alfred::CONVERSATION_DRAFT_MESSAGE, id: @conversation.id)
end
def draft_message_params
params.dig(:draft_message, :message) || ''
end
end

View File

@@ -6,6 +6,12 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def show; end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
# if help_center_url does not contain a protocol, prepend it with https
@help_center_url = "https://#{@help_center_url}" unless @help_center_url.include?('://')
end
private
def portal

View File

@@ -10,14 +10,15 @@ class AsyncDispatcher < BaseDispatcher
def listeners
[
AutomationRuleListener.instance,
CampaignListener.instance,
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance,
AutomationRuleListener.instance
WebhookListener.instance
]
end
end

View File

@@ -1,7 +1,7 @@
module Api::V2::Accounts::ReportsHelper
def generate_agents_report
Current.account.users.map do |agent|
agent_report = generate_report({ type: :agent, id: agent.id })
agent_report = report_builder({ type: :agent, id: agent.id }).summary
[agent.name] + generate_readable_report_metrics(agent_report)
end
end
@@ -15,7 +15,7 @@ module Api::V2::Accounts::ReportsHelper
def generate_teams_report
Current.account.teams.map do |team|
team_report = generate_report({ type: :team, id: team.id })
team_report = report_builder({ type: :team, id: team.id }).summary
[team.name] + generate_readable_report_metrics(team_report)
end
end
@@ -27,7 +27,7 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_report(report_params)
def report_builder(report_params)
V2::ReportBuilder.new(
Current.account,
report_params.merge(
@@ -37,7 +37,11 @@ module Api::V2::Accounts::ReportsHelper
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
).short_summary
)
end
def generate_report(report_params)
report_builder(report_params).short_summary
end
private
@@ -46,7 +50,9 @@ module Api::V2::Accounts::ReportsHelper
[
report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
report_metric[:resolutions_count]
]
end
end

View File

@@ -77,8 +77,8 @@ class ContactAPI extends ApiClient {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
exportContacts() {
return axios.get(`${this.url}/export`);
exportContacts(queryPayload) {
return axios.post(`${this.url}/export`, queryPayload);
}
}

View File

@@ -40,3 +40,347 @@
.hide {
@apply hidden;
}
@layer base {
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
--color-amber-75: 255 243 208;
--color-amber-100: 255 236 183;
--color-amber-200: 255 224 161;
--color-amber-300: 245 208 140;
--color-amber-400: 228 187 120;
--color-amber-500: 214 163 92;
--color-amber-600: 214 163 92;
--color-amber-700: 255 186 26;
--color-amber-800: 145 89 48;
--color-amber-900: 79 52 34;
--color-ash-100: 235 235 239;
--color-ash-200: 228 228 233;
--color-ash-25: 252 252 253;
--color-ash-300: 221 221 227;
--color-ash-400: 211 212 219;
--color-ash-50: 249 249 251;
--color-ash-500: 185 187 198;
--color-ash-600: 139 141 152;
--color-ash-700: 126 128 138;
--color-ash-75: 242 242 245;
--color-ash-800: 96 100 108;
--color-ash-900: 28 32 36;
--color-primary-25: 251 253 255;
--color-primary-50: 245 249 255;
--color-primary-75: 233 243 255;
--color-primary-100: 218 236 255;
--color-primary-200: 201 226 255;
--color-primary-300: 181 213 255;
--color-primary-400: 155 195 252;
--color-primary-500: 117 171 247;
--color-primary-600: 39 129 246;
--color-primary-700: 16 115 233;
--color-primary-800: 8 109 224;
--color-primary-900: 11 50 101;
--color-ruby-100: 255 220 225;
--color-ruby-200: 255 206 214;
--color-ruby-25: 255 252 253;
--color-ruby-300: 248 191 200;
--color-ruby-400: 239 172 184;
--color-ruby-50: 255 247 248;
--color-ruby-500: 229 146 163;
--color-ruby-600: 229 70 102;
--color-ruby-700: 220 59 93;
--color-ruby-75: 254 234 237;
--color-ruby-800: 202 36 77;
--color-ruby-900: 100 23 43;
--color-teal-100: 224 248 243;
--color-teal-200: 204 243 234;
--color-teal-25: 250 254 253;
--color-teal-300: 184 234 224;
--color-teal-400: 161 222 210;
--color-teal-50: 243 251 249;
--color-teal-500: 83 185 171;
--color-teal-600: 18 165 148;
--color-teal-700: 13 155 138;
--color-teal-75: 236 249 255;
--color-teal-800: 0 133 115;
--color-teal-900: 13 61 56;
--color-green-25: 251 254 252;
--color-green-50: 244 251 246;
--color-green-75: 230 246 235;
--color-green-100: 214 241 223;
--color-green-200: 196 232 209;
--color-green-300: 173 221 192;
--color-green-400: 142 206 170;
--color-green-500: 91 185 139;
--color-green-600: 48 164 108;
--color-green-700: 43 154 102;
--color-green-800: 33 131 88;
--color-green-900: 25 59 45;
--color-mint-25: 249 254 253;
--color-mint-50: 242 251 249;
--color-mint-75: 221 249 242;
--color-mint-100: 200 244 233;
--color-mint-200: 179 236 222;
--color-mint-300: 156 224 208;
--color-mint-400: 126 207 189;
--color-mint-500: 76 187 165;
--color-mint-600: 134 234 212;
--color-mint-700: 125 224 203;
--color-mint-800: 2 120 100;
--color-mint-900: 22 67 60;
--color-sky-25: 249 254 255;
--color-sky-50: 241 250 253;
--color-sky-75: 225 246 253;
--color-sky-100: 209 240 250;
--color-sky-200: 190 231 245;
--color-sky-300: 169 218 237;
--color-sky-400: 141 202 227;
--color-sky-500: 96 179 215;
--color-sky-600: 124 226 254;
--color-sky-700: 116 218 248;
--color-sky-800: 0 116 158;
--color-sky-900: 29 62 86;
--color-indigo-25: 253 253 254;
--color-indigo-50: 247 249 255;
--color-indigo-75: 237 242 254;
--color-indigo-100: 225 233 255;
--color-indigo-200: 210 222 255;
--color-indigo-300: 193 208 255;
--color-indigo-400: 171 189 249;
--color-indigo-500: 141 164 239;
--color-indigo-600: 62 99 221;
--color-indigo-700: 51 88 212;
--color-indigo-800: 58 91 199;
--color-indigo-900: 31 45 92;
--color-iris-25: 253 253 255;
--color-iris-50: 248 248 255;
--color-iris-75: 240 241 254;
--color-iris-100: 230 231 255;
--color-iris-200: 218 220 255;
--color-iris-300: 203 205 255;
--color-iris-400: 184 186 248;
--color-iris-500: 155 158 240;
--color-iris-600: 91 91 214;
--color-iris-700: 81 81 205;
--color-iris-800: 87 83 198;
--color-iris-900: 39 41 98;
--color-violet-25: 253 252 254;
--color-violet-50: 250 248 255;
--color-violet-75: 244 240 254;
--color-violet-100: 235 228 255;
--color-violet-200: 225 217 255;
--color-violet-300: 212 202 254;
--color-violet-400: 194 181 245;
--color-violet-500: 170 153 236;
--color-violet-600: 110 86 207;
--color-violet-700: 101 77 196;
--color-violet-800: 101 80 185;
--color-violet-900: 47 38 95;
--color-pink-25: 255 252 254;
--color-pink-50: 254 247 251;
--color-pink-75: 254 233 245;
--color-pink-100: 251 220 239;
--color-pink-200: 246 206 231;
--color-pink-300: 239 191 221;
--color-pink-400: 231 172 208;
--color-pink-500: 221 147 194;
--color-pink-600: 214 64 159;
--color-pink-700: 207 56 151;
--color-pink-800: 194 41 138;
--color-pink-900: 101 18 73;
--color-orange-25: 254 252 251;
--color-orange-50: 255 247 237;
--color-orange-75: 255 239 214;
--color-orange-100: 255 223 181;
--color-orange-200: 255 209 154;
--color-orange-300: 255 193 130;
--color-orange-400: 245 174 115;
--color-orange-500: 236 148 85;
--color-orange-600: 247 107 21;
--color-orange-700: 239 95 0;
--color-orange-800: 204 78 0;
--color-orange-900: 88 45 29;
}
// scss-lint:disable QualifyingElement
body.dark {
--color-amber-25: 31 19 0;
--color-amber-50: 37 24 4;
--color-amber-75: 48 32 11;
--color-amber-100: 57 39 15;
--color-amber-200: 67 46 18;
--color-amber-300: 83 57 22;
--color-amber-400: 111 77 29;
--color-amber-500: 169 118 42;
--color-amber-600: 169 118 42;
--color-amber-700: 255 203 71;
--color-amber-800: 255 204 77;
--color-amber-900: 255 231 179;
--color-ash-100: 46 48 53;
--color-ash-200: 53 55 60;
--color-ash-25: 24 24 26;
--color-ash-300: 60 63 68;
--color-ash-400: 70 75 80;
--color-ash-50: 27 27 31;
--color-ash-500: 90 97 101;
--color-ash-600: 105 110 119;
--color-ash-700: 120 127 133;
--color-ash-75: 39 40 45;
--color-ash-800: 173 177 184;
--color-ash-900: 237 238 240;
--color-primary-25: 10 17 28;
--color-primary-50: 15 24 38;
--color-primary-75: 15 39 72;
--color-primary-100: 10 49 99;
--color-primary-200: 18 61 117;
--color-primary-300: 29 74 134;
--color-primary-400: 40 89 156;
--color-primary-500: 48 106 186;
--color-primary-600: 39 129 246;
--color-primary-700: 21 116 231;
--color-primary-800: 126 182 255;
--color-primary-900: 205 227 255;
--color-ruby-100: 78 19 37;
--color-ruby-200: 94 26 46;
--color-ruby-25: 25 17 19;
--color-ruby-300: 111 37 57;
--color-ruby-400: 136 52 71;
--color-ruby-50: 30 21 23;
--color-ruby-500: 179 68 90;
--color-ruby-600: 229 70 102;
--color-ruby-700: 236 90 114;
--color-ruby-75: 58 20 30;
--color-ruby-800: 255 148 157;
--color-ruby-900: 254 210 225;
--color-teal-100: 2 59 55;
--color-teal-200: 8 72 67;
--color-teal-25: 13 21 20;
--color-teal-300: 28 105 97;
--color-teal-400: 28 105 97;
--color-teal-50: 17 28 27;
--color-teal-500: 32 126 115;
--color-teal-600: 41 163 131;
--color-teal-700: 14 179 158;
--color-teal-75: 13 45 42;
--color-teal-800: 11 216 182;
--color-teal-900: 173 240 221;
--color-green-25: 14 21 18;
--color-green-50: 18 27 23;
--color-green-75: 19 45 33;
--color-green-100: 17 59 41;
--color-green-200: 23 73 51;
--color-green-300: 32 87 62;
--color-green-400: 40 104 74;
--color-green-500: 47 124 87;
--color-green-600: 48 164 108;
--color-green-700: 51 176 116;
--color-green-800: 61 214 140;
--color-green-900: 177 241 203;
--color-mint-25: 14 21 21;
--color-mint-50: 15 27 27;
--color-mint-75: 9 44 43;
--color-mint-100: 0 58 56;
--color-mint-200: 0 71 68;
--color-mint-300: 16 86 80;
--color-mint-400: 30 104 95;
--color-mint-500: 39 127 112;
--color-mint-600: 134 234 212;
--color-mint-700: 168 245 229;
--color-mint-800: 88 213 186;
--color-mint-900: 196 245 225;
--color-sky-25: 14 21 21;
--color-sky-50: 15 27 27;
--color-sky-75: 9 44 43;
--color-sky-100: 0 58 56;
--color-sky-200: 0 71 68;
--color-sky-300: 16 86 80;
--color-sky-400: 30 104 95;
--color-sky-500: 39 127 112;
--color-sky-600: 134 234 212;
--color-sky-700: 168 245 229;
--color-sky-800: 88 213 186;
--color-sky-900: 196 245 225;
--color-indigo-25: 17 19 31;
--color-indigo-50: 20 23 38;
--color-indigo-75: 24 36 73;
--color-indigo-100: 29 46 98;
--color-indigo-200: 37 57 116;
--color-indigo-300: 48 67 132;
--color-indigo-400: 58 79 151;
--color-indigo-500: 67 93 177;
--color-indigo-600: 62 99 221;
--color-indigo-700: 84 114 228;
--color-indigo-800: 158 177 255;
--color-indigo-900: 214 225 255;
--color-iris-25: 19 19 30;
--color-iris-50: 23 22 37;
--color-iris-75: 32 34 72;
--color-iris-100: 38 42 101;
--color-iris-200: 48 51 116;
--color-iris-300: 61 62 130;
--color-iris-400: 74 74 149;
--color-iris-500: 89 88 177;
--color-iris-600: 91 91 214;
--color-iris-700: 110 106 222;
--color-iris-800: 177 169 255;
--color-iris-900: 224 223 254;
--color-violet-25: 20 18 31;
--color-violet-50: 27 21 37;
--color-violet-75: 41 31 67;
--color-violet-100: 51 37 91;
--color-violet-200: 60 46 105;
--color-violet-300: 71 56 118;
--color-violet-400: 86 70 139;
--color-violet-500: 105 88 173;
--color-violet-600: 110 86 207;
--color-violet-700: 125 102 217;
--color-violet-800: 186 167 255;
--color-violet-900: 226 221 254;
--color-pink-25: 25 17 23;
--color-pink-50: 33 18 29;
--color-pink-75: 55 23 47;
--color-pink-100: 75 20 61;
--color-pink-200: 89 28 71;
--color-pink-300: 105 41 85;
--color-pink-400: 131 56 105;
--color-pink-500: 168 72 133;
--color-pink-600: 214 64 159;
--color-pink-700: 222 81 168;
--color-pink-800: 255 141 204;
--color-pink-900: 253 209 234;
--color-orange-25: 23 18 14;
--color-orange-50: 30 22 15;
--color-orange-75: 51 30 11;
--color-orange-100: 70 33 0;
--color-orange-200: 86 40 0;
--color-orange-300: 102 53 12;
--color-orange-400: 126 69 29;
--color-orange-500: 163 88 41;
--color-orange-600: 247 107 21;
--color-orange-700: 255 128 31;
--color-orange-800: 255 160 87;
--color-orange-900: 255 224 194;
}
}

View File

@@ -12,7 +12,6 @@ h6 {
p {
text-rendering: optimizeLegibility;
word-spacing: 0.12em;
@apply mb-2 leading-[1.65] text-sm;

View File

@@ -1,9 +1,9 @@
<template>
<button
class="sm:w-[50%] md:w-1/3 lg:w-1/4 bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-[50%] my-4 mx-auto" />
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>

View File

@@ -185,7 +185,7 @@ import ConversationBasicFilter from './widgets/conversation/ConversationBasicFil
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
@@ -199,11 +199,6 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
@@ -228,7 +223,7 @@ export default {
mixins: [
timeMixin,
conversationMixin,
eventListenerMixins,
keyboardEventListenerMixins,
alertMixin,
filterMixin,
uiSettingsMixin,
@@ -293,7 +288,6 @@ export default {
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
isContextMenuOpen: false,
appliedFilter: [],
@@ -334,6 +328,7 @@ export default {
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
selectedConversations: 'bulkActions/getSelectedConversationIds',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
@@ -691,30 +686,40 @@ export default {
lastConversationIndex,
};
},
handleKeyEvents(e) {
if (hasPressedAltAndJKey(e)) {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
handlePreviousConversation() {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (hasPressedAltAndKKey(e)) {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
},
handleNextConversation() {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
},
getKeyboardEvents() {
return {
'Alt+KeyJ': {
action: () => this.handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => this.handleNextConversation(),
allowOnFocusedInput: true,
},
};
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
@@ -794,7 +799,7 @@ export default {
});
},
resetBulkActions() {
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedInboxes = [];
},
onBasicFilterChange(value, type) {
@@ -825,12 +830,16 @@ export default {
return this.selectedConversations.includes(id);
},
selectConversation(conversationId, inboxId) {
this.selectedConversations.push(conversationId);
this.$store.dispatch(
'bulkActions/setSelectedConversationIds',
conversationId
);
this.selectedInboxes.push(inboxId);
},
deSelectConversation(conversationId, inboxId) {
this.selectedConversations = this.selectedConversations.filter(
item => item !== conversationId
this.$store.dispatch(
'bulkActions/removeSelectedConversationIds',
conversationId
);
this.selectedInboxes = this.selectedInboxes.filter(
item => item !== inboxId
@@ -838,7 +847,10 @@ export default {
},
selectAllConversations(check) {
if (check) {
this.selectedConversations = this.conversationList.map(item => item.id);
this.$store.dispatch(
'bulkActions/setSelectedConversationIds',
this.conversationList.map(item => item.id)
);
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
} else {
this.resetBulkActions();
@@ -854,7 +866,7 @@ export default {
assignee_id: agent.id,
},
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
this.showAlert(
this.$t(
@@ -952,7 +964,7 @@ export default {
add: labels,
},
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
this.showAlert(
this.$t(
@@ -979,13 +991,13 @@ export default {
team_id: team.id,
},
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status) {
async onUpdateConversations(status, snoozedUntil) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
@@ -993,8 +1005,9 @@ export default {
fields: {
status,
},
snoozed_until: snoozedUntil,
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));

View File

@@ -12,7 +12,7 @@
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full font-medium text-sm mb-0"
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
:class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
@@ -20,6 +20,11 @@
"
>
{{ label }}
<helper-text-popup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<woot-button
v-if="showCopyAndDeleteButton"
@@ -41,7 +46,7 @@
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:rounded-r-none rtl:rounded-l-none !mb-0 !text-sm"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: $v.editedValue.$error }"
@blur="$v.editedValue.$touch"
@@ -130,22 +135,25 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin, clickaway],
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false },

View File

@@ -0,0 +1,31 @@
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-ash-900">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-ash-200" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-ash-900">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>

View File

@@ -88,15 +88,9 @@
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
hasPressedCommandPlusAltAndEKey,
hasPressedAltAndMKey,
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -114,7 +108,7 @@ export default {
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [clickaway, alertMixin, eventListenerMixins],
mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
@@ -159,37 +153,52 @@ export default {
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
async handleKeyEvents(e) {
getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
if (hasPressedAltAndMKey(e)) {
if (this.$refs.arrowDownButton) {
this.$refs.arrowDownButton.$el.click();
}
}
if (hasPressedAltAndEKey(e)) {
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
}
if (hasPressedCommandPlusAltAndEKey(e)) {
if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
} else if (allConversations.length > 1) {
allConversations[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
e.preventDefault();
}
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
},
async resolveOrToast() {
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
}
},
onCmdSnoozeConversation(snoozeType) {

View File

@@ -24,6 +24,7 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
@@ -51,6 +52,7 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View File

@@ -47,7 +47,6 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -67,7 +66,7 @@ export default {
AvailabilityStatusBadge,
},
mixins: [clickaway, alertMixin],
mixins: [alertMixin],
data() {
return {

View File

@@ -1,5 +1,5 @@
<template>
<aside class="h-full flex">
<aside class="flex h-full">
<primary-sidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
@@ -36,15 +36,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import {
hasPressedAltAndCKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
hasPressedAltAndVKey,
hasPressedCommandAndForwardSlash,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import router from '../../routes';
export default {
@@ -52,7 +44,7 @@ export default {
PrimarySidebar,
SecondarySidebar,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
mixins: [adminMixin, alertMixin, keyboardEventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
@@ -173,30 +165,27 @@ export default {
closeKeyShortcutModal() {
this.$emit('close-key-shortcut-modal');
},
handleKeyEvents(e) {
if (hasPressedCommandAndForwardSlash(e)) {
this.toggleKeyShortcutModal();
}
if (isEscape(e)) {
this.closeKeyShortcutModal();
}
if (hasPressedAltAndCKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('home')) {
router.push({ name: 'home' });
}
} else if (hasPressedAltAndVKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) {
router.push({ name: 'contacts_dashboard' });
}
} else if (hasPressedAltAndRKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) {
router.push({ name: 'settings_account_reports' });
}
} else if (hasPressedAltAndSKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('agent_list')) {
router.push({ name: 'agent_list' });
}
getKeyboardEvents() {
return {
'$mod+Slash': this.toggleKeyShortcutModal,
'$mod+Escape': this.closeKeyShortcutModal,
'Alt+KeyC': {
action: () => this.navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => this.navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => this.navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => this.navigateToRoute('agent_list'),
},
};
},
navigateToRoute(routeName) {
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
},
isCurrentRouteSameAsNavigation(routeName) {

View File

@@ -106,7 +106,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
@@ -119,7 +118,6 @@ export default {
WootDropdownItem,
AvailabilityStatus,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,

View File

@@ -0,0 +1,304 @@
<script setup>
import { ref, watch } from 'vue';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from './helpers/DatePickerHelper';
import {
isValid,
startOfMonth,
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
setMonth,
setYear,
isAfter,
} from 'date-fns';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
import CalendarYear from './components/CalendarYear.vue';
import CalendarMonth from './components/CalendarMonth.vue';
import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
);
const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS);
const hoveredEndDate = ref(null);
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
const emit = defineEmits(['change']);
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// Watcher will set the input values based on the selected start and end dates
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
}
},
{ immediate: true }
);
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
watch(
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
},
{ immediate: true, deep: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
};
const moveCalendar = (calendar, direction, period = MONTH) => {
const { start, end } = moveCalendarDate(
calendar,
startCurrentDate.value,
endCurrentDate.value,
direction,
period
);
startCurrentDate.value = start;
endCurrentDate.value = end;
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
selectingEndDate.value = true;
} else {
selectedEndDate.value = day;
selectingEndDate.value = false;
}
};
const setViewMode = (calendar, mode) => {
selectedRange.value = CUSTOM_RANGE;
calendarViews.value[calendar] = mode;
};
const openCalendar = (index, calendarType, period = MONTH) => {
const current =
calendarType === START_CALENDAR
? startCurrentDate.value
: endCurrentDate.value;
const newDate =
period === MONTH
? setMonth(startOfMonth(current), index)
: setYear(current, index);
if (calendarType === START_CALENDAR) {
startCurrentDate.value = newDate;
} else {
endCurrentDate.value = newDate;
}
setViewMode(calendarType, period === MONTH ? WEEK : MONTH);
};
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = newDate;
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = newDate;
}
selectingEndDate.value = false;
};
const handleManualInputError = message => {
bus.$emit('newToastMessage', message);
};
const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
// Reset view modes if they are being used to toggle between different calendar views
calendarViews.value = { start: WEEK, end: WEEK };
};
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
bus.$emit('newToastMessage', 'Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
</script>
<template>
<div class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
@open="showDatePicker = !showDatePicker"
/>
<div
v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
<div
v-for="calendar in [START_CALENDAR, END_CALENDAR]"
:key="`${calendar}-calendar`"
class="flex flex-col items-center"
>
<CalendarDateInput
:calendar-type="calendar"
:date-value="
calendar === START_CALENDAR ? manualStartDate : manualEndDate
"
:compare-date="
calendar === START_CALENDAR ? manualEndDate : manualStartDate
"
:is-disabled="selectedRange !== CUSTOM_RANGE"
@update="
calendar === START_CALENDAR
? (manualStartDate = $event)
: (manualEndDate = $event)
"
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
"
>
<CalendarYear
v-if="calendarViews[calendar] === YEAR"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-year="openCalendar($event, calendar, YEAR)"
/>
<CalendarMonth
v-else-if="calendarViews[calendar] === MONTH"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-month="openCalendar($event, calendar)"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev', YEAR)"
@next="moveCalendar(calendar, 'next', YEAR)"
/>
<CalendarWeek
v-else-if="calendarViews[calendar] === WEEK"
:calendar-type="calendar"
:current-date="currentDate"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selecting-end-date="selectingEndDate"
:hovered-end-date="hoveredEndDate"
@update-hovered-end-date="hoveredEndDate = $event"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev')"
@next="moveCalendar(calendar, 'next')"
/>
</div>
</div>
</div>
</div>
<CalendarFooter @change="emitDateRange" @clear="resetDatePicker" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
defineProps({
calendarType: {
type: String,
default: 'start',
},
firstButtonLabel: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
viewMode: {
type: String,
default: '',
},
});
const emit = defineEmits(['prev', 'next', 'set-view']);
const { YEAR } = CALENDAR_PERIODS;
const onClickPrev = type => {
emit('prev', type);
};
const onClickNext = type => {
emit('next', type);
};
const onClickSetView = (type, mode) => {
emit('set-view', type, mode);
};
</script>
<template>
<div class="flex items-start justify-between w-full h-9">
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickPrev(calendarType)"
>
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickNext(calendarType)"
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { computed } from 'vue';
import { parse, isValid, isAfter, isBefore } from 'date-fns';
import {
getIntlDateFormatForLocale,
CALENDAR_TYPES,
} from '../helpers/DatePickerHelper';
const props = defineProps({
calendarType: {
type: String,
default: '',
},
dateValue: Date,
compareDate: Date,
isDisabled: Boolean,
});
const emit = defineEmits(['update', 'validate', 'error']);
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const dateFormat = computed(() => getIntlDateFormatForLocale()?.toUpperCase());
const localDateValue = computed({
get: () => props.dateValue?.toLocaleDateString(navigator.language) || '',
set: newValue => {
const format = getIntlDateFormatForLocale();
const parsedDate = parse(newValue, format, new Date());
if (isValid(parsedDate)) {
emit('update', parsedDate);
}
},
});
const validateDate = () => {
if (!isValid(props.dateValue)) {
emit('error', `Please enter the date in valid format: ${dateFormat.value}`);
return;
}
const { calendarType, compareDate, dateValue } = props;
const isStartCalendar = calendarType === START_CALENDAR;
const isEndCalendar = calendarType === END_CALENDAR;
if (compareDate && isStartCalendar && isAfter(dateValue, compareDate)) {
emit('error', 'Start date must be before the end date.');
} else if (compareDate && isEndCalendar && isBefore(dateValue, compareDate)) {
emit('error', 'End date must be after the start date.');
} else {
emit('validate', dateValue);
}
};
</script>
<template>
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{
calendarType === START_CALENDAR
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
: $t('DATE_PICKER.DATE_RANGE_INPUT.END')
}}
</span>
<input
v-model="localDateValue"
type="text"
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
/>
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script setup>
import { dateRanges } from '../helpers/DatePickerHelper';
defineProps({
selectedRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['set-range']);
const setDateRange = range => {
emit('set-range', range);
};
</script>
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
:class="
range.value === selectedRange
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
const emit = defineEmits(['clear', 'apply']);
const onClickClear = () => {
emit('clear');
};
const onClickApply = () => {
emit('change');
};
</script>
<template>
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
@click="onClickClear"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
@click="onClickApply"
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { format, getMonth, setMonth, startOfMonth } from 'date-fns';
import {
yearName,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH, YEAR } = CALENDAR_PERIODS;
const months = Array.from({ length: 12 }, (_, index) =>
format(setMonth(startOfMonth(new Date()), index), 'MMM')
);
const activeMonthIndex = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getMonth(date);
});
const emit = defineEmits(['select-month', 'prev', 'next', 'set-view']);
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const selectMonth = index => {
emit('select-month', index);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="YEAR"
:calendar-type="calendarType"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate,
MONTH
)
"
@set-view="setViewMode"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid w-full grid-cols-3 gap-x-3 gap-y-2 auto-rows-[61px]">
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex,
}"
@click="selectMonth(index)"
>
{{ month }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,172 @@
<script setup>
import {
monthName,
yearName,
getWeeksForMonth,
isToday,
dayIsInRange,
isCurrentMonth,
isLastDayOfMonth,
isHoveringDayInRange,
isHoveringNextDayInRange,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarWeekLabel from './CalendarWeekLabel.vue';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
currentDate: Date,
startCurrentDate: Date,
endCurrentDate: Date,
selectedStartDate: Date,
selectingEndDate: Boolean,
selectedEndDate: Date,
hoveredEndDate: Date,
});
const emit = defineEmits([
'update-hovered-end-date',
'select-date',
'prev',
'next',
'set-view',
]);
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH } = CALENDAR_PERIODS;
const emitHoveredEndDate = day => {
emit('update-hovered-end-date', day);
};
const emitSelectDate = day => {
emit('select-date', day);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const weeks = calendarType => {
return getWeeksForMonth(
calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isSelectedStartOrEndDate = day => {
return (
dayIsInRange(day, props.selectedStartDate, props.selectedStartDate) ||
dayIsInRange(day, props.selectedEndDate, props.selectedEndDate)
);
};
const isInRange = day => {
return dayIsInRange(day, props.selectedStartDate, props.selectedEndDate);
};
const isInCurrentMonth = day => {
return isCurrentMonth(
day,
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isHoveringInRange = day => {
return isHoveringDayInRange(
day,
props.selectedStartDate,
props.selectingEndDate,
props.hoveredEndDate
);
};
const isNextDayInRange = day => {
return isHoveringNextDayInRange(
day,
props.selectedStartDate,
props.selectedEndDate,
props.hoveredEndDate
);
};
const dayClasses = day => ({
'text-slate-500 dark:text-slate-400 pointer-events-none':
!isInCurrentMonth(day),
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
isInCurrentMonth(day),
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="MONTH"
:calendar-type="calendarType"
:first-button-label="
monthName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
@prev="onClickPrev"
@next="onClickNext"
@set-view="setViewMode"
/>
<CalendarWeekLabel />
<div
v-for="week in weeks(calendarType)"
:key="week[0].getTime()"
class="grid max-w-md grid-cols-7 gap-2 mx-auto overflow-hidden rounded-lg"
>
<div
v-for="day in week"
:key="day.getTime()"
class="flex relative items-center justify-center w-9 h-8 py-1.5 px-2 font-medium text-sm rounded-lg cursor-pointer"
:class="dayClasses(day)"
@mouseenter="emitHoveredEndDate(day)"
@mouseleave="emitHoveredEndDate(null)"
@click="emitSelectDate(day)"
>
{{ day.getDate() }}
<span
v-if="
(isInRange(day) || isHoveringInRange(day)) &&
isNextDayInRange(day) &&
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { calendarWeeks } from '../helpers/DatePickerHelper';
</script>
<template>
<div class="max-w-md mx-auto grid grid-cols-7 gap-2">
<div
v-for="day in calendarWeeks"
:key="day.id"
class="flex items-center justify-center font-medium text-sm w-9 h-7 py-1.5 px-2"
>
{{ day.label }}
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import { getYear, addYears, subYears } from 'date-fns';
import { CALENDAR_TYPES } from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const calculateStartYear = date => {
const year = getYear(date);
return year - (year % 10); // Align with the beginning of a decade
};
const startYear = ref(
calculateStartYear(
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
)
);
const years = computed(() =>
Array.from({ length: 10 }, (_, i) => startYear.value + i)
);
const firstYear = computed(() => years.value[0]);
const lastYear = computed(() => years.value[years.value.length - 1]);
const activeYear = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getYear(date);
});
const onClickPrev = () => {
startYear.value = subYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const onClickNext = () => {
startYear.value = addYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const emit = defineEmits(['select-year']);
const selectYear = year => {
emit('select-year', year);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:calendar-type="calendarType"
:button-label="`${firstYear} - ${lastYear}`"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 w-full auto-rows-[47px]">
<button
v-for="year in years"
:key="year"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
}"
@click="selectYear(year)"
>
{{ year }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
const props = defineProps({
selectedStartDate: Date,
selectedEndDate: Date,
selectedRange: {
type: String,
default: '',
},
});
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
const endDate = props.selectedEndDate;
if (!isValid(startDate) || !isValid(endDate)) {
return 'Select a date range';
}
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
}
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format(
endDate,
formatString
)}`;
});
const activeDateRange = computed(
() => dateRanges.find(range => range.value === props.selectedRange).label
);
const emit = defineEmits(['open']);
const openDatePicker = () => {
emit('open');
};
</script>
<template>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="openDatePicker"
>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="calendar"
size="16"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-200">
{{ formatDateRange }}
</span>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="chevron-down"
size="14"
/>
</button>
</template>

View File

@@ -0,0 +1,219 @@
import {
startOfDay,
subDays,
endOfDay,
subMonths,
addMonths,
subYears,
addYears,
startOfMonth,
isSameMonth,
format,
startOfWeek,
addDays,
eachDayOfInterval,
endOfMonth,
isSameDay,
isWithinInterval,
} from 'date-fns';
// Constants for calendar and date ranges
export const calendarWeeks = [
{ id: 1, label: 'M' },
{ id: 2, label: 'T' },
{ id: 3, label: 'W' },
{ id: 4, label: 'T' },
{ id: 5, label: 'F' },
{ id: 6, label: 'S' },
{ id: 7, label: 'S' },
];
export const dateRanges = [
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_7_DAYS', value: 'last7days' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_30_DAYS', value: 'last30days' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
];
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last7days',
LAST_30_DAYS: 'last30days',
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
CUSTOM_RANGE: 'custom',
};
export const CALENDAR_TYPES = {
START_CALENDAR: 'start',
END_CALENDAR: 'end',
};
export const CALENDAR_PERIODS = {
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
// Utility functions for date operations
export const monthName = currentDate => format(currentDate, 'MMMM');
export const yearName = currentDate => format(currentDate, 'yyyy');
export const getIntlDateFormatForLocale = () => {
const year = 2222;
const month = 12;
const day = 15;
const date = new Date(year, month - 1, day);
const formattedDate = new Intl.DateTimeFormat(navigator.language).format(
date
);
return formattedDate
.replace(`${year}`, 'yyyy')
.replace(`${month}`, 'MM')
.replace(`${day}`, 'dd');
};
// Utility functions for calendar operations
export const chunk = (array, size) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, index) =>
array.slice(index * size, index * size + size)
);
export const getWeeksForMonth = (date, weekStartsOn = 1) => {
const startOfTheMonth = startOfMonth(date);
const startOfTheFirstWeek = startOfWeek(startOfTheMonth, { weekStartsOn });
const endOfTheLastWeek = addDays(startOfTheFirstWeek, 41); // Covering six weeks to fill the calendar
return chunk(
eachDayOfInterval({ start: startOfTheFirstWeek, end: endOfTheLastWeek }),
7
);
};
export const moveCalendarDate = (
calendar,
startCurrentDate,
endCurrentDate,
direction,
period
) => {
const adjustFunctions = {
month: { prev: subMonths, next: addMonths },
year: { prev: subYears, next: addYears },
};
const adjust = adjustFunctions[period][direction];
if (calendar === 'start') {
const newStart = adjust(startCurrentDate, 1);
return { start: newStart, end: endCurrentDate };
}
const newEnd = adjust(endCurrentDate, 1);
return { start: startCurrentDate, end: newEnd };
};
// Date comparison functions
export const isToday = (currentDate, date) =>
date.getDate() === currentDate.getDate() &&
date.getMonth() === currentDate.getMonth() &&
date.getFullYear() === currentDate.getFullYear();
export const isCurrentMonth = (day, referenceDate) =>
isSameMonth(day, referenceDate);
export const isLastDayOfMonth = day => {
const lastDay = endOfMonth(day);
return day.getDate() === lastDay.getDate();
};
export const dayIsInRange = (date, startDate, endDate) => {
if (!startDate || !endDate) {
return false;
}
// Normalize dates to ignore time differences
let startOfDayStart = startOfDay(startDate);
let startOfDayEnd = startOfDay(endDate);
// Swap if start is greater than end
if (startOfDayStart > startOfDayEnd) {
[startOfDayStart, startOfDayEnd] = [startOfDayEnd, startOfDayStart];
}
// Check if the date is within the interval or is the same as the start date
return (
isSameDay(date, startOfDayStart) ||
isWithinInterval(date, {
start: startOfDayStart,
end: startOfDayEnd,
})
);
};
// Handling hovering states in date range pickers
export const isHoveringDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (endDate && hoveredEndDate && startDate <= hoveredEndDate) {
// Ensure the start date is not after the hovered end date
return isWithinInterval(day, { start: startDate, end: hoveredEndDate });
}
return false;
};
export const isHoveringNextDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (startDate && !endDate && hoveredEndDate) {
// If a start date is selected, and we're hovering (but haven't clicked an end date yet)
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: hoveredEndDate });
}
if (startDate && endDate) {
// Normal range checking between selected start and end dates
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: endDate });
}
return false;
};
// Helper func to determine active date ranges based on user selection
export const getActiveDateRange = (range, currentDate) => {
const ranges = {
last7days: () => ({
start: startOfDay(subDays(currentDate, 6)),
end: endOfDay(currentDate),
}),
last30days: () => ({
start: startOfDay(subDays(currentDate, 29)),
end: endOfDay(currentDate),
}),
last3months: () => ({
start: startOfDay(subMonths(currentDate, 3)),
end: endOfDay(currentDate),
}),
last6months: () => ({
start: startOfDay(subMonths(currentDate, 6)),
end: endOfDay(currentDate),
}),
lastYear: () => ({
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
return (
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};

View File

@@ -0,0 +1,309 @@
import {
monthName,
yearName,
getIntlDateFormatForLocale,
getWeeksForMonth,
isToday,
isCurrentMonth,
isLastDayOfMonth,
dayIsInRange,
getActiveDateRange,
isHoveringDayInRange,
isHoveringNextDayInRange,
moveCalendarDate,
chunk,
} from '../DatePickerHelper';
describe('Date formatting functions', () => {
const testDate = new Date(2020, 4, 15); // May 15, 2020
beforeEach(() => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns the correct month name from a date', () => {
expect(monthName(testDate)).toBe('May');
});
it('returns the correct year from a date', () => {
expect(yearName(testDate)).toBe('2020');
});
it('returns the correct date format for the current locale en-US', () => {
const expected = 'MM/dd/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
it('returns the correct date format for the current locale en-IN', () => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-IN');
const expected = 'dd/MM/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
});
describe('chunk', () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
it('correctly chunks an array into smaller arrays of given size', () => {
const expected = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
expect(chunk(array, 3)).toEqual(expected);
});
it('handles arrays that do not divide evenly by the chunk size', () => {
const expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9]];
expect(chunk(array, 2)).toEqual(expected);
});
});
describe('getWeeksForMonth', () => {
it('returns the correct weeks array for a month starting on Monday', () => {
const date = new Date(2020, 3, 1); // April 2020
const weeks = getWeeksForMonth(date, 1);
expect(weeks.length).toBe(6);
expect(weeks[0][0]).toEqual(new Date(2020, 2, 30)); // Check if first day of the first week is correct
});
});
describe('moveCalendarDate', () => {
it('handles the year transition when moving the start date backward by one month from January', () => {
const startDate = new Date(2021, 0, 1);
const endDate = new Date(2021, 0, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'prev',
'month'
);
const expectedStartDate = new Date(2020, 11, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the start date forward by one month from January', () => {
const startDate = new Date(2020, 0, 1);
const endDate = new Date(2020, 1, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'next',
'month'
);
const expectedStartDate = new Date(2020, 1, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the end date forward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'next', 'month');
const expectedEndDate = new Date(2022, 0, 31);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
it('handles the year transition when moving the end date backward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'prev', 'month');
const expectedEndDate = new Date(2021, 10, 30);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
});
describe('isToday', () => {
it('returns true if the dates are the same', () => {
const today = new Date();
const alsoToday = new Date(today);
expect(isToday(today, alsoToday)).toBeTruthy();
});
it('returns false if the dates are not the same', () => {
const today = new Date();
const notToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 1
);
expect(isToday(today, notToday)).toBeFalsy();
});
});
describe('isCurrentMonth', () => {
const referenceDate = new Date(2020, 6, 15); // July 15, 2020
it('returns true if the day is in the same month as the reference date', () => {
const testDay = new Date(2020, 6, 1);
expect(isCurrentMonth(testDay, referenceDate)).toBeTruthy();
});
it('returns false if the day is not in the same month as the reference date', () => {
const testDay = new Date(2020, 5, 30);
expect(isCurrentMonth(testDay, referenceDate)).toBeFalsy();
});
});
describe('isLastDayOfMonth', () => {
it('returns true if the day is the last day of the month', () => {
const testDay = new Date(2020, 6, 31); // July 31, 2020
expect(isLastDayOfMonth(testDay)).toBeTruthy();
});
it('returns false if the day is not the last day of the month', () => {
const testDay = new Date(2020, 6, 30); // July 30, 2020
expect(isLastDayOfMonth(testDay)).toBeFalsy();
});
});
describe('dayIsInRange', () => {
it('returns true if the date is within the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 15);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns true if the date is the same as the start date', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 10);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns false if the date is outside the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 9);
expect(dayIsInRange(testDate, start, end)).toBeFalsy();
});
});
describe('isHoveringDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const endDate = new Date(2020, 6, 20);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 12);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the day is outside the hovered date range', () => {
const testDay = new Date(2020, 6, 16);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeFalsy();
});
});
describe('isHoveringNextDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the next day after the given day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 14);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the next day is outside the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 15);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeFalsy();
});
});
describe('getActiveDateRange', () => {
const currentDate = new Date(2020, 5, 15, 12, 0); // May 15, 2020, at noon
beforeAll(() => {
// Mocking the current date to ensure consistency in tests
jest.useFakeTimers().setSystemTime(currentDate.getTime());
});
afterAll(() => {
jest.useRealTimers();
});
it('returns the correct range for "last7days"', () => {
const range = getActiveDateRange('last7days', new Date());
const expectedStart = new Date(2020, 5, 9);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last30days"', () => {
const range = getActiveDateRange('last30days', new Date());
const expectedStart = new Date(2020, 4, 17);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last3months"', () => {
const range = getActiveDateRange('last3months', new Date());
const expectedStart = new Date(2020, 2, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last6months"', () => {
const range = getActiveDateRange('last6months', new Date());
const expectedStart = new Date(2019, 11, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "lastYear"', () => {
const range = getActiveDateRange('lastYear', new Date());
const expectedStart = new Date(2019, 5, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "custom date range"', () => {
const range = getActiveDateRange('custom', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
it('handles an unknown range label gracefully', () => {
const range = getActiveDateRange('unknown', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
});

View File

@@ -0,0 +1,24 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="relative group w-[inherit] whitespace-normal z-20">
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-slate-600 absolute dark:text-slate-400"
/>
<div
class="bg-white dark:bg-slate-900 w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-slate-75 dark:border-slate-800 rounded-lg shadow-md"
>
<p class="text-slate-800 dark:text-slate-75 mb-0 text-xs">
{{ message }}
</p>
</div>
</div>
</template>

View File

@@ -40,8 +40,7 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
import adminMixin from 'dashboard/mixins/aiMixin';
import aiMixin from 'dashboard/mixins/isAdmin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
@@ -51,7 +50,7 @@ export default {
AICTAModal,
AIAssistanceCTAButton,
},
mixins: [aiMixin, eventListenerMixins, adminMixin, uiSettingsMixin],
mixins: [aiMixin, keyboardEventListenerMixins, adminMixin, uiSettingsMixin],
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
@@ -87,14 +86,17 @@ export default {
},
methods: {
onKeyDownHandler(event) {
const keyPattern = buildHotKeys(event);
const shouldRevertTheContent =
['meta+z', 'ctrl+z'].includes(keyPattern) && !!this.initialMessage;
if (shouldRevertTheContent) {
this.$emit('replace-text', this.initialMessage);
this.initialMessage = '';
}
getKeyboardEvents() {
return {
'$mod+KeyZ': {
action: () => {
if (this.initialMessage) {
this.$emit('replace-text', this.initialMessage);
this.initialMessage = '';
}
},
},
};
},
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {

View File

@@ -1,17 +1,17 @@
<template>
<div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]">
<div class="flex overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in attachments"
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
v-if="isTypeImage(attachment.resource)"
class="image-thumb"
class="object-cover w-6 h-6 rounded-sm"
:src="attachment.thumb"
/>
<span v-else class="w-6 h-6 text-lg relative -top-px text-left">
<span v-else class="relative w-6 h-6 text-lg text-left -top-px">
📄
</span>
</div>
@@ -23,73 +23,62 @@
</span>
</div>
<div class="w-[30%] justify-center">
<span
class="item overflow-hidden text-xs text-ellipsis whitespace-nowrap"
>
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
{{ formatFileSize(attachment.resource) }}
</span>
</div>
<div class="flex items-center justify-center">
<woot-button
v-if="!isTypeAudio(attachment.resource)"
class="remove--attachment clear secondary"
class="!w-6 !h-6 text-sm rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 clear secondary"
icon="dismiss"
@click="() => onRemoveAttachment(index)"
@click="onRemoveAttachment(index)"
/>
</div>
</div>
</div>
</template>
<script>
<script setup>
import { computed } from 'vue';
import { formatBytes } from 'shared/helpers/FileHelper';
export default {
props: {
attachments: {
type: Array,
default: () => [],
},
removeAttachment: {
type: Function,
default: () => {},
},
},
methods: {
onRemoveAttachment(index) {
this.removeAttachment(index);
},
formatFileSize(file) {
const size = file.byte_size || file.size;
return formatBytes(size, 0);
},
isTypeImage(file) {
const type = file.content_type || file.type;
return type.includes('image');
},
isTypeAudio(file) {
const type = file.content_type || file.type;
return type.includes('audio');
},
fileName(file) {
return file.filename || file.name;
},
const props = defineProps({
attachments: {
type: Array,
default: () => [],
},
});
const emits = defineEmits(['remove-attachment']);
const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
});
const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio)
);
const onRemoveAttachment = itemIndex => {
emits(
'remove-attachment',
nonRecordedAudioAttachments.value
.filter((_, index) => index !== itemIndex)
.concat(recordedAudioAttachments.value)
);
};
const formatFileSize = file => {
const size = file.byte_size || file.size;
return formatBytes(size, 0);
};
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const fileName = file => {
return file.filename || file.name;
};
</script>
<style lang="scss" scoped>
.image-thumb {
@apply w-6 h-6 object-cover rounded-sm;
}
.file-name-wrap,
.file-size-wrap {
@apply flex items-center py-0 px-1;
> .item {
@apply m-0 overflow-hidden text-xs font-medium;
}
}
.remove--attachment {
@apply w-6 h-6 rounded-md text-sm cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800;
}
</style>

View File

@@ -10,11 +10,10 @@
</template>
<script>
import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
items: {
type: Array,
@@ -31,14 +30,18 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndNKey(e)) {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
}
getKeyboardEvents() {
return {
'Alt+KeyN': {
action: () => {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0);
} else {
this.onTabChange(this.activeTabIndex + 1);
}
},
},
};
},
onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) {

View File

@@ -18,13 +18,11 @@
<script>
import { Chrome } from 'vue-color';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Chrome,
},
mixins: [clickaway],
props: {
value: {
type: String,

View File

@@ -31,15 +31,9 @@
<script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default {
components: {
@@ -47,7 +41,7 @@ export default {
LabelDropdown,
},
mixins: [clickaway, adminMixin, eventListenerMixins],
mixins: [adminMixin, keyboardEventListenerMixins],
props: {
allLabels: {
@@ -88,17 +82,19 @@ export default {
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
handleKeyEvents(e) {
const keyPattern = buildHotKeys(e);
if (keyPattern === 'l' && !isActiveElementTypeable(e)) {
this.toggleLabels();
e.preventDefault();
} else if (isEscape(e) && this.showSearchDropdownLabel) {
this.closeDropdownLabel();
e.preventDefault();
}
getKeyboardEvents() {
return {
KeyL: {
action: e => {
this.toggleLabels();
e.preventDefault();
},
},
Escape: {
action: () => this.closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
},
},
};

View File

@@ -5,6 +5,7 @@
</template>
<script>
import getUuid from 'widget/helpers/uuid';
import 'video.js/dist/video-js.css';
import 'videojs-record/dist/css/videojs.record.css';
@@ -28,9 +29,25 @@ import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { convertWavToMp3 } from './utils/mp3ConversionUtils';
WaveSurfer.microphone = MicrophonePlugin;
const RECORDER_CONFIG = {
[AUDIO_FORMATS.WAV]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.MP3]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.OGG]: {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
},
};
export default {
name: 'WootAudioRecorder',
mixins: [alertMixin],
@@ -39,6 +56,10 @@ export default {
type: String,
default: AUDIO_FORMATS.WAV,
},
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -88,14 +109,7 @@ export default {
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
}),
...RECORDER_CONFIG[this.audioRecordFormat],
},
},
},
@@ -133,7 +147,11 @@ export default {
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
if (
[AUDIO_FORMATS.WAV, AUDIO_FORMATS.MP3].includes(
this.audioRecordFormat
)
) {
this.player.record().engine.audioType = 'audio/wav';
}
}
@@ -145,12 +163,16 @@ export default {
stopRecord() {
this.fireStateRecorderChanged('stopped');
},
finishRecord() {
const file = new File(
[this.player.recordedData],
this.player.recordedData.name,
{ type: this.player.recordedData.type }
);
async finishRecord() {
let recordedContent = this.player.recordedData;
let fileName = this.player.recordedData.name;
let type = this.player.recordedData.type;
if (this.audioRecordFormat === AUDIO_FORMATS.MP3) {
recordedContent = await convertWavToMp3(this.player.recordedData);
fileName = `${getUuid()}.mp3`;
type = AUDIO_FORMATS.MP3;
}
const file = new File([recordedContent], fileName, { type });
this.fireRecorderBlob(file);
},
progressRecord() {
@@ -231,7 +253,20 @@ export default {
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
}
}
.video-js .vjs-control-bar {
background-color: transparent;
// Added to override the default text and bg style to support dark and light mode.
.video-js .vjs-control-bar,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
}
// Added to fix div overlays the screen and takes over the button clicks
// https://github.com/collab-project/videojs-record/issues/688
// https://github.com/collab-project/videojs-record/pull/709
.vjs-record.video-js .vjs-control.vjs-record-indicator.vjs-hidden,
.vjs-record.video-js .vjs-control.vjs-record-indicator,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before,
.vjs-record.video-js .vjs-control.vjs-record-indicator:after {
@apply pointer-events-none;
}
</style>

View File

@@ -78,10 +78,8 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import {
hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter,
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import {
@@ -121,7 +119,7 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
@@ -522,13 +520,21 @@ export default {
isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
},
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.focusEditorInputField();
}
if (hasPressedAltAndLKey(e)) {
this.focusEditorInputField();
}
getKeyboardEvents() {
return {
'Alt+KeyP': {
action: () => {
this.focusEditorInputField();
},
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => {
this.focusEditorInputField();
},
allowOnFocusedInput: true,
},
};
},
focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state;

View File

@@ -25,7 +25,7 @@ import {
} from '@chatwoot/prosemirror-schema';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
@@ -51,7 +51,7 @@ const createState = (
};
export default {
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },

View File

@@ -136,8 +136,7 @@
<script>
import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage';
import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -154,7 +153,7 @@ import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
mixins: [keyboardEventListenerMixins, uiSettingsMixin, inboxMixin],
props: {
mode: {
type: String,
@@ -340,10 +339,15 @@ export default {
ActiveStorage.start();
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndAKey(e)) {
this.$refs.upload.$children[1].$el.click();
}
getKeyboardEvents() {
return {
'Alt+KeyA': {
action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
},
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-black-50 flex justify-between dark:bg-slate-800">
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div class="button-group">
<woot-button
variant="clear"
@@ -20,7 +20,7 @@
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</woot-button>
</div>
<div class="flex items-center my-0 mx-4">
<div class="flex items-center mx-4 my-0">
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass">
{{ characterLengthWarning }}
@@ -48,14 +48,10 @@
<script>
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import {
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
name: 'ReplyTopPanel',
mixins: [eventListenerMixins],
mixins: [keyboardEventListenerMixins],
props: {
mode: {
type: String,
@@ -99,13 +95,17 @@ export default {
},
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.handleNoteClick();
}
if (hasPressedAltAndLKey(e)) {
this.handleReplyClick();
}
getKeyboardEvents() {
return {
'Alt+KeyP': {
action: () => this.handleNoteClick(),
allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => this.handleReplyClick(),
allowOnFocusedInput: true,
},
};
},
handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);

View File

@@ -0,0 +1,53 @@
import lamejs from 'lamejs';
/**
* Encodes a mono channel audio stream to MP3 format.
* @param {number} channels - Number of audio channels.
* @param {number} sampleRate - Sample rate in Hz.
* @param {Int16Array} samples - Audio samples to be encoded.
* @returns {Blob} - The MP3 encoded audio as a Blob.
*/
export const encodeToMP3 = (channels, sampleRate, samples) => {
const outputBuffer = [];
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128);
const maxSamplesPerFrame = 1152;
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
const sampleSlice = samples.subarray(offset, sliceEnd);
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
if (mp3Buffer.length > 0) {
outputBuffer.push(new Int8Array(mp3Buffer));
}
}
const remainingData = encoder.flush();
if (remainingData.length > 0) {
outputBuffer.push(new Int8Array(remainingData));
}
return new Blob(outputBuffer, { type: 'audio/mp3' });
};
/**
* Converts a WAV audio Blob to an MP3 format Blob.
* @param {Blob} blob - The audio data in WAV format as a Blob.
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
*/
export const convertWavToMp3 = async blob => {
try {
const audioBuffer = await blob.arrayBuffer();
const wavHeader = lamejs.WavHeader.readHeader(new DataView(audioBuffer));
const samples = new Int16Array(
audioBuffer,
wavHeader.dataOffset,
wavHeader.dataLen / 2
);
return encodeToMP3(wavHeader.channels, wavHeader.sampleRate, samples);
} catch (error) {
// eslint-disable-next-line
console.log('Failed to convert WAV to MP3:', error);
throw new Error('Conversion from WAV to MP3 failed.');
}
};

View File

@@ -45,7 +45,6 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -53,7 +52,7 @@ export default {
components: {
FilterItem,
},
mixins: [clickaway, uiSettingsMixin],
mixins: [uiSettingsMixin],
data() {
return {
showActionsDropdown: false,

View File

@@ -2,7 +2,7 @@
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
:class="{
'animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
'active animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,

View File

@@ -77,11 +77,10 @@
</div>
</template>
<script>
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
@@ -99,7 +98,7 @@ export default {
Thumbnail,
SLACardLabel,
},
mixins: [inboxMixin, agentMixin, eventListenerMixins],
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
chat: {
type: Object,
@@ -182,10 +181,12 @@ export default {
},
methods: {
handleKeyEvents(e) {
if (hasPressedAltAndOKey(e)) {
this.$emit('contact-panel-toggle');
}
getKeyboardEvents() {
return {
'Alt+KeyO': {
action: () => this.$emit('contact-panel-toggle'),
},
};
},
},
};

View File

@@ -14,11 +14,7 @@
@click="retrySendMessage"
/>
</div>
<div
v-tooltip.top-start="messageToolTip"
:class="bubbleClass"
@contextmenu="openContextMenu($event)"
>
<div :class="bubbleClass" @contextmenu="openContextMenu($event)">
<bubble-mail-head
:email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
@@ -90,7 +86,7 @@
:id="data.id"
:sender="data.sender"
:story-sender="storySender"
:external-error="externalError"
:external-error="errorMessageTooltip"
:story-id="`${storyId}`"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -412,14 +408,11 @@ export default {
}
: false;
},
messageToolTip() {
if (this.isMessageDeleted) {
return false;
}
errorMessageTooltip() {
if (this.isFailed) {
return this.externalError || this.$t(`CONVERSATION.SEND_FAILED`);
}
return false;
return '';
},
wrapClass() {
return {

View File

@@ -116,13 +116,12 @@ import conversationMixin, {
} from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { LocalStorage } from 'shared/helpers/localStorage';
// constants
@@ -141,7 +140,7 @@ export default {
mixins: [
conversationMixin,
inboxMixin,
eventListenerMixins,
keyboardEventListenerMixins,
configMixin,
aiMixin,
],
@@ -418,10 +417,12 @@ export default {
closePopoutReplyBox() {
this.isPopoutReplyBox = false;
},
handleKeyEvents(e) {
if (isEscape(e)) {
this.closePopoutReplyBox();
}
getKeyboardEvents() {
return {
Escape: {
action: () => this.closePopoutReplyBox(),
},
};
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');

View File

@@ -37,7 +37,6 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
@@ -52,7 +51,7 @@ export default {
EmailTranscriptModal,
ResolveAction,
},
mixins: [alertMixin, clickaway],
mixins: [alertMixin],
data() {
return {
showEmailActionsModal: false,

View File

@@ -100,7 +100,7 @@
<attachment-preview
class="flex-col mt-4"
:attachments="attachedFiles"
:remove-attachment="removeAttachment"
@remove-attachment="removeAttachment"
/>
</div>
<message-signature-missing-alert
@@ -153,8 +153,8 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
@@ -178,7 +178,6 @@ import {
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -218,13 +217,13 @@ export default {
ArticleSearchPopover,
},
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
messageFormatterMixin,
rtlMixin,
fileUploadMixin,
keyboardEventListenerMixins,
],
props: {
popoutReplyBox: {
@@ -502,11 +501,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (
this.isAWhatsAppChannel ||
this.isAPIInbox ||
this.isATelegramChannel
) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -701,24 +699,41 @@ export default {
this.$store.dispatch('draftMessages/delete', { key });
}
},
handleKeyEvents(e) {
const keyCode = buildHotKeys(e);
if (keyCode === 'escape') {
this.hideEmojiPicker();
this.hideMentions();
} else if (keyCode === 'meta+k') {
const ninja = document.querySelector('ninja-keys');
ninja.open();
e.preventDefault();
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
this.onSendReply();
e.preventDefault();
} else if (
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
this.isAValidEvent('cmd_enter')
) {
this.onSendReply();
}
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
'$mod+KeyK': {
action: e => {
e.preventDefault();
const ninja = document.querySelector('ninja-keys');
ninja.open();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
if (this.isAValidEvent('enter')) {
this.onSendReply();
e.preventDefault();
}
},
allowOnFocusedInput: true,
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
allowOnFocusedInput: true,
},
};
},
isAValidEvent(selectedKey) {
return (
@@ -937,6 +952,13 @@ export default {
this.bccEmails = '';
this.toEmails = '';
},
clearRecorder() {
this.isRecordingAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isRecordedAudio
);
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
@@ -944,8 +966,7 @@ export default {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.clearMessage();
this.clearEmailField();
this.clearRecorder();
}
},
toggleAudioRecorderPlayPause() {
@@ -989,7 +1010,13 @@ export default {
}
},
onFinishRecorder(file) {
return file && this.onFileUpload(file);
// Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = {
...file,
isRecordedAudio: true,
};
return file && this.onFileUpload(autoRecordedFile);
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
@@ -1015,13 +1042,12 @@ export default {
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
isRecordedAudio: file?.isRecordedAudio || false,
});
};
},
removeAttachment(itemIndex) {
this.attachedFiles = this.attachedFiles.filter(
(item, index) => itemIndex !== index
);
removeAttachment(attachments) {
this.attachedFiles = attachments;
},
setReplyToInPayload(payload) {
if (this.inReplyTo?.id) {
@@ -1226,6 +1252,7 @@ export default {
}
}
}
.send-button {
@apply mb-0;
}
@@ -1250,6 +1277,7 @@ export default {
.emoji-dialog--rtl {
@apply left-[unset] -right-80;
&::before {
transform: rotate(90deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));

View File

@@ -9,7 +9,7 @@
<woot-input
v-model.trim="$v.toEmailsVal.$model"
type="text"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
:class="{ error: $v.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
@@ -25,7 +25,7 @@
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
<woot-input
v-model.trim="$v.ccEmailsVal.$model"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
type="text"
:class="{ error: $v.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@@ -54,7 +54,7 @@
<woot-input
v-model.trim="$v.bccEmailsVal.$model"
type="text"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
:class="{ error: $v.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')

View File

@@ -75,9 +75,10 @@ export default {
},
methods: {
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 50 * this.selectedIndex;
adjustScroll() {
this.$nextTick(() => {
this.$el.scrollTop = 50 * this.selectedIndex;
});
},
onHover(index) {
this.selectedIndex = index;

View File

@@ -17,7 +17,7 @@
@error="onImgError"
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
<source :src="`${dataUrl}?t=${Date.now()}`" />
</audio>
<gallery-view

View File

@@ -7,8 +7,10 @@
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments,
}"
@click="scrollToMessage"
>
<message-preview
class="cursor-pointer"
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
@@ -19,6 +21,7 @@
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
name: 'ReplyTo',
@@ -42,5 +45,10 @@ export default {
data() {
return { MESSAGE_TYPE };
},
methods: {
scrollToMessage() {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id });
},
},
};
</script>

View File

@@ -180,14 +180,8 @@
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
@@ -205,7 +199,7 @@ export default {
components: {
Thumbnail,
},
mixins: [eventListenerMixins, clickaway, timeMixin],
mixins: [keyboardEventListenerMixins, timeMixin],
props: {
show: {
type: Boolean,
@@ -304,20 +298,30 @@ export default {
this.activeAttachment = attachment;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
},
ArrowLeft: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
},
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;

View File

@@ -49,9 +49,8 @@
</template>
<script>
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
import { mixin as clickaway } from 'vue-clickaway';
const REFRESH_INTERVAL = 60000;
@@ -59,7 +58,6 @@ export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
chat: {
type: Object,
@@ -139,7 +137,10 @@ export default {
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;

View File

@@ -101,14 +101,12 @@
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,

View File

@@ -95,20 +95,40 @@
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
:show.sync="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
},
props: {
conversations: {
@@ -143,17 +163,56 @@ export default {
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
mounted() {
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
destroyed() {
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('select-all-conversations', e.target.checked);
},
submit(agent) {
this.$emit('assign-agent', agent);
},
updateConversations(status) {
this.$emit('update-conversations', status);
updateConversations(status, snoozedUntil) {
this.$emit('update-conversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assign-labels', labels);

View File

@@ -74,11 +74,9 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',

View File

@@ -59,10 +59,8 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',

View File

@@ -1,12 +1,98 @@
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
const { t } = useI18n();
const emits = defineEmits(['update', 'close']);
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const actions = ref([
{ icon: 'checkmark', key: 'resolved' },
{ icon: 'arrow-redo', key: 'open' },
{ icon: 'send-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emits('update', key);
}
};
const onClose = () => {
emits('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div v-on-clickaway="onClose" class="actions-container">
<div class="triangle">
<div
v-on-clickaway="onClose"
class="absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50"
/>
</svg>
</div>
<div class="header flex items-center justify-between">
<span>{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}</span>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-slate-600 dark:text-slate-100">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<woot-button
size="tiny"
variant="clear"
@@ -15,8 +101,8 @@
@click="onClose"
/>
</div>
<div class="container">
<woot-dropdown-menu>
<div class="px-2.5 pt-0 pb-2.5">
<woot-dropdown-menu class="m-0 list-none">
<template v-for="action in actions">
<woot-dropdown-item v-if="showAction(action.key)" :key="action.key">
<woot-button
@@ -34,116 +120,3 @@
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
},
data() {
return {
query: '',
selectedAction: null,
actions: [
{
icon: 'checkmark',
key: 'resolved',
},
{
icon: 'arrow-redo',
key: 'open',
},
{
icon: 'send-clock',
key: 'snoozed',
},
],
};
},
methods: {
updateConversations(key) {
this.$emit('update', key);
},
goBack() {
this.selectedAgent = null;
},
onClose() {
this.$emit('close');
},
showAction(key) {
const actionsMap = {
resolved: this.showResolve,
open: this.showReopen,
snoozed: this.showSnooze,
};
return actionsMap[key] || false;
},
actionLabel(key) {
const labelsMap = {
resolved: this.$t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: this.$t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: this.$t('BULK_ACTION.UPDATE.SNOOZE_UNTIL_NEXT_REPLY'),
};
return labelsMap[key] || '';
},
},
};
</script>
<style scoped lang="scss">
.actions-container {
@apply absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply px-2.5 pt-0 pb-2.5;
}
.triangle {
right: var(--triangle-position);
@apply block z-10 absolute text-left -top-3;
svg path {
@apply fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50;
}
}
}
ul {
@apply m-0 list-none;
}
</style>

View File

@@ -1,117 +0,0 @@
const calculateThreshold = (timeOffset, threshold) => {
// Calculate the time left for the SLA to breach or the time since the SLA has missed
if (threshold === null) return null;
const currentTime = Math.floor(Date.now() / 1000);
return timeOffset + threshold - currentTime;
};
const findMostUrgentSLAStatus = SLAStatuses => {
// Sort the SLAs based on the threshold and return the most urgent SLA
SLAStatuses.sort(
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
);
return SLAStatuses[0];
};
const formatSLATime = seconds => {
const units = {
y: 31536000, // 60 * 60 * 24 * 365
mo: 2592000, // 60 * 60 * 24 * 30
d: 86400, // 60 * 60 * 24
h: 3600, // 60 * 60
m: 60,
};
if (seconds < 60) {
return '1m';
}
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
const parts = [];
Object.keys(units).forEach(unit => {
const value = Math.floor(seconds / units[unit]);
if (seconds < 60 && parts.length > 0) return;
if (parts.length === 2) return;
if (value > 0) {
parts.push(value + unit);
seconds -= value * units[unit];
}
});
return parts.join(' ');
};
const createSLAObject = (
type,
{
sla_first_response_time_threshold: frtThreshold,
sla_next_response_time_threshold: nrtThreshold,
sla_resolution_time_threshold: rtThreshold,
created_at: createdAt,
} = {},
{
first_reply_created_at: firstReplyCreatedAt,
waiting_since: waitingSince,
status,
} = {}
) => {
// Mapping of breach types to their logic
const SLATypes = {
FRT: {
threshold: calculateThreshold(createdAt, frtThreshold),
// Check FRT only if threshold is not null and first reply hasn't been made
condition:
frtThreshold !== null &&
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
},
NRT: {
threshold: calculateThreshold(waitingSince, nrtThreshold),
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
condition:
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
},
RT: {
threshold: calculateThreshold(createdAt, rtThreshold),
// Check RT only if the conversation is open and threshold is not null
condition: status === 'open' && rtThreshold !== null,
},
};
const SLAStatus = SLATypes[type];
return SLAStatus ? { ...SLAStatus, type } : null;
};
const evaluateSLAConditions = (appliedSla, chat) => {
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
const SLATypes = ['FRT', 'NRT', 'RT'];
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
.map(SLAStatus => ({
...SLAStatus,
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: SLAStatus.threshold <= 0,
}));
};
export const evaluateSLAStatus = (appliedSla, chat) => {
if (!appliedSla || !chat)
return { type: '', threshold: '', icon: '', isSlaMissed: false };
// Filter out the SLA and create the object for each breach
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
// Return the most urgent SLA which is latest to breach or has missed
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
return mostUrgent
? {
type: mostUrgent.type,
threshold: formatSLATime(
mostUrgent.threshold <= 0
? -mostUrgent.threshold
: mostUrgent.threshold
),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
}
: { type: '', threshold: '', icon: '', isSlaMissed: false };
};

View File

@@ -1,150 +0,0 @@
import { evaluateSLAStatus } from '../SLAHelper';
beforeEach(() => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('SLAHelper', () => {
describe('evaluateSLAStatus', () => {
it('returns an empty object when sla or chat is not present', () => {
expect(evaluateSLAStatus(null, null)).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
// Case when FRT SLA is missed
it('correctly identifies a missed FRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066540,
};
const chatMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when FRT SLA is not missed
it('correctly identifies an FRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066660,
};
const chatNotMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when NRT SLA is missed
it('correctly identifies a missed NRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704065940,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when NRT SLA is not missed
it('correctly identifies an NRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200 - 2000,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704066060,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when RT SLA is missed
it('correctly identifies a missed RT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065340,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when RT SLA is not missed
it('correctly identifies an RT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065460,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
});
});

View File

@@ -15,6 +15,7 @@
<span v-if="error" class="message">
{{ error }}
</span>
<slot name="masked" />
</label>
</template>

View File

@@ -1,9 +1,16 @@
<template>
<div class="phone-input--wrap relative">
<div class="phone-input" :class="{ 'has-error': error }">
<div
class="flex items-center dark:bg-slate-900 justify-start rounded-md border border-solid"
:class="
error
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
: 'mb-4 border-slate-200 dark:border-slate-600'
"
>
<div
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@click="toggleCountryDropdown"
@click.prevent="toggleCountryDropdown"
>
<h5 v-if="activeCountry" class="mb-0">
{{ activeCountry.emoji }}
@@ -13,14 +20,15 @@
</div>
<span
v-if="activeDialCode"
class="flex bg-white dark:bg-slate-900 font-medium text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
class="flex bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
>
{{ activeDialCode }}
</span>
<input
ref="phoneNumberInput"
:value="phoneNumber"
type="tel"
class="phone-input--field"
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@@ -28,24 +36,36 @@
@blur="onBlur"
/>
</div>
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<div
v-if="showDropdown"
ref="dropdown"
v-on-clickaway="onOutsideClick"
tabindex="0"
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
@keydown.prevent.up="moveUp"
@keydown.prevent.down="moveDown"
@keydown.prevent.enter="
onSelectCountry(filteredCountriesBySearch[selectedIndex])
"
>
<div class="top-0 sticky bg-white dark:bg-slate-900 p-1">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="dropdown-search"
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-200 dark:!border-slate-600"
@input="onSearchCountry"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="country-dropdown--item"
class="flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700"
:class="{
active: country.id === activeCountryCode,
focus: index === selectedIndex,
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
@@ -74,15 +94,8 @@
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [eventListenerMixins],
props: {
value: {
type: [String, Number],
@@ -107,15 +120,6 @@ export default {
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
@@ -125,6 +129,20 @@ export default {
};
},
computed: {
countries() {
return [
{
name: this.dropdownFirstItemName,
dial_code: '',
emoji: '',
id: '',
},
...countries,
];
},
dropdownFirstItemName() {
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
},
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
@@ -159,12 +177,8 @@ export default {
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
@@ -182,49 +196,42 @@ export default {
onBlur(e) {
this.$emit('blur', e.target.value);
},
dropdownItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
onSearchCountry() {
// Reset selected index to 0
this.selectedIndex = 0;
},
moveUp() {
if (!this.showDropdown) return;
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
},
moveDown() {
if (!this.showDropdown) return;
this.selectedIndex = Math.min(
this.selectedIndex + 1,
this.filteredCountriesBySearch.length - 1
);
this.scrollToSelected();
},
focusedItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
onKeyDownHandler(e) {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this;
const { selectedIndex } = this;
if (showDropdown) {
if (hasPressedArrowDownKey(e)) {
e.preventDefault();
this.selectedIndex = Math.min(
selectedIndex + 1,
filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28;
} else if (hasPressedArrowUpKey(e)) {
e.preventDefault();
this.selectedIndex = Math.max(selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (isEnter(e)) {
e.preventDefault();
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
scrollToSelected() {
this.$nextTick(() => {
const dropdown = this.$refs.dropdown;
const selectedItem = this.$refs.dropdownItem[this.selectedIndex];
const dropdownSearchbarHeight = 40;
if (selectedItem) {
const selectedItemTop = selectedItem.offsetTop;
dropdown.scrollTop = selectedItemTop - dropdownSearchbarHeight;
}
}
});
},
onSelectCountry(country) {
if (!country || !this.showDropdown) return;
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
this.$refs.phoneNumberInput.focus();
},
setActiveCountry() {
const { phoneNumber } = this;
@@ -251,46 +258,3 @@ export default {
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
.phone-input {
@apply flex items-center dark:bg-slate-900 justify-start mb-4 rounded-md border border-solid border-slate-200 dark:border-slate-600;
&.has-error {
@apply border border-solid border-red-400 dark:border-red-400;
}
}
.phone-input--field {
@apply mb-0 rounded-tl-none rounded-bl-none border-0 w-full dark:bg-slate-900 text-base px-1.5;
&::placeholder {
@apply font-normal;
}
}
.country-dropdown {
@apply z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900;
.dropdown-search--wrap {
@apply top-0 sticky bg-white dark:bg-slate-900 p-1;
.dropdown-search {
@apply h-8 mb-0 text-sm border border-solid border-slate-200 dark:border-slate-600;
}
}
.country-dropdown--item {
@apply flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700;
&.active {
@apply bg-slate-50 dark:bg-slate-700;
}
&.focus {
@apply bg-slate-25 dark:bg-slate-800;
}
}
}
}
</style>

View File

@@ -84,9 +84,7 @@ export default {
},
},
methods: {
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
},
adjustScroll() {},
onHover(index) {
this.selectedIndex = index;
},

View File

@@ -1,12 +1,7 @@
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mounted() {
document.addEventListener('keydown', this.handleKeyboardEvent);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardEvent);
},
mixins: [keyboardEventListenerMixins],
methods: {
moveSelectionUp() {
if (!this.selectedIndex) {
@@ -14,6 +9,7 @@ export default {
} else {
this.selectedIndex -= 1;
}
this.adjustScroll();
},
moveSelectionDown() {
if (this.selectedIndex === this.items.length - 1) {
@@ -21,19 +17,46 @@ export default {
} else {
this.selectedIndex += 1;
}
this.adjustScroll();
},
processKeyDownEvent(e) {
const keyPattern = buildHotKeys(e);
if (['arrowup', 'ctrl+p'].includes(keyPattern)) {
this.moveSelectionUp();
e.preventDefault();
} else if (['arrowdown', 'ctrl+n'].includes(keyPattern)) {
this.moveSelectionDown();
e.preventDefault();
} else if (keyPattern === 'enter') {
this.onSelect();
e.preventDefault();
}
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyP': {
action: e => {
this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyN': {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
this.onSelect();
e.preventDefault();
},
allowOnFocusedInput: true,
},
};
},
},
};

View File

@@ -1,64 +1,69 @@
import mentionSelectionKeyboardMixin from '../mentionSelectionKeyboardMixin';
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const localVue = createLocalVue();
const buildComponent = ({ data = {}, methods = {} }) => ({
render() {},
data() {
return data;
return { ...data, selectedIndex: 0, items: [1, 2, 3] };
},
methods,
mixins: [mentionSelectionKeyboardMixin],
methods: { ...methods, onSelect: jest.fn(), adjustScroll: jest.fn() },
mixins: [keyboardEventListenerMixins],
});
describe('mentionSelectionKeyboardMixin', () => {
test('register listeners', () => {
jest.spyOn(document, 'addEventListener');
let wrapper;
beforeEach(() => {
const Component = buildComponent({});
shallowMount(Component);
// undefined expected as the method is not defined in the component
expect(document.addEventListener).toHaveBeenCalledWith(
'keydown',
undefined
);
wrapper = shallowMount(Component, { localVue });
});
test('processKeyDownEvent updates index on arrow up', () => {
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
ctrlKey: true,
key: 'p',
preventDefault: jest.fn(),
});
expect(wrapper.vm.selectedIndex).toBe(2);
it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
const preventDefault = jest.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.ArrowUp) {
keyboardEvents.ArrowUp.action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalled();
}
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyP']) {
keyboardEvents['Control+KeyP'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(0);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
});
test('processKeyDownEvent updates index on arrow down', () => {
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
key: 'ArrowDown',
preventDefault: jest.fn(),
});
expect(wrapper.vm.selectedIndex).toBe(1);
it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
const preventDefault = jest.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.ArrowDown) {
keyboardEvents.ArrowDown.action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(1);
expect(preventDefault).toHaveBeenCalled();
}
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyN']) {
keyboardEvents['Control+KeyN'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
});
test('processKeyDownEvent calls select methods on Enter Key', () => {
const onSelectMockFn = jest.fn();
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
methods: { onSelect: () => onSelectMockFn('enterKey pressed') },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
key: 'Enter',
preventDefault: jest.fn(),
});
expect(onSelectMockFn).toHaveBeenCalledWith('enterKey pressed');
wrapper.vm.onSelect();
it('Enter key triggers onSelect method', () => {
const preventDefault = jest.fn();
const keyboardEvents = wrapper.vm.getKeyboardEvents();
if (keyboardEvents && keyboardEvents.Enter) {
keyboardEvents.Enter.action({ preventDefault });
expect(wrapper.vm.onSelect).toHaveBeenCalled();
expect(preventDefault).toHaveBeenCalled();
}
});
});

View File

@@ -87,7 +87,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { SHORTCUT_KEYS } from './constants';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
@@ -95,7 +94,6 @@ export default {
components: {
Hotkey,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,

View File

@@ -53,12 +53,6 @@ export const SHORTCUT_KEYS = [
firstKey: 'Alt / ⌥',
secondKey: 'S',
},
{
id: 10,
label: 'SWITCH_CONVERSATION_STATUS',
firstKey: 'Alt / ⌥',
secondKey: 'B',
},
{
id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE',

View File

@@ -0,0 +1,12 @@
import { getCurrentInstance } from 'vue';
export const useTrack = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$track;
};
export function useAlert(message, action) {
bus.$emit('newToastMessage', message, action);
}

View File

@@ -0,0 +1,30 @@
import { getCurrentInstance, reactive, watchEffect } from 'vue';
/**
* Returns the current route location. Equivalent to using `$route` inside
* templates.
*/
export function useRoute() {
const instance = getCurrentInstance();
const route = reactive(Object.assign({}, instance.proxy.$root.$route));
watchEffect(() => {
Object.assign(route, instance.proxy.$root.$route);
});
return route;
}
/**
* Returns the router instance. Equivalent to using `$router` inside
* templates.
*/
export function useRouter() {
const instance = getCurrentInstance();
const router = instance.proxy.$root.$router;
watchEffect(() => {
if (router) {
Object.assign(router, instance.proxy.$root.$router);
}
});
return router;
}

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { getCurrentInstance } from 'vue';
export const useStore = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$store;
};
export const useStoreGetters = () => {
const store = useStore();
return Object.fromEntries(
Object.keys(store.getters).map(getter => [
getter,
computed(() => store.getters[getter]),
])
);
};
export const mapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};

View File

@@ -0,0 +1,32 @@
import { computed, getCurrentInstance } from 'vue';
import Vue from 'vue';
import VueI18n from 'vue-i18n';
let i18nInstance = VueI18n;
export function useI18n() {
if (!i18nInstance) throw new Error('vue-i18n not initialized');
const i18n = i18nInstance;
const instance = getCurrentInstance();
const vm = instance?.proxy || instance || new Vue({});
const locale = computed({
get() {
return i18n.locale;
},
set(v) {
i18n.locale = v;
},
});
return {
locale,
t: vm.$t.bind(vm),
tc: vm.$tc.bind(vm),
d: vm.$d.bind(vm),
te: vm.$te.bind(vm),
n: vm.$n.bind(vm),
};
}

View File

@@ -1,5 +1,4 @@
import AnalyticsHelper from './AnalyticsHelper';
import LogRocket from 'logrocket';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
@@ -11,12 +10,6 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user);
if (window.logRocketProjectId) {
LogRocket.identify(user.id, {
email: user.email,
name: user.name,
});
}
});
};

View File

@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
"SNOOZE_UNTIL": "Snooze",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},

View File

@@ -48,7 +48,8 @@
"SIDEBAR_SECTIONS": {
"CUSTOM_ATTRIBUTES": "Custom Attributes",
"CONTACT_LABELS": "Contact Labels",
"PREVIOUS_CONVERSATIONS": "Previous Conversations"
"PREVIOUS_CONVERSATIONS": "Previous Conversations",
"NO_RECORDS_FOUND": "No attributes found"
}
},
"EDIT_CONTACT": {
@@ -83,6 +84,7 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}

View File

@@ -280,6 +280,7 @@
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"NO_RECORDS_FOUND": "No attributes found",
"UPDATE": {
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"

View File

@@ -0,0 +1,19 @@
{
"DATE_PICKER": {
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
"START": "Start Date",
"END": "End Date"
},
"DATE_RANGE_OPTIONS": {
"TITLE": "DATE RANGE",
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"CUSTOM_RANGE": "Custom date range"
}
}
}

View File

@@ -107,6 +107,7 @@
"GENERAL": "General",
"REPORTS": "Reports",
"CONVERSATION": "Conversation",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Change Assignee",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Change Team",

View File

@@ -22,7 +22,7 @@
"body": "Add agents to the created inbox."
},
{
"title": "Voila!",
"title": "Voilà!",
"route": "settings_inbox_finish",
"body": "You are all set to go!"
}
@@ -43,7 +43,7 @@
"CHOOSE_PLACEHOLDER": "Select a page from the list",
"INBOX_NAME": "Inbox Name",
"ADD_NAME": "Add a name for your inbox",
"PICK_NAME": "Pick A Name Your Inbox",
"PICK_NAME": "Pick a Name for your Inbox",
"PICK_A_VALUE": "Pick a value"
},
"TWITTER": {
@@ -62,7 +62,7 @@
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Enter your Webhook URL",
"PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "Please enter a valid URL"
},
"CHANNEL_DOMAIN": {
@@ -143,7 +143,7 @@
"ERROR": "This field is required"
},
"PHONE_NUMBER": {
"LABEL": "Phone number",
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
@@ -175,12 +175,12 @@
},
"API_KEY": {
"LABEL": "API Key",
"PLACEHOLDER": "Please enter your Bandwith API Key",
"PLACEHOLDER": "Please enter your Bandwidth API Key",
"ERROR": "This field is required"
},
"API_SECRET": {
"LABEL": "API Secret",
"PLACEHOLDER": "Please enter your Bandwith API Secret",
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
"ERROR": "This field is required"
},
"APPLICATION_ID": {
@@ -239,7 +239,7 @@
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "Webhook Verify Token",
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
"ERROR": "Please enter a valid value."
},
"API_KEY": {
@@ -269,7 +269,7 @@
},
"WEBHOOK_URL": {
"LABEL": "Webhook URL",
"SUBTITLE": "Configure the URL where you want to recieve callbacks on events.",
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
"PLACEHOLDER": "Webhook URL"
},
"SUBMIT_BUTTON": "Create API Channel",
@@ -279,7 +279,7 @@
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate you email inbox.",
"DESC": "Integrate your email inbox.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
@@ -345,7 +345,7 @@
"AGENTS": {
"TITLE": "Agents",
"DESC": "Here you can add agents to manage your newly created inbox. Only these selected agents will have access to your inbox. Agents which are not part of this inbox will not be able to see or respond to messages in this inbox when they login. <br> <b>PS:</b> As an administrator, if you need access to all inboxes, you should add yourself as agent to all inboxes that you create.",
"VALIDATION_ERROR": "Add atleast one agent to your new Inbox",
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
"PICK_AGENTS": "Pick agents for the inbox"
},
"DETAILS": {
@@ -406,7 +406,7 @@
},
"SENDER_NAME_SECTION": {
"TITLE": "Sender name",
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
"FOR_EG": "For eg:",
"FRIENDLY": {
"TITLE": "Friendly",
@@ -508,12 +508,12 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verify Token",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
@@ -585,7 +585,7 @@
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
"UPDATE": "Update IMAP settings",
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
"EDIT": {
"SUCCESS_MESSAGE": "IMAP settings updated successfully",
"ERROR_MESSAGE": "Unable to update IMAP settings"

View File

@@ -10,6 +10,7 @@
"PASSWORD_UPDATE_SUCCESS": "Your password has been changed successfully",
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
"FORM": {
"PICTURE": "Profile Picture",
"AVATAR": "Profile Image",
"ERROR": "Please fix form errors",
"REMOVE_IMAGE": "Remove",
@@ -56,13 +57,19 @@
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration"
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
"ALERT_TYPES": {
"NONE": "None",
"MINE": "Assigned",
"ALL": "All"
},
"ALERT_TYPE": {
"TITLE": "Alert events:",
"TITLE": "Alert events for conversations:",
"NONE": "None",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
@@ -88,6 +95,23 @@
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
},
"NOTIFICATIONS": {
"TITLE": "Notification preferences",
"TYPE_TITLE": "Notification type",
"EMAIL": "Email",
"PUSH": "Push notification",
"TYPES": {
"CONVERSATION_CREATED": "A new conversation is created",
"CONVERSATION_ASSIGNED": "A conversation is assigned to you",
"CONVERSATION_MENTION": "You are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "A new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "A new message is created in a participating conversation",
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
"UPDATE_ERROR": "There is an error while updating the preferences, please try again"
@@ -320,7 +344,6 @@
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
"GO_TO_SETTINGS": "Go to Settings",
"SWITCH_CONVERSATION_STATUS": "Switch to the next conversation status",
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"

View File

@@ -6,6 +6,18 @@
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"LOADING": "Fetching SLAs",
"PAYWALL": {
"TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
"UPGRADE_PROMPT": "Upgrade to a paid plan to access advanced features like audit logs, agent capacity, and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"LIST": {
"404": "There are no SLAs available in this account.",
"EMPTY": {
@@ -93,4 +105,4 @@
"HIDE": "Hide {count} rows"
}
}
}
}

View File

@@ -87,8 +87,8 @@
},
"CONDITION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
"CONTACT_CUSTOM_ATTR_LABEL": "Contact Custom Attributes",
"CONVERSATION_CUSTOM_ATTR_LABEL": "Conversation Custom Attributes"
"CONTACT_CUSTOM_ATTR_LABEL": "سمة مخصصة لجهة اتصال",
"CONVERSATION_CUSTOM_ATTR_LABEL": "سمة مخصصة للمحادثة"
},
"ACTION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
@@ -111,7 +111,7 @@
"UPLOAD_ERROR": "تعذر تحميل المرفق، الرجاء المحاولة مرة أخرى",
"LABEL_IDLE": "ارفع المرفق",
"LABEL_UPLOADING": "جاري الرفع...",
"LABEL_UPLOADED": "Successfully Uploaded",
"LABEL_UPLOADED": "تم الرفع بنجاح",
"LABEL_UPLOAD_FAILED": "فشل الرفع"
}
}

View File

@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "جاري تحميل الوكلاء",
"UPDATE": {
"CHANGE_STATUS": "تغيير الحالة",
"SNOOZE_UNTIL_NEXT_REPLY": "غفوة حتى الرد القادم.",
"SNOOZE_UNTIL": "غفوة",
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},

View File

@@ -1,8 +1,8 @@
{
"CANNED_MGMT": {
"HEADER": "الردود السريعة",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"HEADER_BTN_TXT": "إضافة رد جاهز",
"LOADING": "جاري جلب الردود الجاهزة...",
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام.",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are pre-written reply templates that help you quickly respond to a conversation. To insert a canned response during a chat, agents can type a short code preceded by a '/' character. </p><p> You can manage your canned responses from this page or create new ones using the \"Add canned response\" button.</p><p>Open the <a target=\"_blank\" href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Canned Responses handbook</a> in another tab for a helping hand.</p><p>Also, check out the all-new <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank\">Canned Responses Library</a>.</p>",
"LIST": {
@@ -10,66 +10,66 @@
"TITLE": "إدارة الردود الجاهزة",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"TABLE_HEADER": [
"Short code",
"كود مختصر",
"المحتوى",
"الإجراءات"
]
},
"ADD": {
"TITLE": "Add canned response",
"TITLE": "إضافة رد جاهز",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"CANCEL_BUTTON_TEXT": "إلغاء",
"FORM": {
"SHORT_CODE": {
"LABEL": "Short code",
"PLACEHOLDER": "Please enter a short code.",
"ERROR": "Short Code is required."
"LABEL": "كود مختصر",
"PLACEHOLDER": "من فضلك ادخل الكود مختصر.",
"ERROR": "الكود مختصر مطلوب."
},
"CONTENT": {
"LABEL": "رسالة",
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
"ERROR": "Message is required."
"PLACEHOLDER": "من فضلك ادخل نص لرسالة التي ترغب في حفظها كقالب لاستخدامها لاحقاُ.",
"ERROR": "الرسالة مطلوبة."
},
"SUBMIT": "إرسال"
},
"API": {
"SUCCESS_MESSAGE": "Canned response added successfully.",
"SUCCESS_MESSAGE": "تم إضافة الرد المعد مسبقاً بنجاح.",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
}
},
"EDIT": {
"TITLE": "Edit canned response",
"TITLE": "تعديل الرد المعد مسبقاً",
"CANCEL_BUTTON_TEXT": "إلغاء",
"FORM": {
"SHORT_CODE": {
"LABEL": "Short code",
"PLACEHOLDER": "Please enter a shortcode.",
"ERROR": "Short code is required."
"LABEL": "كود مختصر",
"PLACEHOLDER": "من فضلك ادخل الكود مختصر.",
"ERROR": "الكود مختصر مطلوب."
},
"CONTENT": {
"LABEL": "رسالة",
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
"PLACEHOLDER": "من فضلك ادخل نص لرسالة التي ترغب في حفظها كقالب لاستخدامها لاحقاُ.",
"ERROR": "الرسالة مطلوبة."
},
"SUBMIT": "إرسال"
},
"BUTTON_TEXT": "تعديل",
"API": {
"SUCCESS_MESSAGE": "Canned response is updated successfully.",
"SUCCESS_MESSAGE": "تم تحديث الرد المعد مسبقاً بنجاح.",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"API": {
"SUCCESS_MESSAGE": "Canned response deleted successfully.",
"SUCCESS_MESSAGE": "تم حذف الرد المعد مسبقاً بنجاح.",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"CONFIRM": {
"TITLE": "تأكيد الحذف",
"MESSAGE": "هل أنت متأكد من الحذف ",
"YES": "Yes, delete ",
"NO": "No, keep "
"YES": "نعم, حذف ",
"NO": "لا, قم بالابقاء عليه "
}
}
}

View File

@@ -43,7 +43,7 @@
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Created",
"LATEST": "مضاف",
"OLDEST": "تم إنشاؤها في:"
},
"LAST_ACTIVITY": {
@@ -53,10 +53,10 @@
},
"SORT_ORDER_ITEMS": {
"last_activity_at_asc": {
"TEXT": "Last activity: Oldest first"
"TEXT": "اخر نشاط: آلأقدم أولا"
},
"last_activity_at_desc": {
"TEXT": "Last activity: Newest first"
"TEXT": "اخر نشاط: الأحدث أولا"
},
"created_at_desc": {
"TEXT": "Created at: Newest first"
@@ -111,7 +111,7 @@
},
"LAST_USER_MESSAGE_AT": {
"NAME": "Last user message at",
"LABEL": "Last message"
"LABEL": "أخر رسالة"
}
}
},

View File

@@ -15,7 +15,7 @@
"INITIATED_FROM": "تم البدء من",
"INITIATED_AT": "تم البدء في",
"IP_ADDRESS": "عنوان IP",
"CREATED_AT_LABEL": "Created",
"CREATED_AT_LABEL": "أضيفت",
"NEW_MESSAGE": "رسالة جديدة",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
@@ -39,16 +39,17 @@
},
"MERGE_CONTACT": "دمج جهة الاتصال",
"CONTACT_ACTIONS": "إجراءات جهات الاتصال",
"MUTE_CONTACT": "Block Contact",
"UNMUTE_CONTACT": "Unblock Contact",
"MUTE_CONTACT": "احجب جهة الاتصال",
"UNMUTE_CONTACT": "إلغاء حجب جهة الاتصال",
"MUTED_SUCCESS": "This contact is blocked successfully. You will not be notified of any future conversations.",
"UNMUTED_SUCCESS": "This contact is unblocked successfully.",
"UNMUTED_SUCCESS": "تم إلغاء حجب جهة الاتصال بنجاح.",
"SEND_TRANSCRIPT": "إرسال النص",
"EDIT_LABEL": "تعديل",
"SIDEBAR_SECTIONS": {
"CUSTOM_ATTRIBUTES": "سمات مخصصة",
"CONTACT_LABELS": "تصنفيات جهات الاتصال",
"PREVIOUS_CONVERSATIONS": "المحادثات السابقة"
"PREVIOUS_CONVERSATIONS": "المحادثات السابقة",
"NO_RECORDS_FOUND": "لم يتم العثور على سمات"
}
},
"EDIT_CONTACT": {
@@ -75,16 +76,17 @@
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"BUTTON_LABEL": "تصدير",
"TITLE": "تصدير جهات الاتصال",
"DESC": "تصدير جهات الاتصال إلى ملف CSV.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى",
"CONFIRM": {
"TITLE": "Export Contacts",
"TITLE": "تصدير جهات الاتصال",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "نعم, قم بالتصدير",
"NO": "نعم, إلغاء"
}
},
"DELETE_NOTE": {
@@ -148,15 +150,15 @@
"LABEL": "اسم الشركة"
},
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"PLACEHOLDER": "إدخال اسم الدولة",
"LABEL": "اسم الدولة",
"SELECT_PLACEHOLDER": "اختر",
"REMOVE": "حذف",
"SELECT_COUNTRY": "Select Country"
"SELECT_COUNTRY": "أختر الدولة"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
"PLACEHOLDER": "إدخال اسم المدينة",
"LABEL": "اسم المدينة"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
@@ -197,7 +199,7 @@
},
"INBOX": {
"LABEL": "صندوق الوارد",
"PLACEHOLDER": "Choose source inbox",
"PLACEHOLDER": "اختر صندوق المصدر",
"ERROR": "حدد صندوق الوارد"
},
"SUBJECT": {

View File

@@ -149,7 +149,7 @@
"PRIVATE_NOTE": "إضافة ملاحظة خاصة",
"SEND": "إرسال",
"CREATE": "إضافة ملاحظة",
"INSERT_READ_MORE": "Read more",
"INSERT_READ_MORE": "اقرأ المزيد",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "عرض محرر النصوص",
@@ -280,6 +280,7 @@
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "إنشاء سمة جديدة",
"NO_RECORDS_FOUND": "لم يتم العثور على سمات",
"UPDATE": {
"SUCCESS": "تم تحديث السمة المخصصة بنجاح",
"ERROR": "غير قادر على تحديث السمة. الرجاء المحاولة مرة أخرى لاحقاً"

View File

@@ -3,11 +3,11 @@
"TITLE": "قيم محادثتك",
"PLACEHOLDER": "أخبرنا المزيد...",
"RATINGS": {
"POOR": "😞 Poor",
"FAIR": "😑 Fair",
"AVERAGE": "😐 Average",
"GOOD": "😀 Good",
"EXCELLENT": "😍 Excellent"
"POOR": "😞 سيئ",
"FAIR": "مقبول😑",
"AVERAGE": "😐 متوسط",
"GOOD": "😀 جيد",
"EXCELLENT": "😍 ممتاز"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"DATE_PICKER": {
"APPLY_BUTTON": "تطبيق",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
"START": "Start Date",
"END": "End Date"
},
"DATE_RANGE_OPTIONS": {
"TITLE": "DATE RANGE",
"LAST_7_DAYS": "آخر 7 أيام",
"LAST_30_DAYS": "آخر 30 يوماً",
"LAST_3_MONTHS": "آخر 3 أشهر",
"LAST_6_MONTHS": "آخر 6 أشهر",
"LAST_YEAR": "العام الماضي",
"CUSTOM_RANGE": "تحديد نطاق المدة"
}
}
}

View File

@@ -1,5 +1,5 @@
{
"GENERAL": {
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
"SHOWING_RESULTS": "عرض{firstIndex}-{lastIndex} من {totalCount} إجماليي العناصر"
}
}

View File

@@ -3,7 +3,7 @@
"TITLE": "إعدادات الحساب",
"SUBMIT": "تحديث الإعدادات",
"BACK": "العودة",
"DISMISS": "Dismiss",
"DISMISS": "تجاهل",
"UPDATE": {
"ERROR": "تعذر تحديث الإعدادات، الرجاء المحاولة مرة أخرى!",
"SUCCESS": "تم تحديث إعدادات الحساب بنجاح"
@@ -52,7 +52,7 @@
"LEARN_MORE": "اعرف المزيد",
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
"LIMITS_UPGRADE": "Your account has exceeded the usage limits, please upgrade your plan to continue using Chatwoot",
"OPEN_BILLING": "Open billing"
"OPEN_BILLING": "فتح الفواتير"
},
"FORMS": {
"MULTISELECT": {
@@ -107,16 +107,17 @@
"GENERAL": "عام",
"REPORTS": "التقارير",
"CONVERSATION": "المحادثات",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "تغيير المحال إليه",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_PRIORITY": "تغيير الأولوية",
"CHANGE_TEAM": "تغيير الفريق",
"SNOOZE_CONVERSATION": "تأجيل المحادثة",
"ADD_LABEL": "إضافة تسمية إلى المحادثة",
"REMOVE_LABEL": "إزالة التسمية من المحادثة",
"SETTINGS": "الإعدادات",
"AI_ASSIST": "AI Assist",
"APPEARANCE": "Appearance",
"SNOOZE_NOTIFICATION": "Snooze Notification"
"APPEARANCE": "مظهر",
"SNOOZE_NOTIFICATION": "تأجيل التنبيهات"
},
"COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "الذهاب إلى لوحة المحادثة",
@@ -139,7 +140,7 @@
"ADD_LABELS_TO_CONVERSATION": "إضافة تسمية إلى المحادثة",
"ASSIGN_AN_AGENT": "تعيين وكيل",
"AI_ASSIST": "AI Assist",
"ASSIGN_PRIORITY": "Assign priority",
"ASSIGN_PRIORITY": "تعيين الأولوية",
"ASSIGN_A_TEAM": "تعيين فريق",
"MUTE_CONVERSATION": "كتم المحادثة",
"UNMUTE_CONVERSATION": "إلغاء كتم المحادثة",
@@ -151,21 +152,21 @@
"UNTIL_NEXT_REPLY": "حتى الرد القادم",
"UNTIL_NEXT_WEEK": "حتى الأسبوع القادم",
"UNTIL_TOMORROW": "حتى الغد",
"UNTIL_NEXT_MONTH": "Until next month",
"AN_HOUR_FROM_NOW": "Until an hour from now",
"CUSTOM": "Custom...",
"CHANGE_APPEARANCE": "Change Appearance",
"UNTIL_NEXT_MONTH": "حتي الشهر القادم",
"AN_HOUR_FROM_NOW": "حتي ساعة من الأن",
"CUSTOM": "مخصص...",
"CHANGE_APPEARANCE": "تغيير المظهر",
"LIGHT_MODE": "Light",
"DARK_MODE": "Dark",
"SYSTEM_MODE": "System",
"SNOOZE_NOTIFICATION": "Snooze Notification"
"DARK_MODE": "مظلم",
"SYSTEM_MODE": "نظام",
"SNOOZE_NOTIFICATION": "تأجيل التنبيهات"
}
},
"DASHBOARD_APPS": {
"LOADING_MESSAGE": "تحميل تطبيق لوحة التحكم..."
},
"COMMON": {
"OR": "Or",
"OR": "أو",
"CLICK_HERE": "اضغط هنا"
}
}

View File

@@ -22,7 +22,7 @@
"body": "إضافة موظفين إلى صندوق الوارد الخاص بقناة التواصل التي تم إنشاؤها."
},
{
"title": "مرحى!",
"title": "Voilà!",
"route": "settings_inbox_finish",
"body": "أصبح كل شيء جاهزاً الآن!"
}
@@ -43,7 +43,7 @@
"CHOOSE_PLACEHOLDER": "اختر صفحة من القائمة",
"INBOX_NAME": "اسم صندوق الوارد لقناة التواصل",
"ADD_NAME": "قم بتعيين اسم لصندوق الوارد الخاص بقناتك الجديدة",
"PICK_NAME": "اختر اسم لقناة التواصل الخاصة بك",
"PICK_NAME": "Pick a Name for your Inbox",
"PICK_A_VALUE": "اختر قيمة"
},
"TWITTER": {
@@ -62,7 +62,7 @@
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "رابط Webhook",
"PLACEHOLDER": "أدخل رابط Webhook",
"PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "الرجاء إدخال عنوان URL صالح"
},
"CHANNEL_DOMAIN": {
@@ -112,14 +112,14 @@
"ERROR": "هذا الحقل مطلوب"
},
"API_KEY": {
"USE_API_KEY": "Use API Key Authentication",
"LABEL": "API Key SID",
"PLACEHOLDER": "Please enter your API Key SID",
"USE_API_KEY": "استخدم مفتاح مصادقة API",
"LABEL": "مفتاح API SID",
"PLACEHOLDER": "الرجاء إدخال معرف مفتاح واجهة برمجة التطبيقات API الخاص بك",
"ERROR": "هذا الحقل مطلوب"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Please enter your API Key Secret",
"LABEL": "مفتاح سر API",
"PLACEHOLDER": "الرجاء إدخال سر مفتاح API الخاص بك",
"ERROR": "هذا الحقل مطلوب"
},
"MESSAGING_SERVICE_SID": {
@@ -175,12 +175,12 @@
},
"API_KEY": {
"LABEL": "مفتاح API",
"PLACEHOLDER": "الرجاء إدخال مفتاح API الخاص بك",
"PLACEHOLDER": "Please enter your Bandwidth API Key",
"ERROR": "هذا الحقل مطلوب"
},
"API_SECRET": {
"LABEL": "سرية API",
"PLACEHOLDER": "الرجاء إدخال مفتاح API الخاص بك",
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
"ERROR": "هذا الحقل مطلوب"
},
"APPLICATION_ID": {
@@ -239,7 +239,7 @@
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "رمز التحقق من Webhook",
"PLACEHOLDER": "أدخل رمز التحقق الذي تريد إعداده لفيسبوك على شبكة الويب.",
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
"ERROR": "الرجاء إدخال اسم صالح."
},
"API_KEY": {
@@ -269,7 +269,7 @@
},
"WEBHOOK_URL": {
"LABEL": "رابط Webhook",
"SUBTITLE": "تكوين عنوان URL حيث تريد تلقي ردود المكالمات على الأحداث.",
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
"PLACEHOLDER": "رابط Webhook"
},
"SUBMIT_BUTTON": "إنشاء قناة API",
@@ -279,7 +279,7 @@
},
"EMAIL_CHANNEL": {
"TITLE": "قناة البريد الالكتروني",
"DESC": "ربط البريد الإلكتروني الخاص بك.",
"DESC": "Integrate your email inbox.",
"CHANNEL_NAME": {
"LABEL": "اسم القناة",
"PLACEHOLDER": "الرجاء إدخال اسم القناة",
@@ -345,7 +345,7 @@
"AGENTS": {
"TITLE": "موظف الدعم",
"DESC": "هنا يمكنك إضافة موظفين لإدارة صندوق الوارد الخاص بقناة تواصلك التي تم إنشاؤها حديثاً. الموظفين الذين يتم تحديدهم هنا هم فقط من يمكنهم الوصول إلى صندوق الوارد الخاص بتلك القناة. الموظفين الذين ليسوا جزءاً من صندوق الوارد هذا لن يكونوا قادرين على رؤية أو الرد على الرسائل في قناة التواصل هذه عند تسجيل الدخول. <br> <b>ملحوظة:</b> كمسؤول، إذا كنت بحاجة إلى الوصول إلى جميع صناديق الوارد، يجب عليك إضافة نفسك كموظف لجميع صناديق الوارد الخاصة بقنوات التواصل التي تنشئها.",
"VALIDATION_ERROR": "إضافة وكيل واحد على الأقل إلى علبة الوارد الجديدة",
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
"PICK_AGENTS": "اختر وكلاء لصندوق الوارد"
},
"DETAILS": {
@@ -357,7 +357,7 @@
"DESC": "لقد تم بنجاح ربط صفحة فيسبوك الخاصة بك مع Chatwoot. في المرة القادمة التي يرسل فيها العملاء رسالة إلى صفحتك، ستظهر المحادثة تلقائيًا على صندوق الوارد الخاص بك هنا.<br>نحن نزودك أيضًا بالكود النصي لصندوق دردشة الماسنجر والذي يمكنك إضافته بسهولة إلى الموقع الخاص بك لاستقبال الرسائل من الزوار كذلك. بمجرد أن يتم ذلك على موقع الويب الخاص بك، يمكن للعملاء مراسلتك من موقع الويب الخاص بك بدون الحاجة لأي أدوات خارجية وستظهر المحادثة هنا على Chatwoot.<br>رائع، أليس كذلك؟ نحن بالتأكيد نحاول أن نكون الأفضل :)"
},
"EMAIL_PROVIDER": {
"TITLE": "Select your email provider",
"TITLE": "حدد مزود البريد الإلكتروني الخاص بك",
"DESCRIPTION": "Select an email provider from the list below. If you don't see your email provider in the list, you can select the other provider option and provide the IMAP and SMTP Credentials."
},
"MICROSOFT": {
@@ -406,7 +406,7 @@
},
"SENDER_NAME_SECTION": {
"TITLE": "Sender name",
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
"FOR_EG": "For eg:",
"FRIENDLY": {
"TITLE": "Friendly",
@@ -508,7 +508,7 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "السماح بالرسائل بعد حل المحادثة",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "السماح للمستخدمين النهائيين بإرسال رسائل حتى بعد تسوية المحادثة.",
"WHATSAPP_SECTION_SUBHEADER": "يتم استخدام مفتاح API هذا للتكامل مع واتسب APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "مفتاح API",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
@@ -585,7 +585,7 @@
"NOTE_TEXT": "لتمكين SMTP ، الرجاء تكوين IMAP.",
"UPDATE": "تحديث الإعدادات",
"TOGGLE_AVAILABILITY": "تمكين تكوين IMAP لهذا البريد الوارد",
"TOGGLE_HELP": "تمكين IMAP سيساعد المستخدم على تلقي البريد الإلكتروني",
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
"EDIT": {
"SUCCESS_MESSAGE": "تم تحديث إعدادات IMAP بنجاح",
"ERROR_MESSAGE": "غير قادر على تحديث إعدادات IMAP"

View File

@@ -131,7 +131,7 @@
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "تحتاج مساعدة؟",
"DISMISS": "Dismiss",
"DISMISS": "تجاهل",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",

View File

@@ -46,7 +46,7 @@
"DISMISS": "Dismiss suggestion"
},
"POWERED_BY": "Chatwoot AI",
"DISMISS": "Dismiss",
"DISMISS": "تجاهل",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels"

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