Compare commits

..
Author SHA1 Message Date
Shivam Mishra e47d7ae894 refactor: remove METRIC_TO_HIDDEN_KEY 2026-02-04 21:01:29 +05:30
Shivam MishraandGitHub e6df7f7dd7 Merge branch 'develop' into feat/disable-report-metrics 2026-02-04 20:48:33 +05:30
Shivam Mishra 0f5a25bad7 feat: hide report keys 2026-02-04 19:40:25 +05:30
Shivam Mishra 9f80c41e75 refactor: use useAccount composable 2026-02-04 19:20:14 +05:30
Shivam Mishra a4cee4f72d perf(reports): skip computing hidden metrics in summary
Instead of filtering metrics after computation, skip the expensive
database queries entirely for hidden metrics. This prevents timeouts
on accounts with large datasets.

- MetricBuilder now accepts hidden_metrics param and returns nil for
  hidden metrics (compacted from response)
- Reports controller passes hidden_metrics to builder params
2026-02-04 13:44:15 +05:30
Shivam Mishra a34a916b46 feat(reports): hide metrics in frontend reports UI
Add useHiddenMetrics composable to filter hidden metrics from
report keys. Update WootReports.vue to use the composable so
hidden metrics are not fetched or displayed in the reports UI.
2026-02-04 13:20:41 +05:30
Shivam Mishra b09a2c25fa feat(reports): filter hidden metrics from API responses
Apply MetricFilter concern to reports controllers:
- ReportsController: return empty array for hidden timeseries metrics,
  filter summary and bot_summary responses
- SummaryReportsController: filter hidden metrics from array responses
2026-02-04 13:20:28 +05:30
Shivam Mishra 7e65cc752e feat(reports): add MetricFilter concern for hiding metrics
Add Reports::MetricFilter concern that provides methods to filter
hidden metrics from API responses:
- filter_hidden_metrics_from_hash: filters hash responses
- filter_hidden_metrics_from_array: filters array responses
- filter_summary_with_previous: filters summary with nested previous data
- metric_hidden?: checks if a specific metric should be hidden

Includes alias support (avg_reply_time -> reply_time).
2026-02-04 13:20:14 +05:30
Shivam Mishra 0f02c0cd5c feat(account): add report_hidden_metrics setting
Add report_hidden_metrics to account settings schema to allow
hiding specific metrics from reports on a per-account basis.

Supported metrics: outgoing_messages_count, incoming_messages_count, reply_time
2026-02-04 13:19:57 +05:30
413 changed files with 9212 additions and 13786 deletions
+7 -7
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 20
steps:
- checkout
- node/install:
@@ -350,12 +350,12 @@ jobs:
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
-3
View File
@@ -94,8 +94,6 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
.codex/
.claude/
CLAUDE.local.md
# Histoire deployment
@@ -103,4 +101,3 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
Procfile.worktree
-16
View File
@@ -4,11 +4,6 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -55,13 +50,6 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
@@ -98,7 +86,3 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+1 -3
View File
@@ -191,14 +191,12 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
+20 -22
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.0)
ruby_llm (~> 1.9.1)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -186,7 +186,6 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -298,7 +297,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -309,12 +308,12 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -465,7 +464,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.18.1)
json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -540,7 +539,7 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.0.4)
maxminddb (0.1.22)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
@@ -559,12 +558,12 @@ GEM
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.9.1)
uri (>= 0.11.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -583,14 +582,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.1)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -677,7 +676,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -825,7 +824,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.2)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -969,7 +968,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri (1.0.4)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1004,7 +1003,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -1024,7 +1023,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1038,7 +1037,6 @@ DEPENDENCIES
bullet
bundle-audit
byebug
cld3 (~> 3.7)
climate_control
commonmarker
csv-safe
+1 -1
View File
@@ -1 +1 @@
4.11.1
4.10.1
@@ -1,14 +1,16 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
HIDEABLE_METRICS = %w[incoming_messages_count outgoing_messages_count reply_time].freeze
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
incoming_messages_count: count_unless_hidden('incoming_messages_count'),
outgoing_messages_count: count_unless_hidden('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
reply_time: count_unless_hidden('reply_time')
}.compact
end
def bot_summary
@@ -24,6 +26,18 @@ class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::Ba
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def count_unless_hidden(metric)
return nil if metric_hidden?(metric)
count(metric)
end
def metric_hidden?(metric)
return false unless HIDEABLE_METRICS.include?(metric)
(params[:hidden_metrics] || []).include?(metric)
end
def builder_params(metric)
params.merge({ metric: metric })
end
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def meta
result = conversation_finder.perform_meta_only
result = conversation_finder.perform
@conversations_count = result[:count]
end
@@ -70,7 +70,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@@ -35,9 +35,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
return head :too_many_requests if conversation.blank?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless conversation.account.within_email_rate_limit?
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
@@ -1,10 +1,14 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
include Reports::MetricFilter
before_action :check_authorization
def index
# Return empty timeseries array for hidden metrics (preserves expected response shape)
return render json: [] if metric_hidden?(params[:metric])
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.timeseries
render json: data
@@ -112,7 +116,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:current][:since],
until: range[:current][:until],
timezone_offset: params[:timezone_offset]
timezone_offset: params[:timezone_offset],
hidden_metrics: hidden_metrics
})
end
@@ -120,7 +125,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until],
timezone_offset: params[:timezone_offset]
timezone_offset: params[:timezone_offset],
hidden_metrics: hidden_metrics
})
end
@@ -1,4 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
include Reports::MetricFilter
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
@@ -40,7 +42,7 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
render json: builder.build
render json: filter_hidden_metrics_from_array(builder.build)
end
def permitted_params
@@ -0,0 +1,59 @@
module Reports::MetricFilter
extend ActiveSupport::Concern
HIDEABLE_METRICS = %w[outgoing_messages_count incoming_messages_count reply_time].freeze
# Alias mapping (canonical form)
METRIC_ALIASES = {
'avg_reply_time' => 'reply_time'
}.freeze
private
def filter_hidden_metrics_from_hash(data)
return data if hidden_metrics.blank?
data.reject { |key, _| metric_key_hidden?(key) }
end
def filter_hidden_metrics_from_array(data)
return data if hidden_metrics.blank?
data.map { |item| item.reject { |key, _| metric_key_hidden?(key) } }
end
# Filter summary data including nested 'previous' key (handles both symbol and string keys)
def filter_summary_with_previous(data)
return data if hidden_metrics.blank?
filtered = filter_hidden_metrics_from_hash(data)
previous_data = filtered[:previous] || filtered['previous']
if previous_data.present?
filtered_previous = filter_hidden_metrics_from_hash(previous_data)
# Preserve the original key type
if filtered.key?(:previous)
filtered[:previous] = filtered_previous
else
filtered['previous'] = filtered_previous
end
end
filtered
end
def hidden_metrics
@hidden_metrics ||= (Current.account.report_hidden_metrics || []) & HIDEABLE_METRICS
end
def metric_hidden?(metric_name)
# Normalize to canonical form (e.g., avg_reply_time -> reply_time)
normalized = METRIC_ALIASES[metric_name.to_s] || metric_name.to_s
hidden_metrics.include?(normalized)
end
# Check if a hash key should be hidden (handles both symbol and string keys, plus aliases)
def metric_key_hidden?(key)
key_str = key.to_s
normalized = METRIC_ALIASES[key_str] || key_str
hidden_metrics.include?(normalized)
end
end
@@ -6,8 +6,12 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
def update
@@ -1,35 +0,0 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
-16
View File
@@ -55,22 +55,6 @@ class ConversationFinder
}
end
def perform_meta_only
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
private
def set_up
+21 -17
View File
@@ -57,35 +57,39 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
rescue Net::SMTPAuthenticationError
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
rescue OpenSSL::SSL::SSLError
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
rescue StandardError => e
raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
set_enable_tls(channel_data, smtp)
elsif channel_data[:smtp_enable_starttls_auto]
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
set_enable_starttls_auto(channel_data, smtp)
end
end
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
return unless smtp.respond_to?(method)
def set_enable_starttls_auto(channel_data, smtp)
return unless smtp.respond_to?(:enable_starttls_auto)
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
context ? smtp.send(method, context) : smtp.send(method)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
end
def enable_openssl_mode(smtp_openssl_verify_mode)
+6
View File
@@ -164,4 +164,10 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
@@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {
@@ -78,7 +78,7 @@ textarea {
}
}
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
}
}
input[type='file'] {
input[type='file']:not(.multiselect__input) {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
@@ -126,6 +126,13 @@ label:has(.help-text) {
}
}
// Error handling
.has-multi-select-error {
div.multiselect {
@apply mb-1;
}
}
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
@@ -143,7 +150,9 @@ label:has(.help-text) {
#{$form-input-selector},
input:not([type]),
textarea,
select {
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply field-error;
}
@@ -13,6 +13,7 @@
@import 'base';
// Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
@@ -65,84 +66,4 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}
@@ -0,0 +1,273 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
}
&:hover {
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
}
}
}
.multiselect {
&:not(.no-margin) {
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
}
}
.multiselect__select {
@apply min-h-[2.875rem] p-0 right-0 top-0;
&::before {
@apply right-0;
}
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
}
p {
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-transparent text-center text-n-slate-12;
}
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-transparent;
&::after:hover {
@apply text-n-slate-12 bg-transparent;
}
}
}
}
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@include label-multiselect-hover;
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@apply cursor-pointer;
}
}
.multiselect {
> .multiselect__select {
@apply invisible;
}
> .multiselect__tags {
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
}
}
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@apply h-[2.375rem] min-h-[2.375rem];
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@apply min-h-[2.5rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
}
.multiselect__select {
@apply min-h-[3rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>
@@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<Button
xs
slate
@@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-body-para"
class="flex items-center gap-3 text-sm"
>
<Icon
:icon="feature.icon"
@@ -39,6 +39,7 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
@@ -49,6 +50,7 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
enabled: false,
inboxes: [],
isFetchingInboxes: false,
});
@@ -15,6 +15,7 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
@@ -60,19 +61,39 @@ const handleFetchInboxes = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
)
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
</div>
<div class="flex items-center gap-2">
<Button
@@ -89,18 +110,18 @@ const handleFetchInboxes = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
<div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-body-para">
<span v-if="order" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}}
<span class="text-n-slate-12">{{ order }}</span>
</span>
<div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-body-para">
<span v-if="priority" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}}
@@ -19,15 +19,11 @@ defineProps({
},
});
const emit = defineEmits(['delete', 'navigate']);
const emit = defineEmits(['delete']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
@@ -51,11 +47,7 @@ const handleNavigate = item => {
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<button
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<div class="flex items-center gap-2 col-span-2">
<Icon
v-if="item.icon"
:icon="item.icon"
@@ -69,16 +61,10 @@ const handleNavigate = item => {
:size="20"
rounded-full
/>
<span
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>
<Icon
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
</div>
<div class="flex items-start gap-2 col-span-1">
<span
@@ -2,7 +2,7 @@
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
@@ -15,9 +15,6 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
},
});
// The model value is in seconds (for the backend/DB)
// DurationInput works in minutes internally
// We need to convert between seconds and minutes
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
@@ -28,17 +25,6 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES);
// Convert seconds to minutes for DurationInput
const windowInMinutes = computed({
get() {
return Math.floor((fairDistributionWindow.value || 0) / 60);
},
set(minutes) {
fairDistributionWindow.value = minutes * 60;
},
});
// Detect unit based on minutes (converted from seconds)
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
@@ -48,7 +34,7 @@ const detectUnit = minutes => {
};
onMounted(() => {
windowUnit.value = detectUnit(windowInMinutes.value);
windowUnit.value = detectUnit(fairDistributionWindow.value);
});
</script>
@@ -87,9 +73,9 @@ onMounted(() => {
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days (in minutes) -->
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:model-value="windowInMinutes"
v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
@@ -1,6 +1,4 @@
<script setup>
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
id: {
type: String,
@@ -18,24 +16,12 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledLabel: {
type: String,
default: '',
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive && !props.disabled) {
if (!props.isActive) {
emit('select', props.id);
}
};
@@ -43,37 +29,32 @@ const handleChange = () => {
<template>
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
<div class="flex items-center gap-2">
<h3 class="text-heading-3 text-n-slate-12">
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
</div>
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
@change="handleChange"
/>
</div>
<p class="text-body-main text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ description }}
</p>
<slot />
</div>
</div>
</template>
@@ -6,6 +6,7 @@ const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
@@ -18,10 +19,13 @@ const description = ref(
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
@@ -21,10 +21,10 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-5xl mx-auto">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-heading-1 text-n-slate-12">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
@@ -43,8 +43,8 @@ const handleButtonClick = () => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-4">
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-heading-1 truncate text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
@@ -32,18 +32,17 @@ const updateCurrentPage = page => {
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({
contactId: {
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TELEGRAM: 'i-ri-telegram-fill',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
@@ -67,7 +66,6 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
telegram: '',
tiktok: '',
linkedin: '',
twitter: '',
@@ -105,13 +103,9 @@ const prepareStateBasedOnProps = () => {
countryCode = '',
country = '',
city = '',
socialTelegramUserName = '',
socialProfiles = {},
} = additionalAttributes || {};
const telegramUsername =
socialProfiles?.telegram || socialTelegramUserName || '';
Object.assign(state, {
id,
name,
@@ -125,10 +119,7 @@ const prepareStateBasedOnProps = () => {
countryCode,
country,
city,
socialProfiles: {
...socialProfiles,
telegram: telegramUsername,
},
socialProfiles,
},
});
};
@@ -32,9 +32,9 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -68,7 +68,8 @@ const hasActiveSegments = computed(
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = () => {
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-5xl"
class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -98,8 +98,8 @@ const showPagination = computed(() => {
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto px-6">
<div class="w-full mx-auto max-w-5xl">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@@ -114,12 +114,11 @@ const showPagination = computed(() => {
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0">
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
class="max-w-[67rem]"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
@@ -1,10 +1,8 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
@@ -20,7 +18,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-menu',
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
@@ -30,66 +28,61 @@ const iconByType = {
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-menu';
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div class="flex flex-col py-4 min-w-0">
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
</div>
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
<div class="flex items-center gap-2 min-w-0">
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
{{ attribute.label }}
</h4>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', attribute)"
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -35,20 +35,18 @@ const handleDelete = () => {
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-heading-3 text-n-slate-12 line-clamp-1">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-body-para text-n-slate-11">{{ attribute.type }}</span>
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-body-para text-n-slate-11">{{
attribute.value
}}</span>
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
@@ -129,10 +129,10 @@ const handleDelete = attribute => {
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2">
<h3 class="text-heading-2 text-n-slate-12">
<h3 class="text-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-body-para text-n-slate-11">
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>
@@ -2,7 +2,6 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
type: {
@@ -19,13 +18,11 @@ const attributeConfig = {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
},
};
const config = computed(
@@ -34,9 +31,12 @@ const config = computed(
</script>
<template>
<Label :label="t(config.labelKey)" :color="config.color" compact>
<template #icon>
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
</template>
</Label>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
@@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3">
<div class="w-full max-w-5xl mx-auto">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -95,17 +95,16 @@ const togglePortalSwitcher = () => {
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-3">
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -62,7 +62,6 @@ const onCreate = async () => {
from: route.name,
});
selectedLocale.value = '';
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
@@ -59,23 +59,6 @@ const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
@@ -157,14 +140,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
};
const closeCompose = () => {
@@ -179,12 +160,6 @@ const closeCompose = () => {
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
@@ -196,7 +171,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
discardCompose();
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
@@ -218,11 +193,7 @@ watch(
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) {
clearSelectedContact();
clearFormState();
formState.message = '';
}
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
@@ -294,7 +265,6 @@ useKeyboardEvents(keyboardEvents);
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
@@ -315,7 +285,7 @@ useKeyboardEvents(keyboardEvents);
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="discardCompose"
@discard="closeCompose"
/>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -37,7 +37,6 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
});
const emit = defineEmits([
@@ -58,13 +57,13 @@ const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = props.formState || {
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
};
});
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
@@ -95,7 +95,6 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@@ -128,7 +127,6 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-woot-mail',
icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
@@ -1,31 +0,0 @@
<script setup>
import { useToggle } from '@vueuse/core';
const props = defineProps({
title: {
type: String,
required: true,
},
defaultOpen: {
type: Boolean,
default: true,
},
});
const [isOpen, toggle] = useToggle(props.defaultOpen);
</script>
<template>
<div
v-bind="$attrs"
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
@click="toggle()"
>
<span class="text-heading-2 text-n-slate-12 w-full">
{{ title }}
</span>
</div>
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
<slot />
</div>
</template>
@@ -1,37 +0,0 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
helpText: {
type: String,
default: '',
},
});
</script>
<template>
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
<div
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
>
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
{{ label }}
</label>
<div class="col-span-1 lg:col-span-6">
<slot />
</div>
</div>
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
v-if="helpText"
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ helpText }}
</p>
</div>
<slot name="extra" />
</div>
</template>
@@ -1,45 +0,0 @@
<script setup>
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
header: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
</script>
<template>
<div
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
>
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
<div class="flex items-center gap-3 w-full justify-between">
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<ToggleSwitch v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}
</span>
</div>
<div
v-if="$slots.editor"
class="w-full border-t border-n-weak"
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
>
<slot name="editor" />
</div>
</div>
</template>
@@ -112,19 +112,6 @@ const containerStyles = computed(() => ({
height: `${props.size}px`,
}));
const borderRadiusClass = computed(() => {
if (props.roundedFull) {
return 'rounded-full';
}
// Approximates 25% of size
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
});
const avatarStyles = computed(() => ({
...containerStyles.value,
backgroundColor:
@@ -197,7 +184,7 @@ watch(
<template>
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles"
>
<!-- Status Badge -->
@@ -229,9 +216,9 @@ watch(
<!-- Avatar Container -->
<span
role="img"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
:class="[
borderRadiusClass,
roundedFull ? 'rounded-full' : 'rounded-xl',
{
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid),
@@ -280,8 +267,7 @@ watch(
:handle-image-upload="handleImageUpload"
>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
@@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-5xl mx-auto">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
@@ -140,9 +140,7 @@ const handleCreateAssistant = () => {
>
<Button
icon="i-lucide-chevron-down"
:variant="
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
"
variant="ghost"
color="slate"
size="xs"
:disabled="isFetchingAssistants"
@@ -206,7 +204,7 @@ const handleCreateAssistant = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl h-full mx-auto py-4">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
@@ -224,12 +222,11 @@ const handleCreateAssistant = () => {
<slot />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="handlePageChange"
/>
</footer>
@@ -27,7 +27,7 @@ const openBilling = () => {
<template>
<div
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
@@ -56,13 +56,8 @@ const selectedLabel = computed(() => {
});
const selectOption = option => {
if (selectedValue.value === option.value) {
selectedValue.value = '';
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
selectedValue.value = option.value;
emit('update:modelValue', option.value);
open.value = false;
search.value = '';
};
@@ -79,8 +79,8 @@ defineExpose({
:aria-multiselectable="multiple"
>
<li
v-for="(option, index) in options"
:key="`${option.value}-${index}`"
v-for="option in options"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': isSelected(option),
@@ -53,11 +53,6 @@ const props = defineProps({
default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
},
position: {
type: String,
default: 'center',
validator: value => ['center', 'top'].includes(value),
},
});
const emit = defineEmits(['confirm', 'close']);
@@ -66,7 +61,6 @@ const { t } = useI18n();
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const isOpen = ref(false);
const maxWidthClass = computed(() => {
const classesMap = {
@@ -81,19 +75,13 @@ const maxWidthClass = computed(() => {
return classesMap[props.width] ?? 'max-w-md';
});
const positionClass = computed(() =>
props.position === 'top' ? 'dialog-position-top' : ''
);
const open = () => {
isOpen.value = true;
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
isOpen.value = false;
};
const confirm = () => {
@@ -110,7 +98,6 @@ defineExpose({ open, close });
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[
maxWidthClass,
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@@ -118,7 +105,7 @@ defineExpose({ open, close });
<OnClickOutside @trigger="close">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm"
@click.stop
>
@@ -132,7 +119,7 @@ defineExpose({ open, close });
</p>
</slot>
</div>
<slot v-if="isOpen" />
<slot />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div
@@ -169,9 +156,4 @@ defineExpose({ open, close });
dialog::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.dialog-position-top {
margin-top: clamp(2rem, 5vh, 5rem);
margin-bottom: auto;
}
</style>
@@ -4,10 +4,6 @@ defineProps({
type: String,
default: '',
},
height: {
type: String,
default: 'max-h-96',
},
});
</script>
@@ -19,10 +15,7 @@ defineProps({
>
{{ title }}
</div>
<ul
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
<slot />
</ul>
</div>
@@ -50,7 +50,7 @@ const openLink = link => {
]"
>
<section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
>
<div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter?.filterOperators?.find(
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter?.filterOperators?.[0];
return filter.filterOperators[0];
}
return operatorFromOptions;
@@ -138,11 +138,7 @@ const validate = () => {
return !validationError.value;
};
const resetValidation = () => {
showErrors.value = false;
};
defineExpose({ validate, resetValidation });
defineExpose({ validate });
</script>
<template>
@@ -170,20 +166,18 @@ defineExpose({ validate, resetValidation });
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter?.filterOperators"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator?.hasInput">
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-72"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => {
return props.options?.find(o => o.value === selected.value) || {};
return props.options.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
@@ -87,25 +87,18 @@ const updateSelected = newValue => {
</template>
<DropdownBody
ref="dropdownRef"
class="min-w-56 z-50"
class="min-w-48 z-50"
:class="dropdownPosition"
strong
>
<DropdownSection class="[&>ul]:max-h-72">
<template v-for="option in options" :key="option.value">
<li
v-if="option.disabled"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
>
{{ option.label }}
</li>
<DropdownItem
v-else
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</template>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips, dropdownMaxHeight } = defineProps({
const { options, maxChips } = defineProps({
options: {
type: Array,
required: true,
@@ -17,10 +17,6 @@ const { options, maxChips, dropdownMaxHeight } = defineProps({
type: Number,
default: 3,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
});
const { t } = useI18n();
@@ -127,7 +123,7 @@ const toggleOption = option => {
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection :height="dropdownMaxHeight">
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.id"
@@ -12,12 +12,10 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
disableSearch,
disableDeselect,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
dropdownMaxHeight,
} = defineProps({
options: {
type: Array,
@@ -43,14 +41,6 @@ const {
type: String,
default: '',
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
disableDeselect: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -73,8 +63,6 @@ const selectedItem = computed(() => {
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
@@ -89,7 +77,7 @@ const toggleSelected = option => {
};
if (selected.value && selected.value.id === optionToToggle.id) {
if (!disableDeselect) selected.value = null;
selected.value = null;
} else {
selected.value = optionToToggle;
}
@@ -136,7 +124,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<DropdownSection class="[&>ul]:max-h-80">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
@@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
@@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
expect(icon).toBe('i-ri-phone-fill');
});
it('returns correct icon for Line channel', () => {
@@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-x');
expect(icon).toBe('i-ri-twitter-x-fill');
});
it('returns correct icon for WebWidget channel', () => {
@@ -108,7 +108,7 @@ onMounted(() => {
<label
v-if="label"
:for="uniqueId"
class="mb-0.5 text-heading-3 text-n-slate-12"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
@@ -145,7 +145,7 @@ onMounted(() => {
/>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
@@ -1,71 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: {
type: [Object, String],
required: true,
},
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'slate',
validator: value =>
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
},
});
const COLOR_CLASSES = {
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
};
const isStringLabel = computed(() => typeof props.label === 'string');
const labelTitle = computed(() => {
return isStringLabel.value ? props.label : props.label?.title;
});
const labelDescription = computed(() => {
return (!isStringLabel.value && props.label?.description) || '';
});
const labelColor = computed(() => {
return isStringLabel.value ? null : props.label.color;
});
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
</script>
<template>
<div
:title="labelDescription"
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
]"
>
<span
v-if="labelColor"
class="rounded-sm flex-shrink-0"
:class="compact ? 'size-1.5' : 'size-2'"
:style="{ background: labelColor }"
/>
<slot v-else name="icon" />
<span
class="whitespace-nowrap"
:class="compact ? 'text-label-small' : 'text-label !font-420'"
>
{{ labelTitle }}
</span>
<slot name="action" />
</div>
</template>
@@ -43,7 +43,6 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import { useBranding } from 'shared/composables/useBranding';
/**
* @typedef {Object} Attachment
@@ -144,7 +143,6 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const { replaceInstallationName } = useBranding();
/**
* Computes the message variant based on props
@@ -391,17 +389,13 @@ const shouldRenderMessage = computed(() => {
const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS;
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
const hasExternalError = !!props.contentAttributes?.externalError;
return (
hasAttachments ||
props.content ||
isEmailContentType ||
isUnsupported ||
isAnIntegrationMessage ||
isFailedMessage ||
hasExternalError
isAnIntegrationMessage
);
});
@@ -478,7 +472,7 @@ const avatarInfo = computed(() => {
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
return t('CONVERSATION.NATIVE_APP_ADVISORY');
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
@@ -12,16 +12,11 @@ defineProps({
const emit = defineEmits(['retry']);
const { orientation, status, createdAt, content, attachments } =
useMessageContext();
const { orientation, status, createdAt } = useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => {
const hasContent = content.value !== null;
const hasAttachments = attachments.value && attachments.value.length > 0;
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
});
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
</script>
<template>
@@ -71,10 +71,10 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-[3.375rem] w-full border-t border-n-weak mx-auto bg-n-surface-1 py-3 px-6 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-0% before:to-transparent before:h-4 before:pointer-events-none"
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-body-main line-clamp-1 text-n-slate-11">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
{{ currentPageInformation }}
</span>
</div>
@@ -97,13 +97,11 @@ const pageInfo = computed(() => {
:disabled="isFirstPage"
@click="changePage(currentPage - 1)"
/>
<div class="inline-flex items-center gap-2 text-sm">
<span
class="px-3 tabular-nums py-0.5 font-420 bg-n-input-background text-body-main text-n-slate-12 rounded-md"
>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
{{ formatFullNumber(currentPage) }}
</span>
<span class="truncate text-body-main text-n-slate-11">
<span class="truncate">
{{ pageInfo }}
</span>
</div>
@@ -1,97 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
options: {
type: Array,
default: () => [],
validator: options =>
options.every(
opt => typeof opt === 'object' && 'value' in opt && 'label' in opt
),
},
groups: {
type: Array,
default: () => [],
validator: groups =>
groups.every(
group =>
'label' in group &&
Array.isArray(group.options) &&
group.options.every(opt => 'value' in opt && 'label' in opt)
),
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
});
const modelValue = defineModel({
type: [String, Number, Boolean],
default: '',
});
</script>
<template>
<div class="w-fit relative">
<select
v-model="modelValue"
:disabled="disabled"
class="appearance-none bg-none rounded-lg border-0 outline-1 outline -outline-offset-1 transition-all duration-200 bg-n-surface-1 !mb-0 py-2 px-3 pr-10 text-sm"
:class="{
'outline-n-weak hover:outline-n-slate-6 focus:outline-n-blue-9':
!error && !disabled,
'outline-n-red-9 focus:outline-n-red-9': error && !disabled,
'outline-n-weak bg-n-slate-2 cursor-not-allowed opacity-60': disabled,
}"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<template v-if="groups.length">
<optgroup
v-for="group in groups"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</optgroup>
</template>
<template v-else>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</template>
</select>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon
icon="i-lucide-chevron-down"
class="size-4 text-n-slate-11"
:class="{ 'opacity-50': disabled }"
/>
</div>
</div>
</template>
@@ -25,8 +25,8 @@ const reauthorizationRequired = computed(() => {
</script>
<template>
<span class="size-4 grid place-content-center rounded-full">
<ChannelIcon :inbox="inbox" class="size-4" />
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<ChannelIcon :inbox="inbox" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<div
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -51,18 +50,6 @@ const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
);
});
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -286,7 +273,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
h(ChannelLeaf, {
@@ -595,49 +582,18 @@ const menuItems = computed(() => {
name: 'Settings Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: [
'settings_teams_list',
'settings_teams_new',
'settings_teams_finish',
'settings_teams_add_agents',
'settings_teams_show',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
],
to: accountScopedRoute('settings_teams_list'),
},
...(hasAdvancedAssignment.value
? [
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
activeOn: [
'assignment_policy_index',
'agent_assignment_policy_index',
'agent_assignment_policy_create',
'agent_assignment_policy_edit',
'agent_capacity_policy_index',
'agent_capacity_policy_create',
'agent_capacity_policy_edit',
],
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
icon: 'i-lucide-inbox',
activeOn: [
'settings_inbox_list',
'settings_inbox_show',
'settings_inbox_new',
'settings_inbox_finish',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
],
to: accountScopedRoute('settings_inbox_list'),
},
{
@@ -730,7 +686,7 @@ const menuItems = computed(() => {
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
@@ -851,7 +807,7 @@ const menuItems = computed(() => {
"
/>
<div
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
>
<SidebarProfileMenu
@@ -44,10 +44,8 @@ const shouldRenderComponent = computed(() => {
:active
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
<div class="flex-1 truncate min-w-0">{{ label }}</div>
</template>
</component>
</Policy>
@@ -19,24 +19,20 @@ const updateValue = () => {
<template>
<button
type="button"
class="group relative h-4 rounded-full w-7 flex-shrink-0 select-none focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 transition-colors duration-200 ease-in-out"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6'"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
role="switch"
:aria-checked="modelValue"
@click="updateValue"
>
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span
class="absolute top-1/2 ltr:left-0.5 rtl:right-0.5 -translate-y-1/2 transition-transform duration-[350ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]"
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
:class="
modelValue
? 'ltr:translate-x-3 rtl:-translate-x-3 group-active:ltr:translate-x-[6px] rtl:group-active:-translate-x-[6px]'
: 'ltr:translate-x-0 rtl:translate-x-0'
? 'translate-x-3 bg-n-background'
: 'translate-x-0 bg-n-background'
"
>
<span
class="block h-3 w-3 rounded-full bg-n-background shadow-md transition-[width] duration-[180ms] ease-in-out group-active:w-[18px]"
/>
</span>
/>
</button>
</template>
@@ -1,175 +0,0 @@
<script setup>
import { ref } from 'vue';
import { BaseTable, BaseTableRow, BaseTableCell } from './index';
import Button from '../button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
import ToggleSwitch from '../switch/Switch.vue';
const automationData = ref([
{
id: 1,
name: 'Welcome Message',
description: 'Send welcome message to new contacts',
active: true,
createdOn: 'Apr 21, 2022',
},
{
id: 2,
name: 'Auto-assign to Sales',
description: 'Automatically assign sales conversations to sales team',
active: false,
createdOn: 'May 15, 2022',
},
{
id: 3,
name: 'Tag Premium Users',
description: 'Add premium tag to conversations from premium users',
active: true,
createdOn: 'Jun 10, 2022',
},
]);
const agentData = ref([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'Administrator',
verified: true,
avatarUrl: '',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Agent',
verified: true,
avatarUrl: '',
},
]);
const emptyData = ref([]);
const headers = ['Name', 'Active', 'Created on', 'Actions'];
const agentHeaders = ['Agent', 'Role', 'Verification', 'Actions'];
</script>
<template>
<Story title="Components/Table" :layout="{ type: 'grid', width: '100%' }">
<!-- Basic Table -->
<Variant title="Basic Table">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="headers" :items="automationData">
<template #row="{ items }">
<BaseTableRow
v-for="automation in items"
:key="automation.id"
:item="automation"
>
<template #default>
<BaseTableCell>
<div class="flex items-center gap-2 min-w-0 max-w-full">
<span
class="text-body-main text-n-slate-12 truncate min-w-0 flex-1"
>
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span
class="text-body-main text-n-slate-11 truncate min-w-0 flex-1"
>
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<div class="flex justify-center">
<ToggleSwitch v-model="automation.active" />
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ automation.createdOn }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
<Button icon="i-woot-clone" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Table with Avatars -->
<Variant title="Table with Avatars">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="agentHeaders" :items="agentData">
<template #row="{ items }">
<BaseTableRow v-for="agent in items" :key="agent.id" :item="agent">
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar :user="agent" :size="40" class="flex-shrink-0" />
<div class="flex flex-col min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ agent.name }}
</span>
<span class="text-body-main text-n-slate-11 truncate">
{{ agent.email }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.role }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.verified ? 'Verified' : 'Pending' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Empty State -->
<Variant title="Empty State">
<div class="p-4 bg-n-surface-1">
<BaseTable
:headers="headers"
:items="emptyData"
no-data-message="No automation rules found"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,60 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
noDataMessage: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
});
const hasHeaderSlot = computed(() => !!props.headers.length);
const showHeaders = computed(
() => hasHeaderSlot.value && props.items.length > 0
);
</script>
<template>
<div class="w-full">
<table class="min-w-full table-auto divide-y divide-n-weak">
<thead v-if="showHeaders" class="border-t border-n-weak">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12 capitalize"
>
<slot :name="`header-${index}`" :header="header">
{{ header }}
</slot>
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<template v-if="items.length">
<slot name="row" :items="items" />
</template>
<tr v-else-if="noDataMessage && !loading">
<td
:colspan="headers.length || 1"
class="py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ noDataMessage }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -1,22 +0,0 @@
<script setup>
defineProps({
align: {
type: String,
default: 'start',
validator: value => ['start', 'center', 'end'].includes(value),
},
});
</script>
<template>
<td
class="py-3 ltr:pr-4 rtl:pl-4 text-body-main"
:class="{
'text-start': align === 'start',
'text-center': align === 'center',
'text-end': align === 'end',
}"
>
<slot />
</td>
</template>
@@ -1,14 +0,0 @@
<script setup>
defineProps({
item: {
type: Object,
required: true,
},
});
</script>
<template>
<tr>
<slot :item="item" />
</tr>
</template>
@@ -1,3 +0,0 @@
export { default as BaseTable } from './BaseTable.vue';
export { default as BaseTableRow } from './BaseTableRow.vue';
export { default as BaseTableCell } from './BaseTableCell.vue';
@@ -36,11 +36,6 @@ const props = defineProps({
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
// Skip label-based dedup when the consumer already filters menuItems by ID.
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
skipLabelDedup: { type: Boolean, default: false },
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
autoOpenDropdown: { type: Boolean, default: true },
});
const emit = defineEmits([
@@ -61,7 +56,7 @@ const modelValue = defineModel({
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(props.autoOpenDropdown);
const isFocused = ref(true);
const rules = computed(() => getValidationRules(props.type));
const v$ = useVuelidate(rules, { newTag });
@@ -79,11 +74,11 @@ const showInput = computed(() =>
const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1
? false
: props.showDropdown && isFocused.value
: props.showDropdown
);
const filteredMenuItems = computed(() => {
const items = buildTagMenuItems({
const filteredMenuItems = computed(() =>
buildTagMenuItems({
mode: props.mode,
tags: tags.value,
menuItems: props.menuItems,
@@ -91,14 +86,8 @@ const filteredMenuItems = computed(() => {
isLoading: props.isLoading,
type: props.type,
isNewTagInValidType: isNewTagInValidType.value,
allowCreate: props.allowCreate,
skipLabelDedup: props.skipLabelDedup,
});
if (props.type !== INPUT_TYPES.TEXT) return items;
const query = newTag.value?.trim()?.toLowerCase();
if (!query) return items;
return items.filter(item => item.label?.toLowerCase().includes(query));
});
})
);
const emitDataOnAdd = value => {
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
@@ -123,13 +112,10 @@ const addTag = async () => {
return;
}
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
props.type
);
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
if (isValidatedType || props.allowCreate) {
if (
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
props.allowCreate
) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
@@ -139,31 +125,28 @@ const addTag = async () => {
const removeTag = index => {
tags.value.splice(index, 1);
modelValue.value = tags.value;
emit('remove', index);
emit('remove');
};
const handleDropdownAction = async ({
email: emailAddress,
phoneNumber,
label,
...rest
}) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (!props.showDropdown) return;
const isEmail = props.type === INPUT_TYPES.EMAIL;
const tagValue = isEmail ? emailAddress : phoneNumber || label;
const isEmail = props.type === 'email';
newTag.value = isEmail ? emailAddress : phoneNumber;
if (isEmail || props.type === INPUT_TYPES.TEL) {
newTag.value = tagValue;
if (!(await v$.value.$validate())) return;
}
if (!(await v$.value.$validate())) return;
emit(
'add',
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
);
updateValueAndFocus(tagValue);
const payload = isEmail
? { email: emailAddress, ...rest }
: { phoneNumber, ...rest };
emit('add', payload);
updateValueAndFocus(emailAddress);
};
const handleFocus = () => {
@@ -180,7 +163,7 @@ const handleKeydown = event => {
};
const handleClickOutside = () => {
isFocused.value = false;
if (tags.value.length) isFocused.value = false;
emit('onClickOutside');
};
@@ -261,71 +261,6 @@ describe('tagInputHelper', () => {
});
expect(result).toEqual(menuItems);
});
it('does not create suggestion when allowCreate is false', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: false,
});
expect(result).toEqual([]);
});
it('still returns available menu items when allowCreate is false', () => {
const menuItems = [
{ label: 'Agent 1', value: '1' },
{ label: 'Agent 2', value: '2' },
];
const result = buildTagMenuItems({
...baseParams,
menuItems,
newTag: 'Agent',
allowCreate: false,
});
expect(result).toEqual(menuItems);
});
it('creates new item when allowCreate is true (default)', () => {
const result = buildTagMenuItems({
...baseParams,
type: INPUT_TYPES.EMAIL,
newTag: 'test@example.com',
allowCreate: true,
});
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
label: 'test@example.com',
action: 'create',
});
});
it('skips label dedup when skipLabelDedup is true', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
skipLabelDedup: true,
});
expect(result).toEqual(menuItems);
});
it('filters by label when skipLabelDedup is false (default)', () => {
const menuItems = [
{ label: 'HDMA', value: 1 },
{ label: 'HDMA', value: 2 },
];
const result = buildTagMenuItems({
...baseParams,
tags: ['HDMA'],
menuItems,
});
expect(result).toEqual([]);
});
});
describe('canAddTag', () => {
@@ -84,29 +84,25 @@ export const buildTagMenuItems = ({
isLoading,
type,
isNewTagInValidType,
allowCreate = true,
skipLabelDedup = false,
}) => {
if (mode === MODE.SINGLE && tags.length >= 1) return [];
const availableMenuItems = skipLabelDedup
? menuItems
: menuItems.filter(item => !tags.includes(item.label));
const availableMenuItems = menuItems.filter(
item => !tags.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Validation passes (email/phone) and There are no menu items available
// 4. allowCreate is enabled
const trimmedNewTag = newTag?.trim();
const shouldShowCreateSuggestion =
allowCreate &&
const shouldShowTypedValue =
trimmedNewTag &&
!tags.includes(trimmedNewTag) &&
!isLoading &&
!availableMenuItems.length;
if (shouldShowCreateSuggestion) {
if (shouldShowTypedValue) {
const { isValid, formattedValue } = validateAndFormatNewTag(
trimmedNewTag,
type,
@@ -18,7 +18,7 @@ const props = defineProps({
},
year: {
type: [Number, String],
default: '',
required: true,
},
});
@@ -47,7 +47,7 @@ defineProps({
<div
v-if="isComingSoon"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
>
<span class="text-n-slate-12 font-medium text-sm">
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
@@ -145,7 +145,6 @@ const {
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
@@ -860,7 +859,6 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
@@ -10,7 +10,6 @@ export default {
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
@@ -64,7 +63,6 @@ export default {
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@@ -0,0 +1,31 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
@@ -1,14 +1,11 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { ref, watch } from 'vue';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
isNavigableRange,
getRangeAtOffset,
} from './helpers/DatePickerHelper';
import {
isValid,
@@ -16,14 +13,14 @@ import {
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
differenceInCalendarWeeks,
setMonth,
setYear,
getWeek,
isAfter,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
@@ -35,187 +32,98 @@ import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const emit = defineEmits(['dateRangeChanged']);
const { t } = useI18n();
const dateRange = defineModel('dateRange', {
type: Array,
default: undefined,
});
const rangeType = defineModel('rangeType', {
type: String,
default: undefined,
});
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
// Use dates from v-model if provided, otherwise default to last 7 days
const selectedStartDate = ref(
dateRange.value?.[0]
? startOfDay(dateRange.value[0])
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
);
const selectedEndDate = ref(
dateRange.value?.[1]
? endOfDay(dateRange.value[1])
: endOfDay(currentDate.value)
);
// Calendar month positioning (left and right calendars)
// These control which months are displayed in the dual calendar view
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
);
const selectingEndDate = ref(false);
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
const selectedRange = ref(LAST_7_DAYS);
const hoveredEndDate = ref(null);
const monthOffset = ref(0);
const showMonthNavigation = computed(() =>
isNavigableRange(selectedRange.value)
);
const canNavigateNext = computed(() => {
if (!isNavigableRange(selectedRange.value)) return false;
// Compare selected start to the current period's start to determine if we're in the past
const currentRange = getActiveDateRange(
selectedRange.value,
currentDate.value
);
return selectedStartDate.value < currentRange.start;
});
const navigationLabel = computed(() => {
const range = selectedRange.value;
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
return new Intl.DateTimeFormat(navigator.language, {
month: 'long',
}).format(selectedStartDate.value);
}
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
const currentWeekRange = getActiveDateRange(range, currentDate.value);
const isCurrentWeek =
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
if (isCurrentWeek) return null;
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
}
return null;
});
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
// Watcher 1: Sync v-model props from parent component
// Handles: URL params, parent component updates, rangeType changes
watch(
[rangeType, dateRange],
([newRangeType, newDateRange]) => {
if (newRangeType && newRangeType !== selectedRange.value) {
selectedRange.value = newRangeType;
monthOffset.value = 0;
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// If rangeType changes without dateRange, recompute dates from the range
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
if (activeDates) {
selectedStartDate.value = startOfDay(activeDates.start);
selectedEndDate.value = endOfDay(activeDates.end);
}
}
// Watcher will set the input values based on the selected start and end dates
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
}
// When parent provides new dateRange (e.g., from URL params)
// Skip if navigating with arrows — offset controls dates in that case
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
selectedStartDate.value = startOfDay(newDateRange[0]);
selectedEndDate.value = endOfDay(newDateRange[1]);
// Update calendar to show the months of the new date range
startCurrentDate.value = startOfMonth(newDateRange[0]);
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
? startOfMonth(addMonths(newDateRange[1], 1))
: startOfMonth(newDateRange[1]);
// Recalculate offset so arrow navigation is relative to restored range
// TODO: When offset resolves to 0 (current period), the end date may be
// stale if the URL was saved on a previous day. "This month" / "This week"
// should show up-to-today dates for the current period. For now, the stale
// end date is shown until the user clicks an arrow or re-selects the range.
if (isNavigableRange(selectedRange.value)) {
const current = getActiveDateRange(
selectedRange.value,
currentDate.value
);
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
monthOffset.value = differenceInCalendarWeeks(
newDateRange[0],
current.start,
{ weekStartsOn: 1 }
);
} else {
monthOffset.value = differenceInCalendarMonths(
newDateRange[0],
current.start
);
}
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
}
},
{ immediate: true }
);
// Watcher 2: Keep manual input fields in sync with selected dates
// Updates the input field values when dates change programmatically
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
manualStartDate.value = isValid(newStart)
? newStart
: selectedStartDate.value;
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
},
{ immediate: true }
{ immediate: true, deep: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
monthOffset.value = 0;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
// Position calendar to show the months of the selected range
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
};
const navigateMonth = direction => {
monthOffset.value += direction === 'prev' ? -1 : 1;
if (monthOffset.value > 0) monthOffset.value = 0;
const { start, end } = getRangeAtOffset(
selectedRange.value,
monthOffset.value,
currentDate.value
);
selectedStartDate.value = start;
selectedEndDate.value = end;
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
emit('dateRangeChanged', [start, end, selectedRange.value]);
};
const moveCalendar = (calendar, direction, period = MONTH) => {
@@ -226,27 +134,12 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
direction,
period
);
// Prevent calendar months from overlapping
const monthDiff = differenceInCalendarMonths(end, start);
if (monthDiff === 0) {
// If they would be the same month, adjust the other calendar
if (calendar === START_CALENDAR) {
endCurrentDate.value = addMonths(start, 1);
startCurrentDate.value = start;
} else {
startCurrentDate.value = subMonths(end, 1);
endCurrentDate.value = end;
}
} else {
startCurrentDate.value = start;
endCurrentDate.value = end;
}
startCurrentDate.value = start;
endCurrentDate.value = end;
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
monthOffset.value = 0;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
@@ -282,10 +175,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = startOfMonth(newDate);
startCurrentDate.value = newDate;
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = startOfMonth(newDate);
endCurrentDate.value = newDate;
}
selectingEndDate.value = false;
};
@@ -295,22 +188,13 @@ const handleManualInputError = message => {
};
const resetDatePicker = () => {
// Calculate Last 7 days from today
const startDate = startOfDay(subDays(currentDate.value, 6));
const endDate = endOfDay(currentDate.value);
selectedStartDate.value = startDate;
selectedEndDate.value = endDate;
// Position calendar to show the months of Last 7 days
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
startCurrentDate.value = startOfMonth(startDate);
endCurrentDate.value = isSameMonth(startDate, endDate)
? startOfMonth(addMonths(startDate, 1))
: startOfMonth(endDate);
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
monthOffset.value = 0;
// Reset view modes if they are being used to toggle between different calendar views
calendarViews.value = { start: WEEK, end: WEEK };
};
@@ -319,58 +203,26 @@ const emitDateRange = () => {
useAlert('Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [
selectedStartDate.value,
selectedEndDate.value,
selectedRange.value,
]);
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
// Called when picker opens - positions calendar to show selected date range
// Fixes issue where calendar showed wrong months when loaded from URL params
const initializeCalendarMonths = () => {
if (selectedStartDate.value && selectedEndDate.value) {
startCurrentDate.value = startOfMonth(selectedStartDate.value);
endCurrentDate.value = isSameMonth(
selectedStartDate.value,
selectedEndDate.value
)
? startOfMonth(addMonths(selectedEndDate.value, 1))
: startOfMonth(selectedEndDate.value);
}
};
const toggleDatePicker = () => {
showDatePicker.value = !showDatePicker.value;
if (showDatePicker.value) initializeCalendarMonths();
};
const closeDatePicker = () => {
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
emitDateRange();
} else {
showDatePicker.value = false;
}
showDatePicker.value = false;
};
</script>
<template>
<div class="relative flex-shrink-0 font-inter">
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
:show-month-navigation="showMonthNavigation"
:can-navigate-next="canNavigateNext"
:navigation-label="navigationLabel"
@open="toggleDatePicker"
@navigate-month="navigateMonth"
@open="showDatePicker = !showDatePicker"
/>
<div
v-if="showDatePicker"
v-on-clickaway="closeDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
>
<CalendarDateRange
:selected-range="selectedRange"
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click.stop="onClickPrev(calendarType)"
@click="onClickPrev(calendarType)"
/>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click.stop="onClickSetView(calendarType, viewMode)"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
@click.stop="onClickSetView(calendarType, YEAR)"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click.stop="onClickNext(calendarType)"
@click="onClickNext(calendarType)"
/>
</div>
</template>
@@ -18,25 +18,24 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<template v-for="range in dateRanges" :key="range.label">
<div v-if="range.separator" class="w-full border-t border-n-strong" />
<button
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</template>
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</div>
</div>
</template>
@@ -23,6 +23,7 @@ const onClickApply = () => {
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply"
/>
@@ -78,7 +78,7 @@ const selectMonth = index => {
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click.stop="selectMonth(index)"
@click="selectMonth(index)"
>
{{ month }}
</button>

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