Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6ab8de3d | ||
|
|
a745f418cf | ||
|
|
305e13c830 | ||
|
|
d5e30f7545 | ||
|
|
df7ec2f596 |
+1
-1
@@ -1 +1 @@
|
|||||||
4.16.1
|
4.16.0
|
||||||
|
|||||||
@@ -2,14 +2,6 @@
|
|||||||
# It initializes with necessary attributes and provides a perform method
|
# It initializes with necessary attributes and provides a perform method
|
||||||
# to create a user and account user in a transaction.
|
# to create a user and account user in a transaction.
|
||||||
class AgentBuilder
|
class AgentBuilder
|
||||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
|
||||||
|
|
||||||
class LimitExceededError < StandardError
|
|
||||||
def initialize
|
|
||||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Initializes an AgentBuilder with necessary attributes.
|
# Initializes an AgentBuilder with necessary attributes.
|
||||||
# @param email [String] the email of the user.
|
# @param email [String] the email of the user.
|
||||||
# @param name [String] the name of the user.
|
# @param name [String] the name of the user.
|
||||||
@@ -22,23 +14,15 @@ class AgentBuilder
|
|||||||
# Creates a user and account user in a transaction.
|
# Creates a user and account user in a transaction.
|
||||||
# @return [User] the created user.
|
# @return [User] the created user.
|
||||||
def perform
|
def perform
|
||||||
account.with_lock do
|
ActiveRecord::Base.transaction do
|
||||||
raise LimitExceededError unless can_add_agent?
|
@user = find_or_create_user
|
||||||
|
create_account_user
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
@user = find_or_create_user
|
|
||||||
create_account_user
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@user
|
@user
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def can_add_agent?
|
|
||||||
account.usage_limits[:agents] > account.account_users.count
|
|
||||||
end
|
|
||||||
|
|
||||||
# Finds a user by email or creates a new one with a temporary password.
|
# Finds a user by email or creates a new one with a temporary password.
|
||||||
# @return [User] the found or created user.
|
# @return [User] the found or created user.
|
||||||
def find_or_create_user
|
def find_or_create_user
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
before_action :validate_limit, only: [:create]
|
||||||
|
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@agents = agents
|
@agents = agents
|
||||||
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
)
|
)
|
||||||
|
|
||||||
@agent = builder.perform
|
@agent = builder.perform
|
||||||
rescue AgentBuilder::LimitExceededError => e
|
|
||||||
render_payment_required(e.message)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
def bulk_create
|
def bulk_create
|
||||||
emails = params[:emails]
|
emails = params[:emails]
|
||||||
|
|
||||||
bulk_create_agents(emails)
|
emails.each do |email|
|
||||||
|
builder = AgentBuilder.new(
|
||||||
|
email: email,
|
||||||
|
name: email.split('@').first,
|
||||||
|
inviter: current_user,
|
||||||
|
account: Current.account
|
||||||
|
)
|
||||||
|
begin
|
||||||
|
builder.perform
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# This endpoint is used to bulk create agents during onboarding
|
# This endpoint is used to bulk create agents during onboarding
|
||||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||||
clear_onboarding_step
|
Current.account.custom_attributes.delete('onboarding_step')
|
||||||
|
Current.account.save!
|
||||||
head :ok
|
head :ok
|
||||||
rescue AgentBuilder::LimitExceededError => e
|
|
||||||
render_payment_required(e.message)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||||
end
|
end
|
||||||
|
|
||||||
def bulk_create_agents(emails)
|
def validate_limit_for_bulk_create
|
||||||
Current.account.with_lock do
|
limit_available = params[:emails].count <= available_agent_count
|
||||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
|
||||||
|
|
||||||
emails.each { |email| create_agent_from_email(email) }
|
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_agent_from_email(email)
|
def validate_limit
|
||||||
builder = AgentBuilder.new(
|
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||||
email: email,
|
|
||||||
name: email.split('@').first,
|
|
||||||
inviter: current_user,
|
|
||||||
account: Current.account
|
|
||||||
)
|
|
||||||
builder.perform
|
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
|
||||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_onboarding_step
|
|
||||||
Current.account.custom_attributes.delete('onboarding_step')
|
|
||||||
Current.account.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def available_agent_count
|
def available_agent_count
|
||||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
Current.account.usage_limits[:agents] - agents.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_add_agent?
|
||||||
|
available_agent_count.positive?
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_user_record(agent)
|
def delete_user_record(agent)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
|
||||||
private
|
|
||||||
|
|
||||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
|
||||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
|
||||||
def check_authorization
|
|
||||||
authorize(:hook)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_hook, except: [:create]
|
before_action :fetch_hook, except: [:create]
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
|
||||||
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
|
|||||||
@hook = Current.account.hooks.find(params[:id])
|
@hook = Current.account.hooks.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_authorization
|
||||||
|
authorize(:hook)
|
||||||
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||||
before_action :fetch_hook, only: [:destroy]
|
before_action :fetch_hook, only: [:destroy]
|
||||||
before_action :check_authorization, only: [:destroy]
|
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
revoke_linear_token
|
revoke_linear_token
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_hook, only: [:destroy]
|
before_action :fetch_hook, only: [:destroy]
|
||||||
before_action :check_authorization, only: [:destroy]
|
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@hook.destroy!
|
@hook.destroy!
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||||
include Shopify::IntegrationHelper
|
include Shopify::IntegrationHelper
|
||||||
before_action :setup_shopify_context, only: [:orders]
|
before_action :setup_shopify_context, only: [:orders]
|
||||||
before_action :fetch_hook, except: [:auth]
|
before_action :fetch_hook, except: [:auth]
|
||||||
before_action :check_authorization, only: [:destroy]
|
|
||||||
before_action :validate_contact, only: [:orders]
|
before_action :validate_contact, only: [:orders]
|
||||||
|
|
||||||
def auth
|
def auth
|
||||||
|
|||||||
@@ -47,10 +47,18 @@ class Webhooks::WhatsappController < ActionController::API
|
|||||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||||
return if metadata.blank?
|
return if metadata.blank?
|
||||||
|
|
||||||
Whatsapp::WebhookChannelFinderService.new(
|
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||||
display_phone_number: metadata[:display_phone_number],
|
phone_number_id = metadata[:phone_number_id]
|
||||||
phone_number_id: metadata[:phone_number_id]
|
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||||
).perform
|
|
||||||
|
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_phone_number(phone_number)
|
||||||
|
return if phone_number.blank?
|
||||||
|
|
||||||
|
phone_number = phone_number.to_s
|
||||||
|
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def inactive_whatsapp_number?
|
def inactive_whatsapp_number?
|
||||||
|
|||||||
@@ -26,20 +26,13 @@ class CaptainAssistant extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getMetrics({ assistantId, range, signal }) {
|
getStats({ assistantId, range, signal }) {
|
||||||
const requestConfig = {
|
const requestConfig = {
|
||||||
params: { range, timezone_offset: getTimezoneOffset() },
|
params: { range, timezone_offset: getTimezoneOffset() },
|
||||||
};
|
};
|
||||||
if (signal) requestConfig.signal = signal;
|
if (signal) requestConfig.signal = signal;
|
||||||
|
|
||||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
|
||||||
}
|
|
||||||
|
|
||||||
getFaqStats({ assistantId, signal }) {
|
|
||||||
const requestConfig = {};
|
|
||||||
if (signal) requestConfig.signal = signal;
|
|
||||||
|
|
||||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSummary({ assistantId, range, stats }) {
|
getSummary({ assistantId, range, stats }) {
|
||||||
|
|||||||
+1
-6
@@ -28,12 +28,7 @@ const inboxes = computed(() => {
|
|||||||
return {
|
return {
|
||||||
name: inbox.name,
|
name: inbox.name,
|
||||||
id: inbox.id,
|
id: inbox.id,
|
||||||
icon: getInboxIconByType(
|
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||||
inbox.channelType,
|
|
||||||
inbox.medium,
|
|
||||||
'line',
|
|
||||||
inbox.voiceEnabled
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed } from 'vue';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||||
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
|
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||||
@@ -61,7 +60,7 @@ const resultLabel = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const providerIcon = computed(() =>
|
const providerIcon = computed(() =>
|
||||||
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
|
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdAtLabel = computed(() =>
|
const createdAtLabel = computed(() =>
|
||||||
@@ -151,7 +150,7 @@ const conversationRoute = computed(() => ({
|
|||||||
<div
|
<div
|
||||||
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
|
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
|
||||||
<Avatar
|
<Avatar
|
||||||
:src="call.contact.avatar"
|
:src="call.contact.avatar"
|
||||||
:name="contactName"
|
:name="contactName"
|
||||||
@@ -170,12 +169,9 @@ const conversationRoute = computed(() => ({
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||||
<CallStatusBadge :kind="kind" class="shrink-0" />
|
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||||
<div
|
<template v-if="agentActionLabel">
|
||||||
v-if="agentActionLabel"
|
|
||||||
class="gap-x-1.5 min-w-0 flex items-center"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
|
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||||
>
|
>
|
||||||
{{ agentActionLabel }}
|
{{ agentActionLabel }}
|
||||||
</span>
|
</span>
|
||||||
@@ -196,7 +192,7 @@ const conversationRoute = computed(() => ({
|
|||||||
{{ call.agent.name }}
|
{{ call.agent.name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</template>
|
||||||
<span
|
<span
|
||||||
v-else-if="resultLabel"
|
v-else-if="resultLabel"
|
||||||
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||||
@@ -216,10 +212,10 @@ const conversationRoute = computed(() => ({
|
|||||||
content: call.inbox.name,
|
content: call.inbox.name,
|
||||||
delay: { show: 500, hide: 0 },
|
delay: { show: 500, hide: 0 },
|
||||||
}"
|
}"
|
||||||
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
|
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
|
||||||
>
|
>
|
||||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||||
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
<span class="text-body-main truncate text-n-slate-11">
|
||||||
{{ call.inbox.name }}
|
{{ call.inbox.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +232,7 @@ const conversationRoute = computed(() => ({
|
|||||||
content: createdAtLabel,
|
content: createdAtLabel,
|
||||||
delay: { show: 500, hide: 0 },
|
delay: { show: 500, hide: 0 },
|
||||||
}"
|
}"
|
||||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
|
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
|
||||||
>
|
>
|
||||||
{{ createdAtLabel }}
|
{{ createdAtLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Null while a fetch is in flight so stale counts are never shown.
|
// Null while a fetch is in flight so stale counts are never shown.
|
||||||
@@ -118,16 +117,10 @@ const moreFiltersSections = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedAssignee = computed(
|
|
||||||
() => props.agents.find(agent => agent.id === assigneeId.value) || null
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedAssigneeLabel = computed(
|
const selectedAssigneeLabel = computed(
|
||||||
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
() =>
|
||||||
);
|
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||||
|
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||||
const isOtherActivitySelected = computed(() =>
|
|
||||||
OTHER_ACTIVITIES.includes(activity.value)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||||
@@ -150,7 +143,7 @@ const applyMoreFilter = ({ action, value }) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||||
{{
|
{{
|
||||||
totalCount === null
|
totalCount === null
|
||||||
@@ -164,13 +157,13 @@ const applyMoreFilter = ({ action, value }) => {
|
|||||||
color="blue"
|
color="blue"
|
||||||
size="sm"
|
size="sm"
|
||||||
:icon="ACTIVITY_ICONS[activity]"
|
:icon="ACTIVITY_ICONS[activity]"
|
||||||
class="shrink-0 !h-7 !px-2"
|
class="shrink-0"
|
||||||
@click="setActivity(null)"
|
@click="setActivity(null)"
|
||||||
>
|
>
|
||||||
{{ activeChipLabel }}
|
{{ activeChipLabel }}
|
||||||
<Icon icon="i-lucide-x" />
|
<Icon icon="i-lucide-x" />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
|
<div class="w-px h-4 bg-n-strong shrink-0" />
|
||||||
<Button
|
<Button
|
||||||
v-for="chip in inactiveChips"
|
v-for="chip in inactiveChips"
|
||||||
:key="chip"
|
:key="chip"
|
||||||
@@ -179,7 +172,7 @@ const applyMoreFilter = ({ action, value }) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
:icon="ACTIVITY_ICONS[chip]"
|
:icon="ACTIVITY_ICONS[chip]"
|
||||||
:label="activityLabel(chip)"
|
:label="activityLabel(chip)"
|
||||||
class="shrink-0 text-n-slate-11 !h-7 !px-2"
|
class="shrink-0 text-n-slate-12"
|
||||||
@click="setActivity(chip)"
|
@click="setActivity(chip)"
|
||||||
/>
|
/>
|
||||||
<OnClickOutside
|
<OnClickOutside
|
||||||
@@ -191,10 +184,7 @@ const applyMoreFilter = ({ action, value }) => {
|
|||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="i-lucide-phone"
|
icon="i-lucide-phone"
|
||||||
class="!h-7 !px-2"
|
class="text-n-slate-12"
|
||||||
:class="
|
|
||||||
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
|
|
||||||
"
|
|
||||||
@click="toggleMenu('activity')"
|
@click="toggleMenu('activity')"
|
||||||
>
|
>
|
||||||
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||||
@@ -218,19 +208,10 @@ const applyMoreFilter = ({ action, value }) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="i-woot-empty-assignee"
|
icon="i-lucide-user-round-cog"
|
||||||
class="max-w-52 !h-7 !px-2"
|
class="max-w-52 text-n-slate-12"
|
||||||
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
|
|
||||||
@click="toggleMenu('assignee')"
|
@click="toggleMenu('assignee')"
|
||||||
>
|
>
|
||||||
<template v-if="selectedAssignee" #icon>
|
|
||||||
<Avatar
|
|
||||||
:src="selectedAssignee.thumbnail"
|
|
||||||
:name="selectedAssignee.name"
|
|
||||||
:size="16"
|
|
||||||
rounded-full
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -247,9 +228,8 @@ const applyMoreFilter = ({ action, value }) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="i-lucide-list-filter"
|
icon="i-lucide-list-filter"
|
||||||
class="!h-7 !px-2"
|
|
||||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||||
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||||
@click="toggleMenu('more')"
|
@click="toggleMenu('more')"
|
||||||
>
|
>
|
||||||
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||||
|
|||||||
@@ -83,12 +83,8 @@ const campaignStatus = computed(() => {
|
|||||||
const inboxName = computed(() => props.inbox?.name || '');
|
const inboxName = computed(() => props.inbox?.name || '');
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const {
|
const { medium, channel_type: type } = props.inbox;
|
||||||
medium,
|
return getInboxIconByType(type, medium);
|
||||||
channel_type: type,
|
|
||||||
voice_enabled: voiceEnabled,
|
|
||||||
} = props.inbox;
|
|
||||||
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, useSlots, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||||
@@ -23,11 +22,8 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
|
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const slots = useSlots();
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const isContactSidebarOpen = ref(false);
|
|
||||||
|
|
||||||
const contactId = computed(() => route.params.contactId);
|
const contactId = computed(() => route.params.contactId);
|
||||||
|
|
||||||
const selectedContactName = computed(() => {
|
const selectedContactName = computed(() => {
|
||||||
@@ -60,26 +56,15 @@ const handleBreadcrumbClick = () => {
|
|||||||
const toggleBlock = () => {
|
const toggleBlock = () => {
|
||||||
emit('toggleBlock', isContactBlocked.value);
|
emit('toggleBlock', isContactBlocked.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConversationSidebarToggle = () => {
|
|
||||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeMobileSidebar = () => {
|
|
||||||
if (!isContactSidebarOpen.value) return;
|
|
||||||
isContactSidebarOpen.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||||
>
|
>
|
||||||
<div
|
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
|
||||||
>
|
|
||||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
<div class="w-full mx-auto max-w-5xl">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||||
>
|
>
|
||||||
@@ -119,81 +104,10 @@ const closeMobileSidebar = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
<div class="w-full py-4 mx-auto max-w-5xl">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop sidebar -->
|
|
||||||
<div
|
|
||||||
v-if="slots.sidebar"
|
|
||||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
|
||||||
>
|
|
||||||
<div class="shrink-0">
|
|
||||||
<slot name="sidebarHeader" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
|
||||||
<slot name="sidebar" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile sidebar container -->
|
|
||||||
<div
|
|
||||||
v-if="slots.sidebar"
|
|
||||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
|
||||||
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
|
||||||
>
|
|
||||||
<!-- Toggle button -->
|
|
||||||
<div
|
|
||||||
v-on-click-outside="[
|
|
||||||
closeMobileSidebar,
|
|
||||||
{ ignore: ['#contact-sidebar-content'] },
|
|
||||||
]"
|
|
||||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
|
||||||
:class="[
|
|
||||||
isContactSidebarOpen
|
|
||||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
|
||||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
ghost
|
|
||||||
slate
|
|
||||||
sm
|
|
||||||
class="!rounded-full rtl:rotate-180"
|
|
||||||
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
|
||||||
:icon="
|
|
||||||
isContactSidebarOpen
|
|
||||||
? 'i-lucide-panel-right-close'
|
|
||||||
: 'i-lucide-panel-right-open'
|
|
||||||
"
|
|
||||||
data-contact-sidebar-toggle
|
|
||||||
@click="handleConversationSidebarToggle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
|
||||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
|
||||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
|
||||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
|
||||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
|
||||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="isContactSidebarOpen"
|
|
||||||
id="contact-sidebar-content"
|
|
||||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="shrink-0">
|
|
||||||
<slot name="sidebarHeader" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
|
||||||
<slot name="sidebar" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+30
-18
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
||||||
|
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -108,8 +109,12 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
|
<ContactSidebarSection
|
||||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
v-if="hasContactAttributes"
|
||||||
|
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES')"
|
||||||
|
body-class="p-0"
|
||||||
|
>
|
||||||
|
<div v-if="!hasNoUsedAttributes" class="flex flex-col px-4 py-1.5">
|
||||||
<ContactCustomAttributeItem
|
<ContactCustomAttributeItem
|
||||||
v-for="attribute in usedAttributes"
|
v-for="attribute in usedAttributes"
|
||||||
:key="attribute.id"
|
:key="attribute.id"
|
||||||
@@ -117,36 +122,43 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
|||||||
:attribute="attribute"
|
:attribute="attribute"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
|
|
||||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
<div
|
||||||
<span class="text-sm font-medium text-n-slate-10">{{
|
v-if="!hasNoUnusedAttributes"
|
||||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
class="flex flex-col gap-3 px-4 py-4"
|
||||||
count: unusedAttributesCount,
|
:class="{ 'border-t border-n-weak': !hasNoUsedAttributes }"
|
||||||
})
|
>
|
||||||
}}</span>
|
<span
|
||||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||||
</div>
|
>
|
||||||
<div class="flex flex-col gap-3">
|
{{
|
||||||
<div v-if="!hasNoUnusedAttributes" class="relative">
|
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
count: unusedAttributesCount,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<div class="relative">
|
||||||
|
<span
|
||||||
|
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
|
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
|
||||||
"
|
"
|
||||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
|
v-if="filteredUnusedAttributes.length === 0"
|
||||||
class="flex items-center justify-start h-11"
|
class="flex items-center justify-start h-11"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-n-slate-11">
|
<p class="text-sm text-n-slate-11">
|
||||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
|
<div v-else class="flex flex-col">
|
||||||
<ContactCustomAttributeItem
|
<ContactCustomAttributeItem
|
||||||
v-for="attribute in filteredUnusedAttributes"
|
v-for="attribute in filteredUnusedAttributes"
|
||||||
:key="attribute.id"
|
:key="attribute.id"
|
||||||
@@ -154,7 +166,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ContactSidebarSection>
|
||||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+19
-13
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
|
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||||
|
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -34,19 +35,24 @@ const contactConversations = computed(() =>
|
|||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-else-if="contactConversations.length > 0">
|
||||||
v-else-if="contactConversations.length > 0"
|
<ContactSidebarSection
|
||||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.HISTORY')"
|
||||||
>
|
:count="contactConversations.length"
|
||||||
<ConversationCard
|
body-class="p-1.5 max-h-[60vh] overflow-y-auto"
|
||||||
v-for="conversation in contactConversations"
|
>
|
||||||
:key="conversation.id"
|
<div class="flex flex-col">
|
||||||
:conversation="conversation"
|
<ConversationCard
|
||||||
:contact="contactsById(conversation.meta.sender.id)"
|
v-for="conversation in contactConversations"
|
||||||
:state-inbox="stateInbox(conversation.inboxId)"
|
:key="conversation.id"
|
||||||
:account-labels="accountLabelsValue"
|
:conversation="conversation"
|
||||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
:contact="contactsById(conversation.meta.sender.id)"
|
||||||
/>
|
:state-inbox="stateInbox(conversation.inboxId)"
|
||||||
|
:account-labels="accountLabelsValue"
|
||||||
|
class="border-0 rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContactSidebarSection>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import GalleryView from 'dashboard/components/widgets/conversation/components/Ga
|
|||||||
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
|
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
|
||||||
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
|
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||||
|
|
||||||
const MEDIA_PEEK_LIMIT = 12;
|
const MEDIA_PEEK_LIMIT = 10;
|
||||||
const FILES_PEEK_LIMIT = 6;
|
const FILES_PEEK_LIMIT = 4;
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -73,17 +74,21 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-6">
|
<div>
|
||||||
<div v-if="isFetching" class="flex justify-center p-3">
|
<div v-if="isFetching" class="flex justify-center p-3">
|
||||||
<Spinner class="size-5" />
|
<Spinner class="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
|
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
|
||||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="flex flex-col gap-5">
|
<ContactSidebarSection
|
||||||
|
v-else
|
||||||
|
body-class="flex flex-col gap-5 px-4 py-4 max-h-[60vh] overflow-y-auto"
|
||||||
|
>
|
||||||
<Media
|
<Media
|
||||||
:attachments="attachments"
|
:attachments="attachments"
|
||||||
:peek-limit="MEDIA_PEEK_LIMIT"
|
:peek-limit="MEDIA_PEEK_LIMIT"
|
||||||
|
:columns="5"
|
||||||
show-jump-to-message
|
show-jump-to-message
|
||||||
@select="onMediaSelect"
|
@select="onMediaSelect"
|
||||||
@jump-to-message="onJumpToMessage"
|
@jump-to-message="onJumpToMessage"
|
||||||
@@ -95,7 +100,7 @@ onMounted(() => {
|
|||||||
@select="onFileSelect"
|
@select="onFileSelect"
|
||||||
@jump-to-message="onJumpToMessage"
|
@jump-to-message="onJumpToMessage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ContactSidebarSection>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
v-if="showGallery && selectedAttachment"
|
v-if="showGallery && selectedAttachment"
|
||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
|
|||||||
+36
-34
@@ -12,6 +12,7 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
|||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
|
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
|
||||||
|
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -103,43 +104,44 @@ const onMergeContacts = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-8 px-6">
|
<div>
|
||||||
<div class="flex flex-col gap-2">
|
<ContactSidebarSection
|
||||||
<h4 class="text-base text-n-slate-12">
|
:title="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE')"
|
||||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
|
body-class="flex flex-col gap-5 px-4 py-4"
|
||||||
</h4>
|
>
|
||||||
<p class="text-sm text-n-slate-11">
|
<p class="text-sm text-n-slate-11">
|
||||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<ContactMergeForm
|
||||||
<ContactMergeForm
|
v-model:primary-contact-id="state.primaryContactId"
|
||||||
v-model:primary-contact-id="state.primaryContactId"
|
:selected-contact="selectedContact"
|
||||||
:selected-contact="selectedContact"
|
:primary-contact-list="primaryContactList"
|
||||||
:primary-contact-list="primaryContactList"
|
:is-searching="isSearching"
|
||||||
:is-searching="isSearching"
|
:has-error="!!v$.primaryContactId.$error"
|
||||||
:has-error="!!v$.primaryContactId.$error"
|
:error-message="
|
||||||
:error-message="
|
v$.primaryContactId.$error
|
||||||
v$.primaryContactId.$error
|
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
|
||||||
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
|
: ''
|
||||||
: ''
|
"
|
||||||
"
|
@search="onContactSearch"
|
||||||
@search="onContactSearch"
|
|
||||||
/>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<Button
|
|
||||||
variant="faded"
|
|
||||||
color="slate"
|
|
||||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
|
||||||
@click="resetState"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<div class="flex items-center justify-end gap-3">
|
||||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
|
<Button
|
||||||
class="w-full"
|
variant="faded"
|
||||||
:is-loading="isMergingContact"
|
color="slate"
|
||||||
:disabled="isMergingContact"
|
size="sm"
|
||||||
@click="onMergeContacts"
|
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||||
/>
|
class="bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
</div>
|
@click="resetState"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
|
||||||
|
size="sm"
|
||||||
|
:is-loading="isMergingContact"
|
||||||
|
:disabled="isMergingContact"
|
||||||
|
@click="onMergeContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ContactSidebarSection>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+73
-36
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, computed } from 'vue';
|
import { reactive, computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
@@ -8,6 +8,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
|||||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||||
import ContactNoteItem from './components/ContactNoteItem.vue';
|
import ContactNoteItem from './components/ContactNoteItem.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -25,6 +26,16 @@ const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
|||||||
const isCreatingNote = computed(() => uiFlags.value.isCreating);
|
const isCreatingNote = computed(() => uiFlags.value.isCreating);
|
||||||
const notes = computed(() => notesByContact.value(route.params.contactId));
|
const notes = computed(() => notesByContact.value(route.params.contactId));
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
const filteredNotes = computed(() => {
|
||||||
|
const query = searchQuery.value.trim().toLowerCase();
|
||||||
|
if (!query) return notes.value;
|
||||||
|
return notes.value.filter(note =>
|
||||||
|
(note.content || '').toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const getWrittenBy = note => {
|
const getWrittenBy = note => {
|
||||||
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||||
return isCurrentUser
|
return isCurrentUser
|
||||||
@@ -55,47 +66,73 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<ContactSidebarSection
|
||||||
<Editor
|
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.NOTES')"
|
||||||
v-model="state.message"
|
:count="notes.length || null"
|
||||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
body-class="p-0"
|
||||||
focus-on-mount
|
>
|
||||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
|
<div class="border-b border-n-weak">
|
||||||
>
|
<Editor
|
||||||
<template #actions>
|
v-model="state.message"
|
||||||
<div class="flex items-center gap-3">
|
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||||
<Button
|
class="[&>div]:!border-transparent [&>div]:!bg-transparent [&>div]:px-4 [&>div]:py-3"
|
||||||
variant="link"
|
>
|
||||||
color="blue"
|
<template #actions>
|
||||||
size="sm"
|
<div class="flex items-center gap-3">
|
||||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
<Button
|
||||||
class="hover:no-underline"
|
variant="link"
|
||||||
:is-loading="isCreatingNote"
|
color="blue"
|
||||||
:disabled="!state.message || isCreatingNote"
|
size="sm"
|
||||||
@click="onAdd(state.message)"
|
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
||||||
/>
|
class="hover:no-underline"
|
||||||
</div>
|
:is-loading="isCreatingNote"
|
||||||
</template>
|
:disabled="!state.message || isCreatingNote"
|
||||||
</Editor>
|
@click="onAdd(state.message)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Editor>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="isFetchingNotes"
|
v-if="isFetchingNotes"
|
||||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="notes.length > 0">
|
<template v-else-if="notes.length > 0">
|
||||||
<ContactNoteItem
|
<div class="px-4 py-3 border-b border-n-weak">
|
||||||
v-for="note in notes"
|
<div class="relative">
|
||||||
:key="note.id"
|
<span
|
||||||
class="mx-6 py-4"
|
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
|
||||||
:note="note"
|
/>
|
||||||
:written-by="getWrittenBy(note)"
|
<input
|
||||||
allow-delete
|
v-model="searchQuery"
|
||||||
@delete="onDelete"
|
type="search"
|
||||||
/>
|
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SEARCH_PLACEHOLDER')"
|
||||||
</div>
|
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
|
||||||
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 max-h-[60vh] overflow-y-auto [&>div]:!border-b-0">
|
||||||
|
<ContactNoteItem
|
||||||
|
v-for="note in filteredNotes"
|
||||||
|
:key="note.id"
|
||||||
|
class="py-3.5"
|
||||||
|
:note="note"
|
||||||
|
:written-by="getWrittenBy(note)"
|
||||||
|
allow-delete
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="filteredNotes.length === 0"
|
||||||
|
class="py-6 text-sm leading-6 text-center text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_RESULTS') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="px-4 py-6 text-sm leading-6 text-center text-n-slate-11">
|
||||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
|
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</ContactSidebarSection>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
bodyClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'px-4 py-4',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="flex flex-col overflow-hidden border rounded-2xl border-n-weak bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
v-if="title || $slots.action"
|
||||||
|
class="flex items-center justify-between gap-2 px-5 py-4 border-b border-n-weak"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
<span
|
||||||
|
v-if="count !== null && count !== ''"
|
||||||
|
class="font-medium tracking-normal normal-case ms-1 text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ count }}
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<slot name="action" />
|
||||||
|
</header>
|
||||||
|
<div :class="bodyClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -12,6 +12,15 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
|
|||||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
|
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||||
|
import ContactStats from 'dashboard/components-next/Contacts/Pages/ContactStats.vue';
|
||||||
|
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||||
|
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
|
||||||
|
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
|
||||||
|
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
|
||||||
|
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
|
||||||
|
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -38,6 +47,34 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
|||||||
|
|
||||||
const contactData = ref({});
|
const contactData = ref({});
|
||||||
|
|
||||||
|
const metaSeparator = '•';
|
||||||
|
|
||||||
|
const contactId = computed(() => props.selectedContact?.id);
|
||||||
|
|
||||||
|
const CONTACT_SECTIONS = [
|
||||||
|
{ value: 'details', label: 'CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS' },
|
||||||
|
{ value: 'attributes', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES' },
|
||||||
|
{ value: 'history', label: 'CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS' },
|
||||||
|
{ value: 'notes', label: 'CONTACTS_LAYOUT.PROFILE.STATS.NOTES' },
|
||||||
|
{ value: 'media', label: 'CONTACTS_LAYOUT.PROFILE.STATS.FILES' },
|
||||||
|
{ value: 'merge', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.MERGE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeSection = ref('details');
|
||||||
|
|
||||||
|
const sectionTabs = computed(() =>
|
||||||
|
CONTACT_SECTIONS.map(section => ({ label: t(section.label) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSectionIndex = computed(() =>
|
||||||
|
CONTACT_SECTIONS.findIndex(section => section.value === activeSection.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSectionChange = tab => {
|
||||||
|
const index = sectionTabs.value.findIndex(item => item.label === tab.label);
|
||||||
|
if (index !== -1) activeSection.value = CONTACT_SECTIONS[index].value;
|
||||||
|
};
|
||||||
|
|
||||||
const getInitialContactData = () => {
|
const getInitialContactData = () => {
|
||||||
if (!props.selectedContact) return {};
|
if (!props.selectedContact) return {};
|
||||||
return { ...props.selectedContact };
|
return { ...props.selectedContact };
|
||||||
@@ -45,6 +82,9 @@ const getInitialContactData = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
Object.assign(contactData.value, getInitialContactData());
|
Object.assign(contactData.value, getInitialContactData());
|
||||||
|
if (contactId.value) {
|
||||||
|
store.dispatch('contacts/fetchAttachments', contactId.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdAt = computed(() => {
|
const createdAt = computed(() => {
|
||||||
@@ -121,8 +161,8 @@ const handleAvatarDelete = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-start gap-8 pb-6">
|
<div class="flex flex-col gap-8 pb-10">
|
||||||
<div class="flex flex-col items-start gap-3">
|
<div class="flex items-start gap-5 min-w-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
:src="avatarSrc || ''"
|
:src="avatarSrc || ''"
|
||||||
:name="selectedContact?.name || ''"
|
:name="selectedContact?.name || ''"
|
||||||
@@ -131,73 +171,112 @@ const handleAvatarDelete = async () => {
|
|||||||
@upload="handleAvatarUpload"
|
@upload="handleAvatarUpload"
|
||||||
@delete="handleAvatarDelete"
|
@delete="handleAvatarDelete"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2 min-w-0">
|
||||||
<h3 class="text-base font-medium text-n-slate-12">
|
<h1
|
||||||
|
class="text-xl font-semibold leading-tight tracking-tight truncate text-n-slate-12"
|
||||||
|
>
|
||||||
{{ selectedContact?.name }}
|
{{ selectedContact?.name }}
|
||||||
</h3>
|
</h1>
|
||||||
<div class="flex flex-col gap-1.5">
|
<div
|
||||||
|
class="flex flex-wrap items-center text-sm gap-x-2 gap-y-1 text-n-slate-11"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-if="selectedContact?.identifier"
|
v-if="selectedContact?.identifier"
|
||||||
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
|
class="inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span class="i-ph-user-gear text-n-slate-10 size-4" />
|
<span class="i-ph-user-gear text-n-slate-10 size-4" />
|
||||||
{{ selectedContact?.identifier }}
|
{{ selectedContact?.identifier }}
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
<span v-if="selectedContact?.identifier" class="text-n-slate-8">
|
||||||
<span
|
{{ metaSeparator }}
|
||||||
v-if="selectedContact?.identifier"
|
</span>
|
||||||
class="i-ph-activity text-n-slate-10 size-4"
|
<span>
|
||||||
/>
|
{{ t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
|
||||||
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
|
</span>
|
||||||
•
|
<span class="text-n-slate-8">{{ metaSeparator }}</span>
|
||||||
|
<span>
|
||||||
{{
|
{{
|
||||||
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
||||||
date: lastActivityAt,
|
date: lastActivityAt,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ContactLabels :contact-id="selectedContact?.id" />
|
||||||
</div>
|
</div>
|
||||||
<ContactLabels :contact-id="selectedContact?.id" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-6">
|
|
||||||
<ContactsForm
|
<ContactStats :contact-id="contactId" :last-seen="lastActivityAt" />
|
||||||
ref="contactsFormRef"
|
|
||||||
:contact-data="contactData"
|
<div class="flex flex-col gap-4">
|
||||||
is-details-view
|
<TabBar
|
||||||
@update="handleFormUpdate"
|
:tabs="sectionTabs"
|
||||||
|
:initial-active-tab="activeSectionIndex"
|
||||||
|
class="max-w-full bg-n-alpha-black2"
|
||||||
|
@tab-changed="handleSectionChange"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
|
<div>
|
||||||
size="sm"
|
<template v-if="activeSection === 'details'">
|
||||||
:is-loading="isUpdating"
|
<div class="flex flex-col gap-4">
|
||||||
:disabled="isUpdating || isFormInvalid"
|
<ContactSidebarSection
|
||||||
@click="updateContact"
|
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS')"
|
||||||
/>
|
body-class="px-4 py-4"
|
||||||
</div>
|
>
|
||||||
<Policy :permissions="['administrator']">
|
<div class="flex flex-col items-start gap-6">
|
||||||
<div
|
<ContactsForm
|
||||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
ref="contactsFormRef"
|
||||||
>
|
:contact-data="contactData"
|
||||||
<div class="flex flex-col gap-2">
|
is-details-view
|
||||||
<h6 class="text-base font-medium text-n-slate-12">
|
@update="handleFormUpdate"
|
||||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
/>
|
||||||
</h6>
|
<Button
|
||||||
<span class="text-sm text-n-slate-11">
|
:label="
|
||||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||||
</span>
|
"
|
||||||
</div>
|
size="sm"
|
||||||
<Button
|
:is-loading="isUpdating"
|
||||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
:disabled="isUpdating || isFormInvalid"
|
||||||
color="ruby"
|
@click="updateContact"
|
||||||
@click="openConfirmDeleteContactDialog"
|
/>
|
||||||
|
</div>
|
||||||
|
</ContactSidebarSection>
|
||||||
|
<Policy :permissions="['administrator']">
|
||||||
|
<ContactSidebarSection
|
||||||
|
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
|
body-class="flex flex-col items-start gap-4 p-4"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
|
color="ruby"
|
||||||
|
size="sm"
|
||||||
|
@click="openConfirmDeleteContactDialog"
|
||||||
|
/>
|
||||||
|
</ContactSidebarSection>
|
||||||
|
<ConfirmContactDeleteDialog
|
||||||
|
ref="confirmDeleteContactDialogRef"
|
||||||
|
:selected-contact="selectedContact"
|
||||||
|
@go-to-contacts-list="emit('goToContactsList')"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ContactCustomAttributes
|
||||||
|
v-else-if="activeSection === 'attributes'"
|
||||||
|
:selected-contact="selectedContact"
|
||||||
|
/>
|
||||||
|
<ContactHistory v-else-if="activeSection === 'history'" />
|
||||||
|
<ContactNotes v-else-if="activeSection === 'notes'" />
|
||||||
|
<ContactMedia v-else-if="activeSection === 'media'" />
|
||||||
|
<ContactMerge
|
||||||
|
v-else-if="activeSection === 'merge'"
|
||||||
|
:selected-contact="selectedContact"
|
||||||
|
@go-to-contacts-list="emit('goToContactsList')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmContactDeleteDialog
|
</div>
|
||||||
ref="confirmDeleteContactDialogRef"
|
|
||||||
:selected-contact="selectedContact"
|
|
||||||
@go-to-contacts-list="emit('goToContactsList')"
|
|
||||||
/>
|
|
||||||
</Policy>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
contactId: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
lastSeen: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const conversationsByContact = useMapGetter(
|
||||||
|
'contactConversations/getAllConversationsByContactId'
|
||||||
|
);
|
||||||
|
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||||
|
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
|
||||||
|
|
||||||
|
const statTiles = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'conversations',
|
||||||
|
label: t('CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS'),
|
||||||
|
value: conversationsByContact.value(props.contactId)?.length ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notes',
|
||||||
|
label: t('CONTACTS_LAYOUT.PROFILE.STATS.NOTES'),
|
||||||
|
value: notesByContact.value(props.contactId)?.length ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'files',
|
||||||
|
label: t('CONTACTS_LAYOUT.PROFILE.STATS.FILES'),
|
||||||
|
value:
|
||||||
|
attachmentsByContact.value(props.contactId)?.filter(a => a.data_url)
|
||||||
|
?.length ?? 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 overflow-hidden border sm:grid-cols-4 gap-px rounded-xl border-n-weak bg-n-weak"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="tile in statTiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ tile.label }}
|
||||||
|
</span>
|
||||||
|
<span class="text-3xl font-semibold tabular-nums text-n-slate-12">
|
||||||
|
{{ tile.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1">
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ t('CONTACTS_LAYOUT.PROFILE.STATS.LAST_SEEN') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-base font-medium truncate text-n-slate-12">
|
||||||
|
{{ lastSeen || t('CONTACTS_LAYOUT.PROFILE.STATS.NEVER') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
|||||||
const inboxName = computed(() => inbox.value?.name);
|
const inboxName = computed(() => inbox.value?.name);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
const { channelType, medium } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
return getInboxIconByType(channelType, medium);
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastActivityAt = computed(() => {
|
const lastActivityAt = computed(() => {
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
|||||||
const inbox = computed(() => props.stateInbox);
|
const inbox = computed(() => props.stateInbox);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
const { channelType, medium } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
return getInboxIconByType(channelType, medium);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSlaThreshold = computed(() => {
|
const hasSlaThreshold = computed(() => {
|
||||||
|
|||||||
@@ -234,7 +234,6 @@ onMounted(() => resetContacts());
|
|||||||
ref="popoverRef"
|
ref="popoverRef"
|
||||||
:align="align"
|
:align="align"
|
||||||
:show-content-border="false"
|
:show-content-border="false"
|
||||||
:close-on-scroll="false"
|
|
||||||
@show="onPopoverShow"
|
@show="onPopoverShow"
|
||||||
@hide="onPopoverHide"
|
@hide="onPopoverHide"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-3
@@ -37,11 +37,10 @@ const transformInbox = ({
|
|||||||
channelType,
|
channelType,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
medium,
|
medium,
|
||||||
voiceEnabled,
|
|
||||||
...rest
|
...rest
|
||||||
}) => ({
|
}) => ({
|
||||||
id,
|
id,
|
||||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||||
label: generateLabelForContactableInboxesList({
|
label: generateLabelForContactableInboxesList({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
@@ -55,7 +54,6 @@ const transformInbox = ({
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
channelType,
|
channelType,
|
||||||
medium,
|
medium,
|
||||||
voiceEnabled,
|
|
||||||
...rest,
|
...rest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
-14
@@ -79,20 +79,6 @@ describe('composeConversationHelper', () => {
|
|||||||
channelType: INBOX_TYPES.EMAIL,
|
channelType: INBOX_TYPES.EMAIL,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the voice glyph for a voice-enabled inbox', () => {
|
|
||||||
const inboxes = [
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'WhatsApp Cloud',
|
|
||||||
channelType: INBOX_TYPES.WHATSAPP,
|
|
||||||
voiceEnabled: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = helpers.buildContactableInboxesList(inboxes);
|
|
||||||
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCapitalizedNameFromEmail', () => {
|
describe('getCapitalizedNameFromEmail', () => {
|
||||||
|
|||||||
@@ -20,10 +20,21 @@ const props = defineProps({
|
|||||||
attachments: { type: Array, default: () => [] },
|
attachments: { type: Array, default: () => [] },
|
||||||
peekLimit: { type: Number, default: 0 },
|
peekLimit: { type: Number, default: 0 },
|
||||||
showJumpToMessage: { type: Boolean, default: false },
|
showJumpToMessage: { type: Boolean, default: false },
|
||||||
|
columns: { type: Number, default: 3 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'jumpToMessage']);
|
const emit = defineEmits(['select', 'jumpToMessage']);
|
||||||
|
|
||||||
|
const gridColsClass = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
3: 'grid-cols-3',
|
||||||
|
4: 'grid-cols-4',
|
||||||
|
5: 'grid-cols-5',
|
||||||
|
6: 'grid-cols-6',
|
||||||
|
})[props.columns] || 'grid-cols-3'
|
||||||
|
);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const mediaAttachments = computed(() =>
|
const mediaAttachments = computed(() =>
|
||||||
@@ -177,7 +188,7 @@ const onDownloadFile = async attachment => {
|
|||||||
@click="showAll = !showAll"
|
@click="showAll = !showAll"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid gap-2" :class="gridColsClass">
|
||||||
<div
|
<div
|
||||||
v-for="(attachment, index) in visibleMedia"
|
v-for="(attachment, index) in visibleMedia"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ const downloadRecording = () => {
|
|||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon
|
<Icon
|
||||||
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
|
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
class="size-4 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -127,10 +127,10 @@ const downloadRecording = () => {
|
|||||||
min="0"
|
min="0"
|
||||||
:max="duration || 0"
|
:max="duration || 0"
|
||||||
:value="currentTime"
|
:value="currentTime"
|
||||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
|
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||||
@input="seek"
|
@input="seek"
|
||||||
/>
|
/>
|
||||||
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
|
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||||
{{ displayedTime }}
|
{{ displayedTime }}
|
||||||
</span>
|
</span>
|
||||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||||
|
|||||||
@@ -58,12 +58,8 @@ const menuItems = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
const {
|
const { medium, channel_type: type } = props.inbox;
|
||||||
medium,
|
return getInboxIconByType(type, medium, 'outline');
|
||||||
channel_type: type,
|
|
||||||
voice_enabled: voiceEnabled,
|
|
||||||
} = props.inbox;
|
|
||||||
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
const handleAction = ({ action, value }) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, toRef } from 'vue';
|
import { computed, toRef } from 'vue';
|
||||||
|
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ defineOptions({ inheritAttrs: false });
|
|||||||
|
|
||||||
const inboxRef = toRef(props, 'inbox');
|
const inboxRef = toRef(props, 'inbox');
|
||||||
|
|
||||||
|
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||||
const channelIcon = useChannelIcon(inboxRef);
|
const channelIcon = useChannelIcon(inboxRef);
|
||||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||||
|
|
||||||
@@ -29,7 +31,13 @@ const icon = computed(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="inline-flex" v-bind="$attrs">
|
<span class="relative inline-flex" v-bind="$attrs">
|
||||||
<Icon :icon="icon" class="size-full" />
|
<Icon :icon="icon" class="size-full" />
|
||||||
|
<span
|
||||||
|
v-if="hasVoiceBadge"
|
||||||
|
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||||
INBOX_TYPES,
|
|
||||||
TWILIO_CHANNEL_MEDIUM,
|
|
||||||
isVoiceCallEnabled,
|
|
||||||
getInboxVoiceIcon,
|
|
||||||
} from 'dashboard/helper/inbox';
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const channelTypeIconMap = {
|
const channelTypeIconMap = {
|
||||||
@@ -66,9 +61,16 @@ export function useChannelIcon(inbox) {
|
|||||||
icon = 'i-woot-whatsapp';
|
icon = 'i-woot-whatsapp';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
|
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||||
if (isVoiceCallEnabled(inboxDetails)) {
|
// is presented as a Voice channel, so show the phone icon.
|
||||||
icon = getInboxVoiceIcon(type, inboxDetails.medium);
|
const voiceEnabled =
|
||||||
|
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||||
|
if (
|
||||||
|
type === INBOX_TYPES.TWILIO &&
|
||||||
|
voiceEnabled &&
|
||||||
|
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||||
|
) {
|
||||||
|
icon = 'i-woot-voice';
|
||||||
}
|
}
|
||||||
|
|
||||||
return icon ?? 'i-ri-global-fill';
|
return icon ?? 'i-ri-global-fill';
|
||||||
|
|||||||
@@ -19,32 +19,13 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-woot-whatsapp');
|
expect(icon).toBe('i-woot-whatsapp');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
|
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||||
const inbox = {
|
const inbox = {
|
||||||
channel_type: 'Channel::TwilioSms',
|
channel_type: 'Channel::TwilioSms',
|
||||||
voice_enabled: true,
|
voice_enabled: true,
|
||||||
};
|
};
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
expect(icon).toBe('i-woot-voice-call');
|
expect(icon).toBe('i-woot-voice');
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
|
|
||||||
const inbox = {
|
|
||||||
channel_type: 'Channel::Whatsapp',
|
|
||||||
voice_enabled: true,
|
|
||||||
};
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
|
|
||||||
const inbox = {
|
|
||||||
channel_type: 'Channel::TwilioSms',
|
|
||||||
medium: 'whatsapp',
|
|
||||||
voice_enabled: true,
|
|
||||||
};
|
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
|
||||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for Line channel', () => {
|
it('returns correct icon for Line channel', () => {
|
||||||
|
|||||||
@@ -457,11 +457,10 @@ function handleReplyTo() {
|
|||||||
|
|
||||||
const avatarInfo = computed(() => {
|
const avatarInfo = computed(() => {
|
||||||
if (props.contentAttributes?.externalEcho) {
|
if (props.contentAttributes?.externalEcho) {
|
||||||
const { name, avatar_url, channel_type, medium, voice_enabled } =
|
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||||
inbox.value;
|
|
||||||
const iconName = avatar_url
|
const iconName = avatar_url
|
||||||
? null
|
? null
|
||||||
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
|
: getInboxIconByType(channel_type, medium);
|
||||||
return {
|
return {
|
||||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||||
src: avatar_url || '',
|
src: avatar_url || '',
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import {
|
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||||
useBreakpoints,
|
|
||||||
breakpointsTailwind,
|
|
||||||
useEventListener,
|
|
||||||
} from '@vueuse/core';
|
|
||||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
@@ -20,10 +16,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
closeOnScroll: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
showContentBorder: {
|
showContentBorder: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
@@ -49,12 +41,8 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
|
|||||||
{ align: props.align }
|
{ align: props.align }
|
||||||
);
|
);
|
||||||
|
|
||||||
const SCROLL_CLOSE_THRESHOLD = 24;
|
|
||||||
const triggerTopAtOpen = ref(0);
|
|
||||||
|
|
||||||
const show = async () => {
|
const show = async () => {
|
||||||
isActive.value = true;
|
isActive.value = true;
|
||||||
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
|
||||||
if (!isMobile.value) {
|
if (!isMobile.value) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
updatePosition();
|
updatePosition();
|
||||||
@@ -68,22 +56,6 @@ const hide = () => {
|
|||||||
emit('hide');
|
emit('hide');
|
||||||
};
|
};
|
||||||
|
|
||||||
// The teleported popover tracks its trigger while ancestors scroll; allow
|
|
||||||
// small drift (trackpad inertia), but close once the trigger moves further.
|
|
||||||
useEventListener(
|
|
||||||
window,
|
|
||||||
'scroll',
|
|
||||||
event => {
|
|
||||||
if (!props.closeOnScroll || !showPopover.value) return;
|
|
||||||
if (popoverRef.value?.contains(event.target)) return;
|
|
||||||
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
|
||||||
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
|
|
||||||
hide();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ capture: true, passive: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggle = async () => {
|
const toggle = async () => {
|
||||||
if (isActive.value) hide();
|
if (isActive.value) hide();
|
||||||
else await show();
|
else await show();
|
||||||
|
|||||||
@@ -77,15 +77,6 @@ const handleClose = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocusOut = event => {
|
|
||||||
// Keep the menu open while focus stays inside it (e.g. the label search
|
|
||||||
// input); close it once focus leaves the menu entirely.
|
|
||||||
if (menuRef.value?.contains(event.relatedTarget)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
isLocked.value = false;
|
isLocked.value = false;
|
||||||
});
|
});
|
||||||
@@ -98,7 +89,7 @@ onUnmounted(() => {
|
|||||||
class="fixed outline-none z-[9999] cursor-pointer"
|
class="fixed outline-none z-[9999] cursor-pointer"
|
||||||
:style="position"
|
:style="position"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@focusout="handleFocusOut"
|
@blur="handleClose"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
EditorState,
|
EditorState,
|
||||||
Selection,
|
Selection,
|
||||||
imageResizeView,
|
imageResizeView,
|
||||||
toggleMark,
|
|
||||||
wrapInList,
|
|
||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import {
|
import {
|
||||||
suggestionsPlugin,
|
suggestionsPlugin,
|
||||||
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
|||||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||||
|
import { toggleMark } from 'prosemirror-commands';
|
||||||
|
import { wrapInList } from 'prosemirror-schema-list';
|
||||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import {
|
|||||||
// constants
|
// constants
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { REPLY_POLICY } from 'shared/constants/links';
|
import { REPLY_POLICY } from 'shared/constants/links';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants, {
|
||||||
|
META_RESTRICTION_STATUS_URL,
|
||||||
|
} from 'dashboard/constants/globals';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ export default {
|
|||||||
currentUserId: 'getCurrentUserID',
|
currentUserId: 'getCurrentUserID',
|
||||||
listLoadingStatus: 'getAllMessagesLoaded',
|
listLoadingStatus: 'getAllMessagesLoaded',
|
||||||
currentAccountId: 'getCurrentAccountId',
|
currentAccountId: 'getCurrentAccountId',
|
||||||
|
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||||
}),
|
}),
|
||||||
isOpen() {
|
isOpen() {
|
||||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||||
@@ -170,6 +173,13 @@ export default {
|
|||||||
instagramInbox
|
instagramInbox
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
isInstagramRestrictionBannerVisible() {
|
||||||
|
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||||
|
},
|
||||||
|
instagramRestrictionStatusUrl() {
|
||||||
|
return META_RESTRICTION_STATUS_URL;
|
||||||
|
},
|
||||||
|
|
||||||
replyWindowBannerMessage() {
|
replyWindowBannerMessage() {
|
||||||
if (this.isAWhatsAppChannel) {
|
if (this.isAWhatsAppChannel) {
|
||||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||||
@@ -454,7 +464,15 @@ export default {
|
|||||||
>
|
>
|
||||||
<div ref="topBannerRef">
|
<div ref="topBannerRef">
|
||||||
<Banner
|
<Banner
|
||||||
v-if="!currentChat.can_reply"
|
v-if="isInstagramRestrictionBannerVisible"
|
||||||
|
color-scheme="warning"
|
||||||
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
|
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||||
|
:href-link="instagramRestrictionStatusUrl"
|
||||||
|
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||||
|
/>
|
||||||
|
<Banner
|
||||||
|
v-else-if="!currentChat.can_reply"
|
||||||
color-scheme="alert"
|
color-scheme="alert"
|
||||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||||
:banner-message="replyWindowBannerMessage"
|
:banner-message="replyWindowBannerMessage"
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ import {
|
|||||||
getSortedAgentsByAvailability,
|
getSortedAgentsByAvailability,
|
||||||
getAgentsByUpdatedPresence,
|
getAgentsByUpdatedPresence,
|
||||||
} from 'dashboard/helper/agentHelper.js';
|
} from 'dashboard/helper/agentHelper.js';
|
||||||
import { picoSearch } from '@scmmishra/pico-search';
|
|
||||||
import MenuItem from './menuItem.vue';
|
import MenuItem from './menuItem.vue';
|
||||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
|
|
||||||
const MENU = {
|
const MENU = {
|
||||||
MARK_AS_READ: 'mark-as-read',
|
MARK_AS_READ: 'mark-as-read',
|
||||||
@@ -34,8 +31,6 @@ export default {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
MenuItemWithSubmenu,
|
MenuItemWithSubmenu,
|
||||||
AgentLoadingPlaceholder,
|
AgentLoadingPlaceholder,
|
||||||
NextInput,
|
|
||||||
Icon,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
chatId: {
|
chatId: {
|
||||||
@@ -92,7 +87,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
MENU,
|
MENU,
|
||||||
labelSearchQuery: '',
|
|
||||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||||
readOption: {
|
readOption: {
|
||||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||||
@@ -222,14 +216,6 @@ export default {
|
|||||||
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
||||||
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
||||||
},
|
},
|
||||||
filteredLabels() {
|
|
||||||
const labels = this.labelSearchQuery
|
|
||||||
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
|
|
||||||
: this.labels;
|
|
||||||
// Assigned labels first, keeping each group's existing order.
|
|
||||||
const isAssigned = label => this.conversationLabels.includes(label.title);
|
|
||||||
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||||
@@ -349,49 +335,21 @@ export default {
|
|||||||
:option="labelMenuConfig"
|
:option="labelMenuConfig"
|
||||||
:sub-menu-available="!!labels.length"
|
:sub-menu-available="!!labels.length"
|
||||||
>
|
>
|
||||||
<div class="pb-1 w-[12.5rem]">
|
<MenuItem
|
||||||
<NextInput
|
v-for="label in labels"
|
||||||
v-model="labelSearchQuery"
|
:key="label.id"
|
||||||
type="search"
|
:option="generateMenuLabelConfig(label, 'label')"
|
||||||
size="sm"
|
:variant="
|
||||||
class="w-full"
|
conversationLabels.includes(label.title)
|
||||||
custom-input-class="!ps-8 !text-xs"
|
? 'label-assigned'
|
||||||
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
|
: 'label'
|
||||||
@click.stop
|
"
|
||||||
@keydown.stop
|
@click.stop="
|
||||||
>
|
conversationLabels.includes(label.title)
|
||||||
<template #prefix>
|
? $emit('removeLabel', label)
|
||||||
<Icon
|
: $emit('assignLabel', label)
|
||||||
icon="i-lucide-search"
|
"
|
||||||
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
|
/>
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</NextInput>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
|
|
||||||
<MenuItem
|
|
||||||
v-for="label in filteredLabels"
|
|
||||||
:key="label.id"
|
|
||||||
:option="generateMenuLabelConfig(label, 'label')"
|
|
||||||
:variant="
|
|
||||||
conversationLabels.includes(label.title)
|
|
||||||
? 'label-assigned'
|
|
||||||
: 'label'
|
|
||||||
"
|
|
||||||
@mousedown.prevent
|
|
||||||
@click.stop="
|
|
||||||
conversationLabels.includes(label.title)
|
|
||||||
? $emit('removeLabel', label)
|
|
||||||
: $emit('assignLabel', label)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="!filteredLabels.length"
|
|
||||||
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
|
|
||||||
>
|
|
||||||
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</MenuItemWithSubmenu>
|
</MenuItemWithSubmenu>
|
||||||
<MenuItemWithSubmenu
|
<MenuItemWithSubmenu
|
||||||
v-if="isAllowed([MENU.AGENT])"
|
v-if="isAllowed([MENU.AGENT])"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
|
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||||
<fluent-icon
|
<fluent-icon
|
||||||
v-if="variant === 'icon' && option.icon"
|
v-if="variant === 'icon' && option.icon"
|
||||||
:icon="option.icon"
|
:icon="option.icon"
|
||||||
@@ -52,7 +52,7 @@ defineProps({
|
|||||||
<Icon
|
<Icon
|
||||||
v-if="variant === 'label-assigned'"
|
v-if="variant === 'label-assigned'"
|
||||||
icon="i-lucide-check"
|
icon="i-lucide-check"
|
||||||
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
|
class="flex-shrink-0 size-3.5 mr-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useAgentsList } from '../useAgentsList';
|
import { useAgentsList } from '../useAgentsList';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||||
|
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||||
|
|
||||||
// Mock vue-i18n
|
// Mock vue-i18n
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
@@ -94,32 +94,6 @@ describe('useAgentsList', () => {
|
|||||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
|
||||||
const namelessBot = {
|
|
||||||
id: 91,
|
|
||||||
name: null,
|
|
||||||
assignee_type: 'AgentBot',
|
|
||||||
availability_status: 'offline',
|
|
||||||
};
|
|
||||||
mockUseMapGetter({
|
|
||||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
|
||||||
...allAgentsData,
|
|
||||||
namelessBot,
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { agentsList } = useAgentsList();
|
|
||||||
// access the computed to trigger evaluation
|
|
||||||
expect(agentsList.value).toBeDefined();
|
|
||||||
|
|
||||||
const passedAgents =
|
|
||||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
|
||||||
expect(passedAgents).toContainEqual({
|
|
||||||
...namelessBot,
|
|
||||||
name: '-',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty assignable agents', () => {
|
it('handles empty assignable agents', () => {
|
||||||
mockUseMapGetter({
|
mockUseMapGetter({
|
||||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import {
|
||||||
getAgentsByUpdatedPresence,
|
getAgentsByUpdatedPresence,
|
||||||
getSortedAgentsByAvailability,
|
getSortedAgentsByAvailability,
|
||||||
} from 'dashboard/helper/agentHelper';
|
} from 'dashboard/helper/agentHelper';
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A composable function that provides a list of agents for assignment.
|
* A composable function that provides a list of agents for assignment.
|
||||||
@@ -53,11 +53,7 @@ export function useAgentsList(
|
|||||||
* @type {import('vue').ComputedRef<Array>}
|
* @type {import('vue').ComputedRef<Array>}
|
||||||
*/
|
*/
|
||||||
const agentsList = computed(() => {
|
const agentsList = computed(() => {
|
||||||
const agents = (assignableAgents.value || []).map(agent =>
|
const agents = assignableAgents.value || [];
|
||||||
!agent.name && agent.assignee_type === 'AgentBot'
|
|
||||||
? { ...agent, name: '-' }
|
|
||||||
: agent
|
|
||||||
);
|
|
||||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||||
agents,
|
agents,
|
||||||
currentUser.value,
|
currentUser.value,
|
||||||
|
|||||||
@@ -78,3 +78,5 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||||
|
export const META_RESTRICTION_STATUS_URL =
|
||||||
|
'https://status.chatwoot.com/incident/948346';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
export const getAgentsByAvailability = (agents, availability) => {
|
export const getAgentsByAvailability = (agents, availability) => {
|
||||||
return agents
|
return agents
|
||||||
.filter(agent => agent.availability_status === availability)
|
.filter(agent => agent.availability_status === availability)
|
||||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
InputRule,
|
|
||||||
inputRules,
|
|
||||||
MessageMarkdownSerializer,
|
MessageMarkdownSerializer,
|
||||||
MessageMarkdownTransformer,
|
MessageMarkdownTransformer,
|
||||||
messageSchema,
|
messageSchema,
|
||||||
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
|||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||||
|
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||||
|
|||||||
@@ -55,30 +55,11 @@ export const getVoiceCallProvider = inbox => {
|
|||||||
|
|
||||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||||
|
|
||||||
// Combined channel + voice-wave badge glyph per voice-call provider.
|
|
||||||
export const VOICE_CALL_ICONS = {
|
|
||||||
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
|
|
||||||
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getVoiceCallIcon = provider =>
|
|
||||||
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
|
|
||||||
|
|
||||||
export const TWILIO_CHANNEL_MEDIUM = {
|
export const TWILIO_CHANNEL_MEDIUM = {
|
||||||
WHATSAPP: 'whatsapp',
|
WHATSAPP: 'whatsapp',
|
||||||
SMS: 'sms',
|
SMS: 'sms',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInboxVoiceIcon = (channelType, medium) => {
|
|
||||||
const isWhatsapp =
|
|
||||||
channelType === INBOX_TYPES.WHATSAPP ||
|
|
||||||
(channelType === INBOX_TYPES.TWILIO &&
|
|
||||||
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
|
|
||||||
return getVoiceCallIcon(
|
|
||||||
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const INBOX_ICON_MAP_FILL = {
|
const INBOX_ICON_MAP_FILL = {
|
||||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||||
@@ -201,14 +182,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInboxIconByType = (
|
export const getInboxIconByType = (type, medium, variant = 'fill') => {
|
||||||
type,
|
|
||||||
medium,
|
|
||||||
variant = 'fill',
|
|
||||||
voiceEnabled = false
|
|
||||||
) => {
|
|
||||||
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
|
|
||||||
|
|
||||||
const iconMap =
|
const iconMap =
|
||||||
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
||||||
const defaultIcon =
|
const defaultIcon =
|
||||||
|
|||||||
@@ -26,18 +26,6 @@ describe('agentHelper', () => {
|
|||||||
offlineAgentsData
|
offlineAgentsData
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not throw when an agent has a null name', () => {
|
|
||||||
const agents = [
|
|
||||||
{ id: 1, name: null, availability_status: 'offline' },
|
|
||||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
|
||||||
expect(
|
|
||||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
|
||||||
).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getSortedAgentsByAvailability', () => {
|
describe('getSortedAgentsByAvailability', () => {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
INBOX_TYPES,
|
INBOX_TYPES,
|
||||||
VOICE_CALL_PROVIDERS,
|
|
||||||
getInboxClassByType,
|
getInboxClassByType,
|
||||||
getInboxIconByType,
|
getInboxIconByType,
|
||||||
getInboxVoiceIcon,
|
|
||||||
getInboxWarningIconClass,
|
getInboxWarningIconClass,
|
||||||
getVoiceCallIcon,
|
|
||||||
} from '../inbox';
|
} from '../inbox';
|
||||||
|
|
||||||
describe('#Inbox Helpers', () => {
|
describe('#Inbox Helpers', () => {
|
||||||
@@ -169,63 +166,4 @@ describe('#Inbox Helpers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getVoiceCallIcon', () => {
|
|
||||||
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
|
|
||||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
|
|
||||||
'i-woot-whatsapp-voice'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the generic voice-call glyph for the twilio provider', () => {
|
|
||||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
|
|
||||||
'i-woot-voice-call'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the generic voice-call glyph for an unknown provider', () => {
|
|
||||||
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
|
|
||||||
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getInboxVoiceIcon', () => {
|
|
||||||
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
|
|
||||||
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
|
|
||||||
'i-woot-whatsapp-voice'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
|
|
||||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
|
|
||||||
'i-woot-whatsapp-voice'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
|
|
||||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
|
|
||||||
'i-woot-voice-call'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getInboxIconByType with voice enabled', () => {
|
|
||||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
|
|
||||||
expect(
|
|
||||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
|
|
||||||
).toBe('i-woot-whatsapp-voice');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
|
|
||||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
|
|
||||||
'i-woot-voice-call'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the normal channel icon when voice is not enabled', () => {
|
|
||||||
expect(
|
|
||||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
|
|
||||||
).toBe('i-woot-whatsapp');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -506,6 +506,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PROFILE": {
|
||||||
|
"STATS": {
|
||||||
|
"CONVERSATIONS": "Conversations",
|
||||||
|
"NOTES": "Notes",
|
||||||
|
"FILES": "Files",
|
||||||
|
"LAST_SEEN": "Last seen",
|
||||||
|
"NEVER": "Never"
|
||||||
|
},
|
||||||
|
"SECTIONS": {
|
||||||
|
"DETAILS": "Details",
|
||||||
|
"NOTES": "Notes"
|
||||||
|
}
|
||||||
|
},
|
||||||
"SIDEBAR": {
|
"SIDEBAR": {
|
||||||
"TABS": {
|
"TABS": {
|
||||||
"ATTRIBUTES": "Attributes",
|
"ATTRIBUTES": "Attributes",
|
||||||
@@ -564,6 +577,8 @@
|
|||||||
},
|
},
|
||||||
"NOTES": {
|
"NOTES": {
|
||||||
"PLACEHOLDER": "Add a note",
|
"PLACEHOLDER": "Add a note",
|
||||||
|
"SEARCH_PLACEHOLDER": "Search notes",
|
||||||
|
"NO_RESULTS": "No notes match your search",
|
||||||
"WROTE": "wrote",
|
"WROTE": "wrote",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"SAVE": "Save note",
|
"SAVE": "Save note",
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||||
|
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||||
|
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||||
"REPLYING_TO": "You are replying to:",
|
"REPLYING_TO": "You are replying to:",
|
||||||
"REMOVE_SELECTION": "Remove Selection",
|
"REMOVE_SELECTION": "Remove Selection",
|
||||||
"DOWNLOAD": "Download",
|
"DOWNLOAD": "Download",
|
||||||
@@ -193,8 +195,6 @@
|
|||||||
},
|
},
|
||||||
"ASSIGN_AGENT": "Assign agent",
|
"ASSIGN_AGENT": "Assign agent",
|
||||||
"ASSIGN_LABEL": "Assign label",
|
"ASSIGN_LABEL": "Assign label",
|
||||||
"SEARCH_LABELS": "Search labels",
|
|
||||||
"NO_LABELS_FOUND": "No labels found",
|
|
||||||
"AGENTS_LOADING": "Loading agents...",
|
"AGENTS_LOADING": "Loading agents...",
|
||||||
"ASSIGN_TEAM": "Assign team",
|
"ASSIGN_TEAM": "Assign team",
|
||||||
"DELETE": "Delete conversation",
|
"DELETE": "Delete conversation",
|
||||||
|
|||||||
@@ -58,7 +58,9 @@
|
|||||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||||
|
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||||
|
"STATUS_LINK": "View status update"
|
||||||
},
|
},
|
||||||
"TIKTOK": {
|
"TIKTOK": {
|
||||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
|
|||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
if (!inbox.value) return null;
|
if (!inbox.value) return null;
|
||||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
const { channelType, medium } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
return getInboxIconByType(channelType, medium);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
|
|||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
if (!inbox.value) return null;
|
if (!inbox.value) return null;
|
||||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
const { channelType, medium } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
return getInboxIconByType(channelType, medium);
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileAttachments = computed(() => {
|
const fileAttachments = computed(() => {
|
||||||
|
|||||||
@@ -129,22 +129,22 @@ onMounted(async () => {
|
|||||||
v-else
|
v-else
|
||||||
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
||||||
>
|
>
|
||||||
<header class="shrink-0">
|
<header class="px-6 pt-6 pb-4 shrink-0">
|
||||||
<div class="w-full px-6 pt-6">
|
<div class="w-full">
|
||||||
<h1 class="text-xl font-medium text-n-slate-12">
|
<h1 class="text-xl font-medium text-n-slate-12">
|
||||||
{{ t('CALLS_PAGE.HEADER') }}
|
{{ t('CALLS_PAGE.HEADER') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<CallsFilterBar
|
||||||
|
v-model:activity="activity"
|
||||||
|
v-model:assignee-id="assigneeId"
|
||||||
|
v-model:inbox-id="inboxId"
|
||||||
|
class="mt-5"
|
||||||
|
:total-count="isFetching ? null : meta.count"
|
||||||
|
:agents="agents"
|
||||||
|
:inboxes="voiceInboxes"
|
||||||
|
:show-assignee="isAdmin"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CallsFilterBar
|
|
||||||
v-model:activity="activity"
|
|
||||||
v-model:assignee-id="assigneeId"
|
|
||||||
v-model:inbox-id="inboxId"
|
|
||||||
class="mt-5 pb-4 border-b border-n-weak mx-6"
|
|
||||||
:total-count="isFetching ? null : meta.count"
|
|
||||||
:agents="agents"
|
|
||||||
:inboxes="voiceInboxes"
|
|
||||||
:show-assignee="isAdmin"
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto">
|
<main class="flex-1 px-6 overflow-y-auto">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
|
|||||||
@@ -26,28 +26,25 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
|||||||
const selectedRange = ref('this_month');
|
const selectedRange = ref('this_month');
|
||||||
|
|
||||||
const assistantId = computed(() => route.params.assistantId);
|
const assistantId = computed(() => route.params.assistantId);
|
||||||
const metricStats = ref(null);
|
const stats = ref(null);
|
||||||
const faqStats = ref(null);
|
const isFetching = ref(false);
|
||||||
const isFetchingMetrics = ref(false);
|
|
||||||
|
|
||||||
// Increments on every fetch so a response (or retry) from a superseded
|
// Increments on every fetch so a response (or retry) from a superseded
|
||||||
// range/assistant can't clobber the latest request's state.
|
// range/assistant can't clobber the latest request's state.
|
||||||
let metricsFetchToken = 0;
|
let fetchToken = 0;
|
||||||
let faqStatsFetchToken = 0;
|
let abortController = null;
|
||||||
let metricsAbortController = null;
|
|
||||||
let faqStatsAbortController = null;
|
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchStats = async () => {
|
||||||
metricsFetchToken += 1;
|
fetchToken += 1;
|
||||||
const token = metricsFetchToken;
|
const token = fetchToken;
|
||||||
metricsAbortController?.abort();
|
abortController?.abort();
|
||||||
metricsAbortController = new AbortController();
|
abortController = new AbortController();
|
||||||
const { signal } = metricsAbortController;
|
const { signal } = abortController;
|
||||||
metricStats.value = null;
|
stats.value = null;
|
||||||
isFetchingMetrics.value = true;
|
isFetching.value = true;
|
||||||
|
|
||||||
const requestMetrics = () =>
|
const requestStats = () =>
|
||||||
CaptainAssistant.getMetrics({
|
CaptainAssistant.getStats({
|
||||||
assistantId: assistantId.value,
|
assistantId: assistantId.value,
|
||||||
range: selectedRange.value,
|
range: selectedRange.value,
|
||||||
signal,
|
signal,
|
||||||
@@ -55,54 +52,25 @@ const fetchMetrics = async () => {
|
|||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
({ data } = await requestMetrics());
|
({ data } = await requestStats());
|
||||||
} catch {
|
} catch {
|
||||||
// One silent retry before giving up, unless the request was aborted.
|
// One silent retry before giving up, unless the request was aborted.
|
||||||
try {
|
try {
|
||||||
if (token === metricsFetchToken && !signal.aborted)
|
if (token === fetchToken && !signal.aborted)
|
||||||
({ data } = await requestMetrics());
|
({ data } = await requestStats());
|
||||||
} catch {
|
} catch {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token !== metricsFetchToken || signal.aborted) return;
|
if (token !== fetchToken || signal.aborted) return;
|
||||||
metricStats.value = data;
|
stats.value = data;
|
||||||
isFetchingMetrics.value = false;
|
isFetching.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchFaqStats = async () => {
|
onUnmounted(() => abortController?.abort());
|
||||||
faqStatsFetchToken += 1;
|
|
||||||
const token = faqStatsFetchToken;
|
|
||||||
faqStatsAbortController?.abort();
|
|
||||||
faqStatsAbortController = new AbortController();
|
|
||||||
const { signal } = faqStatsAbortController;
|
|
||||||
faqStats.value = null;
|
|
||||||
|
|
||||||
try {
|
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||||
const { data } = await CaptainAssistant.getFaqStats({
|
|
||||||
assistantId: assistantId.value,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
|
||||||
} catch {
|
|
||||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const summaryStats = computed(() => {
|
|
||||||
if (!metricStats.value || !faqStats.value) return null;
|
|
||||||
|
|
||||||
return { ...metricStats.value, knowledge: faqStats.value };
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
metricsAbortController?.abort();
|
|
||||||
faqStatsAbortController?.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
|
||||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
|
||||||
|
|
||||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||||
// neutral, so we can colour the delta independently of its sign.
|
// neutral, so we can colour the delta independently of its sign.
|
||||||
@@ -122,7 +90,7 @@ const formatDuration = hours =>
|
|||||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||||
|
|
||||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||||
const data = metricStats.value?.[statKey];
|
const data = stats.value?.[statKey];
|
||||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||||
|
|
||||||
const sign = data.trend > 0 ? '+' : '';
|
const sign = data.trend > 0 ? '+' : '';
|
||||||
@@ -216,9 +184,9 @@ const closeDrilldown = () => {
|
|||||||
<div class="flex flex-col gap-6 pb-8">
|
<div class="flex flex-col gap-6 pb-8">
|
||||||
<InboxBanner />
|
<InboxBanner />
|
||||||
|
|
||||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||||
|
|
||||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
<WelcomeCard :range="selectedRange" :stats="stats" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||||
@@ -231,15 +199,13 @@ const closeDrilldown = () => {
|
|||||||
:trend="metric.trend"
|
:trend="metric.trend"
|
||||||
:hint="metric.hint"
|
:hint="metric.hint"
|
||||||
:trend-good="metric.trendGood"
|
:trend-good="metric.trendGood"
|
||||||
:loading="isFetchingMetrics"
|
:loading="isFetching"
|
||||||
:clickable="
|
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
|
||||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
|
||||||
"
|
|
||||||
@click="openDrilldown(metric)"
|
@click="openDrilldown(metric)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||||
|
|
||||||
<QuickLinks />
|
<QuickLinks />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, computed, ref } from 'vue';
|
import { onMounted, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
@@ -8,12 +8,6 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
|
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
|
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
|
||||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
|
||||||
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
|
|
||||||
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
|
|
||||||
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
|
|
||||||
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
|
|
||||||
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
|
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -22,9 +16,6 @@ const router = useRouter();
|
|||||||
const contact = useMapGetter('contacts/getContactById');
|
const contact = useMapGetter('contacts/getContactById');
|
||||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const activeTab = ref('attributes');
|
|
||||||
const contactMergeRef = ref(null);
|
|
||||||
|
|
||||||
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
|
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
|
||||||
const isMergingContact = computed(() => uiFlags.value.isMerging);
|
const isMergingContact = computed(() => uiFlags.value.isMerging);
|
||||||
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
|
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
|
||||||
@@ -37,25 +28,6 @@ const showSpinner = computed(
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const CONTACT_TABS_OPTIONS = [
|
|
||||||
{ key: 'ATTRIBUTES', value: 'attributes' },
|
|
||||||
{ key: 'HISTORY', value: 'history' },
|
|
||||||
{ key: 'NOTES', value: 'notes' },
|
|
||||||
{ key: 'MEDIA', value: 'media' },
|
|
||||||
{ key: 'MERGE', value: 'merge' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabs = computed(() => {
|
|
||||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
|
||||||
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
|
|
||||||
value: tab.value,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeTabIndex = computed(() => {
|
|
||||||
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const goToContactsList = () => {
|
const goToContactsList = () => {
|
||||||
if (window.history.state?.back || window.history.length > 1) {
|
if (window.history.state?.back || window.history.length > 1) {
|
||||||
router.back();
|
router.back();
|
||||||
@@ -74,10 +46,6 @@ const fetchActiveContact = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTabChange = tab => {
|
|
||||||
activeTab.value = tab.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchContactNotes = () => {
|
const fetchContactNotes = () => {
|
||||||
const { contactId } = route.params;
|
const { contactId } = route.params;
|
||||||
if (contactId) store.dispatch('contactNotes/get', { contactId });
|
if (contactId) store.dispatch('contactNotes/get', { contactId });
|
||||||
@@ -151,40 +119,6 @@ onMounted(() => {
|
|||||||
:selected-contact="selectedContact"
|
:selected-contact="selectedContact"
|
||||||
@go-to-contacts-list="goToContactsList"
|
@go-to-contacts-list="goToContactsList"
|
||||||
/>
|
/>
|
||||||
<template #sidebarHeader>
|
|
||||||
<div class="px-6 pt-6 pb-3">
|
|
||||||
<TabBar
|
|
||||||
:tabs="tabs"
|
|
||||||
:initial-active-tab="activeTabIndex"
|
|
||||||
class="w-full [&>button]:w-full bg-n-alpha-black2"
|
|
||||||
@tab-changed="handleTabChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #sidebar>
|
|
||||||
<div
|
|
||||||
v-if="isFetchingItem"
|
|
||||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<ContactCustomAttributes
|
|
||||||
v-if="activeTab === 'attributes'"
|
|
||||||
:selected-contact="selectedContact"
|
|
||||||
/>
|
|
||||||
<ContactNotes v-if="activeTab === 'notes'" />
|
|
||||||
<ContactHistory v-if="activeTab === 'history'" />
|
|
||||||
<ContactMedia v-if="activeTab === 'media'" />
|
|
||||||
<ContactMerge
|
|
||||||
v-if="activeTab === 'merge'"
|
|
||||||
ref="contactMergeRef"
|
|
||||||
:selected-contact="selectedContact"
|
|
||||||
@go-to-contacts-list="goToContactsList"
|
|
||||||
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</ContactsDetailsLayout>
|
</ContactsDetailsLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
|||||||
<BaseTableCell class="max-w-0">
|
<BaseTableCell class="max-w-0">
|
||||||
<div class="flex items-center gap-4 min-w-0">
|
<div class="flex items-center gap-4 min-w-0">
|
||||||
<Avatar
|
<Avatar
|
||||||
:name="bot.name || ''"
|
:name="bot.name"
|
||||||
:src="bot.thumbnail"
|
:src="bot.thumbnail"
|
||||||
:size="40"
|
:size="40"
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
|
|||||||
+7
-9
@@ -79,15 +79,13 @@ const breadcrumbItems = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildInboxList = allInboxes =>
|
const buildInboxList = allInboxes =>
|
||||||
allInboxes?.map(
|
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||||
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
|
name,
|
||||||
name,
|
id,
|
||||||
id,
|
email,
|
||||||
email,
|
phoneNumber,
|
||||||
phoneNumber,
|
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
})) || [];
|
||||||
})
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
const policyInboxes = computed(() =>
|
const policyInboxes = computed(() =>
|
||||||
buildInboxList(selectedPolicy.value?.inboxes)
|
buildInboxList(selectedPolicy.value?.inboxes)
|
||||||
|
|||||||
+7
-17
@@ -64,23 +64,13 @@ const allInboxes = computed(
|
|||||||
inboxes.value
|
inboxes.value
|
||||||
?.slice()
|
?.slice()
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.map(
|
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||||
({
|
name,
|
||||||
name,
|
id,
|
||||||
id,
|
email,
|
||||||
email,
|
phoneNumber,
|
||||||
phoneNumber,
|
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||||
channelType,
|
})) || []
|
||||||
medium,
|
|
||||||
voiceEnabled,
|
|
||||||
}) => ({
|
|
||||||
name,
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
phoneNumber,
|
|
||||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
|
||||||
})
|
|
||||||
) || []
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const formData = computed(() => ({
|
const formData = computed(() => ({
|
||||||
|
|||||||
+1
-2
@@ -29,8 +29,7 @@ const inboxIcon = computed(() => {
|
|||||||
return getInboxIconByType(
|
return getInboxIconByType(
|
||||||
props.inbox.channelType,
|
props.inbox.channelType,
|
||||||
props.inbox.medium,
|
props.inbox.medium,
|
||||||
'line',
|
'line'
|
||||||
props.inbox.voiceEnabled
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ export default {
|
|||||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||||
<div
|
<div
|
||||||
data-testid="agent-selector"
|
|
||||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||||
>
|
>
|
||||||
<TagInput
|
<TagInput
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
|||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
|
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||||
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
|||||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
|
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
Banner,
|
||||||
BotConfiguration,
|
BotConfiguration,
|
||||||
CollaboratorsPage,
|
CollaboratorsPage,
|
||||||
ConfigurationPage,
|
ConfigurationPage,
|
||||||
@@ -80,6 +84,7 @@ export default {
|
|||||||
WhatsappManualMigrationBanner,
|
WhatsappManualMigrationBanner,
|
||||||
Widget,
|
Widget,
|
||||||
AccessToken,
|
AccessToken,
|
||||||
|
Icon,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin],
|
||||||
setup() {
|
setup() {
|
||||||
@@ -281,12 +286,8 @@ export default {
|
|||||||
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
|
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
|
||||||
},
|
},
|
||||||
inboxIcon() {
|
inboxIcon() {
|
||||||
const {
|
const { medium, channel_type: type } = this.inbox;
|
||||||
medium,
|
return getInboxIconByType(type, medium, 'line');
|
||||||
channel_type: type,
|
|
||||||
voice_enabled: voiceEnabled,
|
|
||||||
} = this.inbox;
|
|
||||||
return getInboxIconByType(type, medium, 'line', voiceEnabled);
|
|
||||||
},
|
},
|
||||||
bannerMaxWidth() {
|
bannerMaxWidth() {
|
||||||
const narrowTabs = ['collaborators', 'bot-configuration'];
|
const narrowTabs = ['collaborators', 'bot-configuration'];
|
||||||
@@ -347,6 +348,12 @@ export default {
|
|||||||
instagramUnauthorized() {
|
instagramUnauthorized() {
|
||||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||||
},
|
},
|
||||||
|
showInstagramRestrictionSettingsBanner() {
|
||||||
|
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||||
|
},
|
||||||
|
metaRestrictionStatusUrl() {
|
||||||
|
return META_RESTRICTION_STATUS_URL;
|
||||||
|
},
|
||||||
tiktokUnauthorized() {
|
tiktokUnauthorized() {
|
||||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||||
},
|
},
|
||||||
@@ -813,6 +820,29 @@ export default {
|
|||||||
:class="bannerMaxWidth"
|
:class="bannerMaxWidth"
|
||||||
@start="openWhatsAppManualMigrationDialog"
|
@start="openWhatsAppManualMigrationDialog"
|
||||||
/>
|
/>
|
||||||
|
<Banner
|
||||||
|
v-if="showInstagramRestrictionSettingsBanner"
|
||||||
|
color="amber"
|
||||||
|
class="mx-6 mb-4 max-w-4xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3 text-start">
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
class="flex-shrink-0 size-4 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||||
|
<a
|
||||||
|
:href="metaRestrictionStatusUrl"
|
||||||
|
class="link underline"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="selectedTabKey === 'inbox-settings'"
|
v-if="selectedTabKey === 'inbox-settings'"
|
||||||
|
|||||||
@@ -68,10 +68,6 @@ const isAgentBot = computed(
|
|||||||
() => props.selectedItem?.assignee_type === 'AgentBot'
|
() => props.selectedItem?.assignee_type === 'AgentBot'
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedItemName = computed(() =>
|
|
||||||
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedThumbnail = computed(
|
const selectedThumbnail = computed(
|
||||||
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
||||||
);
|
);
|
||||||
@@ -99,16 +95,16 @@ const selectedThumbnail = computed(
|
|||||||
<h4
|
<h4
|
||||||
v-else
|
v-else
|
||||||
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||||
:title="selectedItemName"
|
:title="selectedItem.name"
|
||||||
>
|
>
|
||||||
{{ selectedItemName }}
|
{{ selectedItem.name }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<Avatar
|
<Avatar
|
||||||
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
||||||
:src="selectedThumbnail"
|
:src="selectedThumbnail"
|
||||||
:status="selectedItem.availability_status"
|
:status="selectedItem.availability_status"
|
||||||
:name="selectedItemName"
|
:name="selectedItem.name"
|
||||||
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
||||||
:size="24"
|
:size="24"
|
||||||
hide-offline-status
|
hide-offline-status
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
filteredOptions() {
|
filteredOptions() {
|
||||||
return this.options.filter(option => {
|
return this.options.filter(option => {
|
||||||
return (option.name || '')
|
return option.name.toLowerCase().includes(this.search.toLowerCase());
|
||||||
.toLowerCase()
|
|
||||||
.includes(this.search.toLowerCase());
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
noResult() {
|
noResult() {
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
|
|||||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ChatInputWrap,
|
ChatInputWrap,
|
||||||
@@ -26,9 +24,6 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
inReplyTo: null,
|
inReplyTo: null,
|
||||||
isSendingTranscript: false,
|
|
||||||
transcriptCooldown: false,
|
|
||||||
transcriptCooldownTimer: null,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -62,9 +57,6 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
|
||||||
clearTimeout(this.transcriptCooldownTimer);
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||||
...mapActions('conversationAttributes', ['getAttributes']),
|
...mapActions('conversationAttributes', ['getAttributes']),
|
||||||
@@ -98,35 +90,19 @@ export default {
|
|||||||
toggleReplyTo(message) {
|
toggleReplyTo(message) {
|
||||||
this.inReplyTo = message;
|
this.inReplyTo = message;
|
||||||
},
|
},
|
||||||
startTranscriptCooldown() {
|
|
||||||
this.transcriptCooldown = true;
|
|
||||||
clearTimeout(this.transcriptCooldownTimer);
|
|
||||||
this.transcriptCooldownTimer = setTimeout(() => {
|
|
||||||
this.transcriptCooldown = false;
|
|
||||||
}, TRANSCRIPT_COOLDOWN_MS);
|
|
||||||
},
|
|
||||||
async sendTranscript() {
|
async sendTranscript() {
|
||||||
if (
|
if (this.hasEmail) {
|
||||||
!this.hasEmail ||
|
try {
|
||||||
this.isSendingTranscript ||
|
await sendEmailTranscript();
|
||||||
this.transcriptCooldown
|
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||||
) {
|
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||||
return;
|
type: 'success',
|
||||||
}
|
});
|
||||||
this.isSendingTranscript = true;
|
} catch (error) {
|
||||||
try {
|
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||||
await sendEmailTranscript();
|
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||||
this.startTranscriptCooldown();
|
});
|
||||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
}
|
||||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
|
||||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
this.isSendingTranscript = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -168,7 +144,6 @@ export default {
|
|||||||
v-if="showEmailTranscriptButton"
|
v-if="showEmailTranscriptButton"
|
||||||
type="clear"
|
type="clear"
|
||||||
class="font-normal"
|
class="font-normal"
|
||||||
:disabled="isSendingTranscript || transcriptCooldown"
|
|
||||||
@click="sendTranscript"
|
@click="sendTranscript"
|
||||||
>
|
>
|
||||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||||
|
|||||||
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get_channel_from_wb_payload(wb_params)
|
def get_channel_from_wb_payload(wb_params)
|
||||||
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
|
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||||
Whatsapp::WebhookChannelFinderService.new(
|
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||||
display_phone_number: metadata[:display_phone_number],
|
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||||
phone_number_id: metadata[:phone_number_id]
|
# validate to ensure the phone number id matches the whatsapp channel
|
||||||
).perform
|
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform_reply
|
def perform_reply
|
||||||
return send_template_message if template_params.present?
|
should_send_template_message = template_params.present? || !message.conversation.can_reply?
|
||||||
return send_session_message if message.conversation.can_reply?
|
if should_send_template_message
|
||||||
|
send_template_message
|
||||||
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
else
|
||||||
|
send_session_message
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_template_message
|
def send_template_message
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
|
|
||||||
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
|
|
||||||
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
|
|
||||||
# raw digits first and then a normalized fallback, accepting only a candidate whose
|
|
||||||
# phone_number_id matches.
|
|
||||||
class Whatsapp::WebhookChannelFinderService
|
|
||||||
def initialize(display_phone_number:, phone_number_id:)
|
|
||||||
@display_phone_number = display_phone_number
|
|
||||||
@phone_number_id = phone_number_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform
|
|
||||||
return if digits.blank?
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
|
|
||||||
channel_by_normalized_number
|
|
||||||
]
|
|
||||||
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def digits
|
|
||||||
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
|
|
||||||
end
|
|
||||||
|
|
||||||
def channel_by_normalized_number
|
|
||||||
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
|
|
||||||
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
|
|
||||||
return unless normalizer
|
|
||||||
|
|
||||||
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
shared: &shared
|
shared: &shared
|
||||||
version: '4.16.1'
|
version: '4.16.0'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *shared
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ en:
|
|||||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||||
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
|
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
|
||||||
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
|
|
||||||
reauthorization:
|
reauthorization:
|
||||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||||
|
|||||||
+1
-2
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
|
|||||||
resources :assistants do
|
resources :assistants do
|
||||||
member do
|
member do
|
||||||
post :playground
|
post :playground
|
||||||
get :metrics
|
get :stats
|
||||||
get :faq_stats
|
|
||||||
get :summary
|
get :summary
|
||||||
get :drilldown
|
get :drilldown
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,23 +37,6 @@ class Captain::AssistantStatsBuilder
|
|||||||
build_metrics(current, previous)
|
build_metrics(current, previous)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
|
||||||
def faq_stats
|
|
||||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
|
||||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
|
||||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
|
||||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
|
||||||
)
|
|
||||||
total = approved + pending
|
|
||||||
|
|
||||||
{
|
|
||||||
approved: approved,
|
|
||||||
pending: pending,
|
|
||||||
documents: documents,
|
|
||||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :window
|
attr_reader :window
|
||||||
@@ -73,7 +56,8 @@ class Captain::AssistantStatsBuilder
|
|||||||
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
||||||
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
||||||
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
||||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
|
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
|
||||||
|
knowledge: knowledge
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -89,7 +73,7 @@ class Captain::AssistantStatsBuilder
|
|||||||
auto_resolution: rate(resolution[:resolved], handled),
|
auto_resolution: rate(resolution[:resolved], handled),
|
||||||
handoff: rate(resolution[:handoff], handled),
|
handoff: rate(resolution[:handoff], handled),
|
||||||
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
||||||
reopen: reopen_rate(range, resolution[:resolved]),
|
reopen: reopen_rate(range),
|
||||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -174,9 +158,7 @@ class Captain::AssistantStatsBuilder
|
|||||||
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
||||||
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
||||||
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
||||||
def reopen_rate(range, resolved_count)
|
def reopen_rate(range)
|
||||||
return 0 if resolved_count.zero?
|
|
||||||
|
|
||||||
resolved_scope = account.reporting_events
|
resolved_scope = account.reporting_events
|
||||||
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||||
conversation_id: handled_scope(range).select(:conversation_id))
|
conversation_id: handled_scope(range).select(:conversation_id))
|
||||||
@@ -196,7 +178,24 @@ class Captain::AssistantStatsBuilder
|
|||||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||||
.distinct.count('reporting_events.conversation_id')
|
.distinct.count('reporting_events.conversation_id')
|
||||||
rate(reopened, resolved_count)
|
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||||
|
def knowledge
|
||||||
|
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||||
|
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||||
|
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||||
|
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||||
|
)
|
||||||
|
total = approved + pending
|
||||||
|
|
||||||
|
{
|
||||||
|
approved: approved,
|
||||||
|
pending: pending,
|
||||||
|
documents: documents,
|
||||||
|
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def rate(numerator, denominator)
|
def rate(numerator, denominator)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||||
before_action -> { check_authorization(Captain::Assistant) }
|
before_action -> { check_authorization(Captain::Assistant) }
|
||||||
|
|
||||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
|
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@assistants = account_assistants.ordered
|
@assistants = account_assistants.ordered
|
||||||
@@ -42,14 +42,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
|||||||
@tools = assistant.available_agent_tools
|
@tools = assistant.available_agent_tools
|
||||||
end
|
end
|
||||||
|
|
||||||
def metrics
|
def stats
|
||||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||||
end
|
end
|
||||||
|
|
||||||
def faq_stats
|
|
||||||
render json: Captain::AssistantStatsBuilder.new(@assistant).faq_stats
|
|
||||||
end
|
|
||||||
|
|
||||||
def summary
|
def summary
|
||||||
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
||||||
result = cached_or_generated_summary(window, summary_stats)
|
result = cached_or_generated_summary(window, summary_stats)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
module Enterprise::Api::V1::Accounts::AgentsController
|
module Enterprise::Api::V1::Accounts::AgentsController
|
||||||
def create
|
def create
|
||||||
super
|
super
|
||||||
return if @agent.blank?
|
|
||||||
|
|
||||||
associate_agent_with_custom_role
|
associate_agent_with_custom_role
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class CallFinder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def paginated_calls
|
def paginated_calls
|
||||||
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
|
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
|
||||||
.order(created_at: :desc)
|
.order(created_at: :desc)
|
||||||
.page(@params[:page] || 1)
|
.page(@params[:page] || 1)
|
||||||
.per(RESULTS_PER_PAGE)
|
.per(RESULTS_PER_PAGE)
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def metrics?
|
def stats?
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def faq_stats?
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ end
|
|||||||
json.inbox do
|
json.inbox do
|
||||||
json.id call.inbox_id
|
json.id call.inbox_id
|
||||||
json.name call.inbox.name
|
json.name call.inbox.name
|
||||||
json.channel_type call.inbox.channel_type
|
|
||||||
json.medium call.inbox.channel.try(:medium)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if call.accepted_by_agent
|
if call.accepted_by_agent
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chatwoot/chatwoot",
|
"name": "@chatwoot/chatwoot",
|
||||||
"version": "4.16.1",
|
"version": "4.16.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"eslint": "eslint app/**/*.{js,vue}",
|
"eslint": "eslint app/**/*.{js,vue}",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"@amplitude/analytics-browser": "^2.11.10",
|
"@amplitude/analytics-browser": "^2.11.10",
|
||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@chatwoot/ninja-keys": "1.2.3",
|
||||||
"@chatwoot/prosemirror-schema": "1.3.23",
|
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||||
"@chatwoot/utils": "^0.0.56",
|
"@chatwoot/utils": "^0.0.56",
|
||||||
"@formkit/core": "^1.7.2",
|
"@formkit/core": "^1.7.2",
|
||||||
"@formkit/vue": "^1.7.2",
|
"@formkit/vue": "^1.7.2",
|
||||||
@@ -86,6 +86,9 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"opus-recorder": "^8.0.5",
|
"opus-recorder": "^8.0.5",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"prosemirror-commands": "^1.7.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-schema-list": "^1.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
"snakecase-keys": "^8.0.1",
|
"snakecase-keys": "^8.0.1",
|
||||||
|
|||||||
Generated
+22
-6
@@ -25,8 +25,8 @@ importers:
|
|||||||
specifier: 1.2.3
|
specifier: 1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
'@chatwoot/prosemirror-schema':
|
'@chatwoot/prosemirror-schema':
|
||||||
specifier: 1.3.23
|
specifier: 1.3.22
|
||||||
version: 1.3.23
|
version: 1.3.22
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.56
|
specifier: ^0.0.56
|
||||||
version: 0.0.56
|
version: 0.0.56
|
||||||
@@ -180,6 +180,15 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||||
|
prosemirror-commands:
|
||||||
|
specifier: ^1.7.1
|
||||||
|
version: 1.7.1
|
||||||
|
prosemirror-inputrules:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
|
prosemirror-schema-list:
|
||||||
|
specifier: ^1.5.1
|
||||||
|
version: 1.5.1
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
@@ -452,8 +461,8 @@ packages:
|
|||||||
'@chatwoot/ninja-keys@1.2.3':
|
'@chatwoot/ninja-keys@1.2.3':
|
||||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||||
|
|
||||||
'@chatwoot/prosemirror-schema@1.3.23':
|
'@chatwoot/prosemirror-schema@1.3.22':
|
||||||
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
|
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.56':
|
'@chatwoot/utils@0.0.56':
|
||||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||||
@@ -3992,6 +4001,9 @@ packages:
|
|||||||
prosemirror-tables@1.5.0:
|
prosemirror-tables@1.5.0:
|
||||||
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
||||||
|
|
||||||
|
prosemirror-transform@1.10.0:
|
||||||
|
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
|
||||||
|
|
||||||
prosemirror-transform@1.12.0:
|
prosemirror-transform@1.12.0:
|
||||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||||
|
|
||||||
@@ -5124,7 +5136,7 @@ snapshots:
|
|||||||
hotkeys-js: 3.8.7
|
hotkeys-js: 3.8.7
|
||||||
lit: 2.2.6
|
lit: 2.2.6
|
||||||
|
|
||||||
'@chatwoot/prosemirror-schema@1.3.23':
|
'@chatwoot/prosemirror-schema@1.3.22':
|
||||||
dependencies:
|
dependencies:
|
||||||
markdown-it-sup: 2.0.0
|
markdown-it-sup: 2.0.0
|
||||||
prosemirror-commands: 1.7.1
|
prosemirror-commands: 1.7.1
|
||||||
@@ -9023,7 +9035,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prosemirror-model: 1.22.3
|
prosemirror-model: 1.22.3
|
||||||
prosemirror-state: 1.4.3
|
prosemirror-state: 1.4.3
|
||||||
prosemirror-transform: 1.12.0
|
prosemirror-transform: 1.10.0
|
||||||
|
|
||||||
prosemirror-state@1.4.3:
|
prosemirror-state@1.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9039,6 +9051,10 @@ snapshots:
|
|||||||
prosemirror-transform: 1.12.0
|
prosemirror-transform: 1.12.0
|
||||||
prosemirror-view: 1.34.1
|
prosemirror-view: 1.34.1
|
||||||
|
|
||||||
|
prosemirror-transform@1.10.0:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.22.3
|
||||||
|
|
||||||
prosemirror-transform@1.12.0:
|
prosemirror-transform@1.12.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prosemirror-model: 1.22.3
|
prosemirror-model: 1.22.3
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ RSpec.describe AgentBuilder, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
it 'locks the account while checking and creating the agent' do
|
|
||||||
expect(account).to receive(:with_lock).and_call_original
|
|
||||||
|
|
||||||
agent_builder.perform
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when user does not exist' do
|
context 'when user does not exist' do
|
||||||
it 'creates a new user' do
|
it 'creates a new user' do
|
||||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||||
@@ -73,17 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
|
|||||||
expect(user.encrypted_password).not_to be_empty
|
expect(user.encrypted_password).not_to be_empty
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the account has reached its agent limit' do
|
|
||||||
before do
|
|
||||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises a limit exceeded error without creating a user' do
|
|
||||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
|
||||||
|
|
||||||
expect(User.from_email(email)).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
it 'deletes the linear integration' do
|
||||||
|
|
||||||
it 'deletes the linear integration when the user is an administrator' do
|
|
||||||
# Stub the HTTP call to Linear's revoke endpoint
|
# Stub the HTTP call to Linear's revoke endpoint
|
||||||
allow(HTTParty).to receive(:post).with(
|
allow(HTTParty).to receive(:post).with(
|
||||||
'https://api.linear.app/oauth/revoke',
|
'https://api.linear.app/oauth/revoke',
|
||||||
@@ -23,19 +21,11 @@ RSpec.describe 'Linear Integration API', type: :request do
|
|||||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||||
|
|
||||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||||
headers: admin.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(account.hooks.count).to eq(0)
|
expect(account.hooks.count).to eq(0)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns unauthorized for an agent and keeps the integration' do
|
|
||||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
|
||||||
headers: agent.create_new_auth_token,
|
|
||||||
as: :json
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
expect(account.hooks.count).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||||
|
|||||||
@@ -159,33 +159,19 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
create(:integrations_hook, :shopify, account: account)
|
create(:integrations_hook, :shopify, account: account)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when it is an administrator' do
|
context 'when it is an authenticated user' do
|
||||||
it 'deletes the shopify integration' do
|
it 'deletes the shopify integration' do
|
||||||
expect do
|
|
||||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
|
||||||
headers: admin.create_new_auth_token,
|
|
||||||
as: :json
|
|
||||||
end.to change { account.hooks.count }.by(-1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when it is an agent' do
|
|
||||||
it 'returns unauthorized and keeps the integration' do
|
|
||||||
expect do
|
expect do
|
||||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
end.not_to(change { account.hooks.count })
|
end.to change { account.hooks.count }.by(-1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:ok)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
|||||||
|
|
||||||
expect(metrics.keys).to contain_exactly(
|
expect(metrics.keys).to contain_exactly(
|
||||||
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
:conversations_handled, :auto_resolution_rate, :handoff_rate,
|
||||||
:hours_saved, :reopen_rate, :conversation_depth
|
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
|
||||||
)
|
)
|
||||||
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
|
||||||
end
|
end
|
||||||
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#faq_stats' do
|
describe '#metrics knowledge' do
|
||||||
before do
|
before do
|
||||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
||||||
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'returns approved, pending, document counts and coverage' do
|
it 'returns approved, pending, document counts and coverage' do
|
||||||
knowledge = described_class.new(assistant).faq_stats
|
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||||
|
|
||||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||||
end
|
end
|
||||||
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
|||||||
it 'reports zero coverage when there are no responses' do
|
it 'reports zero coverage when there are no responses' do
|
||||||
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
||||||
|
|
||||||
knowledge = described_class.new(assistant).faq_stats
|
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
|
||||||
|
|
||||||
expect(knowledge[:coverage]).to eq(0)
|
expect(knowledge[:coverage]).to eq(0)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,27 +21,6 @@ RSpec.describe 'Agents API', type: :request do
|
|||||||
expect(response).to have_http_status(:payment_required)
|
expect(response).to have_http_status(:payment_required)
|
||||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'prevents adding an agent if the last seat is consumed before creation' do
|
|
||||||
account.update!(limits: { agents: account.account_users.count + 1 })
|
|
||||||
competing_agent_created = false
|
|
||||||
|
|
||||||
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
|
|
||||||
unless competing_agent_created
|
|
||||||
create(:user, account: account, role: :agent)
|
|
||||||
competing_agent_created = true
|
|
||||||
end
|
|
||||||
|
|
||||||
method.call(*args)
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:payment_required)
|
|
||||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
|
||||||
expect(User.from_email(params[:email])).to be_nil
|
|
||||||
expect(account.account_users.count).to eq(account.usage_limits[:agents])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ RSpec.describe 'Calls API', type: :request do
|
|||||||
item = body['payload'].find { |c| c['id'] == agent_call.id }
|
item = body['payload'].find { |c| c['id'] == agent_call.id }
|
||||||
expect(item['transcript']).to eq('hello world')
|
expect(item['transcript']).to eq('hello world')
|
||||||
expect(item['contact']['phone_number']).to eq(contact.phone_number)
|
expect(item['contact']['phone_number']).to eq(contact.phone_number)
|
||||||
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'scopes the list to calls the agent accepted' do
|
it 'scopes the list to calls the agent accepted' do
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
|
|||||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||||
|
|
||||||
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
|
permissions :index?, :show?, :playground? do
|
||||||
context 'when administrator' do
|
context 'when administrator' do
|
||||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -345,94 +345,6 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
|||||||
end.not_to change(Conversation, :count)
|
end.not_to change(Conversation, :count)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do
|
|
||||||
brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
|
||||||
sync_templates: false, validate_provider_config: false)
|
|
||||||
wb_params = {
|
|
||||||
object: 'whatsapp_business_account',
|
|
||||||
entry: [{
|
|
||||||
changes: [{
|
|
||||||
value: {
|
|
||||||
metadata: {
|
|
||||||
phone_number_id: brazil_channel.provider_config['phone_number_id'],
|
|
||||||
display_phone_number: '554199887766'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
|
||||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params)
|
|
||||||
job.perform_now(wb_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do
|
|
||||||
argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud',
|
|
||||||
sync_templates: false, validate_provider_config: false)
|
|
||||||
wb_params = {
|
|
||||||
object: 'whatsapp_business_account',
|
|
||||||
entry: [{
|
|
||||||
changes: [{
|
|
||||||
value: {
|
|
||||||
metadata: {
|
|
||||||
phone_number_id: argentina_channel.provider_config['phone_number_id'],
|
|
||||||
display_phone_number: '5491112345678'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
|
||||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params)
|
|
||||||
job.perform_now(wb_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'finds channel when display_phone_number contains formatting characters' do
|
|
||||||
formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud',
|
|
||||||
sync_templates: false, validate_provider_config: false)
|
|
||||||
wb_params = {
|
|
||||||
object: 'whatsapp_business_account',
|
|
||||||
entry: [{
|
|
||||||
changes: [{
|
|
||||||
value: {
|
|
||||||
metadata: {
|
|
||||||
phone_number_id: formatted_channel.provider_config['phone_number_id'],
|
|
||||||
display_phone_number: '+1 415-555-2671'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
|
||||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params)
|
|
||||||
job.perform_now(wb_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do
|
|
||||||
normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
|
||||||
sync_templates: false, validate_provider_config: false)
|
|
||||||
create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud',
|
|
||||||
sync_templates: false, validate_provider_config: false).tap do |raw_channel|
|
|
||||||
raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id'))
|
|
||||||
end
|
|
||||||
wb_params = {
|
|
||||||
object: 'whatsapp_business_account',
|
|
||||||
entry: [{
|
|
||||||
changes: [{
|
|
||||||
value: {
|
|
||||||
metadata: {
|
|
||||||
phone_number_id: normalized_channel.provider_config['phone_number_id'],
|
|
||||||
display_phone_number: '554199887766'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
|
||||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params)
|
|
||||||
job.perform_now(wb_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
|
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
|
||||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||||
validate_provider_config: false)
|
validate_provider_config: false)
|
||||||
|
|||||||
@@ -72,21 +72,6 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
expect(message.reload.source_id).to eq('123456789')
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
|
|
||||||
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
|
|
||||||
conversation: conversation, account: conversation.account)
|
|
||||||
message = create(:message, message_type: :outgoing, content: 'test',
|
|
||||||
conversation: conversation, account: conversation.account)
|
|
||||||
|
|
||||||
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
|
|
||||||
|
|
||||||
described_class.new(message: message).perform
|
|
||||||
|
|
||||||
expect(message.reload.status).to eq('failed')
|
|
||||||
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
|
||||||
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'marks message as failed when template name is blank' do
|
it 'marks message as failed when template name is blank' do
|
||||||
processor = instance_double(Whatsapp::TemplateProcessorService)
|
processor = instance_double(Whatsapp::TemplateProcessorService)
|
||||||
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
|
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export class AddAgentModal {
|
|
||||||
private page: Page;
|
|
||||||
|
|
||||||
constructor(page: Page) {
|
|
||||||
this.page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
getModalTitle() {
|
|
||||||
return this.page.locator('[data-test-id="modal-header-title"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAgentNameInput() {
|
|
||||||
return this.page.getByRole('textbox', { name: 'Agent Name' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmailInput() {
|
|
||||||
return this.page.getByRole('textbox', { name: 'Email Address' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getRoleCombobox() {
|
|
||||||
return this.page.getByRole('combobox', { name: 'Role' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getSubmitButton() {
|
|
||||||
return this.page.locator('form').getByRole('button', { name: 'Add Agent' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getCancelButton() {
|
|
||||||
return this.page.getByRole('button', { name: 'Cancel' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getSuccessMessage() {
|
|
||||||
return this.page.getByText('Agent added successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillAgentName(name: string) {
|
|
||||||
await this.getAgentNameInput().fill(name);
|
|
||||||
await this.page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillEmail(email: string) {
|
|
||||||
await this.getEmailInput().fill(email);
|
|
||||||
await this.page.waitForTimeout(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitForm() {
|
|
||||||
await this.getSubmitButton().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelForm() {
|
|
||||||
await this.getCancelButton().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async createAgent(name: string, email: string) {
|
|
||||||
await this.fillAgentName(name);
|
|
||||||
await this.fillEmail(email);
|
|
||||||
await this.submitForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export class AddAgentsForm {
|
|
||||||
constructor(private page: Page) {}
|
|
||||||
|
|
||||||
getPageHeading() {
|
|
||||||
return this.page
|
|
||||||
.locator('form')
|
|
||||||
.getByRole('heading', { name: 'Agents', level: 2, exact: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
getAgentDropdown() {
|
|
||||||
return this.page.getByPlaceholder('Pick agents for the inbox');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAgentSelector() {
|
|
||||||
return this.page.getByTestId('agent-selector');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAgentOption(agentName: string) {
|
|
||||||
return this.getAgentSelector().getByRole('button', {
|
|
||||||
name: agentName,
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getDropdownButtons() {
|
|
||||||
return this.getAgentSelector().getByRole('button');
|
|
||||||
}
|
|
||||||
|
|
||||||
getSubmitButton() {
|
|
||||||
return this.page.getByRole('button', { name: 'Add agents' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async openAgentDropdown() {
|
|
||||||
await this.getAgentDropdown().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectAgent(agentName: string) {
|
|
||||||
await this.openAgentDropdown();
|
|
||||||
await this.getAgentOption(agentName).waitFor({ state: 'visible' });
|
|
||||||
await this.getAgentOption(agentName).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectAgentByIndex(index: number = 0) {
|
|
||||||
await this.openAgentDropdown();
|
|
||||||
const buttons = this.getDropdownButtons();
|
|
||||||
await buttons.first().waitFor({ state: 'visible' });
|
|
||||||
await buttons.nth(index).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeDropdown() {
|
|
||||||
await this.page.keyboard.press('Escape');
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitForm() {
|
|
||||||
await this.getSubmitButton().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async addAgents(agentNames: string[]) {
|
|
||||||
for (const agentName of agentNames) {
|
|
||||||
await this.selectAgent(agentName);
|
|
||||||
}
|
|
||||||
await this.closeDropdown();
|
|
||||||
await this.submitForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
async addFirstAgent() {
|
|
||||||
await this.selectAgentByIndex(0);
|
|
||||||
await this.closeDropdown();
|
|
||||||
await this.submitForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export class AgentPage {
|
|
||||||
private page: Page;
|
|
||||||
|
|
||||||
constructor(page: Page) {
|
|
||||||
this.page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
async navigate(accountId: number = 1) {
|
|
||||||
await this.page.goto(`/app/accounts/${accountId}/settings/agents/list`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPageHeading() {
|
|
||||||
return this.page.getByRole('heading', { name: 'Agents', level: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
getDescriptionText() {
|
|
||||||
return this.page.getByText(
|
|
||||||
'An agent is a member of your customer support team who can view and respond to user messages.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getLearnLink() {
|
|
||||||
return this.page.getByRole('link', { name: 'Learn about user roles' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getAddAgentButton() {
|
|
||||||
return this.page.getByRole('button', { name: 'Add Agent' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async openAddAgentModal() {
|
|
||||||
await this.getAddAgentButton().click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export class ApiChannelForm {
|
|
||||||
constructor(private page: Page) {}
|
|
||||||
|
|
||||||
getChannelNameInput() {
|
|
||||||
return this.page.getByRole('textbox', { name: 'Channel Name' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getWebhookUrlInput() {
|
|
||||||
return this.page.getByRole('textbox', { name: 'Webhook URL' });
|
|
||||||
}
|
|
||||||
|
|
||||||
getSubmitButton() {
|
|
||||||
return this.page.getByRole('button', { name: 'Create API Channel' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillChannelName(name: string) {
|
|
||||||
await this.getChannelNameInput().fill(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillWebhookUrl(url: string) {
|
|
||||||
await this.getWebhookUrlInput().fill(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitForm() {
|
|
||||||
await this.getSubmitButton().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async createApiChannel(channelName: string, webhookUrl?: string) {
|
|
||||||
await this.fillChannelName(channelName);
|
|
||||||
if (webhookUrl) {
|
|
||||||
await this.fillWebhookUrl(webhookUrl);
|
|
||||||
}
|
|
||||||
await this.submitForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
getValidationError() {
|
|
||||||
return this.page.locator('.message, .error-message').first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export class ChannelSelector {
|
|
||||||
constructor(private page: Page) {}
|
|
||||||
|
|
||||||
getPageHeading() {
|
|
||||||
return this.page.getByRole('heading', { name: /choose channel/i });
|
|
||||||
}
|
|
||||||
|
|
||||||
getApiChannelCard() {
|
|
||||||
return this.page.getByRole('button', { name: /API.*Make a custom channel/i });
|
|
||||||
}
|
|
||||||
|
|
||||||
getWebsiteChannelCard() {
|
|
||||||
return this.page.getByRole('button', { name: /Website.*Create a live-chat widget/i });
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectApiChannel() {
|
|
||||||
await this.getApiChannelCard().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectWebsiteChannel() {
|
|
||||||
await this.getWebsiteChannelCard().click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export class FinishSetup {
|
|
||||||
constructor(private page: Page) {}
|
|
||||||
|
|
||||||
getPageHeading() {
|
|
||||||
return this.page.getByRole('heading', {
|
|
||||||
name: 'Your Inbox is ready!',
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getGoToInboxButton() {
|
|
||||||
return this.page.getByRole('button', { name: /go to inbox|view inbox/i });
|
|
||||||
}
|
|
||||||
|
|
||||||
getMoreSettingsButton() {
|
|
||||||
return this.page.getByRole('button', { name: /more settings|settings/i });
|
|
||||||
}
|
|
||||||
|
|
||||||
getWebhookUrl() {
|
|
||||||
return this.page.locator('code, pre').filter({ hasText: /http/i }).first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async goToInbox() {
|
|
||||||
await this.getGoToInboxButton().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
async goToSettings() {
|
|
||||||
await this.getMoreSettingsButton().click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1 @@
|
|||||||
export { Login } from './login.component';
|
export { Login } from './login.component';
|
||||||
export { AgentPage } from './agent-page.component';
|
|
||||||
export { AddAgentModal } from './add-agent-modal.component';
|
|
||||||
export { AddAgentsForm } from './add-agents-form.component';
|
|
||||||
export { SettingsInboxPage } from './settings-inbox-page.component';
|
|
||||||
export { ChannelSelector } from './channel-selector.component';
|
|
||||||
export { ApiChannelForm } from './api-channel-form.component';
|
|
||||||
export { FinishSetup } from './finish-setup.component';
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
type DashboardApi = {
|
|
||||||
delete: (url: string) => Promise<unknown>;
|
|
||||||
get: (url: string) => Promise<{
|
|
||||||
data: {
|
|
||||||
payload: Array<{ id: number }>;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SettingsInboxPage {
|
|
||||||
constructor(private page: Page) {}
|
|
||||||
|
|
||||||
async navigate(accountId: number = 1) {
|
|
||||||
await this.page.goto(`/app/accounts/${accountId}/settings/inboxes/list`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAddInboxButton() {
|
|
||||||
return this.page.getByRole('link', { name: 'Add Inbox' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickAddInboxButton() {
|
|
||||||
await this.getAddInboxButton().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
getPageHeading() {
|
|
||||||
return this.page.getByRole('heading', { name: /inboxes/i });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteInbox(accountId: number, inboxId: number) {
|
|
||||||
await this.page.evaluate(
|
|
||||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
|
||||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
|
||||||
await api.delete(
|
|
||||||
`/api/v1/accounts/${currentAccountId}/inboxes/${currentInboxId}`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ accountId, inboxId }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isInboxPresent(accountId: number, inboxId: number) {
|
|
||||||
return this.page.evaluate(
|
|
||||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
|
||||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
|
||||||
const response = await api.get(
|
|
||||||
`/api/v1/accounts/${currentAccountId}/inboxes`
|
|
||||||
);
|
|
||||||
return response.data.payload.some(
|
|
||||||
inbox => inbox.id === currentInboxId
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ accountId, inboxId }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { AddAgentModal, AgentPage, Login } from '@components/ui';
|
|
||||||
|
|
||||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
|
||||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
|
||||||
|
|
||||||
test.describe('Agent Onboarding - UI', () => {
|
|
||||||
let loginComponent: Login;
|
|
||||||
let agentPage: AgentPage;
|
|
||||||
let addAgentModal: AddAgentModal;
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
loginComponent = new Login(page);
|
|
||||||
agentPage = new AgentPage(page);
|
|
||||||
addAgentModal = new AddAgentModal(page);
|
|
||||||
|
|
||||||
await loginComponent.navigate();
|
|
||||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
|
|
||||||
const accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
|
||||||
await agentPage.navigate(accountId);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should validate all UI elements on agents page', async () => {
|
|
||||||
await expect(agentPage.getPageHeading()).toBeVisible();
|
|
||||||
await expect(agentPage.getDescriptionText()).toBeVisible();
|
|
||||||
|
|
||||||
const learnLink = agentPage.getLearnLink();
|
|
||||||
await expect(learnLink).toBeVisible();
|
|
||||||
await expect(learnLink).toHaveAttribute('href', 'https://chwt.app/hc/agents');
|
|
||||||
|
|
||||||
await expect(agentPage.getAddAgentButton()).toBeVisible();
|
|
||||||
await agentPage.openAddAgentModal();
|
|
||||||
|
|
||||||
await expect(addAgentModal.getModalTitle()).toBeVisible();
|
|
||||||
await expect(addAgentModal.getModalTitle()).toHaveText('Add agent to your team');
|
|
||||||
|
|
||||||
await expect(addAgentModal.getAgentNameInput()).toBeVisible();
|
|
||||||
await expect(addAgentModal.getEmailInput()).toBeVisible();
|
|
||||||
await expect(addAgentModal.getRoleCombobox()).toBeVisible();
|
|
||||||
await expect(addAgentModal.getSubmitButton()).toBeVisible();
|
|
||||||
await expect(addAgentModal.getCancelButton()).toBeVisible();
|
|
||||||
|
|
||||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
|
||||||
|
|
||||||
await addAgentModal.getAgentNameInput().fill('Test');
|
|
||||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
|
||||||
|
|
||||||
await addAgentModal.getAgentNameInput().clear();
|
|
||||||
await addAgentModal.getEmailInput().fill('test@example.com');
|
|
||||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
|
||||||
|
|
||||||
await addAgentModal.getAgentNameInput().fill('Test');
|
|
||||||
await expect(addAgentModal.getSubmitButton()).toBeEnabled();
|
|
||||||
|
|
||||||
await addAgentModal.cancelForm();
|
|
||||||
await expect(addAgentModal.getModalTitle()).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
AddAgentsForm,
|
|
||||||
ApiChannelForm,
|
|
||||||
ChannelSelector,
|
|
||||||
FinishSetup,
|
|
||||||
Login,
|
|
||||||
SettingsInboxPage,
|
|
||||||
} from '@components/ui';
|
|
||||||
|
|
||||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
|
||||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
|
||||||
|
|
||||||
test.describe('Inbox Creation - UI Flow', () => {
|
|
||||||
const testInbox = {
|
|
||||||
name: `Test Inbox ${Date.now()}`,
|
|
||||||
webhookUrl: 'https://example.com/webhook',
|
|
||||||
};
|
|
||||||
|
|
||||||
let accountId: number | undefined;
|
|
||||||
let inboxId: number | undefined;
|
|
||||||
|
|
||||||
test.beforeEach(() => {
|
|
||||||
accountId = undefined;
|
|
||||||
inboxId = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async ({ page }) => {
|
|
||||||
const currentAccountId = accountId;
|
|
||||||
const currentInboxId = inboxId;
|
|
||||||
if (!currentAccountId || !currentInboxId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsInboxPage = new SettingsInboxPage(page);
|
|
||||||
await settingsInboxPage.deleteInbox(currentAccountId, currentInboxId);
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() =>
|
|
||||||
settingsInboxPage.isInboxPresent(currentAccountId, currentInboxId),
|
|
||||||
{
|
|
||||||
message: `Inbox ${currentInboxId} was not deleted`,
|
|
||||||
timeout: 30_000,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should complete full inbox creation flow with UI validation', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const loginComponent = new Login(page);
|
|
||||||
await loginComponent.navigate();
|
|
||||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
|
||||||
await page.waitForURL(/\/app\/accounts\/\d+\/dashboard/);
|
|
||||||
|
|
||||||
accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
|
||||||
const settingsInboxPage = new SettingsInboxPage(page);
|
|
||||||
await settingsInboxPage.navigate(accountId);
|
|
||||||
|
|
||||||
await expect(settingsInboxPage.getPageHeading()).toBeVisible();
|
|
||||||
await expect(settingsInboxPage.getAddInboxButton()).toBeVisible();
|
|
||||||
|
|
||||||
await settingsInboxPage.clickAddInboxButton();
|
|
||||||
await page.waitForURL(/\/settings\/inboxes\/new/);
|
|
||||||
|
|
||||||
const channelSelector = new ChannelSelector(page);
|
|
||||||
await expect(channelSelector.getPageHeading()).toBeVisible();
|
|
||||||
await channelSelector.selectApiChannel();
|
|
||||||
|
|
||||||
page.on('response', async response => {
|
|
||||||
if (
|
|
||||||
response.url().includes('/api/v1/accounts/') &&
|
|
||||||
response.url().includes('/inboxes') &&
|
|
||||||
response.request().method() === 'POST' &&
|
|
||||||
response.status() === 200
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const responseData = await response.json();
|
|
||||||
if (responseData.id) {
|
|
||||||
inboxId = responseData.id;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore non-JSON responses
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const apiChannelForm = new ApiChannelForm(page);
|
|
||||||
await apiChannelForm.fillChannelName(testInbox.name);
|
|
||||||
await apiChannelForm.fillWebhookUrl(testInbox.webhookUrl);
|
|
||||||
await apiChannelForm.submitForm();
|
|
||||||
await expect.poll(() => inboxId).toBeTruthy();
|
|
||||||
|
|
||||||
const addAgentsForm = new AddAgentsForm(page);
|
|
||||||
await expect(addAgentsForm.getPageHeading()).toBeVisible();
|
|
||||||
await addAgentsForm.addFirstAgent();
|
|
||||||
|
|
||||||
await page.waitForURL(/\/settings\/inboxes\/.*\/finish/);
|
|
||||||
const finishSetup = new FinishSetup(page);
|
|
||||||
await expect(finishSetup.getPageHeading()).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -269,16 +269,6 @@ export const icons = {
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
},
|
},
|
||||||
'voice-call': {
|
|
||||||
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
},
|
|
||||||
'whatsapp-voice': {
|
|
||||||
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
},
|
|
||||||
instagram: {
|
instagram: {
|
||||||
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||||
width: 24,
|
width: 24,
|
||||||
@@ -480,15 +470,5 @@ export const icons = {
|
|||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
},
|
},
|
||||||
'audio-play': {
|
|
||||||
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
},
|
|
||||||
'audio-pause': {
|
|
||||||
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
},
|
|
||||||
/** Ends */
|
/** Ends */
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user