Compare commits

..
Author SHA1 Message Date
Pranav 5171c81384 Add elastic search to the query 2024-11-08 14:21:59 -08:00
201 changed files with 1883 additions and 8729 deletions
-2
View File
@@ -57,5 +57,3 @@ exclude_patterns:
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+4 -4
View File
@@ -49,7 +49,7 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
##-- gems for database --#
@@ -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
+32 -32
View File
@@ -209,7 +209,7 @@ GEM
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1)
digest-crc (0.6.5)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
@@ -284,39 +284,39 @@ GEM
activesupport (>= 6.1)
gmail_xoauth (0.4.3)
oauth (>= 0.3.6)
google-apis-core (0.15.1)
google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.47.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.19.0)
google-apis-core (>= 0.9.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (2.2.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.52.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.19.0)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
@@ -331,10 +331,10 @@ GEM
grpc (~> 1.41)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
googleauth (1.5.2)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
@@ -397,13 +397,6 @@ 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)
@@ -462,6 +455,7 @@ GEM
net-smtp
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
@@ -479,7 +473,6 @@ GEM
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.4.1)
@@ -539,6 +532,9 @@ 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)
@@ -710,6 +706,9 @@ 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)
@@ -824,6 +823,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -885,7 +885,7 @@ DEPENDENCIES
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-storage (>= 1.48.0)
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
groupdate
grpc
@@ -897,8 +897,6 @@ DEPENDENCIES
jbuilder
json_refs
json_schemer
judoscale-rails
judoscale-sidekiq
jwt
kaminari
koala
@@ -918,6 +916,7 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
opensearch-ruby
pg
pg_search
pgvector
@@ -943,6 +942,7 @@ DEPENDENCIES
rubocop-rspec
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
@@ -1,53 +1,22 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
before_action :check_admin_authorization?
before_action :fetch_hook
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
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
end
private
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: {})
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
end
end
@@ -4,7 +4,6 @@ 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
+2 -3
View File
@@ -26,8 +26,7 @@ class AccountDashboard < Administrate::BaseDashboard
conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany,
custom_attributes: Field::String
account_users: Field::HasMany
}.merge(enterprise_attribute_types).freeze
# COLLECTION_ATTRIBUTES
@@ -46,7 +45,7 @@ class AccountDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
SHOW_PAGE_ATTRIBUTES = (%i[
id
name
+12 -101
View File
@@ -1,20 +1,12 @@
/* global axios */
const DEFAULT_API_VERSION = 'v1';
const DEFAULT_OPTIONS = {
apiVersion: DEFAULT_API_VERSION,
enterprise: false,
accountScoped: false,
cancelPreviousRequests: false,
};
class ApiClient {
constructor(resource, options = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
this.apiVersion = `/api/${this.options.apiVersion}`;
this.apiVersion = `/api/${options.apiVersion || DEFAULT_API_VERSION}`;
this.options = options;
this.resource = resource;
this.abortControllers = new Map();
this.activeRequests = new Map();
}
get url() {
@@ -47,105 +39,24 @@ class ApiClient {
return url;
}
generateRequestKey(method, id = '', customRequestKey = '') {
if (customRequestKey) return customRequestKey;
return `${method}-${this.url}${id ? `/${id}` : ''}`;
get() {
return axios.get(this.url);
}
cancelRequest(requestKey) {
const controller = this.abortControllers.get(requestKey);
if (controller) {
controller.abort();
this.abortControllers.delete(requestKey);
return true;
}
return false;
show(id) {
return axios.get(`${this.url}/${id}`);
}
setupRequest(method, options = {}) {
if (!this.options.cancelPreviousRequests) {
return {};
}
const { id, customRequestKey } = options;
const requestKey = this.generateRequestKey(method, id, customRequestKey);
const previousRequests = this.activeRequests.get(method) || [];
const remainingRequests = previousRequests.filter(key => {
if (key === requestKey) {
this.cancelRequest(key);
return false;
}
return true;
});
this.activeRequests.set(method, [...remainingRequests, requestKey]);
const controller = new AbortController();
this.abortControllers.set(requestKey, controller);
return { requestKey, signal: controller.signal };
create(data) {
return axios.post(this.url, data);
}
async request(method, config = {}) {
const { id, data, customRequestKey, ...axiosConfig } = config;
const { requestKey, signal } = this.setupRequest(method, {
id,
customRequestKey,
});
try {
const url = id ? `${this.url}/${id}` : this.url;
return await axios({
method,
url,
data,
signal,
...axiosConfig,
});
} finally {
this.abortControllers.delete(requestKey);
if (this.options.cancelPreviousRequests) {
const methodRequests = this.activeRequests.get(method) || [];
this.activeRequests.set(
method,
methodRequests.filter(key => key !== requestKey)
);
}
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, data);
}
get(config = {}) {
return this.request('GET', config);
}
show(id, config = {}) {
return this.request('GET', { ...config, id });
}
create(data, config = {}) {
return this.request('POST', { ...config, data });
}
update(id, data, config = {}) {
return this.request('PATCH', { ...config, id, data });
}
delete(id, config = {}) {
return this.request('DELETE', { ...config, id });
}
cancelAllRequests() {
this.abortControllers.forEach(controller => controller.abort());
this.abortControllers.clear();
this.activeRequests.clear();
}
// Get all active request keys
getActiveRequests() {
return Array.from(this.abortControllers.keys());
}
destroy() {
this.cancelAllRequests();
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
+2 -2
View File
@@ -33,8 +33,8 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
}
}
+3 -10
View File
@@ -5,11 +5,7 @@ const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
class ReportsAPI extends ApiClient {
constructor() {
super('reports', {
accountScoped: true,
apiVersion: 'v2',
cancelPreviousRequests: true,
});
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getReports({
@@ -21,8 +17,7 @@ class ReportsAPI extends ApiClient {
groupBy,
businessHours,
}) {
return this.request('GET', {
customRequestKey: `${metric}-report-GET`,
return axios.get(`${this.url}`, {
params: {
metric,
since: from,
@@ -52,9 +47,7 @@ class ReportsAPI extends ApiClient {
}
getConversationMetric(type = 'account', page = 1) {
return this.request('GET', {
requestPath: `${this.url}/conversations`,
return axios.get(`${this.url}/conversations`, {
params: {
type,
page,
@@ -25,7 +25,7 @@
}
.mx-input {
@apply h-[2.5rem] flex border border-solid border-n-weak rounded-md shadow-none;
@apply h-[2.5rem] flex border border-solid border-slate-200 dark:border-slate-600 rounded-md shadow-none;
}
.mx-input:disabled,
@@ -39,7 +39,7 @@
}
.mx-datepicker-main {
@apply border-0 bg-n-solid-2 rounded-xl;
@apply border-0 bg-white dark:bg-slate-800;
.cell {
&.disabled {
@@ -53,14 +53,6 @@
}
}
.mx-calendar+.mx-calendar {
@apply border-l border-n-weak;
}
.mx-datepicker-footer {
@apply border border-n-weak;
}
.mx-time {
@apply border-0 bg-white dark:bg-slate-800;
@@ -31,13 +31,7 @@ hr {
ul,
ol,
dl {
@apply list-disc list-outside leading-[1.65];
}
ul:not(.reset-base),
ol:not(.reset-base),
dl:not(.reset-base) {
@apply mb-0;
@apply mb-2 list-disc list-outside leading-[1.65];
}
// 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,56 +82,60 @@ const inboxIcon = computed(() => {
</script>
<template>
<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>
<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>
</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
</template>
<template #footer>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
/>
</div>
</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>
</template>
</CardLayout>
</template>
@@ -1,8 +1,7 @@
<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({
@@ -34,11 +33,10 @@ 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">
<Avatar
<Thumbnail
:author="sender || { name: senderName }"
:name="senderName"
:src="senderThumbnailSrc"
:size="16"
rounded-full
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[900px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<div class="w-full max-w-[900px] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -27,6 +27,7 @@ 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,6 +27,7 @@ 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,6 +27,7 @@ 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"
@@ -63,12 +63,14 @@ defineExpose({ dialogRef });
overflow-y-auto
@confirm="handleSubmit"
>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
<template #form>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</template>
</Dialog>
</template>
@@ -1,13 +1,11 @@
<script setup>
defineProps({
const props = defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
};
@@ -15,18 +13,11 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
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"
>
<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" />
<slot name="header" />
<slot name="footer" />
</div>
</template>
@@ -1,102 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
isExpanded: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const { t } = useI18n();
const handleFormUpdate = updatedData => {
emit('updateContact', { id: props.id, updatedData });
};
const onClickViewDetails = async () => {
emit('showContact', props.id);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-between gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ name }}
</span>
<template v-if="additionalAttributes?.companyName">
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.CARD.OF') }}
</span>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ additionalAttributes.companyName }}
</span>
</template>
</div>
<div class="flex items-center gap-3">
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="emit('toggle')"
/>
<template #after>
<transition
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[360px]"
leave-from-class="opacity-100 max-h-[360px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
>
<div v-show="isExpanded" class="w-full">
<div class="p-6 border-t border-n-strong">
<ContactsForm
:contact-data="{
id,
name,
email,
phoneNumber,
additionalAttributes,
}"
@update="handleFormUpdate"
/>
</div>
</div>
</transition>
</template>
</CardLayout>
</template>
@@ -1,67 +0,0 @@
<script setup>
import { ref } from 'vue';
import ContactsCard from '../ContactsCard.vue';
import contacts from './fixtures';
const expandedCardId = ref(null);
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
</script>
<template>
<Story
title="Components/Contacts/ContactsCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default with expandable function">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="contacts[0]"
:is-expanded="expandedCardId === contacts[0].id"
@toggle="toggleExpanded(contacts[0].id)"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="With Company Name and without phone number">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="{ ...contacts[1], phoneNumber: '' }"
:is-expanded="false"
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="Expanded State">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="contacts[2]"
is-expanded
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
<Variant title="Without Email and Phone">
<div class="flex flex-col p-4">
<ContactsCard
v-bind="{ ...contacts[3], email: '', phoneNumber: '' }"
:is-expanded="false"
@toggle="() => {}"
@update-contact="() => {}"
@show-contact="() => {}"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,149 +0,0 @@
export default [
{
additionalAttributes: {
socialProfiles: {},
},
availabilityStatus: null,
email: 'johndoe@chatwoot.com',
id: 370,
name: 'John Doe',
phoneNumber: '+918634322418',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Felix',
customAttributes: {},
lastActivityAt: 1731608270,
createdAt: 1731586271,
},
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: null,
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Upload',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
{
additionalAttributes: {
city: 'Kerala',
country: 'India',
description:
"I'm Candice developer focusing on building things for the web 🌍. Currently, Im working as a Product Developer here at @chatwootapp ⚡️🔥",
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
availabilityStatus: null,
email: 'cmathersonj@va.test',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+917474774742',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Emery',
customAttributes: {
dateContact: '2024-11-12T03:23:06.963Z',
linkContact: 'https://sd.sd',
textContact: 'hey',
numberContact: '12',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
availabilityStatus: null,
email: 'ofolkardi@taobao.test',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBPZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--08dcac8eb72ef12b2cad92d58dddd04cd8a5f513/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/32.jpg',
customAttributes: {},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
socialProfiles: {},
},
availabilityStatus: null,
email: 'wcasteloth@exblog.jp',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+919384',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/thumbs/svg?seed=Jade',
customAttributes: {},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
availabilityStatus: null,
email: 'ederingtong@printfriendly.test',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Jade',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -1,112 +0,0 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
type: Object,
required: true,
},
primaryContactId: {
type: [Number, null],
default: null,
},
primaryContactList: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:primaryContactId', 'search']);
const { t } = useI18n();
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
</div>
<ComboBox
id="inbox"
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
isSearching
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
"
:search-placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
:has-error="hasError"
:message="errorMessage"
class="[&>div>button]:bg-n-alpha-black2"
@update:model-value="value => emit('update:primaryContactId', value)"
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
/>
<div
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="selectedContact.name || ''"
:src="selectedContact.thumbnail || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.email }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -1,297 +0,0 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { required, email, minLength } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
const props = defineProps({
contactData: {
type: Object,
default: null,
},
isDetailsView: {
type: Boolean,
default: false,
},
isNewContact: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
LAST_NAME: { field: 'lastName' },
EMAIL_ADDRESS: { field: 'email' },
PHONE_NUMBER: { field: 'phoneNumber' },
CITY: { field: 'additionalAttributes.city' },
COUNTRY: { field: 'additionalAttributes.country' },
BIO: { field: 'additionalAttributes.description' },
COMPANY_NAME: { field: 'additionalAttributes.companyName' },
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
TWITTER: 'i-ri-twitter-x-fill',
};
const defaultState = {
id: 0,
name: '',
email: '',
firstName: '',
lastName: '',
phoneNumber: '',
additionalAttributes: {
description: '',
companyName: '',
countryCode: '',
country: '',
city: '',
socialProfiles: {
facebook: '',
github: '',
instagram: '',
linkedin: '',
twitter: '',
},
},
};
const state = reactive({ ...defaultState });
const validationRules = {
firstName: { required, minLength: minLength(2) },
email: { email },
};
const v$ = useVuelidate(validationRules, state);
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
return; // Added to prevent state update for new contact form
}
const {
id,
name = '',
email: emailAddress,
phoneNumber,
additionalAttributes = {},
} = props.contactData || {};
const [firstName = '', lastName = ''] = name.split(' ');
const {
description,
companyName,
countryCode,
country,
city,
socialProfiles = {},
} = additionalAttributes || {};
Object.assign(state, {
id,
name,
firstName,
lastName,
email: emailAddress,
phoneNumber,
additionalAttributes: {
description,
companyName,
countryCode,
country,
city,
socialProfiles,
},
});
};
const countryOptions = computed(() =>
countries.map(({ name }) => ({ label: name, value: name }))
);
const editDetailsForm = computed(() =>
Object.keys(FORM_CONFIG).map(key => ({
key,
placeholder: t(
`CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM.${key}.PLACEHOLDER`
),
}))
);
const socialProfilesForm = computed(() =>
Object.entries(SOCIAL_CONFIG).map(([key, icon]) => ({
key,
placeholder: t(`CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.FORM.${key}.PLACEHOLDER`),
icon,
}))
);
const isValidationField = key => {
const field = FORM_CONFIG[key]?.field;
return ['firstName', 'email'].includes(field);
};
const getValidationKey = key => {
return FORM_CONFIG[key]?.field;
};
// Creates a computed property for two-way form field binding
const getFormBinding = key => {
const field = FORM_CONFIG[key]?.field;
if (!field) return null;
return computed({
get: () => {
// Handle firstName/lastName fields
if (field === 'firstName' || field === 'lastName') {
return state[field]?.toString() || '';
}
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
// Example: 'email' → state.email
// Example: 'additionalAttributes.city' → state.additionalAttributes.city
return (nested ? state[base][nested] : state[base])?.toString() || '';
},
set: async value => {
// Handle name fields specially to maintain the combined 'name' field
if (field === 'firstName' || field === 'lastName') {
state[field] = value;
// Example: firstName="John", lastName="Doe" → name="John Doe"
state.name = `${state.firstName} ${state.lastName}`.trim();
} else {
// Handle nested vs non-nested fields
const [base, nested] = field.split('.');
if (nested) {
// Example: additionalAttributes.city = "New York"
state[base][nested] = value;
} else {
// Example: email = "test@example.com"
state[base] = value;
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
const getMessageType = key => {
return isValidationField(key) && v$.value[getValidationKey(key)]?.$error
? 'error'
: 'info';
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
// Expose state to parent component for avatar upload
defineExpose({
state,
});
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-2 gap-4">
<template v-for="item in editDetailsForm" :key="item.key">
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.country"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
}"
@update:model-value="emit('update', state)"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error)]:!border-transparent' : ''
}`"
class="w-full"
@input="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
@blur="
isValidationField(item.key) &&
v$[getValidationKey(item.key)].$touch()
"
/>
</template>
</div>
</div>
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="item in socialProfilesForm"
:key="item.key"
class="flex items-center h-8 gap-2 px-2 rounded-lg"
:class="{
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
}"
>
<Icon
:icon="item.icon"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<input
v-model="
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
"
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
:placeholder="item.placeholder"
:size="item.placeholder.length"
@input="emit('update', state)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -1,73 +0,0 @@
<script setup>
import ContactMergeForm from '../ContactMergeForm.vue';
import { contactData, primaryContactList } from './fixtures';
const handleSearch = query => {
console.log('Searching for:', query);
};
const handleUpdate = value => {
console.log('Primary contact updated:', value);
};
</script>
<template>
<Story
title="Components/Contacts/ContactMergeForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="With Selected Primary Contact">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="1"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Error State">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
has-error
error-message="Please select a primary contact"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Empty Primary Contact List">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="[]"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,80 +0,0 @@
<script setup>
import ContactsForm from '../ContactsForm.vue';
import { contactData } from './fixtures';
const handleUpdate = updatedData => {
console.log('Form updated:', updatedData);
};
</script>
<template>
<Story
title="Components/Contacts/ContactsForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default without border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm :contact-data="contactData" @update="handleUpdate" />
</div>
</Variant>
<Variant title="Details View with border">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="contactData"
is-details-view
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="Minimal Data">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
id: 21,
name: 'Ophelia Folkard',
email: 'ofolkardi@taobao.test',
phoneNumber: '',
additionalAttributes: {
city: '',
country: '',
description: '',
companyName: '',
countryCode: '',
socialProfiles: {
github: '',
twitter: '',
facebook: '',
linkedin: '',
instagram: '',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
<Variant title="With All Social Profiles">
<div class="p-6 border rounded-lg border-n-strong">
<ContactsForm
:contact-data="{
...contactData,
additionalAttributes: {
...contactData.additionalAttributes,
socialProfiles: {
github: 'cmathersonj',
twitter: 'cmather',
facebook: 'cmathersonj',
linkedin: 'cmathersonj',
instagram: 'cmathersonjs',
},
},
}"
@update="handleUpdate"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,47 +0,0 @@
export const contactData = {
id: 370,
name: 'John Doe',
email: 'johndoe@chatwoot.com',
phoneNumber: '+918634322418',
additionalAttributes: {
city: 'Kerala',
country: 'India',
description: 'Curious about the web.',
companyName: 'Chatwoot',
countryCode: 'IN',
socialProfiles: {
github: 'johndoe',
twitter: 'johndoe',
facebook: 'johndoe',
linkedin: 'johndoe',
instagram: 'johndoe',
},
},
};
export const primaryContactList = [
{
id: 1,
name: 'Jane Smith',
email: 'jane@chatwoot.com',
thumbnail: '',
label: '(ID: 1) Jane Smith',
value: 1,
},
{
id: 2,
name: 'Mike Johnson',
email: 'mike@chatwoot.com',
thumbnail: '',
label: '(ID: 2) Mike Johnson',
value: 2,
},
{
id: 3,
name: 'Sarah Wilson',
email: 'sarah@chatwoot.com',
thumbnail: '',
label: '(ID: 3) Sarah Wilson',
value: 3,
},
];
@@ -1,97 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
required: true,
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits([
'search',
'filter',
'update:sort',
'message',
'add',
'import',
'export',
]);
</script>
<template>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<div class="flex items-center gap-2">
<Button
icon="i-lucide-list-filter"
color="slate"
size="sm"
variant="ghost"
@click="emit('filter')"
/>
<ContactSortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<ContactMoreActions
@add="emit('add')"
@import="emit('import')"
@export="emit('export')"
/>
</div>
<div class="w-px h-4 bg-n-strong" />
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
</div>
</div>
</header>
</template>
@@ -1,62 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const contactMenuItems = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
if (action === 'add') {
emit('add');
} else if (action === 'import') {
emit('import');
} else if (action === 'export') {
emit('export');
}
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
</template>
@@ -1,138 +0,0 @@
<script setup>
import { ref, computed, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
value: 'country',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
value: 'city',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
value: 'last_activity_at',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
// Converted the props to refs for better reactivity
const activeSort = toRef(props, 'activeSort');
const activeOrdering = toRef(props, 'activeOrdering');
const activeSortLabel = computed(() => {
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
return (
selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
);
});
const activeOrderingLabel = computed(() => {
const selectedMenu = orderingMenus.find(
menu => menu.value === activeOrdering.value
);
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL');
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
size="sm"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -1,106 +0,0 @@
<script setup>
import { ref } from 'vue';
import ContactHeader from '../ContactHeader.vue';
// Base state controls
const searchValue = ref('');
const activeSort = ref('last_activity_at');
const activeOrdering = ref('');
const onSearch = value => {
searchValue.value = value;
console.log('🔍 Search:', value);
};
const onSort = ({ sort, order }) => {
activeSort.value = sort;
activeOrdering.value = order;
console.log('🔄 Sort changed:', { sort, order });
};
const onFilter = () => {
console.log('🏷️ Filter clicked');
};
const onMessage = () => {
console.log('💬 Message clicked');
};
const onAdd = () => {
console.log(' Add contact clicked');
};
const onImport = () => {
console.log('📥 Import contacts clicked');
};
const onExport = () => {
console.log('📤 Export contacts clicked');
};
</script>
<template>
<Story
title="Components/Contacts/ContactHeader"
:layout="{ type: 'grid', width: '900px' }"
>
<Variant title="Default">
<div class="w-full h-[400px]">
<ContactHeader
header-title="Contacts"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
<Variant title="Empty State">
<div class="w-full">
<ContactHeader
:show-search="false"
header-title="Contacts"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
<Variant title="Segment View">
<div class="w-full">
<ContactHeader
:show-search="false"
header-title="Segment: VIP Customers"
button-label="Message"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@search="onSearch"
@filter="onFilter"
@update:sort="onSort"
@message="onMessage"
@add="onAdd"
@import="onImport"
@export="onExport"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,63 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
note: {
type: Object,
required: true,
},
writtenBy: {
type: String,
required: true,
},
});
const emit = defineEmits(['delete']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const handleDelete = () => {
emit('delete', props.note.id);
};
</script>
<template>
<div
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
</span>
</div>
</div>
<Button
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</template>
@@ -1,39 +0,0 @@
<script setup>
import ContactNoteItem from '../ContactNoteItem.vue';
import notes from './fixtures';
const controls = {
writtenBy: {
type: 'text',
default: 'You',
},
};
// Example delete handler
const onDelete = noteId => {
console.log('Note deleted:', noteId);
};
</script>
<template>
<Story
title="Components/Contacts/ContactNoteItem"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Multiple Notes">
<div class="flex flex-col border rounded-lg border-n-strong">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:written-by="
note.id === notes[1].id
? controls.writtenBy.default
: note.user.name
"
@delete="onDelete"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,69 +0,0 @@
export default [
{
id: 12,
content:
'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
accountId: null,
contactId: null,
user: {
id: 30,
account_id: 2,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'bruce@paperlayer.test',
available_name: 'Bruce',
name: 'Bruce',
role: 'administrator',
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
custom_role_id: null,
},
createdAt: 1730786556,
updatedAt: 1730786556,
},
{
id: 10,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* Theyll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'hillary@chatwoot.com',
available_name: 'Hillary',
name: 'Hillary',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782566,
updatedAt: 1730782566,
},
{
id: 9,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* Theyll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'john@chatwoot.com',
available_name: 'John',
name: 'John',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782564,
updatedAt: 1730782564,
},
];
@@ -1,28 +0,0 @@
<script setup>
import ContactEmptyState from './ContactEmptyState.vue';
</script>
<template>
<Story
title="Components/Contacts/EmptyState"
:layout="{ type: 'grid', width: '900px' }"
>
<!-- Default Story -->
<Variant title="Default">
<ContactEmptyState
title="No contacts found"
subtitle="Create your first contact to get started"
button-label="Add Contact"
/>
</Variant>
<!-- Without Button -->
<Variant title="Without Button">
<ContactEmptyState
title="No contacts"
subtitle="These are your current contacts"
:show-button="false"
/>
</Variant>
</Story>
</template>
@@ -1,51 +0,0 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
showButton: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ContactsCard
v-for="contact in contactContent.slice(0, 5)"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:is-expanded="0 === contact.id"
@toggle="toggleExpanded(contact.id)"
/>
</div>
</template>
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
</template>
</EmptyStateLayout>
</template>
@@ -1,228 +0,0 @@
export default [
{
additionalAttributes: {
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
twitter: 'candice_w_dev',
facebook: 'candice.dev',
linkedin: 'candice-matherson',
instagram: 'candice.codes',
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-11-11T11:53:09.299Z',
linkContact: 'https://example.com',
listContact: 'Follow-Up',
textContact: 'Hi there!',
numberContact: '42',
checkboxContact: false,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'San Francisco',
country: 'United States',
description: 'Passionate about design and user experience.',
companyName: 'Designify',
countryCode: 'US',
socialProfiles: {
github: 'ophelia-folkard',
twitter: 'oph_designs',
facebook: 'ophelia.folkard',
linkedin: 'ophelia-folkard',
instagram: 'ophelia.design',
},
},
availabilityStatus: 'offline',
email: 'ophelia.folkard@designify.com',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '+14155552672',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-10-05T10:12:34.567Z',
linkContact: 'https://designify.com',
listContact: 'Prospects',
textContact: 'Looking forward to connecting!',
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Austin',
country: 'United States',
description: 'Avid coder and tech enthusiast.',
companyName: 'CodeHub',
countryCode: 'US',
socialProfiles: {
github: 'willy_castelot',
twitter: 'willy_code',
facebook: 'willy.castelot',
linkedin: 'willy-castelot',
instagram: 'willy.coder',
},
},
availabilityStatus: 'offline',
email: 'willy.castelot@codehub.io',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+14155552673',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets collaborate!',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Seattle',
country: 'United States',
description: 'Product manager with a love for innovation.',
companyName: 'InnovaTech',
countryCode: 'US',
socialProfiles: {
github: 'elisabeth-d',
twitter: 'elisabeth_innova',
facebook: 'elisabeth.derington',
linkedin: 'elisabeth-derington',
instagram: 'elisabeth.innovates',
},
},
availabilityStatus: 'offline',
email: 'elisabeth.derington@innova.com',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '+14155552674',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets schedule a call.',
},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Chicago',
country: 'United States',
description: 'Marketing specialist and content creator.',
companyName: 'Contently',
countryCode: 'US',
socialProfiles: {
github: 'olia-olenchenko',
twitter: 'olia_content',
facebook: 'olia.olenchenko',
linkedin: 'olia-olenchenko',
instagram: 'olia.creates',
},
},
availabilityStatus: 'offline',
email: 'olia.olenchenko@contently.com',
id: 18,
name: 'Olia Olenchenko',
phoneNumber: '+14155552675',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Boston',
country: 'United States',
description: 'SEO expert and analytics enthusiast.',
companyName: 'OptiSearch',
countryCode: 'US',
socialProfiles: {
github: 'nate-vannuchi',
twitter: 'nate_seo',
facebook: 'nathaniel.vannuchi',
linkedin: 'nathaniel-vannuchi',
instagram: 'nate.optimizes',
},
},
availabilityStatus: 'offline',
email: 'nathaniel.vannuchi@optisearch.com',
id: 17,
name: 'Nathaniel Vannuchi',
phoneNumber: '+14155552676',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Denver',
country: 'United States',
description: 'UI/UX designer with a flair for minimalist designs.',
companyName: 'Minimal Designs',
countryCode: 'US',
socialProfiles: {
github: 'merrile-petruk',
twitter: 'merrile_ux',
facebook: 'merrile.petruk',
linkedin: 'merrile-petruk',
instagram: 'merrile.designs',
},
},
availabilityStatus: 'offline',
email: 'merrile.petruk@minimal.com',
id: 16,
name: 'Merrile Petruk',
phoneNumber: '+14155552677',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Miami',
country: 'United States',
description: 'Entrepreneur with a background in e-commerce.',
companyName: 'Ecom Solutions',
countryCode: 'US',
socialProfiles: {
github: 'cordell-d',
twitter: 'cordell_entrepreneur',
facebook: 'cordell.dalinder',
linkedin: 'cordell-dalinder',
instagram: 'cordell.ecom',
},
},
availabilityStatus: 'offline',
email: 'cordell.dalinder@ecomsolutions.com',
id: 15,
name: 'Cordell Dalinder',
phoneNumber: '+14155552678',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -1,95 +0,0 @@
<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>
@@ -1,59 +0,0 @@
<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>
@@ -1,86 +0,0 @@
<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>
@@ -1,207 +0,0 @@
<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>
@@ -1,477 +0,0 @@
<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>
@@ -1,102 +0,0 @@
<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>
@@ -1,90 +0,0 @@
<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>
@@ -1,46 +0,0 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const handleChange = value => {
emit('update', value);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</template>
@@ -1,148 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { parseISO } from 'date-fns';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const rules = {
editedValue: {
required,
isDate: value => new Date(value).toISOString(),
},
};
const v$ = useVuelidate(rules, { editedValue });
const formattedDate = computed(() => {
return props.attribute.value
? new Date(props.attribute.value).toLocaleDateString()
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
});
const hasError = computed(() => v$.value.$errors.length > 0);
const defaultDateValue = computed({
get() {
const existingDate = editedValue.value ?? props.attribute.value;
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
return isEditingValue.value && !hasError.value
? new Date().toISOString().slice(0, 10)
: '';
},
set(value) {
editedValue.value = value ? new Date(value).toISOString() : value;
},
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value && !editedValue.value) {
v$.value.$reset();
editedValue.value = new Date().toISOString();
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', parseISO(editedValue.value));
isEditingValue.value = false;
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
{{ formattedDate }}
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
: ''
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -1,100 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
const attributeListMenuItems = computed(() => {
return (
props.attribute.attributeValues?.map(value => ({
label: value,
value,
action: 'select',
isSelected: value === props.attribute.value,
})) || []
);
});
const handleAttributeAction = async action => {
emit('update', action.value);
toggleAttributeListDropdown(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
>
<span
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</div>
</template>
@@ -1,202 +0,0 @@
<!-- Attribute type "Text, URL, Number" -->
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { isValidURL } from 'dashboard/helper/URLHelper.js';
import { getRegexp } from 'shared/helpers/Validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const isAttributeTypeLink = computed(
() => props.attribute.attributeDisplayType === 'link'
);
const isAttributeTypeText = computed(
() => props.attribute.attributeDisplayType === 'text'
);
const isAttributeTypeNumber = computed(
() => props.attribute.attributeDisplayType === 'number'
);
const rules = computed(() => ({
editedValue: {
required,
...(isAttributeTypeLink.value && {
url: value => !value || isValidURL(value),
}),
...(isAttributeTypeText.value &&
props.attribute.regexPattern && {
regexValidation: value => {
if (!value) return true;
return getRegexp(props.attribute.regexPattern).test(value);
},
}),
},
}));
const v$ = useVuelidate(rules, { editedValue });
const hasError = computed(() => v$.value.$error);
const attributeErrorMessage = computed(() => {
if (!hasError.value) return '';
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (
isAttributeTypeText.value &&
props.attribute.regexPattern &&
v$.value.editedValue.regexValidation?.$invalid
) {
return (
props.attribute.regexCue ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
}
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
});
const getInputType = computed(() => {
switch (props.attribute.attributeDisplayType) {
case 'link':
return 'url';
case 'number':
return 'number';
default:
return 'text';
}
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value) {
v$.value.$reset();
editedValue.value = props.attribute.value || '';
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', editedValue.value);
toggleEditValue(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
:href="attribute.value"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
@click.stop
>
{{ attribute.value }}
</a>
<template v-else>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
}}
</template>
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -1,83 +0,0 @@
<script setup>
import Attributes from './fixtures';
import OtherAttribute from '../OtherAttribute.vue';
import ListAttribute from '../ListAttribute.vue';
import DateAttribute from '../DateAttribute.vue';
import CheckboxAttribute from '../CheckboxAttribute.vue';
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const getCurrentComponent = type => {
return componentMap[type] || componentMap.default;
};
const handleUpdate = (type, value) => {
console.log(`${type} updated:`, value);
};
const handleDelete = type => {
console.log(`${type} deleted`);
};
</script>
<template>
<Story
title="Components/CustomAttributes"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Create View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
<Variant title="Saved View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
is-editing-view
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -1,39 +0,0 @@
export default [
{
attributeKey: 'textContact',
attributeDisplayName: 'Text Input',
attributeDisplayType: 'text',
value: 'Sample text value',
},
{
attributeKey: 'linkContact',
attributeDisplayName: 'URL Input',
attributeDisplayType: 'link',
value: 'https://www.chatwoot.com',
},
{
attributeKey: 'numberContact',
attributeDisplayName: 'Number Input',
attributeDisplayType: 'number',
value: '42',
},
{
attributeKey: 'listContact',
attributeDisplayName: 'List Input',
attributeDisplayType: 'list',
value: 'Option 2',
attributeValues: ['Option 1', 'Option 2', 'Option 3'],
},
{
attributeKey: 'dateContact',
attributeDisplayName: 'Date Input',
attributeDisplayType: 'date',
value: '2024-03-25T00:00:00.000Z',
},
{
attributeKey: 'checkboxContact',
attributeDisplayName: 'Checkbox Input',
attributeDisplayType: 'checkbox',
value: true,
},
];
@@ -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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.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,72 +124,75 @@ const handleClick = id => {
<template>
<CardLayout>
<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">
<template #header>
<div class="flex justify-between gap-1">
<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"
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)"
>
{{ statusText }}
{{ title }}
</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 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>
</div>
</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 || '-' }}
</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 }}
</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="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</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>
</div>
</template>
</CardLayout>
</template>
@@ -79,55 +79,59 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<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)"
<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"
>
{{ 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"
/>
<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>
</div>
</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>
</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>
</CardLayout>
</template>
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[900px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -95,7 +95,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3">
<div class="w-full max-w-[900px] mx-auto py-3">
<slot name="content" />
</div>
</main>
@@ -53,64 +53,66 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<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">
<template #header>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
articleCount
)
}}
{{ locale }} ({{ localeCode }})
</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"
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.CATEGORIES_COUNT',
categoryCount
)
}}
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</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 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()"
/>
<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"
/>
<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>
</div>
</div>
</template>
</CardLayout>
</template>
@@ -27,7 +27,6 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
@@ -36,37 +35,19 @@ const emit = defineEmits([
const { t } = useI18n();
const saveAndSync = value => {
emit('saveArticle', value);
};
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
const articleTitle = computed({
get: () => props.article.title,
set: value => {
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
saveArticle({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
quickSave({ content });
saveAndSyncDebounced({ content });
saveArticle({ content });
},
});
@@ -112,7 +93,7 @@ const previewArticle = () => {
/>
<ArticleEditorControls
:article="article"
@save-article="saveAndSync"
@save-article="saveArticle"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.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,14 +186,17 @@ onMounted(() => {
text-variant="info"
@click="openAgentsList = !openAgentsList"
>
<Avatar
<Thumbnail
:author="author"
:name="authorName"
:src="authorThumbnailSrc"
:size="20"
rounded-full
:src="authorThumbnailSrc"
/>
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
{{ authorName || '-' }}
<span
v-if="author"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{ author.available_name }}
</span>
</Button>
<DropdownMenu
@@ -97,14 +97,16 @@ defineExpose({ dialogRef });
:disable-confirm-button="isUpdatingCategory || isInvalidForm"
@confirm="onUpdateCategory"
>
<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 #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>
</Dialog>
</template>
@@ -85,15 +85,17 @@ defineExpose({ dialogRef });
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
@confirm="onCreate"
>
<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 #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>
</Dialog>
</template>
@@ -55,18 +55,20 @@ defineExpose({ dialogRef });
"
@confirm="handleDialogConfirm"
>
<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 #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>
</Dialog>
</template>
@@ -59,20 +59,21 @@ defineExpose({ dialogRef });
}}
</p>
</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>
<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>
</Dialog>
</template>
@@ -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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
@@ -187,12 +187,10 @@ 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>
<Avatar
<EditableAvatar
label="Avatar"
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
@@ -120,27 +120,29 @@ defineExpose({ dialogRef });
:is-loading="isCreatingPortal"
@confirm="handleDialogConfirm"
>
<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 #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>
</Dialog>
</template>
@@ -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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
const emit = defineEmits(['close', 'createPortal']);
@@ -148,13 +148,14 @@ const redirectToPortalHomePage = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<Avatar
<Thumbnail
v-if="portal"
:author="portal"
:name="portal.name"
:src="getPortalThumbnailSrc(portal)"
:size="20"
:src="getPortalThumbnailSrc(portal)"
:show-author-name="false"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>
@@ -1,49 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
defineProps({
labelMenuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['updateLabel']);
const { t } = useI18n();
const showDropdown = ref(false);
</script>
<template>
<div class="relative">
<button
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="showDropdown = !showDropdown"
>
<span class="i-lucide-plus" />
<span class="text-sm text-n-slate-11">
{{ t('LABEL.TAG_BUTTON') }}
</span>
</button>
<DropdownMenu
v-if="showDropdown"
v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="emit('updateLabel', $event)"
>
<template #thumbnail="{ item }">
<div
class="rounded-sm size-2"
:style="{ backgroundColor: item.thumbnail.color }"
/>
</template>
</DropdownMenu>
</div>
</template>
@@ -1,22 +0,0 @@
<script setup>
defineProps({
label: {
type: Object,
default: null,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
{{ label.title }}
</span>
</div>
</template>
@@ -1,27 +0,0 @@
<script setup>
import AddLabel from '../AddLabel.vue';
import { labelMenuItems } from './fixtures';
function onUpdateLabel(label) {
console.log('Label updated:', label);
}
</script>
<template>
<Story title="Components/Label/Add Label">
<Variant title="Default (button with label menu items with active state)">
<div class="h-[300px] p-4">
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="onUpdateLabel"
/>
</div>
</Variant>
<Variant title="Empty List (button with empty label menu)">
<div class="h-[300px] p-4">
<AddLabel :label-menu-items="[]" @update-label="onUpdateLabel" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import Label from '../LabelItem.vue';
import { label } from './fixtures';
</script>
<template>
<Story title="Components/Label/Label item">
<Variant title="Default">
<Label :label="label" />
</Variant>
<Variant title="Custom Label">
<Label
:label="{
title: 'Custom Label',
color: '#FF5733',
}"
/>
</Variant>
</Story>
</template>
@@ -1,62 +0,0 @@
export const label = {
id: 1,
title: 'delivery',
color: '#A2FDD5',
};
export const labelMenuItems = [
{
label: 'delivery',
value: 3,
thumbnail: {
color: '#A2FDD5',
},
isSelected: true,
action: 'addLabel',
},
{
label: 'lead',
value: 6,
thumbnail: {
color: '#F161C8',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'ops-handover',
value: 4,
thumbnail: {
color: '#A53326',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'billing',
value: 1,
thumbnail: {
color: '#28AD21',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'premium-customer',
value: 5,
thumbnail: {
color: '#6FD4EF',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'software',
value: 2,
thumbnail: {
color: '#8F6EF2',
},
isSelected: false,
action: 'addLabel',
},
];
@@ -5,30 +5,37 @@ import Avatar from './Avatar.vue';
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="flex p-4 space-x-4 bg-white dark:bg-slate-900">
<div class="p-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="Different Shapes">
<div class="gap-4 p-4 bg-white dark:bg-slate-900">
<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">
<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>
@@ -36,78 +43,24 @@ 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/avataaars/svg?seed=Felix"
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
:size="48"
name=""
class="bg-green-300 dark:bg-green-900"
allow-upload
/>
<Avatar
:size="72"
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
name=""
allow-upload
/>
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
name=""
:size="96"
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"
class="bg-indigo-300 dark:bg-indigo-900"
allow-upload
/>
<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"
:size="96"
class="bg-woot-300 dark:bg-woot-900"
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,9 +1,6 @@
<script setup>
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 { computed, ref, watch } from 'vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
@@ -30,140 +27,33 @@ const props = defineProps({
status: {
type: String,
default: null,
validator: value =>
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
},
iconName: {
type: String,
default: null,
validator: value => {
if (!value) return true;
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
},
},
});
const emit = defineEmits(['upload', 'delete']);
const { t } = useI18n();
const emit = defineEmits(['upload']);
const isImageValid = ref(true);
const fileInput = ref(null);
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);
function invalidateCurrentImage() {
isImageValid.value = false;
}
const initials = computed(() => {
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 splitNames = props.name.split(' ');
const getColorsByNameLength = computed(() => {
if (!props.name) return AVATAR_COLORS.default;
if (splitNames.length > 1) {
const firstName = splitNames[0];
const lastName = splitNames[splitNames.length - 1];
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,
});
return firstName[0] + lastName[0];
}
};
const handleDeleteAvatar = () => {
if (fileInput.value) {
fileInput.value.value = null;
}
emit('delete');
};
const handleDismiss = event => {
event.stopPropagation();
handleDeleteAvatar();
};
const firstName = splitNames[0];
return firstName[0];
});
watch(
() => props.src,
@@ -174,87 +64,57 @@ watch(
</script>
<template>
<span class="relative inline-flex group/avatar" :style="containerStyles">
<!-- Status Badge -->
<slot name="badge" :size="size">
<span
class="relative inline"
:style="{
width: `${size}px`,
height: `${size}px`,
}"
>
<slot name="badge" :size>
<div
v-if="status"
class="absolute z-20 border rounded-full border-n-slate-3"
:style="badgeStyles"
:class="STATUS_CLASSES[status]"
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',
}"
/>
</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="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"
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`,
}"
>
<!-- Avatar Content -->
<img
v-if="src && isImageValid"
:src="src"
:alt="name"
@error="invalidateCurrentImage"
/>
<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 -->
<span v-else>
{{ initials }}
</span>
<div
v-if="allowUpload"
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"
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')"
>
<Icon
icon="i-lucide-upload"
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"
class="text-white dark:text-white size-4"
/>
</div>
</span>
@@ -0,0 +1,36 @@
<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>
@@ -0,0 +1,105 @@
<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>
@@ -1,94 +1,55 @@
<script setup>
import { computed, useSlots, useAttrs } from 'vue';
import { computed, useSlots } from 'vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import {
VARIANT_OPTIONS,
COLOR_OPTIONS,
SIZE_OPTIONS,
EXCLUDED_ATTRS,
} from './constants.js';
const props = defineProps({
label: { type: [String, Number], default: '' },
label: {
type: String,
default: '',
},
variant: {
type: String,
default: null,
validator: value => VARIANT_OPTIONS.includes(value) || value === null,
default: 'solid',
validator: value =>
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
},
color: {
type: String,
default: null,
validator: value => COLOR_OPTIONS.includes(value) || value === null,
default: 'blue',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
},
size: {
type: String,
default: null,
validator: value => SIZE_OPTIONS.includes(value) || value === null,
default: 'md',
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
},
icon: {
type: String,
default: '',
},
trailingIcon: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const slots = useSlots();
const attrs = useAttrs();
defineOptions({
inheritAttrs: false,
});
const filteredAttrs = computed(() => {
const standardAttrs = {};
Object.entries(attrs)
.filter(([key]) => !EXCLUDED_ATTRS.includes(key))
.forEach(([key, value]) => {
standardAttrs[key] = value;
});
return standardAttrs;
});
const computedVariant = computed(() => {
if (props.variant) return props.variant;
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
if (attrs.solid || attrs.solid === '') return 'solid';
if (attrs.outline || attrs.outline === '') return 'outline';
if (attrs.faded || attrs.faded === '') return 'faded';
if (attrs.link || attrs.link === '') return 'link';
if (attrs.ghost || attrs.ghost === '') return 'ghost';
return 'solid'; // Default variant
});
const computedColor = computed(() => {
if (props.color) return props.color;
if (attrs.blue || attrs.blue === '') return 'blue';
if (attrs.ruby || attrs.ruby === '') return 'ruby';
if (attrs.amber || attrs.amber === '') return 'amber';
if (attrs.slate || attrs.slate === '') return 'slate';
if (attrs.teal || attrs.teal === '') return 'teal';
return 'blue'; // Default color
});
const computedSize = computed(() => {
if (props.size) return props.size;
if (attrs.xs || attrs.xs === '') return 'xs';
if (attrs.sm || attrs.sm === '') return 'sm';
if (attrs.md || attrs.md === '') return 'md';
if (attrs.lg || attrs.lg === '') return 'lg';
return 'md';
});
const STYLE_CONFIG = {
colors: {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-blue-text hover:underline outline-transparent',
link: 'text-n-brand hover:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
@@ -152,24 +113,23 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => {
const variantMap = {
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded,
solid: STYLE_CONFIG.colors[computedColor.value].solid,
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[props.color].outline,
faded: STYLE_CONFIG.colors[props.color].faded,
solid: STYLE_CONFIG.colors[props.color].solid,
};
return variantMap[computedVariant.value];
return variantMap[props.variant];
});
const isIconOnly = computed(() => !props.label && !slots.default);
const isLink = computed(() => computedVariant.value === 'link');
const isLink = computed(() => props.variant === 'link');
const buttonClasses = computed(() => {
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
const classes = [
variantClasses.value,
computedVariant.value !== 'link' &&
STYLE_CONFIG.sizes[sizeConfig][computedSize.value],
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
].filter(Boolean);
return classes.join(' ');
@@ -178,7 +138,7 @@ const buttonClasses = computed(() => {
const linkButtonClasses = computed(() => {
const classes = [
variantClasses.value,
STYLE_CONFIG.sizes.link[computedSize.value],
STYLE_CONFIG.sizes.link[props.size],
].filter(Boolean);
return classes.join(' ');
@@ -187,11 +147,10 @@ const linkButtonClasses = computed(() => {
<template>
<button
v-bind="filteredAttrs"
:class="{
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true,
[STYLE_CONFIG.fontSize[size]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
>
@@ -202,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 v-if="label" class="min-w-0 truncate">{{ label }}</span>
<span class="min-w-0 truncate">{{ label }}</span>
</slot>
</button>
</template>
@@ -1,15 +0,0 @@
export const VARIANT_OPTIONS = ['solid', 'outline', 'faded', 'link', 'ghost'];
export const COLOR_OPTIONS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
export const SIZE_OPTIONS = ['xs', 'sm', 'md', 'lg'];
export const EXCLUDED_ATTRS = [
'variant',
'color',
'size',
'icon',
'trailingIcon',
'isLoading',
...VARIANT_OPTIONS,
...COLOR_OPTIONS,
...SIZE_OPTIONS,
];
@@ -43,7 +43,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:modelValue', 'search']);
const emit = defineEmits(['update:modelValue']);
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"
@search="emit('search', $event)"
@update:search-value="search = $event"
@select="selectOption"
/>
@@ -11,6 +11,10 @@ const props = defineProps({
type: Array,
required: true,
},
searchValue: {
type: String,
required: true,
},
searchPlaceholder: {
type: String,
default: '',
@@ -29,15 +33,10 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:searchValue', 'select', 'search']);
const emit = defineEmits(['update:searchValue', 'select']);
const { t } = useI18n();
const searchValue = defineModel('searchValue', {
type: String,
default: '',
});
const searchInput = ref(null);
const isSelected = option => {
@@ -47,11 +46,6 @@ 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(),
});
@@ -70,7 +64,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="onInputSearch"
@input="emit('update:searchValue', $event.target.value)"
/>
</div>
<ul
@@ -7,7 +7,6 @@ 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();
@@ -18,9 +17,6 @@ const openEditDialog = () => {
const openConfirmDialog = () => {
confirmDialog.value.open();
};
const openConfirmDialogWithCustomFooter = () => {
confirmDialogWithCustomFooter.value.open();
};
// eslint-disable-next-line no-unused-vars
const onConfirm = dialog => {};
@@ -48,22 +44,24 @@ const onConfirm = dialog => {};
confirm-button-label="Save"
@confirm="onConfirm()"
>
<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 #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>
</Dialog>
</Variant>
@@ -79,21 +77,5 @@ 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, computed } from 'vue';
import { ref } 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';
const props = defineProps({
defineProps({
type: {
type: String,
default: 'edit',
@@ -14,7 +14,7 @@ const props = defineProps({
},
title: {
type: String,
default: '',
required: true,
},
description: {
type: String,
@@ -48,11 +48,6 @@ const props = 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']);
@@ -64,19 +59,6 @@ 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();
};
@@ -95,11 +77,8 @@ defineExpose({ open, close });
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[
maxWidthClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close"
>
@@ -109,7 +88,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 v-if="title || description" class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ title }}
</h3>
@@ -119,29 +98,28 @@ defineExpose({ open, close });
</p>
</slot>
</div>
<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 name="form">
<!-- Form content will be injected here -->
</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,19 +51,5 @@ 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,11 +1,10 @@
<script setup>
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { defineProps, defineEmits } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
const props = defineProps({
defineProps({
menuItems: {
type: Array,
required: true,
@@ -17,61 +16,21 @@ const props = defineProps({
type: Number,
default: 20,
},
showSearch: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['action']);
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 });
const handleAction = (action, value) => {
emit('action', { action, value });
};
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 filteredMenuItems"
v-for="item in menuItems"
: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="{
@@ -80,29 +39,20 @@ onMounted(() => {
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
@keydown.enter="handleAction(item)"
@click="handleAction(item.action, item.value)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Thumbnail
v-if="item.thumbnail"
:author="item.thumbnail"
:name="item.thumbnail.name"
:size="thumbnailSize"
:src="item.thumbnail.src"
/>
<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>
@@ -1,79 +0,0 @@
<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: () => console.log('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => console.log('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => console.log('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => console.log('Change Appearance'),
},
{
label: 'Open SuperAdmin',
icon: 'i-lucide-castle',
link: '/super_admin',
target: '_blank',
},
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => console.log('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>
@@ -1,9 +0,0 @@
<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>
@@ -1,30 +0,0 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
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 space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-click-outside="closeMenu" class="absolute">
<slot />
</div>
</div>
</template>
@@ -1,55 +0,0 @@
<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>
@@ -1,22 +0,0 @@
<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>
@@ -1,3 +0,0 @@
<template>
<div class="h-0 border-b border-n-strong -mx-2" />
</template>
@@ -1,13 +0,0 @@
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,
};
@@ -1,19 +0,0 @@
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,58 +0,0 @@
<script setup>
import { ref } from 'vue';
import ConditionRow from './ConditionRow.vue';
import Button from 'next/button/Button.vue';
import { filterTypes } from './fixtures/filterTypes.js';
const DEFAULT_FILTER = {
attributeKey: 'status',
filterOperator: 'equal_to',
values: [],
queryOperator: 'and',
};
const filters = ref([{ ...DEFAULT_FILTER }]);
const removeFilter = index => {
filters.value.splice(index, 1);
};
const showQueryOperator = true;
const addFilter = () => {
filters.value.push({ ...DEFAULT_FILTER });
};
</script>
<template>
<Story
title="Components/Filters/ConditionRow"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px] p-2 space-y-2">
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
<ConditionRow
v-if="index === 0"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:show-query-operator="false"
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
v-model:query-operator="filters[index - 1].queryOperator"
:show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
<Button sm label="Add Filter" @click="addFilter" />
</div>
</Story>
</template>
@@ -1,197 +0,0 @@
<script setup>
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
import { validateSingleFilter } from 'dashboard/helper/validations.js';
// filterTypes: import('vue').ComputedRef<FilterType[]>
const { filterTypes } = defineProps({
showQueryOperator: { type: Boolean, default: false },
filterTypes: { type: Array, required: true },
});
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const attributeKey = defineModel('attributeKey', {
type: String,
required: true,
});
const values = defineModel('values', {
type: [String, Number, Array, Object],
required: true,
});
const filterOperator = defineModel('filterOperator', {
type: String,
required: true,
});
const queryOperator = defineModel('queryOperator', {
type: String,
required: false,
default: undefined,
validator: value => ['and', 'or'].includes(value),
});
const getFilterFromFilterTypes = key =>
filterTypes.find(filterObj => filterObj.attributeKey === key);
const currentFilter = computed(() =>
getFilterFromFilterTypes(attributeKey.value)
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter.filterOperators[0];
}
return operatorFromOptions;
};
const currentOperator = computed(() =>
getOperator(currentFilter.value, filterOperator.value)
);
const getInputType = (operator, filter) =>
operator.inputOverride ?? filter.inputType;
const inputType = computed(() =>
getInputType(currentOperator.value, currentFilter.value)
);
const queryOperatorOptions = computed(() => {
return [
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
},
];
});
const booleanOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const validationError = computed(() => {
return validateSingleFilter({
attributeKey: attributeKey.value,
filter_operator: filterOperator.value,
values: values.value,
});
});
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
* the values and operator remain compatible with the new attribute type. For example,
* switching from a text field to a multi-select should reset the value from '' (empty string)
* to an empty array.
*/
const filter = getFilterFromFilterTypes(newAttributeKey);
const newOperator = getOperator(filter, filterOperator.value);
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
values.value = {};
} else {
values.value = '';
}
filterOperator.value = newOperator.value;
};
watch([attributeKey, values, filterOperator], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-center gap-2 rounded-md"
:class="{
'animate-wiggle': showErrors && validationError,
}"
>
<FilterSelect
v-if="showQueryOperator"
v-model="queryOperator"
variant="faded"
hide-icon
class="text-sm"
:options="queryOperatorOptions"
/>
<FilterSelect
v-model="attributeKey"
variant="faded"
:options="filterTypes"
@update:model-value="resetModelOnAttributeKeyChange"
/>
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
disable-search
:options="booleanOptions"
/>
<input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
<Button
sm
solid
slate
icon="i-lucide-trash"
@click.stop="emit('remove')"
/>
</div>
<span v-if="showErrors && validationError" class="text-sm text-n-ruby-11">
{{ t(`FILTER.ERRORS.${validationError}`) }}
</span>
</li>
</template>
@@ -1,558 +0,0 @@
export const filterTypes = [
{
attributeKey: 'status',
value: 'status',
attributeName: 'Status',
label: 'Status',
inputType: 'multiSelect',
options: [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeName: 'Assignee name',
label: 'Assignee name',
inputType: 'searchSelect',
options: [
{ id: 14, name: 'Ben Nugent' },
{ id: 30, name: 'Bruce' },
{ id: 16, name: 'Cathy Simms' },
{ id: 7, name: 'Charles Miner' },
{ id: 10, name: 'Craig D' },
{ id: 9, name: 'Dan Gore' },
{ id: 13, name: 'Danny Cordray' },
{ id: 3, name: 'David Wallace' },
{ id: 4, name: 'Deangelo Vickers' },
{ id: 33, name: 'Devon White' },
{ id: 8, name: 'Ed Truck' },
{ id: 31, name: 'Frank' },
{ id: 29, name: 'Gideon' },
{ id: 24, name: 'Glenn Max' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
value: 'team_id',
attributeName: 'Team name',
label: 'Team name',
inputType: 'searchSelect',
options: [
{ id: 223, name: '💰 sales' },
{ id: 224, name: '💼 management' },
{ id: 225, name: '👩‍💼 administration' },
{ id: 226, name: '🚛 warehouse' },
],
dataType: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
value: 'display_id',
attributeName: 'Conversation identifier',
label: 'Conversation identifier',
inputType: 'plainText',
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeName: 'Campaign name',
label: 'Campaign name',
inputType: 'searchSelect',
options: [],
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'labels',
value: 'labels',
attributeName: 'Labels',
label: 'Labels',
inputType: 'multiSelect',
options: [
{ id: 'billing', name: 'billing' },
{ id: 'delivery', name: 'delivery' },
{ id: 'lead', name: 'lead' },
{ id: 'ops-handover', name: 'ops-handover' },
{ id: 'premium-customer', name: 'premium-customer' },
{ id: 'software', name: 'software' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeName: 'Referer link',
label: 'Referer link',
inputType: 'plainText',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeName: 'Created at',
label: 'Created at',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: 'Last activity',
label: 'Last activity',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'are_you_a_paid_customer',
value: 'are_you_a_paid_customer',
attributeName: 'Are you a paid customer?',
label: 'Are you a paid customer?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'date_of_purchase',
value: 'date_of_purchase',
attributeName: 'Date of Purchase',
label: 'Date of Purchase',
inputType: 'date',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'your_website',
value: 'your_website',
attributeName: 'Your website',
label: 'Your website',
inputType: 'plainText',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'are_you_residing_in_india',
value: 'are_you_residing_in_india',
attributeName: 'Are you residing in India?',
label: 'Are you residing in India?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'cloud',
value: 'cloud',
attributeName: 'Cloud',
label: 'Cloud',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'license_type',
value: 'license_type',
attributeName: 'License Type',
label: 'License Type',
inputType: 'searchSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [
{
id: 'Personal',
name: 'Personal',
},
{
id: 'Enterprise',
name: 'Enterprise',
},
{
id: 'Teams',
name: 'Teams',
},
{
id: 'Professional',
name: 'Professional',
},
],
attributeModel: 'customAttributes',
},
];
@@ -1,66 +0,0 @@
<script setup>
import { ref } from 'vue';
import FilterSelect from './FilterSelect.vue';
const options = [
{ value: 'EQUAL_TO', label: 'Equal To', icon: 'i-ph-equals-bold' },
{
value: 'NOT_EQUAL_TO',
label: 'Not Equal To',
icon: 'i-ph-not-equals-bold',
},
{ value: 'IS_PRESENT', label: 'Is Present', icon: 'i-ph-member-of-bold' },
{
value: 'IS_NOT_PRESENT',
label: 'Is Not Present',
icon: 'i-ph-not-member-of-bold',
},
{ value: 'CONTAINS', label: 'Contains', icon: 'i-ph-superset-of-bold' },
{
value: 'DOES_NOT_CONTAIN',
label: 'Does Not Contain',
icon: 'i-ph-not-superset-of-bold',
},
{
value: 'IS_GREATER_THAN',
label: 'Is Greater Than',
icon: 'i-ph-greater-than-bold',
},
{ value: 'IS_LESS_THAN', label: 'Is Less Than', icon: 'i-ph-less-than-bold' },
{
value: 'DAYS_BEFORE',
label: 'Days Before',
icon: 'i-ph-calendar-minus-bold',
},
{
value: 'STARTS_WITH',
label: 'Starts With',
icon: 'i-ph-caret-line-right-bold',
},
];
const selected = ref(options[0].value);
</script>
<template>
<Story
title="Components/Filters/Filter Select"
:layout="{ type: 'grid', width: '250px' }"
>
<Variant title="With Icon & Label">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Without Icon">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" hide-icon :options="options" />
</div>
</Variant>
<Variant title="Without Label">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" hide-label :options="options" />
</div>
</Variant>
</Story>
</template>
@@ -1,76 +0,0 @@
<script setup>
import { computed } from 'vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
import Button from 'next/button/Button.vue';
// [{label, icon, value}]
const props = defineProps({
options: {
type: Array,
required: true,
},
hideLabel: {
type: Boolean,
default: false,
},
hideIcon: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'faded',
},
});
const selected = defineModel({
type: [String, Number],
required: true,
});
const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
if (props.hideIcon) return null;
return selectedOption.value.icon || 'i-lucide-chevron-down';
});
const updateSelected = newValue => {
selected.value = newValue;
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<slot name="trigger" :toggle="toggle">
<Button
sm
slate
:variant
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
:label="hideLabel ? null : selectedOption.label"
@click="toggle"
/>
</slot>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -1,26 +0,0 @@
<script setup>
import { ref } from 'vue';
import MultiSelect from './MultiSelect.vue';
const options = [
{ name: 'Open', id: 'open' },
{ name: 'Closed', id: 'closed' },
{ name: 'Pending', id: 'pending' },
{ name: 'Resolved', id: 'resolved' },
{ name: 'Spam', id: 'spam' },
{ name: 'All', id: 'all' },
];
const selected = ref([]);
</script>
<template>
<Story
title="Components/Filters/Multiselect Input"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px]">
<MultiSelect v-model="selected" :options="options" />
</div>
</Story>
</template>
@@ -1,146 +0,0 @@
<script setup>
import { defineModel, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips } = defineProps({
options: {
type: Array,
required: true,
},
maxChips: {
type: Number,
default: 3,
},
});
const { t } = useI18n();
const selected = defineModel({
type: [Array, String],
required: true,
});
const hasItems = computed(() => {
if (!selected.value) return false;
if (!Array.isArray(selected.value)) return false;
if (selected.value.length === 0) return false;
return true;
});
const selectedIds = computed(() => {
if (!hasItems.value) return [];
return selected.value.map(value => value.id);
});
const selectedItems = computed(() => {
// Options has additional properties, so we need to use them directly
if (!hasItems.value) return [];
return options.filter(option => selectedIds.value.includes(option.id));
});
const selectedVisibleItems = computed(() => {
if (!hasItems.value) return [];
// avoid showing "+1 more" coz it takes up space anway, might as well show it
if (selectedItems.value.length === maxChips + 1) return selectedItems.value;
// if we have more than maxChips then show only maxChips
return selectedItems.value.slice(0, maxChips);
});
const remainingItems = computed(() => {
if (!hasItems.value) return [];
if (selectedItems.value.length === maxChips + 1) return [];
return selectedItems.value.slice(maxChips);
});
const remainingTooltip = computed(() => {
if (!hasItems.value) return '';
return remainingItems.value.map(item => item.name).join(', ');
});
const toggleOption = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
const optionToToggle = {
id: option.id,
name: option.name,
};
const idToToggle = optionToToggle.id;
if (!hasItems.value) {
selected.value = [optionToToggle];
return;
}
if (selectedIds.value.includes(idToToggle)) {
selected.value = selected.value.filter(value => value.id !== idToToggle);
} else {
selected.value = [...selected.value, optionToToggle];
}
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<button
v-if="hasItems"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
v-for="item in selectedVisibleItems"
:key="item.name"
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<span class="truncate">{{ item.name }}</span>
</div>
<div
v-if="remainingItems.length > 0"
v-tooltip.top="remainingTooltip"
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
>
<span class="truncate">{{
t('COMBOBOX.MORE', { count: remainingItems.length })
}}</span>
</div>
<div class="flex items-center border-none px-3 gap-2">
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.id"
:icon="option.icon"
preserve-open
@click="toggleOption(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedIds.includes(option.id)"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -1,33 +0,0 @@
<script setup>
import { ref } from 'vue';
import SingleSelect from './SingleSelect.vue';
const options = [
{ name: 'Open', id: 'open' },
{ name: 'Closed', id: 'closed' },
{ name: 'Pending', id: 'pending' },
{ name: 'Resolved', id: 'resolved' },
{ name: 'Spam', id: 'spam' },
{ name: 'All', id: 'all' },
];
const selected = ref(options[0]);
</script>
<template>
<Story
title="Components/Filters/Single Select Input"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="With Search">
<div class="min-h-[400px]">
<SingleSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Without Search">
<div class="min-h-[400px]">
<SingleSelect v-model="selected" disable-search :options="options" />
</div>
</Variant>
</Story>
</template>
@@ -1,124 +0,0 @@
<script setup>
import { defineModel, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
options: {
type: Array,
required: true,
},
disableSearch: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const selected = defineModel({
type: Object,
required: true,
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
const selectedItem = computed(() => {
if (!options) return null;
if (!selected.value) return null;
// there are cases where the selected value is an array
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
});
const toggleSelected = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
const optionToToggle = {
id: option.id,
name: option.name,
};
if (selected.value && selected.value.id === optionToToggle.id) {
selected.value = null;
} else {
selected.value = optionToToggle;
}
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<Button
v-if="selectedItem"
sm
slate
faded
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-[999]">
<div v-if="!disableSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
:icon="option.icon"
@click="toggleSelected(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedItem && selectedItem.id === option.id"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</template>
<template v-else-if="searchTerm">
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm: searchTerm }) }}
</DropdownItem>
</template>
<template v-else>
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_STATE') }}
</DropdownItem>
</template>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -1,17 +0,0 @@
<script setup>
import { useChannelIcon } from './provider';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const channelIcon = useChannelIcon(props.inbox);
</script>
<template>
<Icon :icon="channelIcon" />
</template>
@@ -1,36 +0,0 @@
import { computed } from 'vue';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
'Channel::Api': 'i-ri-cloudy-fill',
'Channel::Email': 'i-ri-mail-fill',
'Channel::FacebookPage': 'i-ri-messenger-fill',
'Channel::Line': 'i-ri-line-fill',
'Channel::Sms': 'i-ri-chat-1-fill',
'Channel::Telegram': 'i-ri-telegram-fill',
'Channel::TwilioSms': 'i-ri-chat-1-fill',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
};
const providerIconMap = {
microsoft: 'i-ri-microsoft-fill',
google: 'i-ri-google-fill',
};
const channelIcon = computed(() => {
const type = inbox.channel_type;
let icon = channelTypeIconMap[type];
if (type === 'Channel::Email' && inbox.provider) {
if (Object.keys(providerIconMap).includes(inbox.provider)) {
icon = providerIconMap[inbox.provider];
}
}
return icon ?? 'i-ri-global-fill';
});
return channelIcon;
}

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