Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e05e947392 | ||
|
|
b0d6089bb6 | ||
|
|
c04b3b4297 | ||
|
|
76a4140224 | ||
|
|
93ebfccac2 | ||
|
|
759615d041 | ||
|
|
a7e3d443c9 | ||
|
|
7a3303e841 | ||
|
|
9cc6b1a4ba | ||
|
|
7d9800d2f8 | ||
|
|
b680fa43ec | ||
|
|
c2e2954dfa | ||
|
|
aaa328be87 | ||
|
|
54afed9fb4 | ||
|
|
8773929c0e | ||
|
|
933ae8aa49 | ||
|
|
4cfa7e4c97 | ||
|
|
ac729cf0cf | ||
|
|
7dc0eba48a | ||
|
|
879da72b10 | ||
|
|
4436d1a9de | ||
|
|
d6b1533866 | ||
|
|
97d7b9d754 | ||
|
|
7a45144526 | ||
|
|
db327378fa |
@@ -175,15 +175,15 @@ gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'opensearch-ruby'
|
||||
gem 'searchkick'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
group :production do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
# for heroku autoscaling
|
||||
gem 'judoscale-rails', require: false
|
||||
gem 'judoscale-sidekiq', require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
|
||||
+9
-8
@@ -397,6 +397,13 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
judoscale-ruby (1.8.2)
|
||||
judoscale-sidekiq (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
sidekiq (>= 5.0)
|
||||
jwt (2.8.1)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
@@ -532,9 +539,6 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -706,9 +710,6 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.4.0)
|
||||
activemodel (>= 6.1)
|
||||
hashie
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -897,6 +898,8 @@ DEPENDENCIES
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
judoscale-rails
|
||||
judoscale-sidekiq
|
||||
jwt
|
||||
kaminari
|
||||
koala
|
||||
@@ -916,7 +919,6 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -942,7 +944,6 @@ DEPENDENCIES
|
||||
rubocop-rspec
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_hook
|
||||
before_action :hook
|
||||
|
||||
def sso_url
|
||||
params_string =
|
||||
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
|
||||
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
|
||||
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
|
||||
|
||||
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
|
||||
|
||||
sso_url = "#{installation_config.value}/sso?#{params_string}"
|
||||
render json: { sso_url: sso_url }, status: :ok
|
||||
def proxy
|
||||
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
|
||||
render plain: response.body, status: response.code
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Current.account.hooks.find_by!(app_id: 'captain')
|
||||
def headers
|
||||
{
|
||||
'X-User-Email' => hook.settings['account_email'],
|
||||
'X-User-Token' => hook.settings['access_token'],
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => '*/*'
|
||||
}
|
||||
end
|
||||
|
||||
def request_path
|
||||
request_route = with_leading_hash_on_route(params[:route])
|
||||
|
||||
return 'api/sessions/profile' if request_route == '/sessions/profile'
|
||||
|
||||
"api/accounts/#{hook.settings['account_id']}#{request_route}"
|
||||
end
|
||||
|
||||
def request_url
|
||||
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
|
||||
URI.join(base_url, request_path).to_s
|
||||
end
|
||||
|
||||
def hook
|
||||
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
|
||||
end
|
||||
|
||||
def request_method
|
||||
method = permitted_params[:method].downcase
|
||||
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
|
||||
|
||||
method
|
||||
end
|
||||
|
||||
def with_leading_hash_on_route(request_route)
|
||||
return '' if request_route.blank?
|
||||
|
||||
request_route.start_with?('/') ? request_route : "/#{request_route}"
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:method, :route, body: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class Platform::Api::V1::AccountsController < PlatformController
|
||||
def create
|
||||
@resource = Account.create!(account_params)
|
||||
update_resource_features
|
||||
@resource.save!
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
end
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
|
||||
fetchCaptainURL() {
|
||||
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
|
||||
requestCaptain(body) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,13 @@ hr {
|
||||
ul,
|
||||
ol,
|
||||
dl {
|
||||
@apply mb-2 list-disc list-outside leading-[1.65];
|
||||
@apply list-disc list-outside leading-[1.65];
|
||||
}
|
||||
|
||||
ul:not(.reset-base),
|
||||
ol:not(.reset-base),
|
||||
dl:not(.reset-base) {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
// Form elements
|
||||
|
||||
@@ -96,7 +96,7 @@ button {
|
||||
}
|
||||
|
||||
// @TODDO - Remove after moving all buttons to woot-button
|
||||
.icon+.button__content {
|
||||
.icon + .button__content {
|
||||
@apply w-auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,60 +82,56 @@ const inboxIcon = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
|
||||
<template #header>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between gap-3 w-fit">
|
||||
<span
|
||||
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ campaignStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message)"
|
||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||
/>
|
||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||
<LiveChatCampaignDetails
|
||||
v-if="isLiveChatType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
:scheduled-at="scheduledAt"
|
||||
/>
|
||||
</div>
|
||||
<CardLayout layout="row">
|
||||
<div class="flex flex-col items-start justify-between flex-1 min-w-0 gap-2">
|
||||
<div class="flex justify-between gap-3 w-fit">
|
||||
<span
|
||||
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ campaignStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message)"
|
||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||
/>
|
||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||
<LiveChatCampaignDetails
|
||||
v-if="isLiveChatType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
icon="i-lucide-sliders-vertical"
|
||||
@click="emit('edit')"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('delete')"
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
:scheduled-at="scheduledAt"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
icon="i-lucide-sliders-vertical"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
+5
-3
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -33,10 +34,11 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Thumbnail
|
||||
:author="sender || { name: senderName }"
|
||||
<Avatar
|
||||
:name="senderName"
|
||||
:src="senderThumbnailSrc"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
|
||||
-1
@@ -27,7 +27,6 @@ defineProps({
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
|
||||
-1
@@ -27,7 +27,6 @@ defineProps({
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
|
||||
@@ -27,7 +27,6 @@ const handleDelete = campaign => emit('delete', campaign);
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:trigger-rules="campaign.trigger_rules"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
|
||||
+7
-9
@@ -63,14 +63,12 @@ defineExpose({ dialogRef });
|
||||
overflow-y-auto
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<template #form>
|
||||
<LiveChatCampaignForm
|
||||
ref="liveChatCampaignFormRef"
|
||||
mode="edit"
|
||||
:selected-campaign="selectedCampaign"
|
||||
:show-action-buttons="false"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
<LiveChatCampaignForm
|
||||
ref="liveChatCampaignFormRef"
|
||||
mode="edit"
|
||||
:selected-campaign="selectedCampaign"
|
||||
:show-action-buttons="false"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'col',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
@@ -13,11 +15,18 @@ const handleClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
|
||||
@click="handleClick"
|
||||
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot name="footer" />
|
||||
<div
|
||||
class="flex w-full gap-3 px-6 py-5"
|
||||
:class="
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
|
||||
"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<slot name="after" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const WIDTH_CONFIG = Object.freeze({
|
||||
DEFAULT_WIDTH: 80,
|
||||
CHAR_WIDTH: {
|
||||
SHORT: 8, // For labels <= 5 chars
|
||||
LONG: 6, // For labels > 5 chars
|
||||
},
|
||||
BASE_WIDTH: 12, // dot + gap
|
||||
THRESHOLD: 5, // character length threshold
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const maxLabels = ref(1);
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
const labelSet = new Set(props.conversationLabels);
|
||||
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
|
||||
});
|
||||
|
||||
const calculateLabelWidth = ({ title = '' }) => {
|
||||
const charWidth =
|
||||
title.length > WIDTH_CONFIG.THRESHOLD
|
||||
? WIDTH_CONFIG.CHAR_WIDTH.LONG
|
||||
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
|
||||
|
||||
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
|
||||
};
|
||||
|
||||
const getAverageWidth = labels => {
|
||||
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
|
||||
|
||||
const totalWidth = labels.reduce(
|
||||
(sum, label) => sum + calculateLabelWidth(label),
|
||||
0
|
||||
);
|
||||
|
||||
return totalWidth / labels.length;
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() =>
|
||||
activeLabels.value?.slice(0, maxLabels.value)
|
||||
);
|
||||
|
||||
const updateVisibleLabels = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const containerWidth = containerRef.value.offsetWidth;
|
||||
const avgWidth = getAverageWidth(activeLabels.value);
|
||||
|
||||
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
v-resize="updateVisibleLabels"
|
||||
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
|
||||
>
|
||||
<template v-for="(label, index) in visibleLabels" :key="label.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
:class="[
|
||||
index !== visibleLabels.length - 1
|
||||
? 'flex-shrink-0 text-ellipsis'
|
||||
: 'flex-shrink',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: label.color }"
|
||||
class="size-1.5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-10 whitespace-nowrap"
|
||||
:class="{ truncate: index === visibleLabels.length - 1 }"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {} } = props.conversation;
|
||||
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {} } = props.conversation;
|
||||
return lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => props.conversation?.slaPolicyId);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid items-center gap-2.5 h-7"
|
||||
:class="
|
||||
hasSlaThreshold
|
||||
? 'grid-cols-[auto_auto_1fr_20px]'
|
||||
: 'grid-cols-[1fr_20px]'
|
||||
"
|
||||
>
|
||||
<SLACardLabel v-if="hasSlaThreshold" :conversation="conversation" />
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="overflow-hidden">
|
||||
<CardLabels
|
||||
:conversation-labels="conversation.labels"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ConversationCard from './ConversationCard.vue';
|
||||
|
||||
// Base conversation object
|
||||
const conversationWithoutMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phone_number: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
last_activity_at: 1712127410,
|
||||
created_at: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 38,
|
||||
messages: [
|
||||
{
|
||||
id: 3597,
|
||||
content: 'Sivin set the priority to low',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 38,
|
||||
messageType: 2,
|
||||
createdAt: 1730885168,
|
||||
updatedAt: '2024-11-06T09:26:08.565Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
contentType: 'text',
|
||||
contentAttributes: {},
|
||||
senderType: null,
|
||||
senderId: null,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent: 'Sivin set the priority to low',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assigneeId: 1,
|
||||
unreadCount: 0,
|
||||
lastActivityAt: 1730885168,
|
||||
contactInbox: {
|
||||
sourceId: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'Test email',
|
||||
},
|
||||
agentLastSeenAt: 0,
|
||||
assigneeLastSeenAt: 0,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [],
|
||||
status: 'open',
|
||||
createdAt: 1730836533,
|
||||
timestamp: 1730885168,
|
||||
firstReplyCreatedAt: 1730836533,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3591,
|
||||
content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 38,
|
||||
message_type: 1,
|
||||
created_at: 1730836533,
|
||||
updated_at: '2024-11-05T19:55:37.158Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: ['test@gmail.com'],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885168,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885168,
|
||||
priority: 'low',
|
||||
waitingSince: 0,
|
||||
slaPolicyId: null,
|
||||
slaEvents: [],
|
||||
};
|
||||
|
||||
const conversationWithMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 37,
|
||||
messages: [
|
||||
{
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 37,
|
||||
messageType: 1,
|
||||
createdAt: 1730885428,
|
||||
updatedAt: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
sourceId:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
contentType: 'text',
|
||||
contentAttributes: {
|
||||
ccEmails: [],
|
||||
bccEmails: [],
|
||||
toEmails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
senderId: 1,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
availableName: 'Sivin',
|
||||
avatarUrl: '',
|
||||
type: 'user',
|
||||
availabilityStatus: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'we',
|
||||
},
|
||||
agentLastSeenAt: 1730885428,
|
||||
assigneeLastSeenAt: 1730885428,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [
|
||||
'billing',
|
||||
'delivery',
|
||||
'lead',
|
||||
'premium-customer',
|
||||
'software',
|
||||
'ops-handover',
|
||||
],
|
||||
muted: false,
|
||||
snoozedUntil: null,
|
||||
status: 'open',
|
||||
createdAt: 1722487645,
|
||||
timestamp: 1730885428,
|
||||
firstReplyCreatedAt: 1722487645,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 37,
|
||||
message_type: 1,
|
||||
created_at: 1730885428,
|
||||
updated_at: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 2,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'willy@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885428,
|
||||
priority: 'urgent',
|
||||
waitingSince: 1730885428,
|
||||
slaPolicyId: 3,
|
||||
appliedSla: {
|
||||
id: 4,
|
||||
sla_id: 3,
|
||||
sla_status: 'active_with_misses',
|
||||
created_at: 1712127410,
|
||||
updated_at: 1712127545,
|
||||
sla_description:
|
||||
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
|
||||
sla_name: 'Premium SLA',
|
||||
sla_first_response_time_threshold: 120,
|
||||
sla_next_response_time_threshold: 180,
|
||||
sla_only_during_business_hours: false,
|
||||
sla_resolution_time_threshold: 360,
|
||||
},
|
||||
slaEvents: [
|
||||
{
|
||||
id: 8,
|
||||
event_type: 'frt',
|
||||
meta: {},
|
||||
updated_at: 1712127545,
|
||||
created_at: 1712127545,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
event_type: 'rt',
|
||||
meta: {},
|
||||
updated_at: 1712127790,
|
||||
created_at: 1712127790,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contactForConversationWithoutMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
|
||||
customAttributes: {},
|
||||
last_activity_at: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const contactForConversationWithMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const webWidgetInbox = computed(() => ({
|
||||
phone_number: '+918585858585',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
}));
|
||||
|
||||
const accountLabels = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'billing',
|
||||
description: 'Label is used for tagging billing related conversations',
|
||||
color: '#28AD21',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'delivery',
|
||||
description: null,
|
||||
color: '#A2FDD5',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'lead',
|
||||
description: null,
|
||||
color: '#F161C8',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'ops-handover',
|
||||
description: null,
|
||||
color: '#A53326',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'premium-customer',
|
||||
description: null,
|
||||
color: '#6FD4EF',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'software',
|
||||
description: null,
|
||||
color: '#8F6EF2',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ConversationCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Conversation without meta">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="conversationWithoutMeta"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
priority: 'medium',
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation without meta (Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithoutMeta,
|
||||
unreadCount: 2,
|
||||
priority: 'high',
|
||||
}"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
unreadCount: 2,
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardMessagePreview from './CardMessagePreview.vue';
|
||||
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stateInbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentContact = computed(() => props.contact);
|
||||
|
||||
const currentContactName = computed(() => currentContact.value?.name);
|
||||
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
|
||||
const currentContactStatus = computed(
|
||||
() => currentContact.value?.availabilityStatus
|
||||
);
|
||||
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { phoneNumber, channelType } = inbox.value;
|
||||
return getInboxIconByType(channelType, phoneNumber);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
const timestamp = props.conversation?.timestamp;
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { slaPolicyId, labels = [] } = props.conversation;
|
||||
return !slaPolicyId && labels.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out rounded-xl"
|
||||
>
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
:size="24"
|
||||
:status="currentContactStatus"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<h4 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ currentContactName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<CardPriorityIcon :priority="conversation.priority || null" />
|
||||
<div
|
||||
v-tooltip.top-start="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-if="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-else
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const slaStatusText = computed(() => {
|
||||
return slaStatus.value?.type?.toUpperCase();
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value),
|
||||
chat: props.conversation,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.conversation, updateSlaStatus);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center min-w-fit gap-0.5 h-6">
|
||||
<div class="flex items-center justify-center size-4">
|
||||
<svg
|
||||
width="10"
|
||||
height="13"
|
||||
viewBox="0 0 10 13"
|
||||
fill="none"
|
||||
:class="isSlaMissed ? 'fill-n-ruby-10' : 'fill-n-slate-9'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.55091 12.412C7.44524 12.412 9.37939 10.4571 9.37939 7.51446C9.37939 2.63072 5.21405 0.599854 2.36808 0.599854C1.81546 0.599854 1.45626 0.800176 1.45626 1.1801C1.45626 1.32516 1.52534 1.48404 1.64277 1.62219C2.27828 2.38204 2.92069 3.27314 2.93451 4.36455C2.93451 4.5925 2.9276 4.78592 2.76181 5.08295L3.05194 5.03459C2.81017 4.21949 2.18848 3.63234 1.5806 3.63234C1.32501 3.63234 1.15232 3.81884 1.15232 4.09514C1.15232 4.23331 1.19377 4.56488 1.19377 4.79974C1.19377 5.95332 0.26123 6.69935 0.26123 8.67495C0.26123 10.92 1.97434 12.412 4.55091 12.412ZM4.68906 10.8923C3.65982 10.8923 2.96905 10.2637 2.96905 9.33119C2.96905 8.3572 3.66672 8.01181 3.75652 7.38322C3.76344 7.32796 3.79107 7.31414 3.83251 7.34867C4.08809 7.57663 4.24697 7.85293 4.37822 8.1776C4.67525 7.77696 4.81341 6.9204 4.73051 6.0293C4.72361 5.97404 4.75814 5.94642 4.80649 5.96713C6.02916 6.53357 6.65085 7.74241 6.65085 8.82693C6.65085 9.92527 6.00844 10.8923 4.68906 10.8923Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-sm truncate"
|
||||
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,7 +13,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -101,7 +101,7 @@ const categoryName = computed(() => {
|
||||
});
|
||||
|
||||
const authorName = computed(() => {
|
||||
return props.author?.name || props.author?.availableName || '-';
|
||||
return props.author?.name || props.author?.availableName || '';
|
||||
});
|
||||
|
||||
const authorThumbnailSrc = computed(() => {
|
||||
@@ -124,75 +124,72 @@ const handleClick = id => {
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-1">
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ title }}
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
:class="statusTextColor"
|
||||
>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="articleMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
||||
@action="handleArticleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ authorName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm truncate text-n-slate-11">
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
{{
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
|
||||
count: views,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
{{ categoryName }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
|
||||
>
|
||||
<Icon icon="i-lucide-eye" class="size-4" />
|
||||
<span class="text-sm">
|
||||
{{
|
||||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
|
||||
count: views,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span class="text-sm text-n-slate-11 line-clamp-1">
|
||||
{{ lastUpdatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -79,59 +79,55 @@ const handleAction = ({ action, value }) => {
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ categoryTitleWithIcon }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
|
||||
>
|
||||
{{
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
|
||||
count: articlesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="categoryMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
{{ categoryTitleWithIcon }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
|
||||
>
|
||||
{{
|
||||
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
|
||||
count: articlesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="categoryMenuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span
|
||||
class="text-sm line-clamp-3"
|
||||
:class="
|
||||
hasDescription
|
||||
? 'text-slate-500 dark:text-slate-400'
|
||||
: 'text-slate-400 dark:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
class="text-sm line-clamp-3"
|
||||
:class="
|
||||
hasDescription
|
||||
? 'text-slate-500 dark:text-slate-400'
|
||||
: 'text-slate-400 dark:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -53,66 +53,64 @@ const handleAction = ({ action, value }) => {
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
>
|
||||
{{ locale }} ({{ localeCode }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{ locale }} ({{ localeCode }})
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
|
||||
articleCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
|
||||
<span
|
||||
v-if="isDefault"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
|
||||
categoryCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
|
||||
articleCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
|
||||
categoryCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="localeMenuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => props.article.title,
|
||||
|
||||
+7
-10
@@ -6,7 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
|
||||
|
||||
@@ -50,7 +50,7 @@ const author = computed(() => {
|
||||
});
|
||||
|
||||
const authorName = computed(
|
||||
() => author.value?.name || author.value?.available_name || '-'
|
||||
() => author.value?.name || author.value?.available_name || ''
|
||||
);
|
||||
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
|
||||
|
||||
@@ -186,17 +186,14 @@ onMounted(() => {
|
||||
text-variant="info"
|
||||
@click="openAgentsList = !openAgentsList"
|
||||
>
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:size="20"
|
||||
:src="authorThumbnailSrc"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-if="author"
|
||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
>
|
||||
{{ author.available_name }}
|
||||
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
|
||||
+9
-11
@@ -97,16 +97,14 @@ defineExpose({ dialogRef });
|
||||
:disable-confirm-button="isUpdatingCategory || isInvalidForm"
|
||||
@confirm="onUpdateCategory"
|
||||
>
|
||||
<template #form>
|
||||
<CategoryForm
|
||||
ref="categoryFormRef"
|
||||
mode="edit"
|
||||
:selected-category="selectedCategory"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
:portal-name="route.params.portalSlug"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:show-action-buttons="false"
|
||||
/>
|
||||
</template>
|
||||
<CategoryForm
|
||||
ref="categoryFormRef"
|
||||
mode="edit"
|
||||
:selected-category="selectedCategory"
|
||||
:active-locale-code="activeLocaleCode"
|
||||
:portal-name="route.params.portalSlug"
|
||||
:active-locale-name="activeLocaleName"
|
||||
:show-action-buttons="false"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+10
-12
@@ -85,17 +85,15 @@ defineExpose({ dialogRef });
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="locales"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="locales"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+13
-15
@@ -55,20 +55,18 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<template #form>
|
||||
<Input
|
||||
v-model="formState.customDomain"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<Input
|
||||
v-model="formState.customDomain"
|
||||
:label="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+15
-16
@@ -59,21 +59,20 @@ defineExpose({ dialogRef });
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<span
|
||||
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
|
||||
>
|
||||
{{ subdomainCNAME }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<span
|
||||
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
|
||||
>
|
||||
{{ subdomainCNAME }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+5
-3
@@ -12,7 +12,7 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
|
||||
|
||||
@@ -187,10 +187,12 @@ const handleAvatarDelete = () => {
|
||||
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
|
||||
</label>
|
||||
<EditableAvatar
|
||||
label="Avatar"
|
||||
<Avatar
|
||||
:src="state.logoUrl"
|
||||
:name="state.name"
|
||||
:size="72"
|
||||
allow-upload
|
||||
icon-name="i-lucide-building-2"
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
|
||||
+22
-24
@@ -120,29 +120,27 @@ defineExpose({ dialogRef });
|
||||
:is-loading="isCreatingPortal"
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
v-model="state.name"
|
||||
type="text"
|
||||
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
|
||||
:message-type="nameError ? 'error' : 'info'"
|
||||
:message="
|
||||
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
v-model="state.slug"
|
||||
type="text"
|
||||
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
|
||||
:message-type="slugError ? 'error' : 'info'"
|
||||
:message="slugError || buildPortalURL(state.slug)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
v-model="state.name"
|
||||
type="text"
|
||||
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.PLACEHOLDER')"
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.LABEL')"
|
||||
:message-type="nameError ? 'error' : 'info'"
|
||||
:message="
|
||||
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
|
||||
"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
v-model="state.slug"
|
||||
type="text"
|
||||
:placeholder="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.PLACEHOLDER')"
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
|
||||
:message-type="slugError ? 'error' : 'info'"
|
||||
:message="slugError || buildPortalURL(state.slug)"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+4
-5
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createPortal']);
|
||||
|
||||
@@ -148,14 +148,13 @@ const redirectToPortalHomePage = () => {
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ portal.name || '' }}
|
||||
</span>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="portal"
|
||||
:author="portal"
|
||||
:name="portal.name"
|
||||
:size="20"
|
||||
:src="getPortalThumbnailSrc(portal)"
|
||||
:show-author-name="false"
|
||||
:size="20"
|
||||
icon-name="i-lucide-building-2"
|
||||
rounded-full
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,37 +5,30 @@ import Avatar from './Avatar.vue';
|
||||
<template>
|
||||
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<div class="flex p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
name=""
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
/>
|
||||
<Avatar name="Amaya" src="" />
|
||||
<Avatar name="" src="" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Default with upload">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
allow-upload
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Invalid or empty SRC">
|
||||
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload />
|
||||
<Avatar name="Bruce Wayne" allow-upload />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Rounded Full">
|
||||
<div class="p-4 space-x-4 bg-white dark:bg-slate-900">
|
||||
<Variant title="Different Shapes">
|
||||
<div class="gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
name=""
|
||||
allow-upload
|
||||
rounded-full
|
||||
:size="48"
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
|
||||
name=""
|
||||
allow-upload
|
||||
:size="48"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
@@ -43,24 +36,78 @@ import Avatar from './Avatar.vue';
|
||||
<Variant title="Different Sizes">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
|
||||
:size="48"
|
||||
class="bg-green-300 dark:bg-green-900"
|
||||
name=""
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
:size="72"
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
|
||||
name=""
|
||||
allow-upload
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
|
||||
name=""
|
||||
:size="96"
|
||||
class="bg-woot-300 dark:bg-woot-900"
|
||||
allow-upload
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Status">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
|
||||
status="online"
|
||||
name="Felix Online"
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade"
|
||||
status="busy"
|
||||
name="Jade Busy"
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery"
|
||||
status="offline"
|
||||
name="Emery Offline"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Custom Icon">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar name="Custom Icon" icon-name="i-lucide-user" :size="48" />
|
||||
<Avatar
|
||||
name="Custom Industry"
|
||||
icon-name="i-lucide-building-2"
|
||||
:size="48"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Upload States">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<!-- Empty state with upload -->
|
||||
<Avatar name="Upload New" allow-upload :size="48" />
|
||||
|
||||
<!-- With image and upload -->
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Upload"
|
||||
name="Replace Image"
|
||||
allow-upload
|
||||
:size="48"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Name Initials">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar name="Catherine" :size="48" />
|
||||
<Avatar name="John Doe" :size="48" />
|
||||
<Avatar name="Rose Doe John" :size="48" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -27,34 +30,141 @@ const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => {
|
||||
if (!value) return true;
|
||||
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
|
||||
},
|
||||
validator: value =>
|
||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['upload']);
|
||||
|
||||
const emit = defineEmits(['upload', 'delete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isImageValid = ref(true);
|
||||
const fileInput = ref(null);
|
||||
|
||||
function invalidateCurrentImage() {
|
||||
isImageValid.value = false;
|
||||
}
|
||||
const AVATAR_COLORS = {
|
||||
dark: [
|
||||
['#4B143D', '#FF8DCC'],
|
||||
['#3F220D', '#FFA366'],
|
||||
['#2A2A2A', '#ADB1B8'],
|
||||
['#023B37', '#0BD8B6'],
|
||||
['#27264D', '#A19EFF'],
|
||||
['#1D2E62', '#9EB1FF'],
|
||||
],
|
||||
light: [
|
||||
['#FBDCEF', '#C2298A'],
|
||||
['#FFE0BB', '#99543A'],
|
||||
['#E8E8E8', '#60646C'],
|
||||
['#CCF3EA', '#008573'],
|
||||
['#EBEBFE', '#4747C2'],
|
||||
['#E1E9FF', '#3A5BC7'],
|
||||
],
|
||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||
};
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
online: 'bg-n-teal-10',
|
||||
busy: 'bg-n-amber-10',
|
||||
offline: 'bg-n-slate-10',
|
||||
};
|
||||
|
||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||
|
||||
const initials = computed(() => {
|
||||
const splitNames = props.name.split(' ');
|
||||
|
||||
if (splitNames.length > 1) {
|
||||
const firstName = splitNames[0];
|
||||
const lastName = splitNames[splitNames.length - 1];
|
||||
|
||||
return firstName[0] + lastName[0];
|
||||
}
|
||||
|
||||
const firstName = splitNames[0];
|
||||
return firstName[0];
|
||||
if (!props.name) return '';
|
||||
const words = removeEmoji(props.name).split(/\s+/);
|
||||
return words.length === 1
|
||||
? words[0].charAt(0).toUpperCase()
|
||||
: words
|
||||
.slice(0, 2)
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
});
|
||||
|
||||
const getColorsByNameLength = computed(() => {
|
||||
if (!props.name) return AVATAR_COLORS.default;
|
||||
|
||||
const index = props.name.length % AVATAR_COLORS.light.length;
|
||||
return {
|
||||
bg: AVATAR_COLORS.light[index][0],
|
||||
darkBg: AVATAR_COLORS.dark[index][0],
|
||||
text: AVATAR_COLORS.light[index][1],
|
||||
darkText: AVATAR_COLORS.dark[index][1],
|
||||
};
|
||||
});
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
|
||||
const avatarStyles = computed(() => ({
|
||||
...containerStyles.value,
|
||||
backgroundColor:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.bg
|
||||
: undefined,
|
||||
color:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.text
|
||||
: undefined,
|
||||
'--dark-bg': getColorsByNameLength.value.darkBg,
|
||||
'--dark-text': getColorsByNameLength.value.darkText,
|
||||
}));
|
||||
|
||||
const badgeStyles = computed(() => {
|
||||
const badgeSize = Math.max(props.size * 0.35, 8); // 35% of avatar size, minimum 8px
|
||||
return {
|
||||
width: `${badgeSize}px`,
|
||||
height: `${badgeSize}px`,
|
||||
top: `${props.size - badgeSize / 1.1}px`,
|
||||
left: `${props.size - badgeSize / 1.1}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const iconStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.6}px`,
|
||||
}));
|
||||
|
||||
const initialsStyles = computed(() => ({
|
||||
fontSize: `${props.size / 2}px`,
|
||||
}));
|
||||
|
||||
const invalidateCurrentImage = () => {
|
||||
isImageValid.value = false;
|
||||
};
|
||||
|
||||
const handleUploadAvatar = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleImageUpload = event => {
|
||||
const [file] = event.target.files;
|
||||
if (file) {
|
||||
emit('upload', {
|
||||
file,
|
||||
url: file ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAvatar = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = null;
|
||||
}
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
const handleDismiss = event => {
|
||||
event.stopPropagation();
|
||||
handleDeleteAvatar();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
@@ -64,57 +174,87 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="relative inline"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="badge" :size>
|
||||
<span class="relative inline-flex group/avatar" :style="containerStyles">
|
||||
<!-- Status Badge -->
|
||||
<slot name="badge" :size="size">
|
||||
<div
|
||||
class="rounded-full w-2.5 h-2.5 absolute z-20"
|
||||
:style="{
|
||||
top: `${size - 10}px`,
|
||||
left: `${size - 10}px`,
|
||||
}"
|
||||
:class="{
|
||||
'bg-n-teal-10': status === 'online',
|
||||
'bg-n-amber-10': status === 'busy',
|
||||
'bg-n-slate-10': status === 'offline',
|
||||
}"
|
||||
v-if="status"
|
||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||
:style="badgeStyles"
|
||||
:class="STATUS_CLASSES[status]"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<!-- Delete Avatar Button -->
|
||||
<div
|
||||
v-if="src && allowUpload"
|
||||
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
||||
</div>
|
||||
|
||||
<!-- Avatar Container -->
|
||||
<span
|
||||
role="img"
|
||||
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
|
||||
:class="{
|
||||
'rounded-full': roundedFull,
|
||||
'rounded-xl': !roundedFull,
|
||||
}"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
||||
:class="[
|
||||
roundedFull ? 'rounded-full' : 'rounded-xl',
|
||||
{
|
||||
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
||||
!showDefaultAvatar && (!src || !isImageValid),
|
||||
'bg-n-slate-3 dark:bg-n-slate-4': showDefaultAvatar,
|
||||
},
|
||||
]"
|
||||
:style="avatarStyles"
|
||||
>
|
||||
<!-- Avatar Content -->
|
||||
<img
|
||||
v-if="src && isImageValid"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
@error="invalidateCurrentImage"
|
||||
/>
|
||||
<span v-else>
|
||||
{{ initials }}
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
<!-- Custom Icon -->
|
||||
<Icon v-if="iconName" :icon="iconName" :style="iconStyles" />
|
||||
|
||||
<!-- Initials -->
|
||||
<span
|
||||
v-else-if="!showDefaultAvatar"
|
||||
:style="initialsStyles"
|
||||
class="select-none"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
|
||||
<!-- Fallback Icon if no name or image -->
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
icon="i-lucide-user"
|
||||
:style="iconStyles"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Upload Overlay and Input -->
|
||||
<div
|
||||
v-if="allowUpload"
|
||||
role="button"
|
||||
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="emit('upload')"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white dark:text-white size-4"
|
||||
class="text-white"
|
||||
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup>
|
||||
import EditableAvatar from './EditableAvatar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<EditableAvatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Sizes">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<EditableAvatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
|
||||
:size="48"
|
||||
class="bg-green-300 dark:bg-green-900"
|
||||
/>
|
||||
<EditableAvatar
|
||||
:size="72"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
/>
|
||||
<EditableAvatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
|
||||
:size="96"
|
||||
class="bg-woot-300 dark:bg-woot-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,105 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 72,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload', 'delete']);
|
||||
|
||||
const avatarSize = computed(() => `${props.size}px`);
|
||||
const iconSize = computed(() => `${props.size / 2}px`);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const imgError = ref(false);
|
||||
|
||||
const shouldShowImage = computed(() => props.src && !imgError.value);
|
||||
|
||||
const handleUploadAvatar = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleImageUpload = event => {
|
||||
const [file] = event.target.files;
|
||||
if (file) {
|
||||
emit('upload', {
|
||||
file,
|
||||
url: file ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAvatar = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = null;
|
||||
}
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
const handleDismiss = event => {
|
||||
event.stopPropagation();
|
||||
handleDeleteAvatar();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col items-center gap-2 select-none rounded-xl outline outline-1 outline-n-container group/avatar"
|
||||
:style="{ width: avatarSize, height: avatarSize }"
|
||||
>
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
:src="src"
|
||||
:alt="name || 'avatar'"
|
||||
class="object-cover w-full h-full shadow-sm rounded-xl"
|
||||
@error="imgError = true"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-building-2"
|
||||
class="text-n-brand/50"
|
||||
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="src"
|
||||
class="absolute z-20 outline outline-1 outline-n-container flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-upload"
|
||||
class="text-white"
|
||||
:style="{ width: `${iconSize}`, height: `${iconSize}` }"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
class="hidden"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
@@ -47,9 +47,9 @@ const STYLE_CONFIG = {
|
||||
blue: {
|
||||
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
|
||||
faded:
|
||||
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
|
||||
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-text outline-n-blue-border',
|
||||
link: 'text-n-brand hover:underline outline-transparent',
|
||||
link: 'text-n-blue-text hover:underline outline-transparent',
|
||||
},
|
||||
ruby: {
|
||||
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
|
||||
@@ -161,7 +161,7 @@ const linkButtonClasses = computed(() => {
|
||||
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
|
||||
|
||||
<slot v-if="label || $slots.default" name="default">
|
||||
<span class="min-w-0 truncate">{{ label }}</span>
|
||||
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -43,7 +43,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -118,13 +118,13 @@ watch(
|
||||
|
||||
<ComboBoxDropdown
|
||||
ref="dropdownRef"
|
||||
v-model:search-value="search"
|
||||
:open="open"
|
||||
:options="filteredOptions"
|
||||
:search-value="search"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-state="emptyState"
|
||||
:selected-values="selectedValue"
|
||||
@update:search-value="search = $event"
|
||||
@search="emit('search', $event)"
|
||||
@select="selectOption"
|
||||
/>
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
searchValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -33,10 +29,15 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:searchValue', 'select']);
|
||||
const emit = defineEmits(['update:searchValue', 'select', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchValue = defineModel('searchValue', {
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const searchInput = ref(null);
|
||||
|
||||
const isSelected = option => {
|
||||
@@ -46,6 +47,11 @@ const isSelected = option => {
|
||||
return option.value === props.selectedValues;
|
||||
};
|
||||
|
||||
const onInputSearch = event => {
|
||||
searchValue.value = event.target.value;
|
||||
emit('search', event.target.value);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
focus: () => searchInput.value?.focus(),
|
||||
});
|
||||
@@ -64,7 +70,7 @@ defineExpose({
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
|
||||
@input="emit('update:searchValue', $event.target.value)"
|
||||
@input="onInputSearch"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
|
||||
@@ -7,6 +7,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
||||
const alertDialog = ref(null);
|
||||
const editDialog = ref(null);
|
||||
const confirmDialog = ref(null);
|
||||
const confirmDialogWithCustomFooter = ref(null);
|
||||
|
||||
const openAlertDialog = () => {
|
||||
alertDialog.value.open();
|
||||
@@ -17,6 +18,9 @@ const openEditDialog = () => {
|
||||
const openConfirmDialog = () => {
|
||||
confirmDialog.value.open();
|
||||
};
|
||||
const openConfirmDialogWithCustomFooter = () => {
|
||||
confirmDialogWithCustomFooter.value.open();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const onConfirm = dialog => {};
|
||||
@@ -44,24 +48,22 @@ const onConfirm = dialog => {};
|
||||
confirm-button-label="Save"
|
||||
@confirm="onConfirm()"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
type="text"
|
||||
placeholder="User Guide | Chatwoot"
|
||||
label="Name"
|
||||
message="This will be the name of your public facing portal"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
type="text"
|
||||
placeholder="user-guide"
|
||||
label="Slug"
|
||||
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
type="text"
|
||||
placeholder="User Guide | Chatwoot"
|
||||
label="Name"
|
||||
message="This will be the name of your public facing portal"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
type="text"
|
||||
placeholder="user-guide"
|
||||
label="Slug"
|
||||
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Variant>
|
||||
|
||||
@@ -77,5 +79,21 @@ const onConfirm = dialog => {};
|
||||
@confirm="onConfirm()"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With custom footer">
|
||||
<Button
|
||||
label="Open Confirm Dialog with custom footer"
|
||||
@click="openConfirmDialogWithCustomFooter"
|
||||
/>
|
||||
<Dialog
|
||||
ref="confirmDialogWithCustomFooter"
|
||||
title="Confirm Action"
|
||||
description="Are you sure you want to perform this action?"
|
||||
>
|
||||
<template #footer>
|
||||
<Button label="Custom Button" @click="onConfirm()" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'edit',
|
||||
@@ -14,7 +14,7 @@ defineProps({
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
@@ -48,6 +48,11 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: 'lg',
|
||||
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'close']);
|
||||
@@ -59,6 +64,19 @@ const isRTL = useMapGetter('accounts/isRTL');
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const classesMap = {
|
||||
'3xl': 'max-w-3xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
xl: 'max-w-xl',
|
||||
lg: 'max-w-lg',
|
||||
md: 'max-w-md',
|
||||
sm: 'max-w-sm',
|
||||
};
|
||||
|
||||
return classesMap[props.width] ?? 'max-w-md';
|
||||
});
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
@@ -77,8 +95,11 @@ defineExpose({ open, close });
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
@close="close"
|
||||
>
|
||||
@@ -88,7 +109,7 @@ defineExpose({ open, close });
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="title || description" class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
@@ -98,28 +119,29 @@ defineExpose({ open, close });
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="form">
|
||||
<!-- Form content will be injected here -->
|
||||
<slot />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
v-if="showConfirmButton"
|
||||
:color="type === 'edit' ? 'blue' : 'ruby'"
|
||||
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disableConfirmButton || isLoading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
v-if="showConfirmButton"
|
||||
:color="type === 'edit' ? 'blue' : 'ruby'"
|
||||
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="disableConfirmButton || isLoading"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OnClickOutside>
|
||||
</dialog>
|
||||
|
||||
@@ -51,5 +51,19 @@ const handleAction = () => {
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With search">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<DropdownMenu
|
||||
:menu-items="[
|
||||
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
|
||||
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
|
||||
{ label: 'Danger', action: 'delete', icon: 'delete' },
|
||||
]"
|
||||
show-search
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -16,21 +17,61 @@ defineProps({
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const handleAction = (action, value) => {
|
||||
emit('action', { action, value });
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchInput = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (!searchQuery.value) return props.menuItems;
|
||||
|
||||
return props.menuItems.filter(item =>
|
||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (searchInput.value && props.showSearch) {
|
||||
searchInput.value.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
||||
>
|
||||
<div v-if="showSearch" class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="item in menuItems"
|
||||
v-for="item in filteredMenuItems"
|
||||
:key="item.action"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
@@ -39,20 +80,29 @@ const handleAction = (action, value) => {
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item.action, item.value)"
|
||||
@click="handleAction(item)"
|
||||
@keydown.enter="handleAction(item)"
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="item.thumbnail"
|
||||
:author="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:size="thumbnailSize"
|
||||
:src="item.thumbnail.src"
|
||||
/>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
|
||||
item.label
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="filteredMenuItems.length === 0"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{ t('DROPDOWN_MENU.EMPTY_STATE') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownContainer from './base/DropdownContainer.vue';
|
||||
import DropdownBody from './base/DropdownBody.vue';
|
||||
import DropdownSection from './base/DropdownSection.vue';
|
||||
import DropdownItem from './base/DropdownItem.vue';
|
||||
import DropdownSeparator from './base/DropdownSeparator.vue';
|
||||
import WootSwitch from 'components/ui/Switch.vue';
|
||||
|
||||
const currentUserAutoOffline = ref(false);
|
||||
|
||||
const menuItems = ref([
|
||||
{
|
||||
label: 'Contact Support',
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => window.alert('Contact Support'),
|
||||
},
|
||||
{
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => window.alert('Keyboard Shortcuts'),
|
||||
},
|
||||
{
|
||||
label: 'Profile Settings',
|
||||
icon: 'i-lucide-user-pen',
|
||||
click: () => window.alert('Profile Settings'),
|
||||
},
|
||||
{
|
||||
label: 'Change Appearance',
|
||||
icon: 'i-lucide-swatch-book',
|
||||
click: () => window.alert('Change Appearance'),
|
||||
},
|
||||
{
|
||||
label: 'Open SuperAdmin',
|
||||
icon: 'i-lucide-castle',
|
||||
link: '/super_admin',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: 'Log Out',
|
||||
icon: 'i-lucide-log-out',
|
||||
click: () => window.alert('Log Out'),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/DropdownPrimitives"
|
||||
:layout="{ type: 'grid', width: 400, height: 800 }"
|
||||
>
|
||||
<Variant title="Profile Menu">
|
||||
<div class="p-4 bg-white h-[500px] dark:bg-slate-900">
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button label="Open Menu" size="sm" @click="toggle" />
|
||||
</template>
|
||||
<DropdownBody class="w-80">
|
||||
<DropdownSection title="Profile Options">
|
||||
<DropdownItem label="Contact Support" class="justify-between">
|
||||
<span>{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}</span>
|
||||
<div class="flex-shrink-0">
|
||||
<WootSwitch v-model="currentUserAutoOffline" />
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSeparator />
|
||||
<DropdownItem
|
||||
v-for="item in menuItems"
|
||||
:key="item.label"
|
||||
v-bind="item"
|
||||
/>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="absolute">
|
||||
<ul
|
||||
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { provideDropdownContext } from './provider.js';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const [isOpen, toggle] = useToggle(false);
|
||||
|
||||
const closeMenu = () => {
|
||||
if (isOpen.value) {
|
||||
emit('close');
|
||||
toggle(false);
|
||||
}
|
||||
};
|
||||
|
||||
provideDropdownContext({
|
||||
isOpen,
|
||||
toggle,
|
||||
closeMenu,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-20 space-y-2">
|
||||
<slot name="trigger" :is-open :toggle="() => toggle()" />
|
||||
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useDropdownContext } from './provider.js';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
link: { type: String, default: '' },
|
||||
click: { type: Function, default: null },
|
||||
preserveOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { closeMenu } = useDropdownContext();
|
||||
|
||||
const componentIs = computed(() => {
|
||||
if (props.link) return 'router-link';
|
||||
if (props.click) return 'button';
|
||||
|
||||
return 'div';
|
||||
});
|
||||
|
||||
const triggerClick = () => {
|
||||
if (props.click) {
|
||||
props.click();
|
||||
if (!props.preserveOpen) closeMenu();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="n-dropdown-item">
|
||||
<component
|
||||
:is="componentIs"
|
||||
v-bind="$attrs"
|
||||
class="flex text-left rtl:text-right items-center p-2 reset-base text-sm text-n-slate-12 w-full border-0"
|
||||
:class="{
|
||||
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
|
||||
}"
|
||||
:href="props.link || null"
|
||||
@click="triggerClick"
|
||||
>
|
||||
<slot>
|
||||
<slot name="icon">
|
||||
<Icon v-if="icon" class="size-4 text-n-slate-11" :icon="icon" />
|
||||
</slot>
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</slot>
|
||||
</component>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="-mx-2 n-dropdown-section">
|
||||
<div
|
||||
v-if="title"
|
||||
class="px-4 mb-3 mt-1 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<ul class="gap-2 grid reset-base list-none px-2">
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="h-0 border-b border-n-strong -mx-2" />
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
import DropdownBody from './DropdownBody.vue';
|
||||
import DropdownContainer from './DropdownContainer.vue';
|
||||
import DropdownItem from './DropdownItem.vue';
|
||||
import DropdownSection from './DropdownSection.vue';
|
||||
import DropdownSeparator from './DropdownSeparator.vue';
|
||||
|
||||
export {
|
||||
DropdownBody,
|
||||
DropdownContainer,
|
||||
DropdownItem,
|
||||
DropdownSection,
|
||||
DropdownSeparator,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { inject, provide } from 'vue';
|
||||
|
||||
const DropdownControl = Symbol('DropdownControl');
|
||||
|
||||
export function useDropdownContext() {
|
||||
const context = inject(DropdownControl, null);
|
||||
|
||||
if (context === null) {
|
||||
throw new Error(
|
||||
`Component is missing a parent <DropdownContainer /> component.`
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function provideDropdownContext(context) {
|
||||
provide(DropdownControl, context);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
@@ -42,9 +42,22 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'blur',
|
||||
'input',
|
||||
'focus',
|
||||
'enter',
|
||||
]);
|
||||
|
||||
const isFocused = ref(false);
|
||||
const inputRef = ref(null);
|
||||
|
||||
const messageClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
@@ -62,7 +75,7 @@ const inputBorderClass = computed(() => {
|
||||
case 'error':
|
||||
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
|
||||
default:
|
||||
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak';
|
||||
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak focus:border-n-brand dark:focus:border-n-brand';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,6 +83,28 @@ const handleInput = event => {
|
||||
emit('update:modelValue', event.target.value);
|
||||
emit('input', event);
|
||||
};
|
||||
|
||||
const handleFocus = event => {
|
||||
emit('focus', event);
|
||||
isFocused.value = true;
|
||||
};
|
||||
|
||||
const handleBlur = event => {
|
||||
emit('blur', event);
|
||||
isFocused.value = false;
|
||||
};
|
||||
|
||||
const handleEnter = event => {
|
||||
emit('enter', event);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,15 +120,25 @@ const handleInput = event => {
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
:class="[customInputClass, inputBorderClass]"
|
||||
:class="[
|
||||
customInputClass,
|
||||
inputBorderClass,
|
||||
{
|
||||
error: messageType === 'error',
|
||||
focus: isFocused,
|
||||
},
|
||||
]"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
@blur="emit('blur')"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
<p
|
||||
v-if="message"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import SelectMenu from './SelectMenu.vue';
|
||||
|
||||
const sampleOptions = [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
{ label: 'Option 4', value: 'option4' },
|
||||
];
|
||||
|
||||
const selectedValue = ref('option1');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/SelectMenu"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="flex flex-col gap-4 p-4 h-[400px]">
|
||||
<SelectMenu
|
||||
v-model="selectedValue"
|
||||
:options="sampleOptions"
|
||||
:label="sampleOptions.find(opt => opt.value === selectedValue)?.label"
|
||||
/>
|
||||
<div class="text-sm">Selected value: {{ selectedValue }}</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Many Options">
|
||||
<div class="flex flex-col gap-4 p-4 h-[400px]">
|
||||
<SelectMenu
|
||||
v-model="selectedValue"
|
||||
:options="
|
||||
Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
value: `value${i + 1}`,
|
||||
}))
|
||||
"
|
||||
label="Select from many"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const labelValue = computed(() => props.label);
|
||||
|
||||
const toggleMenu = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const handleSelect = value => {
|
||||
emit('update:modelValue', value);
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="() => (isOpen = false)"
|
||||
class="relative flex flex-col gap-1 w-fit"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
trailing-icon
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="!w-fit"
|
||||
:class="{ 'dark:!bg-n-alpha-2 !bg-n-slate-9/20': isOpen }"
|
||||
:label="labelValue"
|
||||
@click="toggleMenu"
|
||||
/>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute ltr:left-full rtl:right-full select-none max-w-48 ltr:ml-1 rtl:mr-1 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg rounded-lg border border-n-weak"
|
||||
>
|
||||
<Button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:icon="option.value === modelValue ? 'i-lucide-check' : ''"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="!justify-end !px-2.5 !h-7"
|
||||
:class="{ '!bg-n-alpha-2': option.value === modelValue }"
|
||||
@click="handleSelect(option.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,7 +140,12 @@ const menuItems = computed(() => {
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps => h(ChannelLeaf, { ...leafProps, inbox }),
|
||||
component: leafProps =>
|
||||
h(ChannelLeaf, {
|
||||
label: leafProps.label,
|
||||
active: leafProps.active,
|
||||
inbox,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
{
|
||||
@@ -164,9 +169,25 @@ const menuItems = computed(() => {
|
||||
},
|
||||
{
|
||||
name: 'Captain',
|
||||
icon: 'i-lucide-bot',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
to: accountScopedRoute('captain'),
|
||||
children: [
|
||||
{
|
||||
name: 'Documents',
|
||||
label: 'Documents',
|
||||
to: accountScopedRoute('captain', { page: 'documents' }),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: 'Responses',
|
||||
to: accountScopedRoute('captain', { page: 'responses' }),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: 'Playground',
|
||||
to: accountScopedRoute('captain', { page: 'playground' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Contacts',
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ButtonNext from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSection,
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
|
||||
const emit = defineEmits(['showCreateAccountModal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { accountId, currentAccount } = useAccount();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
|
||||
const close = () => {
|
||||
if (showDropdown.value) {
|
||||
toggleDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeAccount = newId => {
|
||||
const accountUrl = `/app/accounts/${newId}/dashboard`;
|
||||
@@ -26,51 +25,45 @@ const onChangeAccount = newId => {
|
||||
};
|
||||
|
||||
const emitNewAccount = () => {
|
||||
close();
|
||||
emit('showCreateAccountModal');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-20">
|
||||
<button
|
||||
id="sidebar-account-switcher"
|
||||
:data-account-id="accountId"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="account-options"
|
||||
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
|
||||
:class="{ 'bg-n-alpha-1': showDropdown }"
|
||||
@click="toggleDropdown()"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
|
||||
aria-live="polite"
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
id="sidebar-account-switcher"
|
||||
:data-account-id="accountId"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="account-options"
|
||||
class="flex items-center gap-2 justify-between w-full rounded-lg hover:bg-n-alpha-1 px-2"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ currentAccount.name }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="showDropdown" v-on-clickaway="close" class="absolute top-8 z-50">
|
||||
<div
|
||||
class="min-w-72 max-w-96 text-sm bg-n-solid-1 border border-n-weak rounded-xl shadow-sm py-4 px-2 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
class="px-4 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
|
||||
<span
|
||||
class="text-sm font-medium leading-5 text-n-slate-12 truncate"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
|
||||
</div>
|
||||
<div class="px-1 gap-1 grid">
|
||||
<button
|
||||
v-for="account in currentUser.accounts"
|
||||
:id="`account-${account.id}`"
|
||||
:key="account.id"
|
||||
class="flex w-full hover:bg-n-alpha-1 space-x-4"
|
||||
@click="onChangeAccount(account.id)"
|
||||
>
|
||||
{{ currentAccount.name }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="i-lucide-chevron-down size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="min-w-80">
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
|
||||
<DropdownItem
|
||||
v-for="account in currentUser.accounts"
|
||||
:id="`account-${account.id}`"
|
||||
:key="account.id"
|
||||
class="cursor-pointer"
|
||||
@click="onChangeAccount(account.id)"
|
||||
>
|
||||
<template #label>
|
||||
<div
|
||||
:for="account.name"
|
||||
class="text-left rtl:text-right flex gap-2 items-center"
|
||||
@@ -98,19 +91,20 @@ const emitNewAccount = () => {
|
||||
icon="i-lucide-check"
|
||||
class="text-n-teal-11 size-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="globalConfig.createNewAccountFromDashboard" class="px-2">
|
||||
<ButtonNext
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="emitNewAccount"
|
||||
>
|
||||
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
|
||||
</ButtonNext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownItem v-if="globalConfig.createNewAccountFromDashboard">
|
||||
<ButtonNext
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="emitNewAccount"
|
||||
>
|
||||
{{ t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
|
||||
</ButtonNext>
|
||||
</DropdownItem>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
|
||||
@@ -101,7 +101,7 @@ const toggleTrigger = () => {
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="text-sm cursor-pointer select-none gap-1 grid"
|
||||
class="grid gap-1 text-sm cursor-pointer select-none"
|
||||
>
|
||||
<SidebarGroupHeader
|
||||
:icon
|
||||
@@ -117,12 +117,14 @@ const toggleTrigger = () => {
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="list-none m-0 grid sidebar-group-children"
|
||||
class="grid m-0 list-none sidebar-group-children"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
v-bind="child"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
|
||||
@@ -4,11 +4,16 @@ import Auth from 'dashboard/api/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSeparator,
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
|
||||
const emit = defineEmits(['close', 'openKeyShortcutModal']);
|
||||
|
||||
defineOptions({
|
||||
@@ -21,14 +26,6 @@ const router = useRouter();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const [showProfileMenu, toggleProfileMenu] = useToggle(false);
|
||||
|
||||
const closeMenu = () => {
|
||||
if (showProfileMenu.value) {
|
||||
emit('close');
|
||||
toggleProfileMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
@@ -37,7 +34,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
|
||||
icon: 'i-lucide-life-buoy',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
},
|
||||
@@ -46,7 +42,6 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
|
||||
icon: 'i-lucide-keyboard',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
emit('openKeyShortcutModal');
|
||||
},
|
||||
},
|
||||
@@ -55,20 +50,26 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
|
||||
icon: 'i-lucide-user-pen',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
router.push({ name: 'profile_settings_index' });
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.APPEARANCE'),
|
||||
icon: 'i-lucide-swatch-book',
|
||||
icon: 'i-lucide-palette',
|
||||
click: () => {
|
||||
closeMenu();
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'appearance_settings' });
|
||||
},
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.DOCS'),
|
||||
icon: 'i-lucide-book',
|
||||
click: () => {
|
||||
window.open('https://www.chatwoot.com/hc/user-guide/en', '_blank');
|
||||
},
|
||||
},
|
||||
{
|
||||
show: currentUser.value.type === 'SuperAdmin',
|
||||
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
|
||||
@@ -79,7 +80,7 @@ const menuItems = computed(() => {
|
||||
{
|
||||
show: true,
|
||||
label: t('SIDEBAR_ITEMS.LOGOUT'),
|
||||
icon: 'i-lucide-log-out',
|
||||
icon: 'i-lucide-power',
|
||||
click: Auth.logout,
|
||||
},
|
||||
];
|
||||
@@ -91,56 +92,40 @@ const allowedMenuItems = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative z-20 w-full min-w-0">
|
||||
<button
|
||||
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||
v-bind="$attrs"
|
||||
:class="{
|
||||
'bg-n-alpha-1': showProfileMenu,
|
||||
}"
|
||||
@click="toggleProfileMenu"
|
||||
>
|
||||
<Avatar
|
||||
:size="32"
|
||||
:name="currentUser.available_name"
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-n-slate-11 text-xs truncate">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="showProfileMenu"
|
||||
v-on-clickaway="closeMenu"
|
||||
class="absolute left-0 bottom-12 z-50"
|
||||
>
|
||||
<div
|
||||
class="w-72 min-h-32 bg-n-solid-1 border border-n-weak rounded-xl shadow-sm"
|
||||
<DropdownContainer
|
||||
class="relative z-20 w-full min-w-0"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
class="flex gap-2 items-center rounded-lg cursor-pointer text-left w-full hover:bg-n-alpha-1 p-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
@click="toggle"
|
||||
>
|
||||
<SidebarProfileMenuStatus />
|
||||
<div class="border-t border-n-strong mx-2 my-0" />
|
||||
<ul class="list-none m-0 grid gap-1 p-1 text-n-slate-12">
|
||||
<li v-for="item in allowedMenuItems" :key="item.label" class="m-0">
|
||||
<component
|
||||
:is="item.link ? 'a' : 'button'"
|
||||
v-bind="item.link ? { target: item.target, href: item.link } : {}"
|
||||
class="text-left hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
|
||||
@click="item.click"
|
||||
>
|
||||
<Icon :icon="item.icon" class="size-4" />
|
||||
{{ item.label }}
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Avatar
|
||||
:size="32"
|
||||
:name="currentUser.available_name"
|
||||
:src="currentUser.avatar_url"
|
||||
:status="currentUserAvailability"
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-n-slate-12 text-sm leading-4 font-medium truncate">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
<div class="text-n-slate-11 text-xs truncate">
|
||||
{{ currentUser.email }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
|
||||
<SidebarProfileMenuStatus />
|
||||
<DropdownSeparator />
|
||||
<template v-for="item in allowedMenuItems" :key="item.label">
|
||||
<DropdownItem v-if="item.show" v-bind="item" />
|
||||
</template>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, h } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
DropdownBody,
|
||||
DropdownSection,
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -22,14 +29,22 @@ const statusList = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const statusColors = ['bg-n-teal-9', 'bg-n-amber-9', 'bg-n-slate-9'];
|
||||
|
||||
const availabilityStatuses = computed(() => {
|
||||
return statusList.value.map((statusLabel, index) => ({
|
||||
label: statusLabel,
|
||||
value: AVAILABILITY_STATUS_KEYS[index],
|
||||
color: statusColors[index],
|
||||
icon: h('span', { class: [statusColors[index], 'size-[12px] rounded'] }),
|
||||
active: currentUserAvailability.value === AVAILABILITY_STATUS_KEYS[index],
|
||||
}));
|
||||
});
|
||||
|
||||
const activeStatus = computed(() => {
|
||||
return availabilityStatuses.value.find(status => status.active);
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
try {
|
||||
store.dispatch('updateAvailability', {
|
||||
@@ -50,70 +65,58 @@ function updateAutoOffline(autoOffline) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pt-2 text-n-slate-12">
|
||||
<span
|
||||
class="px-3 leading-4 font-medium tracking-[0.2px] text-n-slate-10 text-xs"
|
||||
>
|
||||
{{ t('SIDEBAR.SET_AVAILABILITY_TITLE') }}
|
||||
</span>
|
||||
<ul class="list-none m-0 grid gap-1 p-1">
|
||||
<li
|
||||
v-for="status in availabilityStatuses"
|
||||
:key="status.value"
|
||||
class="flex items-baseline"
|
||||
>
|
||||
<button
|
||||
class="text-left rtl:text-right hover:bg-n-alpha-1 px-2 py-1.5 w-full flex items-center gap-2"
|
||||
:class="{
|
||||
'pointer-events-none bg-n-amber-10/10': status.active,
|
||||
'bg-n-teal-3': status.active && status.value === 'online',
|
||||
'bg-n-amber-3': status.active && status.value === 'busy',
|
||||
'bg-n-slate-3': status.active && status.value === 'offline',
|
||||
}"
|
||||
@click="changeAvailabilityStatus(status.value)"
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-2.5 h-2.5"
|
||||
:class="{
|
||||
'bg-n-teal-10': status.value === 'online',
|
||||
'bg-n-amber-10': status.value === 'busy',
|
||||
'bg-n-slate-10': status.value === 'offline',
|
||||
}"
|
||||
/>
|
||||
<span class="flex-grow">{{ status.label }}</span>
|
||||
<Icon
|
||||
v-if="status.active"
|
||||
icon="i-lucide-check"
|
||||
class="size-4 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-teal-11': status.value === 'online',
|
||||
'text-n-amber-11': status.value === 'busy',
|
||||
'text-n-slate-11': status.value === 'offline',
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border-t border-n-strong mx-2 my-0" />
|
||||
<ul class="list-none m-0 grid gap-1 p-1">
|
||||
<li class="px-2 py-1.5 flex items-start w-full gap-2">
|
||||
<div class="h-5 flex items-center flex-shrink-0">
|
||||
<Icon icon="i-lucide-info" class="size-4" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="h-5 leading-none flex place-items-center text-n-slate-12">
|
||||
<DropdownSection>
|
||||
<div class="grid gap-0">
|
||||
<DropdownItem>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
{{ $t('SIDEBAR.SET_YOUR_AVAILABILITY') }}
|
||||
</div>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
size="sm"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="min-w-[96px]"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggle"
|
||||
>
|
||||
<div class="flex gap-1 items-center flex-grow text-sm">
|
||||
<div class="p-1 flex-shrink-0">
|
||||
<div class="size-2 rounded-sm" :class="activeStatus.color" />
|
||||
</div>
|
||||
<span>{{ activeStatus.label }}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="min-w-32">
|
||||
<DropdownItem
|
||||
v-for="status in availabilityStatuses"
|
||||
:key="status.value"
|
||||
:label="status.label"
|
||||
:icon="status.icon"
|
||||
class="cursor-pointer"
|
||||
@click="changeAvailabilityStatus(status.value)"
|
||||
/>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
</DropdownItem>
|
||||
<DropdownItem>
|
||||
<div class="flex-grow flex items-center gap-1">
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
<Icon
|
||||
v-tooltip.top="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT')"
|
||||
icon="i-lucide-info"
|
||||
class="size-4 text-n-slate-10"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs leading-tight text-n-slate-10 mt-1">
|
||||
{{ t('SIDEBAR.SET_AUTO_OFFLINE.INFO_SHORT') }}
|
||||
</div>
|
||||
</div>
|
||||
<woot-switch
|
||||
class="flex-shrink-0"
|
||||
:model-value="currentUserAutoOffline"
|
||||
@input="updateAutoOffline"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<woot-switch
|
||||
class="flex-shrink-0"
|
||||
:model-value="currentUserAutoOffline"
|
||||
@input="updateAutoOffline"
|
||||
/>
|
||||
</DropdownItem>
|
||||
</div>
|
||||
</DropdownSection>
|
||||
</template>
|
||||
|
||||
@@ -52,7 +52,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul class="m-0 list-none relative group">
|
||||
<ul class="m-0 list-none reset-base relative group">
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
|
||||
@@ -46,6 +46,13 @@ const handleClickOutside = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = event => {
|
||||
if (event.key === ',') {
|
||||
event.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
@@ -81,6 +88,7 @@ watch(
|
||||
:placeholder="placeholder"
|
||||
custom-input-class="flex-grow"
|
||||
@enter-press="addTag"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</div>
|
||||
</OnClickOutside>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
const props = defineProps({
|
||||
author: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 16,
|
||||
},
|
||||
showAuthorName: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasImageLoaded = ref(false);
|
||||
const imgError = ref(false);
|
||||
|
||||
const authorInitial = computed(() => {
|
||||
if (!props.name) return '';
|
||||
const name = removeEmoji(props.name);
|
||||
const words = name.split(/\s+/);
|
||||
|
||||
if (words.length === 1) {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
});
|
||||
|
||||
const fontSize = computed(() => {
|
||||
return props.size / 2;
|
||||
});
|
||||
|
||||
const iconSize = computed(() => {
|
||||
return Math.round(props.size / 1.8);
|
||||
});
|
||||
|
||||
const shouldShowImage = computed(() => {
|
||||
return props.src && !imgError.value;
|
||||
});
|
||||
|
||||
const onImgError = () => {
|
||||
imgError.value = true;
|
||||
};
|
||||
|
||||
const onImgLoad = () => {
|
||||
hasImageLoaded.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full bg-n-slate-3 dark:bg-n-slate-4"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
>
|
||||
<div v-if="author" class="flex items-center justify-center">
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
class="w-full h-full rounded-full"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="showAuthorName"
|
||||
class="flex items-center justify-center font-medium text-n-slate-11"
|
||||
:style="{ fontSize: `${fontSize}px` }"
|
||||
>
|
||||
{{ authorInitial }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full rounded-xl"
|
||||
>
|
||||
<span
|
||||
v-if="iconName"
|
||||
:class="`${iconName} text-n-brand/70`"
|
||||
:style="{ width: `${iconSize}px`, height: `${iconSize}px` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
class="flex items-center justify-center w-4 h-4 rounded-full bg-n-slate-3 dark:bg-n-slate-4"
|
||||
>
|
||||
<span class="i-lucide-user size-2.5 text-n-brand" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@ const primaryMenuItems = accountId => [
|
||||
key: 'captain',
|
||||
label: 'CAPTAIN',
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
toState: frontendURL(`accounts/${accountId}/captain`),
|
||||
toState: frontendURL(`accounts/${accountId}/captain/documents`),
|
||||
toStateName: 'captain',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -247,7 +247,7 @@ export default {
|
||||
<transition-group
|
||||
name="menu-list"
|
||||
tag="ul"
|
||||
class="pt-2 mb-0 ml-0 list-none"
|
||||
class="pt-2 reset-base list-none"
|
||||
>
|
||||
<SecondaryNavItem
|
||||
v-for="menuItem in accessibleMenuItems"
|
||||
|
||||
@@ -242,7 +242,7 @@ export default {
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<ul v-if="hasSubMenu" class="mb-0 ml-0 list-none">
|
||||
<ul v-if="hasSubMenu" class="reset-base list-none">
|
||||
<SecondaryChildNavItem
|
||||
v-for="child in menuItem.children"
|
||||
:key="child.id"
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ describe('AccountSelector', () => {
|
||||
it('title and sub title exist', () => {
|
||||
const headerComponent = accountSelector.findComponent(WootModalHeader);
|
||||
const title = headerComponent.find('[data-test-id="modal-header-title"]');
|
||||
expect(title.text()).toBe('Switch Account');
|
||||
expect(title.text()).toBe('Switch account');
|
||||
const content = headerComponent.find(
|
||||
'[data-test-id="modal-header-content"]'
|
||||
);
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"EMPTY_STATE": "No results found.",
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"DROPDOWN_MENU": {
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"EMPTY_STATE": "No results found."
|
||||
},
|
||||
"DIALOG": {
|
||||
"BUTTONS": {
|
||||
"CANCEL": "Cancel",
|
||||
|
||||
@@ -177,14 +177,16 @@
|
||||
},
|
||||
"SIDEBAR_ITEMS": {
|
||||
"CHANGE_AVAILABILITY_STATUS": "Change",
|
||||
"CHANGE_ACCOUNTS": "Switch Account",
|
||||
"CONTACT_SUPPORT": "Contact Support",
|
||||
"CHANGE_ACCOUNTS": "Switch account",
|
||||
"SWITCH_WORKSPACE": "Switch workspace",
|
||||
"CONTACT_SUPPORT": "Contact support",
|
||||
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
||||
"PROFILE_SETTINGS": "Profile Settings",
|
||||
"KEYBOARD_SHORTCUTS": "Keyboard Shortcuts",
|
||||
"APPEARANCE": "Change Appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "Super Admin Console",
|
||||
"LOGOUT": "Logout"
|
||||
"PROFILE_SETTINGS": "Profile settings",
|
||||
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
"DOCS": "Read documentation",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
"APP_GLOBAL": {
|
||||
"TRIAL_MESSAGE": "days trial remaining.",
|
||||
@@ -279,6 +281,7 @@
|
||||
"REPORTS_INBOX": "Inbox",
|
||||
"REPORTS_TEAM": "Team",
|
||||
"SET_AVAILABILITY_TITLE": "Set yourself as",
|
||||
"SET_YOUR_AVAILABILITY": "Set your availability",
|
||||
"SLA": "SLA",
|
||||
"CUSTOM_ROLES": "Custom Roles",
|
||||
"BETA": "Beta",
|
||||
|
||||
@@ -1,75 +1,107 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { nextTick, watch, computed } from 'vue';
|
||||
import IntegrationsAPI from 'dashboard/api/integrations';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { makeRouter, setupApp } from '@chatwoot/captain';
|
||||
|
||||
import integrations from '../../api/integrations';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const isLoading = ref(true);
|
||||
const captainURL = ref('');
|
||||
const hasError = ref(false);
|
||||
|
||||
const loadCaptainFrame = async integration => {
|
||||
if (!integration || !integration.enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const { data } = await integrations.fetchCaptainURL();
|
||||
captainURL.value = data.sso_url;
|
||||
} catch (error) {
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const routeMap = {
|
||||
documents: '/app/accounts/[account_id]/documents/',
|
||||
playground: '/app/accounts/[account_id]/playground/',
|
||||
responses: '/app/accounts/[account_id]/responses/',
|
||||
};
|
||||
|
||||
const resolvedRoute = computed(() => routeMap[props.page]);
|
||||
|
||||
let router = null;
|
||||
|
||||
watch(
|
||||
() => props.page,
|
||||
() => {
|
||||
if (router) {
|
||||
router.push({ name: resolvedRoute.value });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const buildApp = () => {
|
||||
router = makeRouter();
|
||||
setupApp('#captain', {
|
||||
router,
|
||||
fetchFn: async (source, options) => {
|
||||
const parsedSource = new URL(source);
|
||||
let path = parsedSource.pathname;
|
||||
if (path === `/api/sessions/profile`) {
|
||||
path = '/sessions/profile';
|
||||
} else {
|
||||
path = path.replace(/^\/api\/accounts\/\d+/, '');
|
||||
}
|
||||
|
||||
// include search params
|
||||
path = `${path}${parsedSource.search}`;
|
||||
|
||||
const response = await IntegrationsAPI.requestCaptain({
|
||||
method: options.method ?? 'GET',
|
||||
route: path,
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
json: () => {
|
||||
return response.data;
|
||||
},
|
||||
ok: response.status >= 200 && response.status < 300,
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
router.push({ name: resolvedRoute.value });
|
||||
};
|
||||
|
||||
const captainIntegration = computed(() =>
|
||||
getters['integrations/getIntegration'].value('captain', null)
|
||||
);
|
||||
|
||||
onMounted(() => loadCaptainFrame(captainIntegration.value));
|
||||
|
||||
watch(captainIntegration, updatedIntegration =>
|
||||
loadCaptainFrame(updatedIntegration)
|
||||
watch(
|
||||
() => captainIntegration.value,
|
||||
(newValue, prevValue) => {
|
||||
if (!prevValue && newValue) {
|
||||
nextTick(() => buildApp());
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-1 overflow-auto flex gap-8 flex-col font-inter text-slate-900 dark:text-slate-500"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div v-if="!captainIntegration">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!captainIntegration.enabled"
|
||||
class="flex-1 flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
|
||||
<router-link :to="{ name: 'settings_applications' }">
|
||||
<woot-button class="clear link">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
|
||||
</woot-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isLoading"
|
||||
class="flex-1 flex items-center justify-center"
|
||||
>
|
||||
<Spinner color-scheme="primary" />
|
||||
<span>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.LOADING_CONSOLE') }}</span>
|
||||
</div>
|
||||
<div v-else-if="!isLoading && hasError">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.FAILED_TO_LOAD_CONSOLE') }}
|
||||
</div>
|
||||
<iframe
|
||||
v-else-if="!isLoading && captainURL"
|
||||
:src="captainURL"
|
||||
class="w-full min-h-[800px] h-full"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!captainIntegration">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!captainIntegration.enabled"
|
||||
class="flex-1 flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
|
||||
<router-link :to="{ name: 'settings_applications' }">
|
||||
<woot-button class="clear link">
|
||||
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
|
||||
</woot-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else id="captain" class="w-full" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '@chatwoot/captain/dist/style.css';
|
||||
</style>
|
||||
|
||||
@@ -21,13 +21,14 @@ export default {
|
||||
component: AppContainer,
|
||||
children: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain'),
|
||||
path: frontendURL('accounts/:accountId/captain/:page'),
|
||||
name: 'captain',
|
||||
component: Captain,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
|
||||
+23
-21
@@ -6,16 +6,6 @@ import { GROUP_BY_FILTER } from '../constants';
|
||||
import { generateFileName } from '../../../../../helper/downloadHelper';
|
||||
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
const REPORTS_KEYS = {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
INCOMING_MESSAGES: 'incoming_messages_count',
|
||||
OUTGOING_MESSAGES: 'outgoing_messages_count',
|
||||
FIRST_RESPONSE_TIME: 'avg_first_response_time',
|
||||
RESOLUTION_TIME: 'avg_resolution_time',
|
||||
RESOLUTION_COUNT: 'resolutions_count',
|
||||
REPLY_TIME: 'reply_time',
|
||||
};
|
||||
|
||||
const GROUP_BY_OPTIONS = {
|
||||
DAY: [{ id: 1, groupByKey: 'REPORT.GROUPING_OPTIONS.DAY' }],
|
||||
WEEK: [
|
||||
@@ -72,6 +62,22 @@ export default {
|
||||
filterItemsList() {
|
||||
return this.$store.getters[this.getterKey] || [];
|
||||
},
|
||||
isAgentType() {
|
||||
return this.type === 'agent';
|
||||
},
|
||||
reportKeys() {
|
||||
return {
|
||||
CONVERSATIONS: 'conversations_count',
|
||||
...(!this.isAgentType && {
|
||||
INCOMING_MESSAGES: 'incoming_messages_count',
|
||||
}),
|
||||
OUTGOING_MESSAGES: 'outgoing_messages_count',
|
||||
FIRST_RESPONSE_TIME: 'avg_first_response_time',
|
||||
RESOLUTION_TIME: 'avg_resolution_time',
|
||||
RESOLUTION_COUNT: 'resolutions_count',
|
||||
REPLY_TIME: 'reply_time',
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch(this.actionKey);
|
||||
@@ -92,19 +98,11 @@ export default {
|
||||
}
|
||||
},
|
||||
fetchChartData() {
|
||||
[
|
||||
'CONVERSATIONS',
|
||||
'INCOMING_MESSAGES',
|
||||
'OUTGOING_MESSAGES',
|
||||
'FIRST_RESPONSE_TIME',
|
||||
'RESOLUTION_TIME',
|
||||
'RESOLUTION_COUNT',
|
||||
'REPLY_TIME',
|
||||
].forEach(async key => {
|
||||
Object.keys(this.reportKeys).forEach(async key => {
|
||||
try {
|
||||
const { from, to, groupBy, businessHours } = this;
|
||||
this.$store.dispatch('fetchAccountReport', {
|
||||
metric: REPORTS_KEYS[key],
|
||||
metric: this.reportKeys[key],
|
||||
from,
|
||||
to,
|
||||
type: this.type,
|
||||
@@ -220,6 +218,10 @@ export default {
|
||||
@group-by-filter-change="onGroupByFilterChange"
|
||||
@business-hours-toggle="onBusinessHoursToggle"
|
||||
/>
|
||||
<ReportContainer v-if="filterItemsList.length" :group-by="groupBy" />
|
||||
<ReportContainer
|
||||
v-if="filterItemsList.length"
|
||||
:group-by="groupBy"
|
||||
:report-keys="reportKeys"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -151,8 +151,15 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateAvailability: async ({ commit, dispatch }, params) => {
|
||||
updateAvailability: async (
|
||||
{ commit, dispatch, getters: _getters },
|
||||
params
|
||||
) => {
|
||||
const previousStatus = _getters.getCurrentUserAvailability;
|
||||
|
||||
try {
|
||||
// optimisticly update current status
|
||||
commit(types.SET_CURRENT_USER_AVAILABILITY, params.availability);
|
||||
const response = await authAPI.updateAvailability(params);
|
||||
const userData = response.data;
|
||||
const { id } = userData;
|
||||
@@ -162,16 +169,23 @@ export const actions = {
|
||||
availabilityStatus: params.availability,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
// revert back to previous status if update fails
|
||||
commit(types.SET_CURRENT_USER_AVAILABILITY, previousStatus);
|
||||
}
|
||||
},
|
||||
|
||||
updateAutoOffline: async ({ commit }, { accountId, autoOffline }) => {
|
||||
updateAutoOffline: async (
|
||||
{ commit, getters: _getters },
|
||||
{ accountId, autoOffline }
|
||||
) => {
|
||||
const previousAutoOffline = _getters.getCurrentUserAutoOffline;
|
||||
|
||||
try {
|
||||
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, autoOffline);
|
||||
const response = await authAPI.updateAutoOffline(accountId, autoOffline);
|
||||
commit(types.SET_CURRENT_USER, response.data);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, previousAutoOffline);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -212,6 +226,19 @@ export const mutations = {
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.SET_CURRENT_USER_AUTO_OFFLINE](_state, autoOffline) {
|
||||
const accounts = _state.currentUser.accounts.map(account => {
|
||||
if (account.id === _state.currentUser.account_id) {
|
||||
return { ...account, autoOffline: autoOffline };
|
||||
}
|
||||
return account;
|
||||
});
|
||||
|
||||
_state.currentUser = {
|
||||
..._state.currentUser,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.CLEAR_USER](_state) {
|
||||
_state.currentUser = initialState.currentUser;
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import Cookies from 'js-cookie';
|
||||
import { actions } from '../../auth';
|
||||
import * as types from '../../../mutation-types';
|
||||
import types from '../../../mutation-types';
|
||||
import * as APIHelpers from '../../../utils/api';
|
||||
import '../../../../routes';
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('#actions', () => {
|
||||
await actions.validityCheck({ commit });
|
||||
expect(APIHelpers.setUser).toHaveBeenCalledTimes(1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CURRENT_USER, { id: 1, name: 'John' }],
|
||||
[types.SET_CURRENT_USER, { id: 1, name: 'John' }],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
@@ -45,7 +45,7 @@ describe('#actions', () => {
|
||||
});
|
||||
await actions.updateProfile({ commit }, { name: 'Pranav' });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CURRENT_USER, { id: 1, name: 'John' }],
|
||||
[types.SET_CURRENT_USER, { id: 1, name: 'John' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -61,12 +61,13 @@ describe('#actions', () => {
|
||||
headers: { expiry: 581842904 },
|
||||
});
|
||||
await actions.updateAvailability(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
|
||||
{ availability: 'offline', account_id: 1 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
|
||||
[
|
||||
types.default.SET_CURRENT_USER,
|
||||
types.SET_CURRENT_USER,
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
@@ -81,6 +82,18 @@ describe('#actions', () => {
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is a failure', async () => {
|
||||
axios.post.mockRejectedValue({ error: 'Authentication Failure' });
|
||||
await actions.updateAvailability(
|
||||
{ commit, dispatch, getters: { getCurrentUserAvailability: 'online' } },
|
||||
{ availability: 'offline', account_id: 1 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CURRENT_USER_AVAILABILITY, 'offline'],
|
||||
[types.SET_CURRENT_USER_AVAILABILITY, 'online'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateAutoOffline', () => {
|
||||
@@ -99,12 +112,13 @@ describe('#actions', () => {
|
||||
headers: { expiry: 581842904 },
|
||||
});
|
||||
await actions.updateAutoOffline(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
|
||||
{ autoOffline: false, accountId: 1 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
|
||||
[
|
||||
types.default.SET_CURRENT_USER,
|
||||
types.SET_CURRENT_USER,
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
@@ -113,6 +127,17 @@ describe('#actions', () => {
|
||||
],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is failure', async () => {
|
||||
axios.post.mockRejectedValue({ error: 'Authentication Failure' });
|
||||
await actions.updateAutoOffline(
|
||||
{ commit, dispatch, getters: { getCurrentUserAutoOffline: true } },
|
||||
{ autoOffline: false, accountId: 1 }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CURRENT_USER_AUTO_OFFLINE, false],
|
||||
[types.SET_CURRENT_USER_AUTO_OFFLINE, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateUISettings', () => {
|
||||
@@ -132,11 +157,11 @@ describe('#actions', () => {
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[
|
||||
types.default.SET_CURRENT_USER_UI_SETTINGS,
|
||||
types.SET_CURRENT_USER_UI_SETTINGS,
|
||||
{ uiSettings: { is_contact_sidebar_open: false } },
|
||||
],
|
||||
[
|
||||
types.default.SET_CURRENT_USER,
|
||||
types.SET_CURRENT_USER,
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
@@ -160,8 +185,8 @@ describe('#actions', () => {
|
||||
Cookies.get.mockImplementation(() => false);
|
||||
actions.setUser({ commit, dispatch });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.CLEAR_USER],
|
||||
[types.default.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
|
||||
[types.CLEAR_USER],
|
||||
[types.SET_CURRENT_USER_UI_FLAGS, { isFetching: false }],
|
||||
]);
|
||||
expect(dispatch).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -177,7 +202,7 @@ describe('#actions', () => {
|
||||
{ 1: 'online' }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CURRENT_USER_AVAILABILITY, 'online'],
|
||||
[types.SET_CURRENT_USER_AVAILABILITY, 'online'],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
CLEAR_USER: 'LOGOUT',
|
||||
SET_CURRENT_USER: 'SET_CURRENT_USER',
|
||||
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
|
||||
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
|
||||
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
|
||||
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import './design-system/histoire.scss';
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue';
|
||||
import i18nMessages from 'dashboard/i18n';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import store from 'dashboard/store';
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -13,4 +14,5 @@ const i18n = createI18n({
|
||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.directive('resize', vResizeObserver);
|
||||
});
|
||||
|
||||
@@ -114,31 +114,10 @@ function setContextValue(code) {
|
||||
context.node.input(localValue.value);
|
||||
}
|
||||
|
||||
function dynamicallySetCountryCode(value) {
|
||||
const safeValue = unref(value);
|
||||
// This function is used to set the country code dynamically.
|
||||
// The country and dial code is used to set from the value of the phone number field in the pre-chat form.
|
||||
if (!safeValue) return;
|
||||
|
||||
// check the number first four digit and check weather it is available in the countries array or not.
|
||||
const country = countries.value.find(code =>
|
||||
safeValue.startsWith(code.dial_code)
|
||||
);
|
||||
|
||||
if (country) {
|
||||
// if it is available then set the country code and dial code.
|
||||
activeCountryCode.value = country.id;
|
||||
activeDialCode.value = country.dial_code;
|
||||
// set the phone number without dial code.
|
||||
phoneNumber.value = safeValue.replace(country.dial_code, '');
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(e) {
|
||||
phoneNumber.value = e.target.value;
|
||||
dynamicallySetCountryCode(phoneNumber);
|
||||
// This function is used to set the context value when the user types in the phone number field.
|
||||
setContextValue(activeDialCode);
|
||||
setContextValue(activeDialCode.value);
|
||||
}
|
||||
|
||||
function focusedOrActiveItem(className) {
|
||||
@@ -157,8 +136,8 @@ function scrollToFocusedOrActiveItem(item) {
|
||||
if (focusedOrActiveItemLocal.length > 0) {
|
||||
const dropdown = dropdownRef.value;
|
||||
const dropdownHeight = dropdown.clientHeight;
|
||||
const itemTop = focusedOrActiveItem[0].offsetTop;
|
||||
const itemHeight = focusedOrActiveItem[0].offsetHeight;
|
||||
const itemTop = focusedOrActiveItemLocal[0]?.offsetTop;
|
||||
const itemHeight = focusedOrActiveItemLocal[0]?.offsetHeight;
|
||||
const scrollPosition = itemTop - dropdownHeight / 2 + itemHeight / 2;
|
||||
dropdown.scrollTo({
|
||||
top: scrollPosition,
|
||||
|
||||
@@ -8,8 +8,21 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
avatar_url,
|
||||
max_size: 15 * 1024 * 1024
|
||||
)
|
||||
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
|
||||
if valid_image?(avatar_file)
|
||||
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename,
|
||||
content_type: avatar_file.content_type)
|
||||
end
|
||||
rescue Down::NotFound, Down::Error => e
|
||||
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_image?(file)
|
||||
return false if file.original_filename.blank?
|
||||
|
||||
# TODO: check if the file is an actual image
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class Migration::Search::MessageReindexJob < ApplicationJob
|
||||
queue :searchkick
|
||||
|
||||
|
||||
end
|
||||
@@ -38,10 +38,6 @@
|
||||
#
|
||||
|
||||
class Message < ApplicationRecord
|
||||
searchkick batch_size: 10_000 if SearchConfig.enabled?
|
||||
|
||||
scope :search_import, -> { includes(:inbox, :conversation, :sender) }
|
||||
|
||||
include MessageFilterHelpers
|
||||
include Liquidable
|
||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||
|
||||
@@ -77,7 +77,7 @@ class MailPresenter < SimpleDelegator
|
||||
mail.attachments.map do |attachment|
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(attachment.body.to_s),
|
||||
filename: attachment.filename,
|
||||
filename: attachment.filename.presence || "attachment_#{SecureRandom.hex(4)}",
|
||||
content_type: attachment.content_type
|
||||
)
|
||||
{ original: attachment, blob: blob }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class Line::IncomingMessageService
|
||||
include ::FileTypeHelper
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/iphone/sticker.png'.freeze
|
||||
LINE_STICKER_IMAGE_URL = 'https://stickershop.line-scdn.net/stickershop/v1/sticker/%s/android/sticker.png'.freeze
|
||||
|
||||
def perform
|
||||
# probably test events
|
||||
|
||||
@@ -34,22 +34,11 @@ class SearchService
|
||||
end
|
||||
|
||||
def filter_messages
|
||||
@messages = if SearchConfig.enabled?
|
||||
Message.search(
|
||||
search_query, where: {
|
||||
inbox_id: accessable_inbox_ids,
|
||||
account_id: current_account.id
|
||||
}, order: { created_at: :desc }, limit: 20
|
||||
)
|
||||
else
|
||||
current_account
|
||||
.messages
|
||||
.where(inbox_id: accessable_inbox_ids)
|
||||
.where('messages.content ILIKE :search', search: "%#{search_query}%")
|
||||
.where('created_at >= ?', 3.months.ago)
|
||||
.reorder('created_at DESC')
|
||||
.limit(10)
|
||||
end
|
||||
@messages = current_account.messages.where(inbox_id: accessable_inbox_ids)
|
||||
.where('messages.content ILIKE :search', search: "%#{search_query}%")
|
||||
.where('created_at >= ?', 3.months.ago)
|
||||
.reorder('created_at DESC')
|
||||
.limit(10)
|
||||
end
|
||||
|
||||
def filter_contacts
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '3.14.1'
|
||||
version: '3.15.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -27,6 +27,12 @@ if ENV.fetch('SENTRY_DSN', false).present?
|
||||
require 'sentry-sidekiq'
|
||||
end
|
||||
|
||||
# heroku autoscaling
|
||||
if ENV.fetch('JUDOSCALE_URL', false).present?
|
||||
require 'judoscale-rails'
|
||||
require 'judoscale-sidekiq'
|
||||
end
|
||||
|
||||
module Chatwoot
|
||||
class Application < Rails::Application
|
||||
# Initialize configuration defaults for originally generated Rails version.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
require 'rack-timeout'
|
||||
|
||||
# Reduce noise by filtering state=ready and state=completed which are logged at INFO level
|
||||
Rails.application.config.after_initialize do
|
||||
Rack::Timeout::Logger.level = Logger::ERROR
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
class SearchConfig
|
||||
class << self
|
||||
def enabled?
|
||||
opensearch_url.present?
|
||||
end
|
||||
|
||||
def setup!
|
||||
return unless enabled?
|
||||
|
||||
Searchkick.client_options = client_options
|
||||
end
|
||||
|
||||
def client_options
|
||||
{
|
||||
url: opensearch_url,
|
||||
transport_options: {
|
||||
request: { timeout: timeout }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def opensearch_url
|
||||
ENV.fetch('OPENSEARCH_URL', nil)
|
||||
end
|
||||
|
||||
def timeout
|
||||
ENV.fetch('OPENSEARCH_TIMEOUT', 10).to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
+21
-2
@@ -17,6 +17,14 @@ common: &default_settings
|
||||
|
||||
distributed_tracing:
|
||||
enabled: true
|
||||
sampling:
|
||||
rate: 0.1 # Sample 10% of traces instead of 100%
|
||||
|
||||
# Source: https://docs.newrelic.com/docs/apm/agents/ruby-agent/configuration/ruby-agent-configuration/#attributes
|
||||
attributes:
|
||||
exclude:
|
||||
- request.headers.* # Exclude request headers from traces
|
||||
- response.headers.* # Exclude response headers from traces
|
||||
|
||||
# To disable the agent regardless of other settings, uncomment the following:
|
||||
agent_enabled: <%= ENV['NEW_RELIC_LICENSE_KEY'].present? && ENV.fetch('NEW_RELIC_AGENT_ENABLED', true) %>
|
||||
@@ -32,7 +40,7 @@ common: &default_settings
|
||||
# If `true`, the agent captures log records emitted by this application
|
||||
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED', true) == "false" ? false : true %>
|
||||
# Defines the maximum number of log records to buffer in memory at a time.
|
||||
max_samples_stored: 30000
|
||||
max_samples_stored: 10000
|
||||
metrics:
|
||||
# If `true`, the agent captures metrics related to logging for this application.
|
||||
enabled: true
|
||||
@@ -42,6 +50,16 @@ common: &default_settings
|
||||
# This should not be used when forwarding is enabled.
|
||||
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_DECORATING_ENABLED', false) %>
|
||||
|
||||
# Transaction Tracer settings
|
||||
# Source: https://docs.newrelic.com/docs/apm/agents/ruby-agent/configuration/ruby-agent-configuration/#transaction_tracer
|
||||
transaction_tracer:
|
||||
enabled: true # default: true
|
||||
transaction_threshold: 4.0 # default 2s to 4s - only trace slower transactions
|
||||
record_sql: obfuscated # Keep default - helps with security
|
||||
stack_trace_threshold: 4.0 # Increase from 0.5s to 4s - reduce stack traces
|
||||
explain_enabled: false # Disable SQL explain plans (default: true)
|
||||
explain_threshold: 5.0 # Increase from 0.5s to 5s, only relevant if explain_enabled is true
|
||||
|
||||
|
||||
# Environment-specific settings are in this section.
|
||||
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
|
||||
@@ -53,11 +71,12 @@ development:
|
||||
test:
|
||||
<<: *default_settings
|
||||
# It doesn't make sense to report to New Relic from automated test runs.
|
||||
monitor_mode: false
|
||||
monitor_mode: <%= ENV.fetch('NEW_RELIC_MONITORING_ENABLED', false) %>
|
||||
|
||||
staging:
|
||||
<<: *default_settings
|
||||
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %> (Staging)
|
||||
monitor_mode: <%= ENV.fetch('NEW_RELIC_MONITORING_ENABLED', false) %>
|
||||
|
||||
production:
|
||||
<<: *default_settings
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@ Rails.application.routes.draw do
|
||||
resources :apps, only: [:index, :show]
|
||||
resource :captain, controller: 'captain', only: [] do
|
||||
collection do
|
||||
get :sso_url
|
||||
post :proxy
|
||||
end
|
||||
end
|
||||
resources :hooks, only: [:show, :create, :update, :destroy] do
|
||||
|
||||
+16
-16
@@ -8,21 +8,21 @@ internal_check_new_versions_job:
|
||||
class: 'Internal::CheckNewVersionsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# # executed At every 5th minute..
|
||||
# trigger_scheduled_items_job:
|
||||
# cron: '*/5 * * * *'
|
||||
# class: 'TriggerScheduledItemsJob'
|
||||
# queue: scheduled_jobs
|
||||
# executed At every 5th minute..
|
||||
trigger_scheduled_items_job:
|
||||
cron: '*/5 * * * *'
|
||||
class: 'TriggerScheduledItemsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# # executed At every minute..
|
||||
# trigger_imap_email_inboxes_job:
|
||||
# cron: '*/1 * * * *'
|
||||
# class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||
# queue: scheduled_jobs
|
||||
# executed At every minute..
|
||||
trigger_imap_email_inboxes_job:
|
||||
cron: '*/1 * * * *'
|
||||
class: 'Inboxes::FetchImapEmailInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# # executed daily at 2230 UTC
|
||||
# # which is our lowest traffic time
|
||||
# remove_stale_contact_inboxes_job.rb:
|
||||
# cron: '30 22 * * *'
|
||||
# class: 'Internal::RemoveStaleContactInboxesJob'
|
||||
# queue: scheduled_jobs
|
||||
# executed daily at 2230 UTC
|
||||
# which is our lowest traffic time
|
||||
remove_stale_contact_inboxes_job.rb:
|
||||
cron: '30 22 * * *'
|
||||
class: 'Internal::RemoveStaleContactInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
- action_mailbox_routing
|
||||
- low
|
||||
- scheduled_jobs
|
||||
- searchkick
|
||||
- async_database_migration
|
||||
- active_storage_analysis
|
||||
- active_storage_purge
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "3.14.1",
|
||||
"version": "3.15.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -31,6 +31,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/captain": "0.0.3-alpha.4",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||
"@chatwoot/utils": "^0.0.25",
|
||||
@@ -83,7 +84,7 @@
|
||||
"video.js": "7.18.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"vue": "^3.5.8",
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
"vue-dompurify-html": "^5.1.0",
|
||||
@@ -101,10 +102,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.8.1",
|
||||
"@iconify-json/ri": "^1.2.1",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@iconify-json/logos": "^1.2.0",
|
||||
"@iconify-json/lucide": "^1.2.10",
|
||||
"@iconify-json/logos": "^1.2.3",
|
||||
"@iconify-json/lucide": "^1.2.11",
|
||||
"@iconify-json/ri": "^1.2.3",
|
||||
"@size-limit/file": "^8.2.4",
|
||||
"@vitest/coverage-v8": "2.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
|
||||
Generated
+492
-141
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,79 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Captain Integrations API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:account) { create(:account) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:hook) do
|
||||
create(:integrations_hook, account: account, app_id: 'captain', settings: {
|
||||
access_token: SecureRandom.hex,
|
||||
account_email: Faker::Internet.email,
|
||||
assistant_id: '1',
|
||||
account_id: '1'
|
||||
})
|
||||
end
|
||||
let(:captain_api_url) { 'https://captain.example.com/' }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/integrations/captain/sso_url' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'CAPTAIN_API_URL').first_or_create(value: captain_api_url)
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/integrations/captain/proxy' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
as: :json
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: 'get', route: 'some_route' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'return unauthorized if agent' do
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
context 'when valid request method and route' do
|
||||
let(:route) { 'some_route' }
|
||||
let(:method) { 'get' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
it 'proxies the request to Captain API' do
|
||||
stub_request(:get, "#{captain_api_url}api/accounts/#{hook.settings['account_id']}/#{route}")
|
||||
.with(headers: {
|
||||
'X-User-Email' => hook.settings['account_email'],
|
||||
'X-User-Token' => hook.settings['access_token'],
|
||||
'Content-Type' => 'application/json'
|
||||
})
|
||||
.to_return(status: 200, body: 'Success', headers: {})
|
||||
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: method, route: route },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to eq('Success')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns 404 if hook is not available' do
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
context 'when HTTP method is invalid' do
|
||||
it 'returns unprocessable entity' do
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: 'invalid', route: 'some_route', body: { some: 'data' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns sso url if hook is available' do
|
||||
InstallationConfig.where(name: 'CAPTAIN_APP_URL').first_or_create(value: 'https://app.chatwoot.com')
|
||||
context 'when the hook is not found' do
|
||||
before { hook.destroy }
|
||||
|
||||
hook = create(:integrations_hook, account: account, app_id: 'captain', settings: {
|
||||
access_token: SecureRandom.hex,
|
||||
account_email: Faker::Internet.email,
|
||||
account_id: '1',
|
||||
assistant_id: '1',
|
||||
inbox_ids: '1'
|
||||
})
|
||||
it 'returns not found' do
|
||||
post proxy_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: { method: 'get', route: 'some_route' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
get sso_url_api_v1_account_integrations_captain_url(account_id: account.id),
|
||||
params: {},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
params_string = "token=#{URI.encode_www_form_component(hook['settings']['access_token'])}" \
|
||||
"&email=#{URI.encode_www_form_component(hook['settings']['account_email'])}" \
|
||||
"&account_id=#{URI.encode_www_form_component(hook['settings']['account_id'])}"
|
||||
|
||||
sso_url = "https://app.chatwoot.com/sso?#{params_string}"
|
||||
expect(data['sso_url']).to eq(sso_url)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,11 +60,10 @@ RSpec.describe 'Platform Accounts API', type: :request do
|
||||
} }, headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
|
||||
json_response = response.parsed_body
|
||||
created_account = Account.find(json_response['id'])
|
||||
expect(created_account.enabled_features.keys).to match_array(%w[inbox_management ip_lookup help_center])
|
||||
expect(json_response['name']).to include('Test Account')
|
||||
expect(json_response['features']['inbox_management']).to be(true)
|
||||
expect(json_response['features']['ip_lookup']).to be(true)
|
||||
expect(json_response['features']['help_center']).to be(true)
|
||||
expect(json_response['features']['disable_branding']).to be_nil
|
||||
expect(json_response['features'].keys).to match_array(%w[inbox_management ip_lookup help_center])
|
||||
end
|
||||
|
||||
it 'creates an account with limits settings' do
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
From: test@gmail.com
|
||||
Date: Thu, 4 May 2023 10:35:52 +0530
|
||||
Message-ID: <6215d536e0484_10bc6191402183@tejaswinis-MacBook-Pro.local.mail>
|
||||
Subject: multiple attachments
|
||||
To: test@outlook.com
|
||||
Content-Type: multipart/mixed; boundary="0000000000002488f405fad721cc"
|
||||
|
||||
--0000000000002488f405fad721cc
|
||||
Content-Type: multipart/alternative; boundary="0000000000002488f205fad721ca"
|
||||
|
||||
--0000000000002488f205fad721ca
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Hi people!
|
||||
|
||||
We are excited to inform you that we have recently released some new
|
||||
features and several updates to our platform. These features and updates
|
||||
are designed to enhance your experience and make your trading journey
|
||||
seamless and efficient.
|
||||
|
||||
|
||||
|
||||
> Okay noted
|
||||
|
||||
|
||||
--0000000000002488f205fad721ca--
|
||||
--0000000000002488f405fad721cc
|
||||
Content-Type: image/png; name=""
|
||||
Content-Disposition: attachment; filename=""
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <f_lh8nwk8l3>
|
||||
X-Attachment-Id: f_lh8nwk8l3
|
||||
|
||||
|
||||
--0000000000002488f405fad721cc
|
||||
Content-Type: image/png; name=""
|
||||
Content-Disposition: attachment; filename=""
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <f_lh8nwk8l2>
|
||||
X-Attachment-Id: f_lh8nwk8l2
|
||||
|
||||
|
||||
--0000000000002488f405fad721cc--
|
||||
@@ -17,4 +17,20 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
||||
described_class.perform_now(avatarable, avatar_url)
|
||||
expect(avatarable.avatar).to be_attached
|
||||
end
|
||||
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/10449
|
||||
it 'will not throw error if the avatar url is not valid and the file does not have a filename' do
|
||||
# Create a temporary file with no filename and content type application/xml
|
||||
temp_file = Tempfile.new(['invalid', '.xml'])
|
||||
temp_file.write('<invalid>content</invalid>')
|
||||
temp_file.rewind
|
||||
|
||||
expect(Down).to receive(:download).with(avatar_url, max_size: 15 * 1024 * 1024)
|
||||
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
|
||||
|
||||
expect { described_class.perform_now(avatarable, avatar_url) }.not_to raise_error
|
||||
|
||||
temp_file.close
|
||||
temp_file.unlink # deletes the temp file
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,6 +43,22 @@ RSpec.describe Imap::ImapMailbox do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email has attachments with no filename' do
|
||||
let(:inbound_mail) { create_inbound_email_from_fixture('attachments_without_filename.eml') }
|
||||
|
||||
it 'creates a conversation and a message with properly named attachments' do
|
||||
expect do
|
||||
class_instance.process(inbound_mail.mail, channel)
|
||||
end.to change(Conversation, :count).by(1)
|
||||
|
||||
last_message = conversation.messages.last
|
||||
expect(last_message.attachments.count).to be 2
|
||||
|
||||
filenames = last_message.attachments.map(&:file).map { |file| file.blob.filename.to_s }
|
||||
expect(filenames.all? { |filename| filename.present? && filename.start_with?('attachment_') }).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email has 15 or more attachments' do
|
||||
let(:inbound_mail) { create_inbound_email_from_fixture('multiple_attachments.eml') }
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ describe Line::IncomingMessageService do
|
||||
described_class.new(inbox: line_channel.inbox, params: sticker_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -117,6 +117,13 @@ const tailwindConfig = {
|
||||
width: 2,
|
||||
height: 12,
|
||||
},
|
||||
captain: {
|
||||
body: `<path d="M150.485 213.282C150.485 200.856 160.559 190.782 172.985 190.782C185.411 190.782 195.485 200.856 195.485 213.282V265.282C195.485 277.709 185.411 287.782 172.985 287.782C160.559 287.782 150.485 277.709 150.485 265.282V213.282Z" fill="currentColor"/>
|
||||
<path d="M222.485 213.282C222.485 200.856 232.559 190.782 244.985 190.782C257.411 190.782 267.485 200.856 267.485 213.282V265.282C267.485 277.709 257.411 287.782 244.985 287.782C232.559 287.782 222.485 277.709 222.485 265.282V213.282Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412.222 109.961C317.808 96.6217 240.845 96.0953 144.309 109.902C119.908 113.392 103.762 115.751 91.4521 119.354C80.0374 122.694 73.5457 126.678 68.1762 132.687C57.0576 145.13 55.592 159.204 54.0765 208.287C52.587 256.526 55.5372 299.759 61.1249 348.403C64.1025 374.324 66.1515 391.817 69.4229 405.117C72.526 417.732 76.2792 424.515 81.4954 429.708C86.7533 434.942 93.4917 438.633 105.859 441.629C118.94 444.797 136.104 446.713 161.613 449.5C244.114 458.514 305.869 458.469 388.677 449.548C414.495 446.767 431.939 444.849 445.216 441.702C457.83 438.712 464.612 435.047 469.797 429.962C474.873 424.985 478.752 418.118 482.116 404.874C485.626 391.056 488.014 372.772 491.47 345.913C497.636 297.99 502.076 255.903 502.248 209.798C502.433 160.503 501.426 146.477 490.181 133.468C484.75 127.185 478.148 123.053 466.473 119.612C453.865 115.897 437.283 113.502 412.222 109.961ZM138.414 68.5711C238.977 54.1882 319.888 54.7514 418.047 68.6199L419.483 68.8227C442.724 72.1054 462.359 74.8786 478.244 79.5601C495.387 84.6124 509.724 92.2821 521.706 106.145C544.308 132.295 544.161 163.321 543.965 204.542C543.956 206.327 543.948 208.131 543.941 209.954C543.758 258.703 539.048 302.844 532.821 351.247L532.656 352.528C529.407 377.787 526.729 398.602 522.522 415.166C518.098 432.584 511.485 447.517 498.968 459.792C486.56 471.959 471.897 478.282 454.819 482.33C438.691 486.153 418.624 488.314 394.436 490.919L393.136 491.059C307.385 500.297 242.618 500.349 157.091 491.004L155.772 490.86C131.921 488.255 112.062 486.086 96.056 482.209C79.0408 478.087 64.4759 471.637 52.1005 459.316C39.6835 446.955 33.1618 432.265 28.94 415.102C24.9582 398.915 22.6435 378.759 19.8561 354.488L19.7052 353.174C13.9746 303.287 10.8315 257.908 12.4035 206.997C12.4606 205.15 12.5151 203.323 12.5691 201.516C13.7911 160.603 14.7077 129.914 37.1055 104.847C48.989 91.5477 63.035 84.1731 79.7563 79.2794C95.2643 74.7408 114.386 72.0068 137.018 68.7707C137.482 68.7044 137.948 68.6379 138.414 68.5711Z" fill="currentColor"/>`,
|
||||
width: 556,
|
||||
height: 556,
|
||||
},
|
||||
},
|
||||
},
|
||||
...getIconCollections(['lucide', 'logos', 'ri']),
|
||||
|
||||
Reference in New Issue
Block a user