Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma da0fc87583 fix(billing): stub payment method list in topup spec and trim comments 2026-06-15 18:11:27 +05:30
Tanmay Deep Sharma 8a760da6eb refactor(billing): gate multi-currency on MULTIPLE_CURRENCY_SUPPORTED config instead of feature flag 2026-06-15 17:57:44 +05:30
Tanmay Deep Sharma e1d512cddd fix(billing): fall back to legacy default source when no compatible payment method 2026-06-15 17:42:05 +05:30
Tanmay Deep Sharma bdb3bec11a docs(billing): correct currency switch persist-failure recovery comment 2026-06-15 17:36:04 +05:30
Tanmay Deep Sharma b733ddb8ff fix(billing): clear pending switch marker on stripe preflight failures 2026-06-15 16:44:39 +05:30
Tanmay Deep Sharma 3c5c9574a3 fix(billing): reconcile invoice default payment method by currency, drop default_source shortcut 2026-06-15 16:38:17 +05:30
Tanmay Deep Sharma de1469d2ca fix(billing): serialize concurrent currency switches per account 2026-06-15 16:28:46 +05:30
Tanmay Deep Sharma 49aec5517b fix(billing): cancel-first currency switch and currency-aware default payment method 2026-06-15 16:20:48 +05:30
Tanmay Deep Sharma c6210a4476 fix(billing): harden currency switch idempotency, usd location reset, and default-plan resolution 2026-06-15 15:51:36 +05:30
Tanmay Deep Sharma 6d8893c20c chore(billing): drop unrelated schema.rb changes, keep only migration version bump 2026-06-15 14:52:00 +05:30
Tanmay Deep Sharma 860c9c966c test(billing): cover currency switch service and plan price resolver 2026-06-15 14:42:48 +05:30
Tanmay Deep Sharma 4e90084d9e feat(billing): gate currency switch UI behind billing_currency_switch flag 2026-06-15 14:42:47 +05:30
Tanmay Deep Sharma 552d598060 refactor(billing): split currency switch into eligibility, price resolver, and executor 2026-06-15 14:42:34 +05:30
Tanmay Deep Sharma b2dbdb908a chore(billing): repurpose deprecated quoted_email_reply flag as billing_currency_switch 2026-06-15 14:42:26 +05:30
Tanmay Deep SharmaandGitHub 03feaad417 Merge branch 'feat/billing-brl-pix-new-users' into feat/billing-switch-currency 2026-06-15 14:28:03 +05:30
Tanmay Deep SharmaandGitHub ff35dcb7ff Merge branch 'develop' into feat/billing-brl-pix-new-users 2026-06-15 14:27:55 +05:30
Tanmay Deep SharmaandGitHub fca9d9009d Merge branch 'feat/billing-brl-pix-new-users' into feat/billing-switch-currency 2026-06-15 13:41:31 +05:30
Tanmay Deep Sharma 06e15ff081 chore(billing): default CHATWOOT_CLOUD_TOPUP_OPTIONS to empty hash 2026-06-15 13:39:02 +05:30
Tanmay Deep Sharma 56d9b232d1 fix(billing): only set Stripe billing country for currencies that require an override 2026-06-11 18:13:26 +05:30
Tanmay Deep Sharma 15f920c9af test(billing): cover Brazilian Real currency mapping in Currencies 2026-06-11 18:04:05 +05:30
Tanmay Deep Sharma d191603620 refactor(billing): rename coerce to to_supported and fix find_plan param name 2026-06-11 18:02:46 +05:30
Tanmay Deep SharmaandGitHub 20911a01dc Merge branch 'develop' into feat/billing-brl-pix-new-users 2026-06-11 17:53:48 +05:30
Tanmay Deep Sharma 6f6769b66f fix(billing): revert stripe customer location when currency switch fails 2026-06-10 20:53:25 +05:30
Tanmay Deep Sharma 9ed49bfc80 feat(billing): allow trialing subscriptions to switch currency 2026-06-09 18:43:45 +05:30
Tanmay Deep Sharma 3680da7e48 fix(billing): switch default plan immediately to active sub in new currency 2026-06-09 17:56:25 +05:30
Tanmay Deep Sharma 7fd3338112 feat(billing): allow default-plan accounts to switch currency 2026-06-09 16:13:53 +05:30
Tanmay Deep Sharma 6ffc1c5f75 refactor(billing): restrict currency switch to a single active paid subscription 2026-06-09 16:10:11 +05:30
Tanmay Deep Sharma 37470d7e57 refactor(billing): order currency switch so DB persist runs last, after all Stripe mutations 2026-06-09 15:54:00 +05:30
Tanmay Deep Sharma 6d9242cfb7 fix(billing): block incomplete paid subscriptions from currency switch 2026-06-09 15:45:49 +05:30
Tanmay Deep Sharma 601625aeb3 fix(billing): persist currency only after Stripe customer sync succeeds 2026-06-09 15:22:32 +05:30
Tanmay Deep Sharma 574efb4d9e fix(billing): recreate default subscription in new currency on free-plan switch 2026-06-09 14:50:06 +05:30
Tanmay Deep Sharma 67dc0d7090 fix(billing): clear switch metadata when subscription cancel fails 2026-06-09 14:07:59 +05:30
Tanmay Deep Sharma 54662b17f7 fix(billing): also block unpaid paid subscriptions from currency switch 2026-06-09 14:00:17 +05:30
Tanmay Deep SharmaandGitHub 879606fd19 Merge branch 'feat/billing-brl-pix-new-users' into feat/billing-switch-currency 2026-06-09 13:51:16 +05:30
Tanmay Deep SharmaandGitHub 462100cba1 Merge branch 'develop' into feat/billing-brl-pix-new-users 2026-06-09 13:51:06 +05:30
Tanmay Deep Sharma 461df26d90 fix(billing): reject currency switch while paid subscription is past_due 2026-06-09 13:49:41 +05:30
Tanmay Deep Sharma aab8c848b6 fix(billing): preserve fallback row currency in topup options 2026-06-09 13:43:56 +05:30
Tanmay Deep Sharma 29e1eaed19 Revert "fix(billing): preserve fallback row currency in topup options"
This reverts commit 3bbaacac40.
2026-06-09 13:43:38 +05:30
Tanmay Deep Sharma 3bbaacac40 fix(billing): preserve fallback row currency in topup options 2026-06-09 13:42:23 +05:30
Tanmay Deep Sharma ea2c08669f style(billing): shorten past_due comment under line limit 2026-06-09 13:34:23 +05:30
Tanmay Deep Sharma eeca70e841 fix(billing): exclude past_due subscriptions from currency switch 2026-06-09 13:34:04 +05:30
Tanmay Deep Sharma 1db8b7e722 Merge branch 'feat/billing-brl-pix-new-users' into feat/billing-switch-currency
# Conflicts:
#	app/views/api/v1/models/_account.json.jbuilder
2026-06-09 13:33:02 +05:30
Tanmay Deep Sharma dcfb0d9857 fix(billing): gate currency switch by locale and sync Stripe location after success 2026-06-09 13:24:35 +05:30
Tanmay Deep Sharma d6c75586df feat(billing): show account currency on billing page 2026-06-09 13:12:24 +05:30
Tanmay Deep Sharma 13402a3a9a fix(billing): ignore customer.subscription.created to avoid double-processing currency switches 2026-06-04 19:00:02 +05:30
Tanmay Deep Sharma ba38d54321 style(billing): make comments concise 2026-06-04 18:59:50 +05:30
Tanmay Deep Sharma f1bc3208e9 feat(billing): let accounts switch billing currency with a confirmation note 2026-06-03 11:27:25 +05:30
Tanmay Deep Sharma bbd575d8fa refactor(billing): drop speculative String price_ids branch 2026-06-03 11:27:16 +05:30
Tanmay Deep Sharma 4bf6a15c06 style(billing): make comments concise 2026-06-03 10:35:54 +05:30
Tanmay Deep Sharma 8995203473 fix(billing): keep existing Stripe-billed accounts on USD instead of inferring BRL from locale 2026-06-02 18:05:02 +05:30
Tanmay Deep Sharma 4e33490676 fix(billing): keep topup_checkout un-gated and seed topup options config in specs 2026-06-02 16:26:21 +05:30
Tanmay Deep Sharma 0768ce70ab refactor(billing): require CHATWOOT_CLOUD_TOPUP_OPTIONS, drop hardcoded fallback 2026-06-02 16:17:02 +05:30
Tanmay Deep Sharma b38618b5d3 feat(billing): bill new Brazilian accounts in BRL and show currency-aware credit top-ups 2026-06-02 15:49:13 +05:30
395 changed files with 9371 additions and 13962 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 20
steps:
- checkout
- node/install:
+3 -8
View File
@@ -43,18 +43,13 @@
## General Guidelines
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
+12 -12
View File
@@ -170,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (4.1.2)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -193,7 +193,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -274,8 +274,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -304,7 +304,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.3)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.9)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -598,14 +598,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.4)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
@@ -627,7 +627,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.17.3)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -674,7 +674,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
+1 -1
View File
@@ -1 +1 @@
4.15.1
4.14.2
@@ -91,17 +91,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url')
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if [:share, :post].include?(type.to_sym)
return :fallback if type.to_sym == :share
super
end
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -120,20 +111,13 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
@@ -30,8 +30,7 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
end
@@ -47,15 +47,17 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
end
def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
)
end
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy
authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
end
render_response(
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
)
end
@@ -1,19 +1,17 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
STEP_INBOX_SETUP = 'inbox_setup'.freeze
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
def update
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
@account = Current.account
# The client declares the step it is completing; `account_details` runs
# `complete_account_details`, and so on. The known-step guard above keeps the
# client value from `send`-ing an arbitrary method.
send("complete_#{params[:onboarding_step]}")
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
@@ -24,48 +22,12 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
private
def complete_account_details
# Only act while the cursor still points here, so a stale replay after
# onboarding finished can't re-enter it.
return unless current_step == STEP_ACCOUNT_DETAILS
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
# environment check); self-hosted finishes onboarding here.
if ChatwootApp.chatwoot_cloud?
move_to_step(STEP_INBOX_SETUP)
create_onboarding_inboxes
else
finish_onboarding
end
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def complete_inbox_setup
# Only finalize while the cursor still points here, so a stale or out-of-order
# request can't end onboarding early. Replays are no-ops.
return unless current_step == STEP_INBOX_SETUP
finish_onboarding
end
def current_step
@account.custom_attributes[ONBOARDING_STEP_KEY]
end
def move_to_step(step)
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
@account.save!
end
def finish_onboarding
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
@account.save!
end
def create_onboarding_inboxes
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
def website
custom_attributes_params[:website]
end
def account_params
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end
def team_params
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
params.require(:team).permit(:name, :description, :allow_auto_assign)
end
end
@@ -10,8 +10,7 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
response = dyte_processor_service.add_participant_to_meeting(
@message.content_attributes['data']['meeting_id'],
@conversation.contact,
@message
@conversation.contact
)
render_response(response)
end
@@ -1,29 +0,0 @@
module PortalHomeData
extend ActiveSupport::Concern
private
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
+1 -5
View File
@@ -1,6 +1,5 @@
class DashboardController < ActionController::Base
include SwitchLocale
include PortalHomeData
GLOBAL_CONFIG_KEYS = %w[
LOGO
@@ -25,6 +24,7 @@ class DashboardController < ActionController::Base
DISABLE_USER_PROFILE_UPDATE
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
MULTIPLE_CURRENCY_SUPPORTED
].freeze
before_action :set_application_pack
@@ -64,10 +64,6 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
if @portal.layout == 'documentation'
request.variant = :documentation
load_home_data
end
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -1,6 +1,4 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -16,14 +14,13 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session unless @impersonation
track_user_session
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -57,8 +54,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -71,10 +66,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def authenticate_resource_with_sso_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -82,21 +74,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
end
def make_room_for_impersonation_token
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
@resource.tokens.delete(oldest_client_id) if oldest_client_id
end
def process_sso_auth_token
return if params[:email].blank?
user = User.from_email(params[:email])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(user)
@@ -122,7 +104,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -135,90 +116,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
active_token_count(user) >= MAX_SESSIONS
end
def active_token_count(user)
now = Time.current.to_i
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
end
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
# auto-evict instead of getting stuck on a UI they can't render.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
ip_address city country last_activity_at created_at].freeze
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
@@ -39,11 +39,9 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def switch_locale_with_portal(&)
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
# while UI translations fall back to an available I18n locale (e.g. th).
@locale = params[:locale]
@locale = validate_and_get_locale(params[:locale])
I18n.with_locale(validate_and_get_locale(@locale), &)
I18n.with_locale(@locale, &)
end
def switch_locale_with_article(&)
@@ -51,12 +49,13 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank?
@locale = if article.category.present?
article.category.locale
else
article.locale
end
I18n.with_locale(validate_and_get_locale(@locale), &)
article_locale = if article.category.present?
article.category.locale
else
article.locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
end
def allow_iframe_requests
@@ -1,6 +1,4 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
include PortalHomeData
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
@@ -33,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
+1 -11
View File
@@ -35,17 +35,7 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
:Username
)
end
end
-1
View File
@@ -12,7 +12,6 @@ class ConversationFinder
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
+12
View File
@@ -22,4 +22,16 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
+5 -17
View File
@@ -17,11 +17,15 @@ module PortalHelper
uri.to_s
end
def generate_portal_bg(portal_color, theme)
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
@@ -37,10 +41,6 @@ module PortalHelper
language_map[locale] || locale
end
def html_lang_attribute(locale)
locale.to_s.tr('_', '-')
end
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
@@ -97,18 +97,6 @@ module PortalHelper
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
def render_emoji_or_icon(value, color = nil)
return '' if value.blank?
# Emojis are non-ascii; bare icon names match this safe charset.
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
tag.span(class: icon_class, style: style, 'aria-hidden': true)
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
-6
View File
@@ -40,12 +40,6 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getAttachments(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/attachments`, {
params: { page },
});
}
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
@@ -27,6 +27,15 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
switchCurrency(currency) {
return axios.post(`${this.url}switch_currency`, { currency });
}
}
export default new EnterpriseAccountAPI();
@@ -16,7 +16,6 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -26,7 +25,6 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
host: this.url,
});
@@ -145,12 +145,6 @@
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
// Voice call widget - light mode
--call-widget: 33, 34, 38, 0.95;
--call-widget-border: 255, 255, 255, 0.1;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
.dark {
@@ -297,12 +291,6 @@
--border-blue: 39, 129, 246, 0.5;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
// Voice call widget - dark mode
--call-widget: 50, 53, 61, 1;
--call-widget-border: 255, 255, 255, 0.07;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
}
// NEXT COLORS END
@@ -26,13 +26,6 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -52,7 +45,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess, open });
defineExpose({ dialogRef, onSuccess });
</script>
<template>
@@ -1,153 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add …" option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,7 +15,6 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -42,7 +41,6 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -128,14 +128,9 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
<!-- Mobile sidebar container -->
@@ -184,14 +179,9 @@ const closeMobileSidebar = () => {
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
</Transition>
</div>
@@ -5,11 +5,8 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -31,7 +28,6 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -58,7 +54,6 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -90,23 +85,6 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -118,7 +96,6 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -138,7 +115,6 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -224,7 +200,11 @@ const getFormBinding = key => {
}
}
await emitContactUpdate();
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
@@ -241,12 +221,6 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -299,13 +273,6 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -36,7 +36,7 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
@@ -1,108 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const MEDIA_PEEK_LIMIT = 12;
const FILES_PEEK_LIMIT = 6;
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
const uiFlags = useMapGetter('contacts/getUIFlags');
const attachments = computed(() =>
attachmentsByContact.value(route.params.contactId)
);
const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
const hasContent = computed(() =>
attachments.value.some(
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
)
);
const mediaAttachments = computed(() =>
attachments.value
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showGallery = ref(false);
const selectedAttachment = ref(null);
const onMediaSelect = attachment => {
selectedAttachment.value = attachment;
showGallery.value = true;
};
const onFileSelect = attachment => {
if (attachment.data_url) {
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
}
};
const onJumpToMessage = attachment => {
if (!attachment.conversation_id || !attachment.message_id) return;
router.push({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: attachment.conversation_id,
},
query: { messageId: attachment.message_id },
});
};
onMounted(() => {
store.dispatch('contacts/fetchAttachments', route.params.contactId);
});
</script>
<template>
<div class="px-6">
<div v-if="isFetching" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<div v-else class="flex flex-col gap-5">
<Media
:attachments="attachments"
:peek-limit="MEDIA_PEEK_LIMIT"
show-jump-to-message
@select="onMediaSelect"
@jump-to-message="onJumpToMessage"
/>
<Files
:attachments="attachments"
:peek-limit="FILES_PEEK_LIMIT"
show-jump-to-message
@select="onFileSelect"
@jump-to-message="onJumpToMessage"
/>
</div>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
:attachment="selectedAttachment"
:all-attachments="mediaAttachments"
auto-play
@close="showGallery = false"
/>
</div>
</template>
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6">
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
@@ -92,7 +92,6 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -20,7 +20,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -29,18 +28,13 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
@@ -13,7 +13,6 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -132,7 +131,7 @@ const statusText = computed(() => {
const categoryName = computed(() => {
if (props.category?.slug) {
return props.category.name;
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
@@ -210,9 +209,9 @@ const handleClick = id => {
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-1">
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
@@ -223,17 +222,11 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ categoryName }}</span>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
@@ -245,7 +238,7 @@ const handleClick = id => {
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
@@ -6,7 +6,6 @@ import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
id: {
@@ -21,10 +20,6 @@ const props = defineProps({
type: String,
required: true,
},
iconColor: {
type: String,
default: '',
},
description: {
type: String,
required: true,
@@ -60,6 +55,10 @@ const categoryMenuItems = [
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
@@ -84,16 +83,10 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
<EmojiIcon
v-if="icon"
:value="icon"
:color="iconColor"
class="shrink-0 size-4"
/>
<span class="truncate">{{ title }}</span>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative shrink-0 group">
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
@@ -91,9 +91,6 @@ const togglePortalSwitcher = () => {
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<div class="flex justify-end min-w-0 grow">
<slot name="title-actions" />
</div>
</div>
<slot name="header-actions" />
</div>
@@ -8,7 +8,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
@@ -108,11 +107,10 @@ const selectedCategory = computed(() => {
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon, icon_color: iconColor }) => ({
.map(({ name, id, icon }) => ({
label: name,
value: id,
emoji: icon,
iconColor,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
@@ -204,6 +202,10 @@ onMounted(() => {
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
@@ -211,20 +213,12 @@ onMounted(() => {
@click="openCategoryList = !openCategoryList"
>
<span
class="flex items-center gap-1.5 min-w-0 text-sm text-n-slate-12 hover:text-n-slate-11"
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
<EmojiIcon
v-if="selectedCategory?.icon"
:value="selectedCategory.icon"
:color="selectedCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">
{{
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
}}
</span>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
</span>
</Button>
<DropdownMenu
@@ -13,7 +13,6 @@ import {
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
categories: {
@@ -64,15 +63,18 @@ const activeTabIndex = computed(() => {
return tabs.value.findIndex(tab => tab.value === tabParam);
});
const activeCategory = computed(() =>
props.categories.find(category => category.slug === route.params.categorySlug)
);
const activeCategoryName = computed(() => {
const activeCategory = props.categories.find(
category => category.slug === route.params.categorySlug
);
const activeCategoryName = computed(
() =>
activeCategory.value?.name ||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL')
);
if (activeCategory) {
const { icon, name } = activeCategory;
return `${icon} ${name}`;
}
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
});
const activeLocaleName = computed(() => {
return props.allowedLocales.find(
@@ -92,7 +94,6 @@ const categoryMenuItems = computed(() => {
value: category.slug,
action: 'filter',
emoji: category.icon,
iconColor: category.icon_color,
}));
const hasCategorySlug = !!route.params.categorySlug;
@@ -166,23 +167,14 @@ const handleTabChange = value => {
<div v-if="hasCategoryMenuItems" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
>
<span class="flex items-center gap-1.5 min-w-0">
<EmojiIcon
v-if="activeCategory?.icon"
:value="activeCategory.icon"
:color="activeCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ activeCategoryName }}</span>
</span>
</Button>
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
@@ -24,10 +24,6 @@ const props = defineProps({
type: Set,
default: () => new Set(),
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
@@ -45,7 +41,6 @@ const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
return (
props.isCategoryArticles &&
!props.isSearching &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
@@ -19,7 +18,6 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
@@ -51,12 +49,7 @@ const props = defineProps({
},
});
const emit = defineEmits([
'pageChange',
'fetchPortal',
'refreshArticles',
'search',
]);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -72,9 +65,6 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const searchQuery = ref(route.query.search || '');
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
const { isEnterprise } = useConfig();
@@ -132,7 +122,6 @@ const updateRoute = newParams => {
categorySlug: newParams.categorySlug ?? categorySlug,
...newParams,
},
query: route.query,
});
};
@@ -156,12 +145,7 @@ const showCategoryHeaderControls = computed(
() => props.isCategoryArticles && !isSwitchingPortal.value
);
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
const getEmptyStateText = type => {
if (isSearching.value) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
}
if (props.isCategoryArticles) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
}
@@ -246,7 +230,6 @@ const categoryMenuItems = computed(() =>
value: category.id,
action: 'move',
emoji: category.icon,
iconColor: category.icon_color,
}))
);
@@ -307,19 +290,6 @@ watch(
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
<template #title-actions>
<Input
v-if="!isSwitchingPortal"
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-full max-w-[16rem] min-w-0"
@input="debouncedSearch"
/>
</template>
<template #header-actions>
<div class="flex items-end justify-between">
<ArticleHeaderControls
@@ -451,7 +421,6 @@ watch(
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@@ -463,7 +432,7 @@ watch(
class="pt-14"
:title="getEmptyStateTitle"
:subtitle="getEmptyStateSubtitle"
:show-button="hasNoArticlesInPortal && !isSearching"
:show-button="hasNoArticlesInPortal"
:button-label="
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
"
@@ -37,21 +37,10 @@ const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const filteredCategories = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.categories;
return props.categories.filter(category =>
category.name?.toLowerCase().includes(query)
);
});
const hasCategories = computed(() => filteredCategories.value?.length > 0);
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const hasCategories = computed(() => props.categories?.length > 0);
const updateRoute = (newParams, routeName) => {
const { accountId, portalSlug, locale } = route.params;
@@ -126,7 +115,6 @@ const reorderCategories = async reorderedGroup => {
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
v-model:search-query="searchQuery"
:categories="categories"
:is-category-articles="false"
:allowed-locales="allowedLocales"
@@ -142,18 +130,11 @@ const reorderCategories = async reorderedGroup => {
</div>
<CategoryList
v-else-if="hasCategories"
:categories="filteredCategories"
:disable-drag="isSearching"
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else-if="isSearching"
class="pt-14"
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
/>
<CategoryEmptyState
v-else
class="pt-14"
@@ -38,8 +38,8 @@ const { t } = useI18n();
const route = useRoute();
const handleCategory = async formData => {
const { id, name, slug, icon, iconColor, description, locale } = formData;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
const { id, name, slug, icon, description, locale } = formData;
const categoryData = { name, icon, slug, description };
if (props.mode === 'create') {
categoryData.locale = locale;
@@ -18,7 +18,6 @@ import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
mode: {
@@ -50,9 +49,8 @@ const props = defineProps({
const emit = defineEmits(['submit', 'cancel']);
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const { t } = useI18n();
@@ -74,7 +72,6 @@ const state = reactive({
id: '',
name: '',
icon: '',
iconColor: '',
slug: '',
description: '',
locale: '',
@@ -112,19 +109,8 @@ const slugHelpText = computed(() => {
});
});
const onSelectIcon = ({ type, value, color }) => {
state.icon = value;
state.iconColor = type === 'icon' ? color : '';
isEmojiPickerOpen.value = false;
};
const onColorChange = color => {
state.iconColor = color;
};
const onRemoveIcon = () => {
state.icon = '';
state.iconColor = '';
const onClickInsertEmoji = emoji => {
state.icon = emoji;
isEmojiPickerOpen.value = false;
};
@@ -153,14 +139,7 @@ watch(
newCategory => {
if (props.mode === 'edit' && newCategory) {
const { id, name, icon, slug, description } = newCategory;
Object.assign(state, {
id,
name,
icon,
iconColor: newCategory.icon_color || '',
slug,
description,
});
Object.assign(state, { id, name, icon, slug, description });
}
},
{ immediate: true }
@@ -218,29 +197,19 @@ defineExpose({ state, isSubmitDisabled });
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
type="button"
variant="ghost"
:label="state.icon"
color="slate"
class="!h-[2.38rem] !w-[2.375rem] !p-0 absolute top-[2rem] start-px ltr:!rounded-r-none rtl:!rounded-l-none"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<span v-else class="i-lucide-smile-plus size-4" />
</Button>
<EmojiIconPicker
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-16"
:value="state.icon"
:color="state.iconColor"
show-remove-button
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
:on-click="onClickInsertEmoji"
/>
</OnClickOutside>
</template>
@@ -6,7 +6,6 @@ import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
@@ -28,11 +27,6 @@ const props = defineProps({
const emit = defineEmits(['localeChange', 'newArticle']);
const searchQuery = defineModel('searchQuery', {
type: String,
default: '',
});
const route = useRoute();
const router = useRouter();
const getters = useStoreGetters();
@@ -112,7 +106,6 @@ const breadcrumbItems = computed(() => {
}
),
emoji: selectedCategoryEmoji.value,
iconColor: selectedCategory.value?.icon_color,
});
}
return items;
@@ -168,34 +161,23 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-48"
/>
<div class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-else class="relative flex items-center gap-2">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
@@ -8,10 +8,6 @@ const props = defineProps({
type: Array,
required: true,
},
disableDrag: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'action', 'reorder']);
@@ -19,7 +15,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return !props.disableDrag && localCategories.value?.length > 1;
return localCategories.value?.length > 1;
});
const handleClick = slug => {
@@ -72,7 +68,6 @@ watch(
:id="element.id"
:title="element.name"
:icon="element.icon"
:icon-color="element.icon_color"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
@@ -52,8 +52,8 @@ const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const onUpdateCategory = async () => {
if (!categoryFormRef.value) return;
const { state } = categoryFormRef.value;
const { id, name, slug, icon, iconColor, description } = state;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
const { id, name, slug, icon, description } = state;
const categoryData = { name, icon, slug, description };
categoryData.id = id;
try {
@@ -2,12 +2,8 @@
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useI18n } from 'vue-i18n';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
@@ -23,10 +19,7 @@ const props = defineProps({
},
});
const { t } = useI18n();
const addLocaleDialogRef = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -35,19 +28,6 @@ const openAddLocaleDialog = () => {
};
const localeCount = computed(() => props.locales?.length);
const filteredLocales = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.locales;
return props.locales.filter(
locale =>
locale.name?.toLowerCase().includes(query) ||
locale.code?.toLowerCase().includes(query)
);
});
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const hasResults = computed(() => filteredLocales.value?.length > 0);
</script>
<template>
@@ -59,21 +39,12 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<div class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
type="search"
size="sm"
class="w-48"
/>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</template>
<template #content>
@@ -83,13 +54,7 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
>
<Spinner />
</div>
<EmptyStateLayout
v-else-if="isSearching && !hasResults"
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
:show-backdrop="false"
/>
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
<LocaleList v-else :locales="locales" :portal="portal" />
</template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout>
@@ -53,9 +53,8 @@ const generateUid = () => {
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const {
@@ -216,11 +215,10 @@ useEventListener(document, 'paste', onPaste);
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiIconPicker
<EmojiInput
v-if="isEmojiPickerOpen"
mode="emoji"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
@select="onClickInsertEmoji($event.value)"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
@@ -1,175 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { formatBytes } from 'shared/helpers/FileHelper';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import FileIcon from 'next/icon/FileIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const { t } = useI18n();
const fileAttachments = computed(() =>
[...props.attachments]
.filter(
a =>
a.data_url &&
!MEDIA_TYPES.includes(a.file_type) &&
!NON_FILE_TYPES.includes(a.file_type)
)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showAll = ref(false);
const downloadingId = ref(null);
const isPeekable = computed(() => props.peekLimit > 0);
const visibleFiles = computed(() => {
if (!isPeekable.value || showAll.value) return fileAttachments.value;
return fileAttachments.value.slice(0, props.peekLimit);
});
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const displayName = attachment =>
fileNameFromUrl(attachment.data_url) ||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
const displaySize = attachment => {
if (attachment.file_size) return formatBytes(attachment.file_size);
if (attachment.extension) return attachment.extension.toUpperCase();
return '—';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
const onActivate = attachment => emit('select', attachment);
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
</script>
<template>
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ fileAttachments.length }}
</span>
</h4>
<NextButton
v-if="isPeekable && fileAttachments.length > peekLimit"
ghost
slate
xs
trailing-icon
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
:label="
showAll
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAll = !showAll"
/>
</header>
<ul class="flex flex-col gap-0.5">
<li
v-for="attachment in visibleFiles"
:key="attachment.id"
role="button"
tabindex="0"
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg cursor-pointer hover:bg-n-slate-3 group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onActivate(attachment)"
@keydown.enter="onActivate(attachment)"
@keydown.space.prevent="onActivate(attachment)"
>
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
>
<FileIcon
:file-type="attachment.extension?.toLowerCase() || ''"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
{{ displayName(attachment) }}
</p>
<p class="text-xs text-n-slate-11">
{{ displaySize(attachment) }}
<template v-if="displayTime(attachment)">
· {{ displayTime(attachment) }}
</template>
</p>
</div>
<div class="flex items-center gap-1">
<NextButton
v-if="showJumpToMessage && attachment.message_id"
v-tooltip.top="{
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
delay: { show: 500, hide: 0 },
}"
ghost
slate
sm
icon="i-lucide-external-link"
class="opacity-0 group-hover:opacity-100"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
@click.stop="emit('jumpToMessage', attachment)"
@keydown.enter.stop
@keydown.space.stop
/>
<NextButton
ghost
slate
sm
icon="i-lucide-download"
class="opacity-0 group-hover:opacity-100"
:is-loading="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
/>
</div>
</li>
</ul>
</section>
<template v-else />
</template>
@@ -1,305 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import {
dynamicTime,
formatDuration,
shortTimestamp,
} from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
ATTACHMENT_TYPES,
MEDIA_TYPES,
} from 'dashboard/components-next/message/constants';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const { t } = useI18n();
const mediaAttachments = computed(() =>
[...props.attachments]
.filter(a => a.data_url && MEDIA_TYPES.includes(a.file_type))
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showAll = ref(false);
const failedThumbs = ref(new Set());
const failedPreviews = ref(new Set());
const durations = ref({});
const downloadingId = ref(null);
const isPeekable = computed(() => props.peekLimit > 0);
const visibleMedia = computed(() => {
if (!isPeekable.value || showAll.value) return mediaAttachments.value;
return mediaAttachments.value.slice(0, props.peekLimit);
});
const overflow = computed(() => {
if (!isPeekable.value) return 0;
const total = mediaAttachments.value.length;
return total > props.peekLimit ? total - (props.peekLimit - 1) : 0;
});
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const isVideoType = type =>
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
const isPlayableType = type => isVideoType(type) || isAudioType(type);
const imagePreviewSrc = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
return null;
};
const onPreviewError = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
if (
canRetryWithFull &&
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
) {
failedThumbs.value.add(id);
return;
}
failedPreviews.value.add(id);
};
const hasPreview = attachment =>
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
const hasVideoPreview = attachment =>
isVideoType(attachment.file_type) &&
attachment.data_url &&
!failedPreviews.value.has(attachment.id);
const fallbackIcon = type => {
if (isAudioType(type)) return 'i-lucide-music';
if (isVideoType(type)) return 'i-lucide-video';
return 'i-lucide-image';
};
const onLoadedMetadata = (attachment, event) => {
const seconds = event.target?.duration;
if (Number.isFinite(seconds) && seconds > 0) {
durations.value[attachment.id] = seconds;
}
};
const displayDuration = attachment => {
const seconds = durations.value[attachment.id];
return seconds ? formatDuration(Math.round(seconds)) : '';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
const isOverflowTile = index =>
isPeekable.value &&
!showAll.value &&
overflow.value > 0 &&
index === props.peekLimit - 1;
const onTileActivate = (attachment, index) => {
if (isOverflowTile(index)) {
showAll.value = true;
return;
}
emit('select', attachment);
};
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
</script>
<template>
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ mediaAttachments.length }}
</span>
</h4>
<NextButton
v-if="overflow > 0"
ghost
slate
xs
trailing-icon
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
:label="
showAll
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAll = !showAll"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
role="button"
tabindex="0"
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onTileActivate(attachment, index)"
@keydown.enter="onTileActivate(attachment, index)"
@keydown.space.prevent="onTileActivate(attachment, index)"
>
<template v-if="!isOverflowTile(index)">
<img
v-if="hasPreview(attachment)"
:src="imagePreviewSrc(attachment)"
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
loading="lazy"
:alt="fileNameFromUrl(attachment.data_url)"
@error="onPreviewError(attachment)"
/>
<video
v-else-if="hasVideoPreview(attachment)"
:src="`${attachment.data_url}#t=0.1`"
preload="metadata"
muted
playsinline
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
@error="onPreviewError(attachment)"
/>
<div
v-else
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
>
<Icon
:icon="fallbackIcon(attachment.file_type)"
class="size-6 text-n-slate-11"
/>
</div>
<audio
v-if="isAudioType(attachment.file_type) && attachment.data_url"
:src="attachment.data_url"
preload="metadata"
class="hidden"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
/>
<div
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
/>
<div
v-if="hasVideoPreview(attachment)"
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
>
<div
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
>
<Icon icon="i-lucide-play" class="ms-0.5 size-3.5 text-n-black" />
</div>
</div>
<span
v-if="
isPlayableType(attachment.file_type) &&
displayDuration(attachment)
"
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
>
{{ displayDuration(attachment) }}
</span>
<span
v-if="displayTime(attachment)"
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
>
{{ displayTime(attachment) }}
</span>
<button
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
:disabled="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
</button>
<button
v-if="showJumpToMessage && attachment.message_id"
v-tooltip.top="{
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
delay: { show: 500, hide: 0 },
}"
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 top-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
@click.stop="emit('jumpToMessage', attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-external-link" class="size-3 text-n-black" />
</button>
</template>
<div
v-if="isOverflowTile(index)"
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
>
<span class="text-base font-semibold text-n-slate-12">
{{
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
count: overflow,
})
}}
</span>
</div>
</div>
</div>
</section>
<template v-else />
</template>
@@ -2,7 +2,6 @@
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
defineProps({
items: {
@@ -48,17 +47,8 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span
v-else
class="inline-flex items-center gap-1 text-sm truncate min-w-0"
>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span class="truncate text-n-slate-12">{{ item.label }}</span>
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
</ol>
@@ -75,11 +75,11 @@ const channelIcon = computed(() => {
<template>
<div
class="flex flex-col gap-1 pt-4 bg-n-call-widget rounded-2xl shadow-xl outline outline-1 outline-n-call-widget-border backdrop-blur-md"
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
>
<!-- Top section: status badge + location/inbox + duration -->
<div class="flex flex-col gap-3 pb-3 border-b border-n-call-widget-border">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 px-4">
<!-- Ongoing: status badge on left -->
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
@@ -100,10 +100,10 @@ const channelIcon = computed(() => {
<Icon
v-else-if="!isOngoing"
:icon="channelIcon"
class="size-3.5 text-n-call-widget-sub-text shrink-0"
class="size-3.5 text-n-slate-10 shrink-0"
/>
<span
class="text-xs font-medium text-n-call-widget-sub-text tracking-tight truncate"
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
>
{{ callInfo.location }}
</span>
@@ -112,7 +112,7 @@ const channelIcon = computed(() => {
<!-- Ongoing: duration on right -->
<p
v-if="isOngoing"
class="font-display text-base font-medium text-n-call-widget-sub-text shrink-0 mb-0 tabular-nums tracking-tight"
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
>
{{ duration }}
</p>
@@ -132,7 +132,7 @@ const channelIcon = computed(() => {
slate
ghost
xs
class="!rounded-full -my-1 -me-1 !text-n-call-widget-sub-text"
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div>
@@ -149,13 +149,13 @@ const channelIcon = computed(() => {
</div>
<div class="flex-1 min-w-0">
<p
class="font-display text-sm font-medium text-n-call-widget-text truncate mb-0.5 tracking-tight leading-tight"
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
>
{{ callInfo.contactName }}
</p>
<p
v-if="callInfo.phoneNumber"
class="text-sm text-n-call-widget-sub-text truncate mb-0 tracking-tight leading-tight"
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
>
{{ callInfo.phoneNumber }}
</p>
@@ -217,23 +217,23 @@ const channelIcon = computed(() => {
>
<template #icon>
<span
class="flex items-center gap-1 text-n-call-widget-sub-text group-hover:text-n-call-widget-text"
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
>
<Icon
icon="i-ph-chat-circle-text-bold"
class="size-3.5 text-n-call-widget-sub-text shrink-0"
class="size-3.5 text-n-slate-11 shrink-0"
/>
<span class="text-sm tracking-tight tabular-nums">
#{{ call.conversationId }}
</span>
<Icon
icon="i-ph-caret-right-bold"
class="size-3 text-n-call-widget-sub-text shrink-0"
class="size-3 text-n-slate-11 shrink-0"
/>
</span>
</template>
<span
class="text-sm text-n-call-widget-sub-text tracking-tight group-hover:text-n-call-widget-text"
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
>
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
</span>
@@ -14,9 +14,6 @@ const props = defineProps({
value.every(option => 'value' in option && 'label' in option),
},
placeholder: { type: String, default: '' },
// Fallback label shown when the selected value is not in `options` yet
// (e.g. API-backed lists that load lazily on open).
displayLabel: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
@@ -26,7 +23,7 @@ const props = defineProps({
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search', 'open']);
const emit = defineEmits(['update:modelValue', 'search']);
const { t } = useI18n();
@@ -55,7 +52,7 @@ const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
return selected?.label ?? selectPlaceholder.value;
});
const selectOption = option => {
@@ -75,7 +72,6 @@ const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
emit('open');
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -87,7 +87,7 @@ defineExpose({
}"
role="option"
:aria-selected="isSelected(option)"
@click.stop="emit('select', option)"
@click="emit('select', option)"
>
<span
:class="{
@@ -3,7 +3,6 @@ import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -163,7 +162,7 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
>
{{ section.title }}
</p>
@@ -208,12 +207,9 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -264,12 +260,7 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -1,27 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ICON_COLORS } from './constants';
const { t } = useI18n();
const selectedColor = defineModel({ type: String, default: '' });
</script>
<template>
<div class="flex items-center justify-between gap-1 px-0.5 mx-2 py-1 mb-px">
<button
v-for="color in ICON_COLORS"
:key="color.value"
v-tooltip.top="t(`EMOJI_ICON_PICKER.COLORS.${color.name}`)"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-full size-5 ring-offset-2 ring-offset-n-surface-2 hover:ring-2"
:class="{ 'ring-2': selectedColor === color.value }"
:style="{ backgroundColor: color.value, '--tw-ring-color': color.value }"
@click="selectedColor = color.value"
>
<span
v-if="selectedColor === color.value"
class="text-white i-lucide-check size-3"
/>
</button>
</div>
</template>
@@ -1,35 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { isIconValue, iconClassFor } from './constants';
// Renders a saved picker value: a color-tinted icon or an emoji.
const props = defineProps({
value: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
});
const isIcon = computed(() => isIconValue(props.value));
const iconClass = computed(() => iconClassFor(props.value));
const iconStyle = computed(() =>
props.color ? { color: props.color } : undefined
);
</script>
<template>
<span class="inline-flex items-center justify-center leading-none">
<Icon
v-if="isIcon"
:icon="iconClass"
class="size-full"
:style="iconStyle"
/>
<span v-else v-dompurify-html="value" />
</span>
</template>
@@ -1,311 +0,0 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Virtualizer } from 'virtua/vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ColorPalette from './ColorPalette.vue';
import { CURATED_ICONS } from './icons';
import {
DEFAULT_ICON_COLOR,
ICON_STYLE,
PICKER_MODE,
PICKER_TAB,
isIconValue,
} from './constants';
import {
buildEmojiSections,
getEmojiTint,
getRecentEmojis,
addRecentEmoji,
} from 'shared/components/emoji/pickerHelper';
const props = defineProps({
// 'both' shows Icons + Emojis tabs; 'emoji' shows only the emoji panel.
mode: {
type: String,
default: PICKER_MODE.BOTH,
validator: value => Object.values(PICKER_MODE).includes(value),
},
// Saved value: an emoji char or an icon name (e.g. "rocket-line").
value: {
type: String,
default: '',
},
// Current stored icon color (hex), used to preselect a swatch.
color: {
type: String,
default: '',
},
showRemoveButton: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'remove', 'colorChange']);
const { t } = useI18n();
const showTabs = computed(() => props.mode === PICKER_MODE.BOTH);
const showHeader = computed(
() => showTabs.value || (props.showRemoveButton && props.value)
);
const activeTab = ref(
props.value && !isIconValue(props.value)
? PICKER_TAB.EMOJIS
: PICKER_TAB.ICONS
);
const tabs = computed(() => [
{
id: PICKER_TAB.ICONS,
label: t('EMOJI_ICON_PICKER.TABS.ICONS'),
icon: 'i-lucide-shapes',
},
{
id: PICKER_TAB.EMOJIS,
label: t('EMOJI_ICON_PICKER.TABS.EMOJIS'),
icon: 'i-lucide-smile',
},
]);
// Icons
const iconSearch = ref('');
const selectedColor = ref(props.color || DEFAULT_ICON_COLOR);
// Preselect the outline/filled toggle from the saved icon's style suffix.
const iconStyle = ref(
props.value.endsWith(`-${ICON_STYLE.FILL}`)
? ICON_STYLE.FILL
: ICON_STYLE.LINE
);
const filteredIcons = computed(() => {
const term = iconSearch.value.trim().toLowerCase();
if (!term) return CURATED_ICONS;
return CURATED_ICONS.filter(
icon => icon.name.includes(term) || icon.keywords.includes(term)
);
});
const toggleIconStyle = () => {
iconStyle.value =
iconStyle.value === ICON_STYLE.LINE ? ICON_STYLE.FILL : ICON_STYLE.LINE;
};
// Each icon button tints its hover with the selected color (low opacity).
const iconHoverStyle = computed(() => ({
'--ep-tint': `${selectedColor.value}24`,
}));
const selectIcon = icon => {
// Store the name with its style suffix, e.g. "rocket-fill".
const value = `${icon.name}-${iconStyle.value}`;
emit('select', {
type: 'icon',
value,
icon: value,
color: selectedColor.value,
});
};
watch(selectedColor, color => {
if (isIconValue(props.value)) emit('colorChange', color);
});
// Emojis
const emojiSearch = ref('');
const recentEmojis = ref([]);
onMounted(() => {
recentEmojis.value = getRecentEmojis();
});
const emojiSections = computed(() =>
buildEmojiSections(
emojiSearch.value,
recentEmojis.value,
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
)
);
// Tints an emoji button with a light shade of the emoji's own color on hover.
const applyEmojiTint = (event, emoji) => {
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
};
const selectEmoji = emoji => {
recentEmojis.value = addRecentEmoji(emoji);
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
};
</script>
<template>
<div
role="dialog"
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
>
<div v-if="showHeader" class="flex items-center justify-between gap-2 p-2">
<div v-if="showTabs" class="flex gap-0.5 p-0.5 rounded-lg bg-n-alpha-1">
<button
v-for="tab in tabs"
:key="tab.id"
v-tooltip.top="tab.label"
type="button"
class="flex items-center justify-center !p-0 transition-all rounded-md size-7 active:scale-[0.92]"
:class="
activeTab === tab.id
? 'bg-n-surface-1 shadow-sm text-n-slate-12'
: 'text-n-slate-10 hover:text-n-slate-12'
"
@click="activeTab = tab.id"
>
<span :class="tab.icon" class="size-4" />
</button>
</div>
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
type="button"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-eraser"
class="flex-shrink-0"
@click="emit('remove')"
/>
</div>
<!-- Icons panel -->
<div
v-if="showTabs && activeTab === PICKER_TAB.ICONS"
class="flex flex-col gap-1.5"
:style="iconHoverStyle"
>
<ColorPalette v-model="selectedColor" />
<div class="flex items-center gap-1 px-2">
<Input
v-model="iconSearch"
size="md"
class="flex-1"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_ICON')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
<button
v-tooltip.top="
iconStyle === ICON_STYLE.FILL
? t('EMOJI_ICON_PICKER.STYLE.FILLED')
: t('EMOJI_ICON_PICKER.STYLE.OUTLINE')
"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-lg !p-2 size-9 text-n-slate-11 hover:bg-[var(--ep-tint)] active:scale-[0.92]"
@click="toggleIconStyle"
>
<span
:class="
iconStyle === ICON_STYLE.FILL
? 'i-ri-contrast-2-fill'
: 'i-ri-contrast-2-line'
"
class="size-[1.2rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-if="filteredIcons.length"
class="grid grid-cols-10 h-52 gap-0.5 overflow-y-auto no-scrollbar content-start px-2 pb-2"
>
<button
v-for="icon in filteredIcons"
:key="icon.name"
type="button"
:title="icon.name"
class="flex items-center justify-center !p-0 size-8 transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@click="selectIcon(icon)"
>
<span
:class="icon[iconStyle]"
class="size-[1.125rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-52 text-n-slate-10"
>
<span class="i-lucide-search-x size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_ICON') }}
</span>
</div>
</div>
<!-- Emojis panel -->
<div
v-if="!showTabs || activeTab === PICKER_TAB.EMOJIS"
class="flex flex-col gap-1.5"
:class="{ 'pt-2': !showHeader }"
>
<div class="px-2">
<Input
v-model="emojiSearch"
size="md"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
</div>
<div
v-if="emojiSections.length"
class="h-60 overflow-y-auto px-2 no-scrollbar pb-2"
>
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
<h5
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
>
{{ section.name }}
</h5>
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in section.emojis"
:key="`${section.name}-${emoji.slug}`"
type="button"
:title="emoji.name"
class="flex items-center justify-center !p-0 size-8 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@mouseenter="applyEmojiTint($event, emoji.emoji)"
@click="selectEmoji(emoji)"
>
{{ emoji.emoji }}
</button>
</div>
</Virtualizer>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
>
<span class="i-lucide-smile-plus size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
</span>
</div>
</div>
</div>
</template>
@@ -1,40 +0,0 @@
// Prefix that turns a stored icon name (e.g. "rocket-line") into a class.
// Swapping icon libraries means changing this and the curated set only.
export const ICON_PREFIX = 'i-ri-';
export const ICON_STYLE = {
LINE: 'line',
FILL: 'fill',
};
// Icon values are ascii names (e.g. "rocket-line"); emoji are non-ascii.
export const isIconValue = value =>
typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
export const iconClassFor = value =>
value.startsWith(ICON_PREFIX) ? value : `${ICON_PREFIX}${value}`;
export const ICON_COLORS = [
{ name: 'SLATE', value: '#64748B' },
{ name: 'RED', value: '#EF4444' },
{ name: 'ORANGE', value: '#F97316' },
{ name: 'AMBER', value: '#F59E0B' },
{ name: 'GREEN', value: '#22C55E' },
{ name: 'TEAL', value: '#14B8A6' },
{ name: 'BLUE', value: '#3B82F6' },
{ name: 'INDIGO', value: '#6366F1' },
{ name: 'VIOLET', value: '#8B5CF6' },
{ name: 'PINK', value: '#EC4899' },
];
export const DEFAULT_ICON_COLOR = '#3B82F6';
export const PICKER_MODE = {
BOTH: 'both',
EMOJI: 'emoji',
};
export const PICKER_TAB = {
ICONS: 'icons',
EMOJIS: 'emojis',
};
@@ -1,983 +0,0 @@
// Curated Remix (ri) icons, each with a `line` and `fill` class. The class
// literals are kept here so Tailwind generates them. { name, keywords, line, fill }
export const CURATED_ICONS = [
{
name: 'home',
keywords: 'home house main',
line: 'i-ri-home-line',
fill: 'i-ri-home-fill',
},
{
name: 'star',
keywords: 'favorite star',
line: 'i-ri-star-line',
fill: 'i-ri-star-fill',
},
{
name: 'heart',
keywords: 'like love',
line: 'i-ri-heart-line',
fill: 'i-ri-heart-fill',
},
{
name: 'bookmark',
keywords: 'save',
line: 'i-ri-bookmark-line',
fill: 'i-ri-bookmark-fill',
},
{
name: 'flag-2',
keywords: 'flag report',
line: 'i-ri-flag-2-line',
fill: 'i-ri-flag-2-fill',
},
{
name: 'price-tag-3',
keywords: 'tag label',
line: 'i-ri-price-tag-3-line',
fill: 'i-ri-price-tag-3-fill',
},
{
name: 'map-pin',
keywords: 'location place',
line: 'i-ri-map-pin-line',
fill: 'i-ri-map-pin-fill',
},
{
name: 'sparkling-2',
keywords: 'magic ai shine sparkle',
line: 'i-ri-sparkling-2-line',
fill: 'i-ri-sparkling-2-fill',
},
{
name: 'fire',
keywords: 'flame hot trending',
line: 'i-ri-fire-line',
fill: 'i-ri-fire-fill',
},
{
name: 'lightbulb',
keywords: 'idea tip',
line: 'i-ri-lightbulb-line',
fill: 'i-ri-lightbulb-fill',
},
{
name: 'rocket',
keywords: 'launch startup ship',
line: 'i-ri-rocket-line',
fill: 'i-ri-rocket-fill',
},
{
name: 'focus-3',
keywords: 'target goal aim',
line: 'i-ri-focus-3-line',
fill: 'i-ri-focus-3-fill',
},
{
name: 'award',
keywords: 'prize badge medal',
line: 'i-ri-award-line',
fill: 'i-ri-award-fill',
},
{
name: 'trophy',
keywords: 'win achievement',
line: 'i-ri-trophy-line',
fill: 'i-ri-trophy-fill',
},
{
name: 'vip-crown-2',
keywords: 'crown premium vip',
line: 'i-ri-vip-crown-2-line',
fill: 'i-ri-vip-crown-2-fill',
},
{
name: 'vip-diamond',
keywords: 'gem diamond premium',
line: 'i-ri-vip-diamond-line',
fill: 'i-ri-vip-diamond-fill',
},
{
name: 'gift',
keywords: 'present reward',
line: 'i-ri-gift-line',
fill: 'i-ri-gift-fill',
},
{
name: 'notification-3',
keywords: 'bell alert',
line: 'i-ri-notification-3-line',
fill: 'i-ri-notification-3-fill',
},
{
name: 'global',
keywords: 'globe world web',
line: 'i-ri-global-line',
fill: 'i-ri-global-fill',
},
{
name: 'compass-3',
keywords: 'explore discover',
line: 'i-ri-compass-3-line',
fill: 'i-ri-compass-3-fill',
},
{
name: 'puzzle-2',
keywords: 'extension integration',
line: 'i-ri-puzzle-2-line',
fill: 'i-ri-puzzle-2-fill',
},
{
name: 'ticket-2',
keywords: 'support coupon',
line: 'i-ri-ticket-2-line',
fill: 'i-ri-ticket-2-fill',
},
{
name: 'megaphone',
keywords: 'announce broadcast marketing',
line: 'i-ri-megaphone-line',
fill: 'i-ri-megaphone-fill',
},
{
name: 'briefcase-4',
keywords: 'work job business',
line: 'i-ri-briefcase-4-line',
fill: 'i-ri-briefcase-4-fill',
},
{
name: 'building-2',
keywords: 'company office org',
line: 'i-ri-building-2-line',
fill: 'i-ri-building-2-fill',
},
{
name: 'store-2',
keywords: 'shop commerce',
line: 'i-ri-store-2-line',
fill: 'i-ri-store-2-fill',
},
{
name: 'community',
keywords: 'group team',
line: 'i-ri-community-line',
fill: 'i-ri-community-fill',
},
{
name: 'folder',
keywords: 'directory',
line: 'i-ri-folder-line',
fill: 'i-ri-folder-fill',
},
{
name: 'folder-open',
keywords: 'directory open',
line: 'i-ri-folder-open-line',
fill: 'i-ri-folder-open-fill',
},
{
name: 'file',
keywords: 'document',
line: 'i-ri-file-line',
fill: 'i-ri-file-fill',
},
{
name: 'file-text',
keywords: 'document doc',
line: 'i-ri-file-text-line',
fill: 'i-ri-file-text-fill',
},
{
name: 'file-copy',
keywords: 'documents files',
line: 'i-ri-file-copy-line',
fill: 'i-ri-file-copy-fill',
},
{
name: 'clipboard',
keywords: 'copy tasks',
line: 'i-ri-clipboard-line',
fill: 'i-ri-clipboard-fill',
},
{
name: 'book-2',
keywords: 'read guide book',
line: 'i-ri-book-2-line',
fill: 'i-ri-book-2-fill',
},
{
name: 'book-open',
keywords: 'read guide docs',
line: 'i-ri-book-open-line',
fill: 'i-ri-book-open-fill',
},
{
name: 'graduation-cap',
keywords: 'learn course education',
line: 'i-ri-graduation-cap-line',
fill: 'i-ri-graduation-cap-fill',
},
{
name: 'newspaper',
keywords: 'news article blog',
line: 'i-ri-newspaper-line',
fill: 'i-ri-newspaper-fill',
},
{
name: 'sticky-note',
keywords: 'note memo',
line: 'i-ri-sticky-note-line',
fill: 'i-ri-sticky-note-fill',
},
{
name: 'pencil',
keywords: 'write edit',
line: 'i-ri-pencil-line',
fill: 'i-ri-pencil-fill',
},
{
name: 'quill-pen',
keywords: 'write author',
line: 'i-ri-quill-pen-line',
fill: 'i-ri-quill-pen-fill',
},
{
name: 'mark-pen',
keywords: 'highlight',
line: 'i-ri-mark-pen-line',
fill: 'i-ri-mark-pen-fill',
},
{
name: 'calendar-2',
keywords: 'date schedule calendar',
line: 'i-ri-calendar-2-line',
fill: 'i-ri-calendar-2-fill',
},
{
name: 'calendar-event',
keywords: 'schedule event',
line: 'i-ri-calendar-event-line',
fill: 'i-ri-calendar-event-fill',
},
{
name: 'time',
keywords: 'clock time',
line: 'i-ri-time-line',
fill: 'i-ri-time-fill',
},
{
name: 'timer',
keywords: 'countdown',
line: 'i-ri-timer-line',
fill: 'i-ri-timer-fill',
},
{
name: 'history',
keywords: 'recent past',
line: 'i-ri-history-line',
fill: 'i-ri-history-fill',
},
{
name: 'hourglass',
keywords: 'wait time',
line: 'i-ri-hourglass-line',
fill: 'i-ri-hourglass-fill',
},
{
name: 'shopping-cart-2',
keywords: 'buy ecommerce cart',
line: 'i-ri-shopping-cart-2-line',
fill: 'i-ri-shopping-cart-2-fill',
},
{
name: 'shopping-bag-3',
keywords: 'buy purchase bag',
line: 'i-ri-shopping-bag-3-line',
fill: 'i-ri-shopping-bag-3-fill',
},
{
name: 'box-3',
keywords: 'package box shipping',
line: 'i-ri-box-3-line',
fill: 'i-ri-box-3-fill',
},
{
name: 'archive',
keywords: 'store box archive',
line: 'i-ri-archive-line',
fill: 'i-ri-archive-fill',
},
{
name: 'bank-card',
keywords: 'pay billing card credit',
line: 'i-ri-bank-card-line',
fill: 'i-ri-bank-card-fill',
},
{
name: 'wallet-3',
keywords: 'money pay wallet',
line: 'i-ri-wallet-3-line',
fill: 'i-ri-wallet-3-fill',
},
{
name: 'money-dollar-circle',
keywords: 'money price usd',
line: 'i-ri-money-dollar-circle-line',
fill: 'i-ri-money-dollar-circle-fill',
},
{
name: 'copper-coin',
keywords: 'coins money',
line: 'i-ri-copper-coin-line',
fill: 'i-ri-copper-coin-fill',
},
{
name: 'currency',
keywords: 'money exchange',
line: 'i-ri-currency-line',
fill: 'i-ri-currency-fill',
},
{
name: 'receipt',
keywords: 'invoice bill',
line: 'i-ri-receipt-line',
fill: 'i-ri-receipt-fill',
},
{
name: 'percent',
keywords: 'discount sale',
line: 'i-ri-percent-line',
fill: 'i-ri-percent-fill',
},
{
name: 'calculator',
keywords: 'math',
line: 'i-ri-calculator-line',
fill: 'i-ri-calculator-fill',
},
{
name: 'mail',
keywords: 'email message',
line: 'i-ri-mail-line',
fill: 'i-ri-mail-fill',
},
{
name: 'send-plane',
keywords: 'send submit',
line: 'i-ri-send-plane-line',
fill: 'i-ri-send-plane-fill',
},
{
name: 'chat-3',
keywords: 'chat talk message',
line: 'i-ri-chat-3-line',
fill: 'i-ri-chat-3-fill',
},
{
name: 'message-2',
keywords: 'chat comment',
line: 'i-ri-message-2-line',
fill: 'i-ri-message-2-fill',
},
{
name: 'question-answer',
keywords: 'chat conversation faq',
line: 'i-ri-question-answer-line',
fill: 'i-ri-question-answer-fill',
},
{
name: 'phone',
keywords: 'call',
line: 'i-ri-phone-line',
fill: 'i-ri-phone-fill',
},
{
name: 'vidicon',
keywords: 'video call meeting',
line: 'i-ri-vidicon-line',
fill: 'i-ri-vidicon-fill',
},
{
name: 'mic',
keywords: 'record voice audio',
line: 'i-ri-mic-line',
fill: 'i-ri-mic-fill',
},
{
name: 'group',
keywords: 'team people users',
line: 'i-ri-group-line',
fill: 'i-ri-group-fill',
},
{
name: 'user',
keywords: 'person account',
line: 'i-ri-user-line',
fill: 'i-ri-user-fill',
},
{
name: 'user-add',
keywords: 'add member',
line: 'i-ri-user-add-line',
fill: 'i-ri-user-add-fill',
},
{
name: 'contacts',
keywords: 'person card contact',
line: 'i-ri-contacts-line',
fill: 'i-ri-contacts-fill',
},
{
name: 'emotion-happy',
keywords: 'happy smile emoji',
line: 'i-ri-emotion-happy-line',
fill: 'i-ri-emotion-happy-fill',
},
{
name: 'thumb-up',
keywords: 'like approve',
line: 'i-ri-thumb-up-line',
fill: 'i-ri-thumb-up-fill',
},
{
name: 'hand-heart',
keywords: 'care support',
line: 'i-ri-hand-heart-line',
fill: 'i-ri-hand-heart-fill',
},
{
name: 'at',
keywords: 'mention email at',
line: 'i-ri-at-line',
fill: 'i-ri-at-fill',
},
{
name: 'image-2',
keywords: 'picture photo image',
line: 'i-ri-image-2-line',
fill: 'i-ri-image-2-fill',
},
{
name: 'camera-3',
keywords: 'photo camera',
line: 'i-ri-camera-3-line',
fill: 'i-ri-camera-3-fill',
},
{
name: 'film',
keywords: 'movie video',
line: 'i-ri-film-line',
fill: 'i-ri-film-fill',
},
{
name: 'music-2',
keywords: 'audio song music',
line: 'i-ri-music-2-line',
fill: 'i-ri-music-2-fill',
},
{
name: 'headphone',
keywords: 'audio support',
line: 'i-ri-headphone-line',
fill: 'i-ri-headphone-fill',
},
{
name: 'palette',
keywords: 'color design art',
line: 'i-ri-palette-line',
fill: 'i-ri-palette-fill',
},
{
name: 'brush-3',
keywords: 'paint design brush',
line: 'i-ri-brush-3-line',
fill: 'i-ri-brush-3-fill',
},
{
name: 'scissors-2',
keywords: 'cut',
line: 'i-ri-scissors-2-line',
fill: 'i-ri-scissors-2-fill',
},
{
name: 'code-s-slash',
keywords: 'develop code programming',
line: 'i-ri-code-s-slash-line',
fill: 'i-ri-code-s-slash-fill',
},
{
name: 'terminal-box',
keywords: 'console cli terminal',
line: 'i-ri-terminal-box-line',
fill: 'i-ri-terminal-box-fill',
},
{
name: 'git-branch',
keywords: 'version code git',
line: 'i-ri-git-branch-line',
fill: 'i-ri-git-branch-fill',
},
{
name: 'github',
keywords: 'git code',
line: 'i-ri-github-line',
fill: 'i-ri-github-fill',
},
{
name: 'cpu',
keywords: 'chip processor',
line: 'i-ri-cpu-line',
fill: 'i-ri-cpu-fill',
},
{
name: 'database-2',
keywords: 'storage data',
line: 'i-ri-database-2-line',
fill: 'i-ri-database-2-fill',
},
{
name: 'server',
keywords: 'host backend',
line: 'i-ri-server-line',
fill: 'i-ri-server-fill',
},
{
name: 'cloud',
keywords: 'storage cloud',
line: 'i-ri-cloud-line',
fill: 'i-ri-cloud-fill',
},
{
name: 'wifi',
keywords: 'network internet',
line: 'i-ri-wifi-line',
fill: 'i-ri-wifi-fill',
},
{
name: 'computer',
keywords: 'screen desktop monitor',
line: 'i-ri-computer-line',
fill: 'i-ri-computer-fill',
},
{
name: 'smartphone',
keywords: 'mobile phone',
line: 'i-ri-smartphone-line',
fill: 'i-ri-smartphone-fill',
},
{
name: 'macbook',
keywords: 'laptop computer',
line: 'i-ri-macbook-line',
fill: 'i-ri-macbook-fill',
},
{
name: 'bug',
keywords: 'issue error bug',
line: 'i-ri-bug-line',
fill: 'i-ri-bug-fill',
},
{
name: 'command',
keywords: 'keyboard shortcut',
line: 'i-ri-command-line',
fill: 'i-ri-command-fill',
},
{
name: 'settings-3',
keywords: 'config gear options settings',
line: 'i-ri-settings-3-line',
fill: 'i-ri-settings-3-fill',
},
{
name: 'equalizer',
keywords: 'controls adjust sliders filter',
line: 'i-ri-equalizer-line',
fill: 'i-ri-equalizer-fill',
},
{
name: 'tools',
keywords: 'fix tools config',
line: 'i-ri-tools-line',
fill: 'i-ri-tools-fill',
},
{
name: 'hammer',
keywords: 'build fix hammer',
line: 'i-ri-hammer-line',
fill: 'i-ri-hammer-fill',
},
{
name: 'key-2',
keywords: 'password access key',
line: 'i-ri-key-2-line',
fill: 'i-ri-key-2-fill',
},
{
name: 'lock',
keywords: 'secure private lock',
line: 'i-ri-lock-line',
fill: 'i-ri-lock-fill',
},
{
name: 'lock-unlock',
keywords: 'open access',
line: 'i-ri-lock-unlock-line',
fill: 'i-ri-lock-unlock-fill',
},
{
name: 'shield',
keywords: 'security protect',
line: 'i-ri-shield-line',
fill: 'i-ri-shield-fill',
},
{
name: 'shield-check',
keywords: 'secure verified',
line: 'i-ri-shield-check-line',
fill: 'i-ri-shield-check-fill',
},
{
name: 'eye',
keywords: 'view visible preview',
line: 'i-ri-eye-line',
fill: 'i-ri-eye-fill',
},
{
name: 'search',
keywords: 'find lookup search',
line: 'i-ri-search-line',
fill: 'i-ri-search-fill',
},
{
name: 'filter-3',
keywords: 'sort refine filter',
line: 'i-ri-filter-3-line',
fill: 'i-ri-filter-3-fill',
},
{
name: 'links',
keywords: 'url chain link',
line: 'i-ri-links-line',
fill: 'i-ri-links-fill',
},
{
name: 'share-forward',
keywords: 'share send distribute',
line: 'i-ri-share-forward-line',
fill: 'i-ri-share-forward-fill',
},
{
name: 'plug',
keywords: 'connect integration',
line: 'i-ri-plug-line',
fill: 'i-ri-plug-fill',
},
{
name: 'shut-down',
keywords: 'power on off',
line: 'i-ri-shut-down-line',
fill: 'i-ri-shut-down-fill',
},
{
name: 'qr-scan-2',
keywords: 'qr scan',
line: 'i-ri-qr-scan-2-line',
fill: 'i-ri-qr-scan-2-fill',
},
{
name: 'fingerprint',
keywords: 'identity auth fingerprint',
line: 'i-ri-fingerprint-line',
fill: 'i-ri-fingerprint-fill',
},
{
name: 'bar-chart',
keywords: 'analytics stats graph bar',
line: 'i-ri-bar-chart-line',
fill: 'i-ri-bar-chart-fill',
},
{
name: 'pie-chart',
keywords: 'analytics stats pie',
line: 'i-ri-pie-chart-line',
fill: 'i-ri-pie-chart-fill',
},
{
name: 'line-chart',
keywords: 'analytics trend graph line',
line: 'i-ri-line-chart-line',
fill: 'i-ri-line-chart-fill',
},
{
name: 'stock',
keywords: 'growth increase trending',
line: 'i-ri-stock-line',
fill: 'i-ri-stock-fill',
},
{
name: 'pulse',
keywords: 'activity health monitor',
line: 'i-ri-pulse-line',
fill: 'i-ri-pulse-fill',
},
{
name: 'dashboard-3',
keywords: 'speed performance dashboard gauge',
line: 'i-ri-dashboard-3-line',
fill: 'i-ri-dashboard-3-fill',
},
{
name: 'check',
keywords: 'done complete ok check',
line: 'i-ri-check-line',
fill: 'i-ri-check-fill',
},
{
name: 'checkbox-circle',
keywords: 'done success check',
line: 'i-ri-checkbox-circle-line',
fill: 'i-ri-checkbox-circle-fill',
},
{
name: 'close-circle',
keywords: 'error fail close',
line: 'i-ri-close-circle-line',
fill: 'i-ri-close-circle-fill',
},
{
name: 'information',
keywords: 'info information',
line: 'i-ri-information-line',
fill: 'i-ri-information-fill',
},
{
name: 'error-warning',
keywords: 'warning alert',
line: 'i-ri-error-warning-line',
fill: 'i-ri-error-warning-fill',
},
{
name: 'alert',
keywords: 'warning caution',
line: 'i-ri-alert-line',
fill: 'i-ri-alert-fill',
},
{
name: 'question',
keywords: 'support faq help',
line: 'i-ri-question-line',
fill: 'i-ri-question-fill',
},
{
name: 'layout-grid',
keywords: 'dashboard apps grid',
line: 'i-ri-layout-grid-line',
fill: 'i-ri-layout-grid-fill',
},
{
name: 'table',
keywords: 'grid data table',
line: 'i-ri-table-line',
fill: 'i-ri-table-fill',
},
{
name: 'function',
keywords: 'kanban board',
line: 'i-ri-function-line',
fill: 'i-ri-function-fill',
},
{
name: 'map-2',
keywords: 'location navigate map',
line: 'i-ri-map-2-line',
fill: 'i-ri-map-2-fill',
},
{
name: 'navigation',
keywords: 'direction gps',
line: 'i-ri-navigation-line',
fill: 'i-ri-navigation-fill',
},
{
name: 'car',
keywords: 'drive vehicle car',
line: 'i-ri-car-line',
fill: 'i-ri-car-fill',
},
{
name: 'truck',
keywords: 'delivery shipping',
line: 'i-ri-truck-line',
fill: 'i-ri-truck-fill',
},
{
name: 'plane',
keywords: 'travel flight plane',
line: 'i-ri-plane-line',
fill: 'i-ri-plane-fill',
},
{
name: 'bike',
keywords: 'cycle bike',
line: 'i-ri-bike-line',
fill: 'i-ri-bike-fill',
},
{
name: 'anchor',
keywords: 'ship marine anchor',
line: 'i-ri-anchor-line',
fill: 'i-ri-anchor-fill',
},
{
name: 'leaf',
keywords: 'eco nature green leaf',
line: 'i-ri-leaf-line',
fill: 'i-ri-leaf-fill',
},
{
name: 'plant',
keywords: 'nature plant grow',
line: 'i-ri-plant-line',
fill: 'i-ri-plant-fill',
},
{
name: 'sun',
keywords: 'day light weather sun',
line: 'i-ri-sun-line',
fill: 'i-ri-sun-fill',
},
{
name: 'moon',
keywords: 'night dark moon',
line: 'i-ri-moon-line',
fill: 'i-ri-moon-fill',
},
{
name: 'rainy',
keywords: 'weather rain',
line: 'i-ri-rainy-line',
fill: 'i-ri-rainy-fill',
},
{
name: 'umbrella',
keywords: 'rain protect umbrella',
line: 'i-ri-umbrella-line',
fill: 'i-ri-umbrella-fill',
},
{
name: 'landscape',
keywords: 'outdoor hike mountain',
line: 'i-ri-landscape-line',
fill: 'i-ri-landscape-fill',
},
{
name: 'drop',
keywords: 'water drop',
line: 'i-ri-drop-line',
fill: 'i-ri-drop-fill',
},
{
name: 'cup',
keywords: 'coffee drink cup',
line: 'i-ri-cup-line',
fill: 'i-ri-cup-fill',
},
{
name: 'restaurant',
keywords: 'food eat restaurant',
line: 'i-ri-restaurant-line',
fill: 'i-ri-restaurant-fill',
},
{
name: 'cake-3',
keywords: 'birthday celebrate cake',
line: 'i-ri-cake-3-line',
fill: 'i-ri-cake-3-fill',
},
{
name: 'goblet',
keywords: 'drink wine bar',
line: 'i-ri-goblet-line',
fill: 'i-ri-goblet-fill',
},
{
name: 'heart-pulse',
keywords: 'health medical',
line: 'i-ri-heart-pulse-line',
fill: 'i-ri-heart-pulse-fill',
},
{
name: 'stethoscope',
keywords: 'health doctor medical',
line: 'i-ri-stethoscope-line',
fill: 'i-ri-stethoscope-fill',
},
{
name: 'capsule',
keywords: 'medicine pill health',
line: 'i-ri-capsule-line',
fill: 'i-ri-capsule-fill',
},
{
name: 'add',
keywords: 'add new create plus',
line: 'i-ri-add-line',
fill: 'i-ri-add-fill',
},
{
name: 'delete-bin',
keywords: 'delete remove trash',
line: 'i-ri-delete-bin-line',
fill: 'i-ri-delete-bin-fill',
},
{
name: 'download',
keywords: 'save export download',
line: 'i-ri-download-line',
fill: 'i-ri-download-fill',
},
{
name: 'upload',
keywords: 'import upload',
line: 'i-ri-upload-line',
fill: 'i-ri-upload-fill',
},
{
name: 'refresh',
keywords: 'reload sync update refresh',
line: 'i-ri-refresh-line',
fill: 'i-ri-refresh-fill',
},
{
name: 'external-link',
keywords: 'open new external',
line: 'i-ri-external-link-line',
fill: 'i-ri-external-link-fill',
},
{
name: 'logout-box',
keywords: 'exit signout logout',
line: 'i-ri-logout-box-line',
fill: 'i-ri-logout-box-fill',
},
{
name: 'fullscreen',
keywords: 'expand maximize fullscreen',
line: 'i-ri-fullscreen-line',
fill: 'i-ri-fullscreen-fill',
},
{
name: 'drag-move',
keywords: 'drag reorder move',
line: 'i-ri-drag-move-line',
fill: 'i-ri-drag-move-fill',
},
{
name: 'paint-brush',
keywords: 'brush paint',
line: 'i-ri-paint-brush-line',
fill: 'i-ri-paint-brush-fill',
},
];
@@ -147,14 +147,8 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { replaceInstallationName } = useBranding();
const isCaptainMessage = computed(() => {
const senderType = props.sender?.type ?? props.senderType;
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
});
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -396,10 +390,6 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
report:
isOnChatwootCloud.value &&
isCaptainMessage.value &&
!isMessageDeleted.value,
};
});
@@ -34,7 +34,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
@@ -82,11 +81,7 @@ const isWhatsapp = computed(
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(status.value)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
const endReason = computed(() => call.value?.endReason);
@@ -78,19 +78,12 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IG_REEL,
];
export const NON_FILE_TYPES = [
ATTACHMENT_TYPES.LOCATION,
ATTACHMENT_TYPES.FALLBACK,
ATTACHMENT_TYPES.CONTACT,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
COMPLETED: 'completed',
NO_ANSWER: 'no-answer',
FAILED: 'failed',
REJECTED: 'rejected',
};
export const VOICE_CALL_DIRECTION = {
@@ -21,12 +21,6 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import {
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
resolveSidebarSort,
sortSidebarItems,
} from 'dashboard/helper/sidebarSort';
const props = defineProps({
isMobileSidebarOpen: {
@@ -56,7 +50,6 @@ const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
@@ -86,11 +79,6 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
store.dispatch('conversationUnreadCounts/get');
};
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
if (!currentAccountId || !userId) return;
store.dispatch('sidebarSortPreferences/initialize');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -204,9 +192,6 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
const getSidebarSectionSort = useMapGetter(
'sidebarSortPreferences/getSectionSort'
);
onMounted(() => {
store.dispatch('labels/get');
@@ -222,62 +207,44 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const updateSortPreference = (section, sortBy) => {
store.dispatch('sidebarSortPreferences/setSectionSort', {
section,
sortBy,
});
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const buildSortConfig = section => ({
sortOptions: getSortOptionsForSection(section),
activeSort: getSortForSection(section),
onSortChange: sortBy => updateSortPreference(section, sortBy),
});
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
items.slice().sort((a, b) => {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
})
);
if (unreadCountDiff !== 0) return unreadCountDiff;
return labelKey(a).localeCompare(labelKey(b));
});
const sortedTeams = computed(() =>
sortSidebarItems(teams.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
labelKey: team => team.name,
unreadCountKey: team => getTeamUnreadCount.value(team.id),
})
sortByUnreadCount(
teams.value,
team => team.name,
team => getTeamUnreadCount.value(team.id)
)
);
const sortedInboxes = computed(() =>
sortSidebarItems(inboxes.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
labelKey: inbox => inbox.name,
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
})
sortByUnreadCount(
inboxes.value,
inbox => inbox.name,
inbox => getInboxUnreadCount.value(inbox.id)
)
);
const sortedLabels = computed(() =>
sortSidebarItems(labels.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
labelKey: label => label.title,
unreadCountKey: label => getLabelUnreadCount.value(label.id),
})
sortByUnreadCount(
labels.value,
label => label.title,
label => getLabelUnreadCount.value(label.id)
)
);
const closeMobileSidebar = () => {
@@ -364,10 +331,9 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
collapsible: true,
showTreeLine: true,
children: sortedFolders.value.map(view => ({
children: conversationCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
@@ -378,7 +344,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
collapsible: true,
showTreeLine: true,
children: sortedTeams.value.map(team => ({
@@ -393,7 +358,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
collapsible: true,
showTreeLine: true,
children: sortedInboxes.value.map(inbox => ({
@@ -416,7 +380,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
collapsible: true,
showTreeLine: true,
children: sortedLabels.value.map(label => ({
@@ -1,13 +1,11 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -16,7 +14,7 @@ const props = defineProps({
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
});
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
const router = useRouter();
const { isAllowed, sidebarWidth } = useSidebarContext();
@@ -31,14 +29,6 @@ const toggleSubGroup = name => {
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
};
const handleSortToggle = isSortOpen => {
emit('sortToggle', isSortOpen);
};
onClickOutside(popoverRef, () => emit('close'), {
ignore: ['[data-popover-content]'],
});
const navigateAndClose = to => {
router.push(to);
emit('close');
@@ -133,44 +123,24 @@ onMounted(async () => {
>
<template v-for="child in accessibleChildren" :key="child.name">
<!-- SubGroup with children -->
<li v-if="child.children" class="group/sidebar-section py-0.5">
<div
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
<li v-if="child.children" class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<button
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
</button>
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
<SidebarSortMenu
v-if="child.sortOptions?.length"
:active-sort="child.activeSort"
:options="child.sortOptions"
:open-on-hover="false"
@sort="child.onSortChange"
@toggle="handleSortToggle"
/>
<button
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
@click.stop="toggleSubGroup(child.name)"
>
<span
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
</div>
</div>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
<span
class="size-3 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
<Transition v-bind="transition">
<ul
v-if="expandedSubGroup === child.name"
@@ -55,9 +55,6 @@ const hasChildren = computed(
const isPopoverOpen = computed(() => activePopover.value === props.name);
const triggerRef = ref(null);
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
// The sort dropdown teleports outside the popover; keep the popover open while
// it is showing so moving the cursor onto it does not close everything.
const isSortMenuOpen = ref(false);
const openPopover = () => {
if (triggerRef.value) {
@@ -85,7 +82,7 @@ const handleMouseEnter = () => {
};
const handleMouseLeave = () => {
if (!hasChildren.value || isSortMenuOpen.value) return;
if (!hasChildren.value) return;
scheduleClose(200);
};
@@ -94,15 +91,9 @@ const handlePopoverMouseEnter = () => {
};
const handlePopoverMouseLeave = () => {
if (isSortMenuOpen.value) return;
scheduleClose(100);
};
const handleSortToggle = isOpen => {
isSortMenuOpen.value = isOpen;
cancelClose();
};
// Close popover when mouse leaves the window
const handleWindowBlur = () => {
closeActivePopover();
@@ -282,7 +273,6 @@ watch(
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
@sort-toggle="handleSortToggle"
/>
</div>
</template>
@@ -317,9 +307,6 @@ watch(
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
:is-expanded="isExpanded"
:active-child="activeChild"
:sort-options="child.sortOptions"
:active-sort="child.activeSort"
@update-sort="child.onSortChange"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
@@ -45,9 +45,7 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
>
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<span
class="truncate"
:class="{
@@ -59,7 +57,11 @@ const count = computed(() =>
</span>
<span
v-if="dynamicCount && !expandable"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-slate-12 outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
{{ count }}
</span>
@@ -1,6 +1,5 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
defineProps({
collapsible: {
@@ -19,14 +18,6 @@ defineProps({
type: [Object, String],
default: '',
},
sortOptions: {
type: Array,
default: () => [],
},
activeSort: {
type: String,
default: '',
},
showTreeLine: {
type: Boolean,
default: false,
@@ -37,7 +28,7 @@ defineProps({
},
});
const emit = defineEmits(['toggle', 'update-sort']);
const emit = defineEmits(['toggle']);
const TREE_VERTICAL_LINE =
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
@@ -46,59 +37,34 @@ const TREE_ELBOW =
</script>
<template>
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:aria-expanded="collapsible ? isExpanded : undefined"
:title="label"
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
:class="[
showTreeLine && TREE_VERTICAL_LINE,
showTreeLine &&
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
{
'pointer-events-none': !collapsible,
'cursor-pointer hover:bg-n-alpha-2': collapsible,
'pe-14': collapsible && sortOptions.length,
'pe-8': collapsible && !sortOptions.length,
'pe-10': !collapsible && sortOptions.length,
},
]"
@click.stop="collapsible ? emit('toggle') : undefined"
>
<div class="inline-flex min-w-0 items-center gap-2">
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
<span
class="flex-grow truncate text-start text-sm font-medium leading-5"
>
{{ label }}
</span>
</div>
</component>
<div
v-if="collapsible || sortOptions.length"
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
>
<SidebarSortMenu
v-if="sortOptions.length"
:active-sort="activeSort"
:options="sortOptions"
@sort="sortBy => emit('update-sort', sortBy)"
/>
<button
v-if="collapsible"
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
:aria-expanded="isExpanded"
:aria-label="label"
@click.stop="emit('toggle')"
>
<span
class="size-3 flex-shrink-0"
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
/>
</button>
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:aria-expanded="collapsible ? isExpanded : undefined"
:title="label"
class="relative justify-between flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none min-w-0"
:class="[
showTreeLine && TREE_VERTICAL_LINE,
showTreeLine &&
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
{
'w-full': !collapsible,
'pointer-events-none': !collapsible,
'ms-5 cursor-pointer hover:bg-n-alpha-2': collapsible,
},
]"
@click.stop="emit('toggle')"
>
<div class="min-w-0 inline-flex gap-2 items-center">
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
<span class="text-sm font-medium leading-5 flex-grow truncate text-start">
{{ label }}
</span>
</div>
</div>
<span
v-if="collapsible"
class="size-3 flex-shrink-0 text-n-slate-10 me-0.5"
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
/>
</component>
</template>
@@ -1,209 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
const props = defineProps({
activeSort: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
openOnHover: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['sort', 'toggle']);
const SORT_OPTION_GROUPS = [
{
key: 'created',
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
},
{
key: 'alphabetical',
options: [
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
},
{
key: 'unread_count',
options: [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
},
];
const { t } = useI18n();
const isOpen = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
let closeTimer;
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
isOpen,
{ align: 'start' }
);
const getSortOptionLabel = option => {
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
}
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
}
return '';
};
const getSortGroupLabel = groupKey => {
if (groupKey === 'created') {
return t('SIDEBAR.SORT_GROUPS.CREATED');
}
if (groupKey === 'alphabetical') {
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
}
if (groupKey === 'unread_count') {
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
}
return '';
};
const sortMenuSections = computed(() =>
SORT_OPTION_GROUPS.map(group => ({
title: getSortGroupLabel(group.key),
items: group.options
.filter(option => props.options.includes(option))
.map(option => ({
label: getSortOptionLabel(option),
value: option,
action: 'sort',
isActive: option === props.activeSort,
})),
})).filter(section => section.items.length)
);
const clearCloseTimer = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
};
const openMenu = async () => {
clearCloseTimer();
isOpen.value = true;
emit('toggle', true);
await nextTick();
updatePosition();
};
const closeMenu = () => {
clearCloseTimer();
isOpen.value = false;
emit('toggle', false);
};
const scheduleClose = () => {
clearCloseTimer();
closeTimer = setTimeout(closeMenu, 150);
};
const handleTriggerEnter = () => {
if (props.openOnHover) openMenu();
};
const handleTriggerLeave = () => {
if (props.openOnHover) scheduleClose();
};
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
closeMenu();
};
const handleSortChange = ({ value }) => {
emit('sort', value);
closeMenu();
};
onBeforeUnmount(clearCloseTimer);
</script>
<template>
<div
ref="triggerRef"
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
@mouseenter="handleTriggerEnter"
@mouseleave="handleTriggerLeave"
>
<Button
:title="t('SIDEBAR.SORT_TOOLTIP')"
icon="i-lucide-arrow-up-down"
ghost
slate
xs
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
:class="{ '!bg-n-alpha-2': isOpen }"
@click.stop="openMenu"
/>
<TeleportWithDirection>
<DropdownMenu
v-if="isOpen"
ref="popoverRef"
v-on-click-outside="handleClickOutside"
data-popover-content
:menu-sections="sortMenuSections"
:class="fixedPosition.class"
:style="fixedPosition.style"
class="w-60 !fixed"
@action="handleSortChange"
@mouseenter="clearCloseTimer"
@mouseleave="handleTriggerLeave"
>
<template #trailing-icon="{ item }">
<span
v-if="item.isActive"
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
/>
</template>
</DropdownMenu>
</TeleportWithDirection>
</div>
</template>
@@ -1,6 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -9,6 +8,7 @@ import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
name: { type: String, required: true },
@@ -17,15 +17,11 @@ const props = defineProps({
icon: { type: [Object, String], required: true },
children: { type: Array, default: undefined },
activeChild: { type: Object, default: undefined },
sortOptions: { type: Array, default: () => [] },
activeSort: { type: String, default: '' },
collapsible: { type: Boolean, default: false },
showTreeLine: { type: Boolean, default: false },
endTreeLine: { type: Boolean, default: false },
});
const emit = defineEmits(['update-sort']);
const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null);
const accountId = useMapGetter('getCurrentAccountId');
@@ -125,7 +121,7 @@ watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
</script>
<template>
<li class="group/sidebar-section relative flex flex-col list-none min-w-0">
<li class="relative flex flex-col list-none min-w-0">
<template v-if="hasAccessibleItems">
<SidebarGroupSeparator
v-show="isExpanded"
@@ -135,11 +131,8 @@ watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
:is-expanded="isSubGroupExpanded"
:show-tree-line="showTreeLine"
:end-tree-line="endTreeLine"
:sort-options="sortOptions"
:active-sort="activeSort"
class="my-1"
@toggle="toggleSubGroup"
@update-sort="sortBy => emit('update-sort', sortBy)"
/>
<ul
v-if="children.length"
@@ -81,7 +81,6 @@ const showDivider = index => {
<template v-for="(tab, index) in tabs" :key="index">
<button
:ref="el => (tabRefs[index] = el)"
type="button"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
@@ -308,15 +308,6 @@ function filterByAssigneeTab(conversations) {
return [...conversations];
}
function sortByUnreadStatus(conversations) {
return [...conversations].sort((a, b) => {
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
if (unreadCountDiff !== 0) return unreadCountDiff;
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
});
}
const conversationList = computed(() => {
let localConversationList = [];
@@ -346,13 +337,6 @@ const conversationList = computed(() => {
});
}
if (
!hasAppliedFiltersOrActiveFolders.value &&
activeSortBy.value === wootConstants.SORT_BY_TYPE.UNREAD
) {
localConversationList = sortByUnreadStatus(localConversationList);
}
return localConversationList;
});
@@ -1,141 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { format, parseISO } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
sessions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
const { t } = useI18n();
const revokingId = ref(null);
const revokingAll = ref(false);
const sortedSessions = computed(() =>
[...props.sessions].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
);
const formatDate = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'MMMM d, yyyy');
};
const formatTime = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'hh:mma');
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
watch(
() => props.sessions,
() => {
revokingId.value = null;
revokingAll.value = false;
}
);
const handleRevoke = session => {
revokingId.value = session.id;
emit('revoke', session.id);
};
const handleRevokeAll = () => {
revokingAll.value = true;
emit('revokeAll');
};
</script>
<template>
<div class="w-full max-w-lg mx-auto">
<div
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('SESSION_LIMIT.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
</p>
</div>
<NextButton
type="button"
faded
sm
class="flex-shrink-0 whitespace-nowrap"
:label="$t('SESSION_LIMIT.END_ALL')"
:is-loading="revokingAll"
:disabled="revokingId !== null"
@click="handleRevokeAll"
/>
</div>
<!-- Session List -->
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
<div
v-for="session in sortedSessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0">
<Icon
icon="i-lucide-monitor"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span class="text-xs text-n-slate-10">
{{
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
}}
</span>
</div>
</div>
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
:disabled="revokingId !== null || revokingAll"
@click="handleRevoke(session)"
>
{{ $t('SESSION_LIMIT.END') }}
</button>
</div>
</div>
<!-- Cancel -->
<div class="text-center pt-4">
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:label="$t('SESSION_LIMIT.CANCEL')"
@click="() => emit('cancel')"
/>
</div>
</div>
</div>
</template>
@@ -31,22 +31,12 @@ export default {
}
},
methods: {
createErrorMessage(error) {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') return responseError;
return (
responseError?.error?.message ||
responseError?.message ||
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
);
},
async onClick() {
this.isLoading = true;
try {
await DyteAPI.createAMeeting(this.conversationId);
} catch (error) {
useAlert(this.createErrorMessage(error));
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
} finally {
this.isLoading = false;
}
@@ -78,10 +78,6 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
value: 'created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.unread.TEXT'),
value: 'unread',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
value: 'priority_desc',
@@ -56,9 +56,8 @@ import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
export default {
@@ -67,7 +66,7 @@ export default {
AttachmentPreview,
AudioRecorder,
ReplyBoxBanner,
EmojiIconPicker,
EmojiInput,
MessageSignatureMissingAlert,
ReplyBottomPanel,
ReplyEmailHead,
@@ -1285,15 +1284,13 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiIconPicker
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
mode="emoji"
class="emoji-dialog"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout,
}"
@select="addIntoEditor($event.value)"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
@@ -21,7 +21,6 @@ const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
@@ -30,18 +29,13 @@ const COLOR_MAP = {
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[
VOICE_CALL_STATUS.NO_ANSWER,
VOICE_CALL_STATUS.FAILED,
VOICE_CALL_STATUS.REJECTED,
].includes(props.status)
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
@@ -0,0 +1,32 @@
// Single source of truth for billing currencies on the frontend.
export const DEFAULT_BILLING_CURRENCY = 'usd';
// Order here drives the order of the currency toggle in the UI.
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
export const BILLING_CURRENCY_CONFIG = {
usd: {
code: 'usd',
intlLocale: 'en-US',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
},
brl: {
code: 'brl',
intlLocale: 'pt-BR',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
},
};
export const getCurrencyConfig = code =>
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
export const formatCurrencyAmount = (amount, code, options = {}) => {
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
return new Intl.NumberFormat(intlLocale, {
style: 'currency',
currency: currencyCode.toUpperCase(),
...options,
}).format(amount);
};
@@ -163,11 +163,6 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Context::NoToolbar': {
marks: ['strong', 'em', 'link'],
nodes: ['bulletList', 'orderedList'],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -27,7 +27,6 @@ export default {
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
UNREAD: 'unread',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -155,14 +155,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
});
export const SESSION_EVENTS = Object.freeze({
LIMIT_HIT: 'Session limit reached at login',
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
});
-18
View File
@@ -13,24 +13,6 @@ export const INBOX_TYPES = {
TIKTOK: 'Channel::Tiktok',
};
// Short channel-type slugs used to identify a channel without leaning on its
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
export const CHANNEL_TYPES = {
WEBSITE: 'website',
WHATSAPP: 'whatsapp',
FACEBOOK: 'facebook',
INSTAGRAM: 'instagram',
TIKTOK: 'tiktok',
TELEGRAM: 'telegram',
LINE: 'line',
GMAIL: 'gmail',
OUTLOOK: 'outlook',
SMS: 'sms',
API: 'api',
VOICE: 'voice',
EMAIL: 'email',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
@@ -1,181 +0,0 @@
export const SIDEBAR_SORT_KEYS = Object.freeze({
CREATED_DESC: 'created_at_desc',
CREATED_ASC: 'created_at_asc',
ALPHABETICAL_ASC: 'alphabetical_asc',
ALPHABETICAL_DESC: 'alphabetical_desc',
UNREAD_COUNT_DESC: 'unread_count_desc',
UNREAD_COUNT_ASC: 'unread_count_asc',
});
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
FOLDERS: 'folders',
TEAMS: 'teams',
CHANNELS: 'channels',
LABELS: 'labels',
});
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.LABELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
});
const UNREAD_COUNT_SORT_OPTIONS = [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
];
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
export const isValidSidebarSort = (section, sortBy) => {
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
};
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
export const getSidebarSortOptions = (
section,
{ hasUnreadCounts = true } = {}
) => {
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
if (hasUnreadCounts) return options;
return options.filter(option => !isUnreadCountSort(option));
};
export const resolveSidebarSort = (
section,
sortBy,
{ hasUnreadCounts = true } = {}
) => {
const options = getSidebarSortOptions(section, { hasUnreadCounts });
if (options.includes(sortBy)) return sortBy;
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
}
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
};
export const normalizeSidebarSortPreferences = (preferences = {}) => {
const savedPreferences = preferences || {};
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
(result, section) => {
const sortBy = savedPreferences[section];
result[section] = isValidSidebarSort(section, sortBy)
? sortBy
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
return result;
},
{}
);
};
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const getCreatedValue = item => {
const createdAt = item.created_at || item.createdAt;
if (typeof createdAt === 'number') return createdAt;
if (createdAt) {
const timestamp = Date.parse(createdAt);
if (Number.isFinite(timestamp)) return timestamp;
}
const id = Number(item.id);
return Number.isFinite(id) ? id : 0;
};
const getLabelValue = (item, labelKey) => {
return String(labelKey(item) || '');
};
const compareAlphabetically = (a, b, labelKey) => {
return getLabelValue(a, labelKey).localeCompare(
getLabelValue(b, labelKey),
undefined,
{
sensitivity: 'base',
}
);
};
export const sortSidebarItems = (
items,
{ sortBy, labelKey, unreadCountKey = () => 0 }
) => {
return (items || []).slice().sort((a, b) => {
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return compareAlphabetically(b, a, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(a)) -
normalizeUnreadCount(unreadCountKey(b));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
return compareAlphabetically(a, b, labelKey);
});
};
@@ -1,205 +0,0 @@
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
normalizeSidebarSortPreferences,
resolveSidebarSort,
sortSidebarItems,
} from '../sidebarSort';
const items = [
{
id: 1,
name: 'Billing',
created_at: '2024-01-01T00:00:00.000Z',
},
{
id: 3,
name: 'Accounts',
created_at: '2024-03-01T00:00:00.000Z',
},
{
id: 2,
name: 'Support',
created_at: '2024-02-01T00:00:00.000Z',
},
];
describe('#sortSidebarItems', () => {
it('sorts by created date descending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Support',
'Billing',
]);
});
it('sorts by created date ascending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
it('falls back to id when created date is not present', () => {
const sortedItems = sortSidebarItems(
items.map(({ created_at: _createdAt, ...item }) => item),
{
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
}
);
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
});
it('sorts alphabetically from A to Z', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts alphabetically from Z to A', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Support',
'Billing',
'Accounts',
]);
});
it('sorts by unread count descending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts by unread count ascending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
});
describe('#normalizeSidebarSortPreferences', () => {
it('keeps valid preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
expect(preferences).toEqual({
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
});
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
});
it('falls back to defaults when stored preferences are null', () => {
expect(normalizeSidebarSortPreferences(null)).toEqual(
DEFAULT_SIDEBAR_SORT_PREFERENCES
);
});
});
describe('#getSidebarSortOptions', () => {
it('keeps unread count options when unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
it('keeps unread count sort when unread counts are enabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: true }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
});
it('falls back to alphabetical sort when unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
-1
View File
@@ -7,7 +7,6 @@ export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'rejected',
'no-answer',
'canceled',
'missed',
@@ -79,9 +79,6 @@
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
},
"unread": {
"TEXT": "Unread Count: Highest first"
}
},
"ATTACHMENTS": {
@@ -25,10 +25,6 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -511,7 +511,6 @@
"ATTRIBUTES": "Attributes",
"HISTORY": "History",
"NOTES": "Notes",
"MEDIA": "Media",
"MERGE": "Merge"
},
"HISTORY": {
@@ -304,25 +304,6 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"REPORT_MESSAGE": {
"LABEL": "Report message",
"TITLE": "Report Captain message",
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
"PROBLEM_TYPE": "Problem type",
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
"DESCRIPTION_LABEL": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
"SUBMIT": "Report",
"SUCCESS": "Thanks for reporting. Our team will take a look.",
"ERROR": "Could not report this message. Please try again.",
"REASONS": {
"incorrect_information": "Incorrect information",
"inappropriate_response": "Inappropriate response",
"incomplete_response": "Incomplete response",
"outdated_information": "Outdated information",
"other": "Other"
}
}
},
"SIDEBAR": {
@@ -420,8 +401,7 @@
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file",
"JUMP_TO_MESSAGE": "Jump to message"
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -3,33 +3,5 @@
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
},
"EMOJI_ICON_PICKER": {
"TABS": {
"ICONS": "Icons",
"EMOJIS": "Emojis"
},
"SEARCH_EMOJI": "Search emoji…",
"SEARCH_ICON": "Search icons…",
"FREQUENTLY_USED": "Frequently used",
"NO_EMOJI": "No emoji match your search",
"NO_ICON": "No icons match your search",
"REMOVE": "Remove",
"STYLE": {
"OUTLINE": "Outline icons",
"FILLED": "Filled icons"
},
"COLORS": {
"SLATE": "Slate",
"RED": "Red",
"ORANGE": "Orange",
"AMBER": "Amber",
"GREEN": "Green",
"TEAL": "Teal",
"BLUE": "Blue",
"INDIGO": "Indigo",
"VIOLET": "Violet",
"PINK": "Pink"
}
}
}
@@ -552,7 +552,6 @@
"LOCALE": {
"ALL": "All locales"
},
"SEARCH_PLACEHOLDER": "Search articles...",
"NEW_ARTICLE": "New article"
},
"EMPTY_STATE": {
@@ -580,10 +579,6 @@
"CATEGORY": {
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
},
"SEARCH": {
"TITLE": "No matching articles",
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
}
},
"BULK_TRANSLATE": {
@@ -629,7 +624,6 @@
"CATEGORY_HEADER": {
"NEW_CATEGORY": "New category",
"EDIT_CATEGORY": "Edit category",
"SEARCH_PLACEHOLDER": "Search categories...",
"CATEGORIES_COUNT": "{n} category | {n} categories",
"BREADCRUMB": {
"CATEGORY_LOCALE": "Categories ({localeCode})",
@@ -640,10 +634,6 @@
"TITLE": "No categories found",
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
},
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching categories",
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
},
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles"
},
@@ -701,11 +691,6 @@
"LOCALES_PAGE": {
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
"NEW_LOCALE_BUTTON_TEXT": "New locale",
"SEARCH_PLACEHOLDER": "Search locales...",
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching locales",
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
},
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
@@ -65,8 +65,7 @@
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",

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