feat(V5): Update settings pages UI (#13396)

# Pull Request Template

## Description

This PR updates settings page UI


## Type of change

- [x] New feature (non-breaking change which adds functionality)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Sivin Varghese
2026-02-19 15:04:40 +05:30
committed by GitHub
parent c9619eaed2
commit 7b2b3ac37d
182 changed files with 5187 additions and 4297 deletions

View File

@@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {

View File

@@ -66,4 +66,84 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}

View File

@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>

View File

@@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<Button
xs
slate
@@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-sm"
class="flex items-center gap-3 text-body-para"
>
<Icon
:icon="feature.icon"

View File

@@ -60,23 +60,19 @@ const handleFetchInboxes = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<div class="flex items-center gap-2">
<CardPopover
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
)
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
<div class="flex items-center gap-2">
<Button
@@ -93,18 +89,18 @@ const handleFetchInboxes = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
{{ description }}
</p>
<div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-sm">
<span v-if="order" class="text-n-slate-11 text-body-para">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}}
<span class="text-n-slate-12">{{ order }}</span>
</span>
<div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-sm">
<span v-if="priority" class="text-n-slate-11 text-body-para">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}}

View File

@@ -2,7 +2,7 @@
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';

View File

@@ -1,5 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
id: {
@@ -22,6 +22,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabledLabel: {
type: String,
default: '',
},
disabledMessage: {
type: String,
default: '',
@@ -30,8 +34,6 @@ const props = defineProps({
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
@@ -41,7 +43,7 @@ const handleChange = () => {
<template>
<div
class="relative rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
@@ -49,39 +51,29 @@ const handleChange = () => {
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
<div class="flex items-center gap-2">
<h3 class="text-heading-3 text-n-slate-12">
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
</div>
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
@change="handleChange"
/>
</div>
<p class="text-sm text-n-slate-11">
<p class="text-body-main text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
</p>
<slot />
</div>
</div>
</template>

View File

@@ -21,10 +21,10 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-5xl 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">
<span class="text-heading-1 text-n-slate-12">
{{ headerTitle }}
</span>
<div
@@ -43,8 +43,8 @@ const handleButtonClick = () => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-4">
<slot name="default" />
</div>
</main>

View File

@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"

View File

@@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10">
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-xl font-medium truncate text-n-slate-12">
<span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">

View File

@@ -32,17 +32,18 @@ const updateCurrentPage = page => {
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>

View File

@@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
const props = defineProps({
contactId: {

View File

@@ -32,9 +32,9 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-10">
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}

View File

@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[60rem] px-6"
class="max-w-5xl"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>

View File

@@ -98,8 +98,8 @@ const showPagination = computed(() => {
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<main class="flex-1 overflow-y-auto px-6">
<div class="w-full mx-auto max-w-5xl">
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@@ -114,11 +114,12 @@ const showPagination = computed(() => {
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPagination" class="sticky bottom-0 z-0">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
class="max-w-[67rem]"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>

View File

@@ -1,8 +1,10 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
@@ -18,7 +20,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
text: 'i-lucide-menu',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
@@ -28,61 +30,60 @@ const iconByType = {
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
return iconByType[typeKey] || 'i-lucide-menu';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
<div class="flex flex-col py-4 min-w-0">
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
</div>
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
<div class="flex items-center gap-2 min-w-0">
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
{{ attribute.label }}
</h4>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" />
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>

View File

@@ -35,18 +35,20 @@ const handleDelete = () => {
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
<h5 class="text-heading-3 text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
<span class="text-body-para text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
<span class="text-body-para text-n-slate-11">{{
attribute.value
}}</span>
</div>
</div>
<div class="flex gap-2 items-center">

View File

@@ -129,10 +129,10 @@ const handleDelete = attribute => {
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12">
<h3 class="text-heading-2 text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
<p class="mb-0 text-body-para text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
type: {
@@ -18,11 +19,13 @@ const attributeConfig = {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
},
};
const config = computed(
@@ -31,12 +34,9 @@ const config = computed(
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
<Label :label="t(config.labelKey)" :color="config.color" compact>
<template #icon>
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
</template>
</Label>
</template>

View File

@@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"

View File

@@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<header class="sticky top-0 z-10 px-6 pb-3">
<div class="w-full max-w-5xl mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -95,16 +95,17 @@ const togglePortalSwitcher = () => {
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-3">
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>

View File

@@ -62,6 +62,7 @@ const onCreate = async () => {
from: route.name,
});
selectedLocale.value = '';
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')

View File

@@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-ri-mail-line',
icon: 'i-woot-mail',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,

View File

@@ -0,0 +1,31 @@
<script setup>
import { useToggle } from '@vueuse/core';
const props = defineProps({
title: {
type: String,
required: true,
},
defaultOpen: {
type: Boolean,
default: true,
},
});
const [isOpen, toggle] = useToggle(props.defaultOpen);
</script>
<template>
<div
v-bind="$attrs"
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
@click="toggle()"
>
<span class="text-heading-2 text-n-slate-12 w-full">
{{ title }}
</span>
</div>
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
helpText: {
type: String,
default: '',
},
});
</script>
<template>
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
<div
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
>
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
{{ label }}
</label>
<div class="col-span-1 lg:col-span-6">
<slot />
</div>
</div>
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
v-if="helpText"
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ helpText }}
</p>
</div>
<slot name="extra" />
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
header: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
</script>
<template>
<div
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
>
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
<div class="flex items-center gap-3 w-full justify-between">
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<ToggleSwitch v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}
</span>
</div>
<div
v-if="$slots.editor"
class="w-full border-t border-n-weak"
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
>
<slot name="editor" />
</div>
</div>
</template>

View File

@@ -112,6 +112,19 @@ const containerStyles = computed(() => ({
height: `${props.size}px`,
}));
const borderRadiusClass = computed(() => {
if (props.roundedFull) {
return 'rounded-full';
}
// Approximates 25% of size
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
});
const avatarStyles = computed(() => ({
...containerStyles.value,
backgroundColor:
@@ -184,7 +197,7 @@ watch(
<template>
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0"
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
:style="containerStyles"
>
<!-- Status Badge -->
@@ -216,9 +229,9 @@ watch(
<!-- Avatar Container -->
<span
role="img"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
:class="[
roundedFull ? 'rounded-full' : 'rounded-xl',
borderRadiusClass,
{
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid),
@@ -267,7 +280,8 @@ watch(
:handle-image-upload="handleImageUpload"
>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
@click="handleUploadAvatar"
>
<Icon

View File

@@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div class="w-full max-w-5xl mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
@@ -140,7 +140,9 @@ const handleCreateAssistant = () => {
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
:variant="
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
"
color="slate"
size="xs"
:disabled="isFetchingAssistants"
@@ -204,7 +206,7 @@ const handleCreateAssistant = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<div class="w-full max-w-5xl h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
@@ -222,11 +224,12 @@ const handleCreateAssistant = () => {
<slot />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="handlePageChange"
/>
</footer>

View File

@@ -27,7 +27,7 @@ const openBilling = () => {
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"

View File

@@ -79,8 +79,8 @@ defineExpose({
:aria-multiselectable="multiple"
>
<li
v-for="option in options"
:key="option.value"
v-for="(option, index) in options"
:key="`${option.value}-${index}`"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': isSelected(option),

View File

@@ -50,7 +50,7 @@ const openLink = link => {
]"
>
<section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
>
<div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"

View File

@@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-ri-phone-fill',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {

View File

@@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill');
expect(icon).toBe('i-woot-voice');
});
it('returns correct icon for Line channel', () => {
@@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-twitter-x-fill');
expect(icon).toBe('i-woot-x');
});
it('returns correct icon for WebWidget channel', () => {

View File

@@ -108,7 +108,7 @@ onMounted(() => {
<label
v-if="label"
:for="uniqueId"
class="mb-0.5 text-sm font-medium text-n-slate-12"
class="mb-0.5 text-heading-3 text-n-slate-12"
>
{{ label }}
</label>
@@ -145,7 +145,7 @@ onMounted(() => {
/>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}

View File

@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: {
type: [Object, String],
required: true,
},
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'slate',
validator: value =>
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
},
});
const COLOR_CLASSES = {
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
};
const isStringLabel = computed(() => typeof props.label === 'string');
const labelTitle = computed(() => {
return isStringLabel.value ? props.label : props.label?.title;
});
const labelDescription = computed(() => {
return (!isStringLabel.value && props.label?.description) || '';
});
const labelColor = computed(() => {
return isStringLabel.value ? null : props.label.color;
});
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
</script>
<template>
<div
:title="labelDescription"
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
]"
>
<span
v-if="labelColor"
class="rounded-sm flex-shrink-0"
:class="compact ? 'size-1.5' : 'size-2'"
:style="{ background: labelColor }"
/>
<slot v-else name="icon" />
<span
class="whitespace-nowrap"
:class="compact ? 'text-label-small' : 'text-label !font-420'"
>
{{ labelTitle }}
</span>
<slot name="action" />
</div>
</template>

View File

@@ -71,10 +71,10 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
class="flex justify-between h-[3.375rem] w-full border-t border-n-weak mx-auto bg-n-surface-1 py-3 px-6 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
<span class="min-w-0 text-body-main line-clamp-1 text-n-slate-11">
{{ currentPageInformation }}
</span>
</div>
@@ -97,11 +97,13 @@ const pageInfo = computed(() => {
:disabled="isFirstPage"
@click="changePage(currentPage - 1)"
/>
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
<div class="inline-flex items-center gap-2 text-sm">
<span
class="px-3 tabular-nums py-0.5 font-420 bg-n-input-background text-body-main text-n-slate-12 rounded-md"
>
{{ formatFullNumber(currentPage) }}
</span>
<span class="truncate">
<span class="truncate text-body-main text-n-slate-11">
{{ pageInfo }}
</span>
</div>

View File

@@ -0,0 +1,97 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
options: {
type: Array,
default: () => [],
validator: options =>
options.every(
opt => typeof opt === 'object' && 'value' in opt && 'label' in opt
),
},
groups: {
type: Array,
default: () => [],
validator: groups =>
groups.every(
group =>
'label' in group &&
Array.isArray(group.options) &&
group.options.every(opt => 'value' in opt && 'label' in opt)
),
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
});
const modelValue = defineModel({
type: [String, Number, Boolean],
default: '',
});
</script>
<template>
<div class="w-fit relative">
<select
v-model="modelValue"
:disabled="disabled"
class="appearance-none bg-none rounded-lg border-0 outline-1 outline -outline-offset-1 transition-all duration-200 bg-n-surface-1 !mb-0 py-2 px-3 pr-10 text-sm"
:class="{
'outline-n-weak hover:outline-n-slate-6 focus:outline-n-blue-9':
!error && !disabled,
'outline-n-red-9 focus:outline-n-red-9': error && !disabled,
'outline-n-weak bg-n-slate-2 cursor-not-allowed opacity-60': disabled,
}"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<template v-if="groups.length">
<optgroup
v-for="group in groups"
:key="group.label"
:label="group.label"
>
<option
v-for="option in group.options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</optgroup>
</template>
<template v-else>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</template>
</select>
<div
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
>
<Icon
icon="i-lucide-chevron-down"
class="size-4 text-n-slate-11"
:class="{ 'opacity-50': disabled }"
/>
</div>
</div>
</template>

View File

@@ -25,8 +25,8 @@ const reauthorizationRequired = computed(() => {
</script>
<template>
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<ChannelIcon :inbox="inbox" class="size-3" />
<span class="size-4 grid place-content-center rounded-full">
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<div

View File

@@ -286,7 +286,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
h(ChannelLeaf, {
@@ -595,6 +595,16 @@ const menuItems = computed(() => {
name: 'Settings Teams',
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: [
'settings_teams_list',
'settings_teams_new',
'settings_teams_finish',
'settings_teams_add_agents',
'settings_teams_show',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
],
to: accountScopedRoute('settings_teams_list'),
},
...(hasAdvancedAssignment.value
@@ -603,6 +613,15 @@ const menuItems = computed(() => {
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
activeOn: [
'assignment_policy_index',
'agent_assignment_policy_index',
'agent_assignment_policy_create',
'agent_assignment_policy_edit',
'agent_capacity_policy_index',
'agent_capacity_policy_create',
'agent_capacity_policy_edit',
],
to: accountScopedRoute('assignment_policy_index'),
},
]
@@ -611,6 +630,14 @@ const menuItems = computed(() => {
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
icon: 'i-lucide-inbox',
activeOn: [
'settings_inbox_list',
'settings_inbox_show',
'settings_inbox_new',
'settings_inbox_finish',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
],
to: accountScopedRoute('settings_inbox_list'),
},
{
@@ -703,7 +730,7 @@ const menuItems = computed(() => {
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
@@ -824,7 +851,7 @@ const menuItems = computed(() => {
"
/>
<div
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
>
<SidebarProfileMenu

View File

@@ -44,8 +44,10 @@ const shouldRenderComponent = computed(() => {
:active
/>
<template v-else>
<Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
</template>
</component>
</Policy>

View File

@@ -19,20 +19,24 @@ const updateValue = () => {
<template>
<button
type="button"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
class="group relative h-4 rounded-full w-7 flex-shrink-0 select-none focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 transition-colors duration-200 ease-in-out"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6'"
role="switch"
:aria-checked="modelValue"
@click="updateValue"
>
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
class="absolute top-1/2 ltr:left-0.5 rtl:right-0.5 -translate-y-1/2 transition-transform duration-[350ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]"
:class="
modelValue
? 'translate-x-3 bg-n-background'
: 'translate-x-0 bg-n-background'
? 'ltr:translate-x-3 rtl:-translate-x-3 group-active:ltr:translate-x-[6px] rtl:group-active:-translate-x-[6px]'
: 'ltr:translate-x-0 rtl:translate-x-0'
"
/>
>
<span
class="block h-3 w-3 rounded-full bg-n-background shadow-md transition-[width] duration-[180ms] ease-in-out group-active:w-[18px]"
/>
</span>
</button>
</template>

View File

@@ -0,0 +1,175 @@
<script setup>
import { ref } from 'vue';
import { BaseTable, BaseTableRow, BaseTableCell } from './index';
import Button from '../button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
import ToggleSwitch from '../switch/Switch.vue';
const automationData = ref([
{
id: 1,
name: 'Welcome Message',
description: 'Send welcome message to new contacts',
active: true,
createdOn: 'Apr 21, 2022',
},
{
id: 2,
name: 'Auto-assign to Sales',
description: 'Automatically assign sales conversations to sales team',
active: false,
createdOn: 'May 15, 2022',
},
{
id: 3,
name: 'Tag Premium Users',
description: 'Add premium tag to conversations from premium users',
active: true,
createdOn: 'Jun 10, 2022',
},
]);
const agentData = ref([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'Administrator',
verified: true,
avatarUrl: '',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Agent',
verified: true,
avatarUrl: '',
},
]);
const emptyData = ref([]);
const headers = ['Name', 'Active', 'Created on', 'Actions'];
const agentHeaders = ['Agent', 'Role', 'Verification', 'Actions'];
</script>
<template>
<Story title="Components/Table" :layout="{ type: 'grid', width: '100%' }">
<!-- Basic Table -->
<Variant title="Basic Table">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="headers" :items="automationData">
<template #row="{ items }">
<BaseTableRow
v-for="automation in items"
:key="automation.id"
:item="automation"
>
<template #default>
<BaseTableCell>
<div class="flex items-center gap-2 min-w-0 max-w-full">
<span
class="text-body-main text-n-slate-12 truncate min-w-0 flex-1"
>
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span
class="text-body-main text-n-slate-11 truncate min-w-0 flex-1"
>
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<div class="flex justify-center">
<ToggleSwitch v-model="automation.active" />
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ automation.createdOn }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
<Button icon="i-woot-clone" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Table with Avatars -->
<Variant title="Table with Avatars">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="agentHeaders" :items="agentData">
<template #row="{ items }">
<BaseTableRow v-for="agent in items" :key="agent.id" :item="agent">
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar :user="agent" :size="40" class="flex-shrink-0" />
<div class="flex flex-col min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ agent.name }}
</span>
<span class="text-body-main text-n-slate-11 truncate">
{{ agent.email }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.role }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.verified ? 'Verified' : 'Pending' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Empty State -->
<Variant title="Empty State">
<div class="p-4 bg-n-surface-1">
<BaseTable
:headers="headers"
:items="emptyData"
no-data-message="No automation rules found"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
noDataMessage: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
});
const hasHeaderSlot = computed(() => !!props.headers.length);
const showHeaders = computed(
() => hasHeaderSlot.value && props.items.length > 0
);
</script>
<template>
<div class="w-full">
<table class="min-w-full table-auto divide-y divide-n-weak">
<thead v-if="showHeaders" class="border-t border-n-weak">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12 capitalize"
>
<slot :name="`header-${index}`" :header="header">
{{ header }}
</slot>
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<template v-if="items.length">
<slot name="row" :items="items" />
</template>
<tr v-else-if="noDataMessage && !loading">
<td
:colspan="headers.length || 1"
class="py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ noDataMessage }}
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
defineProps({
align: {
type: String,
default: 'start',
validator: value => ['start', 'center', 'end'].includes(value),
},
});
</script>
<template>
<td
class="py-3 ltr:pr-4 rtl:pl-4 text-body-main"
:class="{
'text-start': align === 'start',
'text-center': align === 'center',
'text-end': align === 'end',
}"
>
<slot />
</td>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
defineProps({
item: {
type: Object,
required: true,
},
});
</script>
<template>
<tr>
<slot :item="item" />
</tr>
</template>

View File

@@ -0,0 +1,3 @@
export { default as BaseTable } from './BaseTable.vue';
export { default as BaseTableRow } from './BaseTableRow.vue';
export { default as BaseTableCell } from './BaseTableCell.vue';

View File

@@ -18,7 +18,7 @@ const props = defineProps({
},
year: {
type: [Number, String],
required: true,
default: '',
},
});

View File

@@ -1,31 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>

View File

@@ -10,12 +10,9 @@ defineProps({
</script>
<template>
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
<ChannelIcon
:inbox="inbox"
class="size-3 ltr:mr-1 rtl:ml-1 flex-shrink-0"
/>
<span class="truncate">
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>

View File

@@ -11,7 +11,7 @@ defineProps({
<h6
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
>
<span class="text-base font-medium text-n-slate-12">
<span class="text-body-main !text-base text-n-slate-12">
{{ message }}
</span>
<Spinner class="text-n-brand" />

View File

@@ -15,7 +15,7 @@ export default {
<template>
<div class="border-b border-solid border-n-weak/60">
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
<div class="max-w-7xl w-full mx-auto pt-4 pb-0 px-6">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>

View File

@@ -61,7 +61,7 @@ export default {
<template>
<label class="input-container">
<span v-if="label">{{ label }}</span>
<span v-if="label" class="text-heading-3">{{ label }}</span>
<input
:value="modelValue"
:type="type"
@@ -71,7 +71,7 @@ export default {
@input="onChange"
@blur="onBlur"
/>
<p v-if="helpText" class="help-text">{{ helpText }}</p>
<p v-if="helpText" class="help-text text-label-small">{{ helpText }}</p>
<span v-if="error" class="message">
{{ error }}
</span>
@@ -81,7 +81,7 @@ export default {
<style scoped lang="scss">
.help-text {
@apply mt-0.5 text-xs not-italic text-n-slate-11;
@apply mt-0.5 not-italic text-n-slate-11;
}
.message {

View File

@@ -36,17 +36,17 @@ const INBOX_ICON_MAP_FILL = {
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.WEB]: 'i-ri-global-line',
[INBOX_TYPES.FB]: 'i-ri-messenger-line',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-line',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-line',
[INBOX_TYPES.API]: 'i-ri-cloudy-line',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-line',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
[INBOX_TYPES.WEB]: 'i-woot-website',
[INBOX_TYPES.FB]: 'i-woot-messenger',
[INBOX_TYPES.TWITTER]: 'i-woot-x',
[INBOX_TYPES.WHATSAPP]: 'i-woot-whatsapp',
[INBOX_TYPES.API]: 'i-woot-api',
[INBOX_TYPES.EMAIL]: 'i-woot-mail',
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';

View File

@@ -99,19 +99,19 @@ describe('#Inbox Helpers', () => {
describe('line variant', () => {
it('returns correct line icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB, null, 'line')).toBe(
'i-ri-global-line'
'i-woot-website'
);
});
it('returns correct line icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB, null, 'line')).toBe(
'i-ri-messenger-line'
'i-woot-messenger'
);
});
it('returns correct line icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK, null, 'line')).toBe(
'i-ri-tiktok-line'
'i-woot-tiktok'
);
});
@@ -147,7 +147,7 @@ describe('#Inbox Helpers', () => {
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp', 'line')
).toBe('i-ri-whatsapp-line');
).toBe('i-woot-whatsapp');
});
it('returns SMS line icon for regular Twilio number', () => {

View File

@@ -4,6 +4,9 @@
"LOADING_EDITOR": "Loading editor...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"COUNT": "{n} bot | {n} bots",
"SEARCH_PLACEHOLDER": "Search bots...",
"NO_RESULTS": "No bots found matching your search",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
"AVATAR": {
@@ -34,7 +37,8 @@
"LOADING": "Fetching bots...",
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"URL": "Webhook URL"
"URL": "Webhook URL",
"ACTIONS": "Actions"
}
},
"DELETE": {

View File

@@ -9,6 +9,7 @@
"ADMINISTRATOR": "Administrator",
"AGENT": "Agent"
},
"COUNT": "{n} agent | {n} agents",
"LIST": {
"404": "There are no agents associated to this account",
"TITLE": "Manage agents in your team",
@@ -96,6 +97,8 @@
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"SEARCH_PLACEHOLDER": "Search agents...",
"NO_RESULTS": "No agents found matching your search",
"SEARCH": {
"NO_RESULTS": "No results found."
},

View File

@@ -5,6 +5,9 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"COUNT": "{n} attribute | {n} attributes",
"SEARCH_PLACEHOLDER": "Search attributes...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"

View File

@@ -3,8 +3,11 @@
"HEADER": "Automation",
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
"LEARN_MORE": "Learn more about automation",
"HEADER_BTN_TXT": "Add Automation Rule",
"COUNT": "{n} automation | {n} automations",
"HEADER_BTN_TXT": "Create Automation",
"LOADING": "Fetching automation rules",
"SEARCH_PLACEHOLDER": "Search automation rules...",
"NO_RESULTS": "No automation rules found matching your search",
"ADD": {
"TITLE": "Add Automation Rule",
"SUBMIT": "Create",
@@ -42,9 +45,9 @@
"LIST": {
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"ACTIVE": "Active",
"CREATED_ON": "Created on"
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
"404": "No automation rules found"
},

View File

@@ -3,8 +3,11 @@
"HEADER": "Canned Responses",
"LEARN_MORE": "Learn more about canned responses",
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
"COUNT": "{n} canned response | {n} canned responses",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_PLACEHOLDER": "Search canned responses...",
"NO_RESULTS": "No canned responses found matching your search",
"SEARCH_404": "There are no items matching this query.",
"LIST": {
"404": "There are no canned responses available in this account.",

View File

@@ -3,8 +3,11 @@
"HEADER": "Custom Roles",
"LEARN_MORE": "Learn more about custom roles",
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
"COUNT": "{n} custom role | {n} custom roles",
"HEADER_BTN_TXT": "Add custom role",
"LOADING": "Fetching custom roles...",
"SEARCH_PLACEHOLDER": "Search custom roles...",
"NO_RESULTS": "No custom roles found matching your search",
"SEARCH_404": "There are no items matching this query.",
"PAYWALL": {
"TITLE": "Upgrade to create custom roles",

View File

@@ -3,6 +3,9 @@
"HEADER": "Inboxes",
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"COUNT": "{n} inbox | {n} inboxes",
"SEARCH_PLACEHOLDER": "Search inboxes...",
"NO_RESULTS": "No inboxes found matching your search",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
@@ -575,7 +578,7 @@
"SUBTITLE": "Use only the configured business name as the sender name in the email header."
},
"BUSINESS_NAME": {
"BUTTON_TEXT": "+ Configure your business name",
"BUTTON_TEXT": "Configure your business name",
"PLACEHOLDER": "Enter your business name",
"SAVE_BUTTON_TEXT": "Save"
}
@@ -625,6 +628,8 @@
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
@@ -758,6 +763,7 @@
"LABEL": "Help Center",
"PLACEHOLDER": "Select Help Center",
"SELECT_PLACEHOLDER": "Select Help Center",
"NONE": "None",
"REMOVE": "Remove Help Center",
"SUB_TEXT": "Attach a Help Center with the inbox"
},
@@ -911,9 +917,11 @@
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
"DAY": {
"DAY": "Day",
"AVAILABILITY": "Availability",
"HOURS": "Hours",
"ENABLE": "Enable availability for this day",
"UNAVAILABLE": "Unavailable",
"HOURS": "hours",
"VALIDATION_ERROR": "Starting time should be before closing time.",
"CHOOSE": "Choose"
},
@@ -1020,11 +1028,12 @@
"IN_A_DAY": "In a day"
},
"WIDGET_COLOR_LABEL": "Widget Color",
"WIDGET_BUBBLE_POSITION_LABEL": "Widget Bubble Position",
"WIDGET_BUBBLE_TYPE_LABEL": "Widget Bubble Type",
"WIDGET_BUBBLE": "Bubble",
"WIDGET_BUBBLE_POSITION_LABEL": "Position:",
"WIDGET_BUBBLE_TYPE_LABEL": "Type:",
"WIDGET_BUBBLE_LAUNCHER_TITLE": {
"DEFAULT": "Chat with us",
"LABEL": "Widget Bubble Launcher Title",
"LABEL": "Launcher Title",
"PLACE_HOLDER": "Chat with us"
},
"UPDATE": {
@@ -1049,7 +1058,7 @@
},
"WIDGET_SCREEN": {
"DEFAULT": "Default",
"CHAT": "Chat"
"CHAT": "Chat mode"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",

View File

@@ -3,6 +3,9 @@
"FETCHING": "Fetching Integrations",
"NO_HOOK_CONFIGURED": "There are no {integrationId} integrations configured in this account.",
"HEADER": "Applications",
"COUNT": "{n} integration | {n} integrations",
"SEARCH_PLACEHOLDER": "Search...",
"NO_RESULTS": "No results found matching your search",
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
@@ -31,6 +34,7 @@
"LIST": {
"FETCHING": "Fetching integration hooks",
"INBOX": "Inbox",
"ACTIONS": "Actions",
"DELETE": {
"BUTTON_TEXT": "Delete"
}

View File

@@ -1,6 +1,7 @@
{
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"HEADER": "Shopify",
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
@@ -19,6 +20,8 @@
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
"LOADING": "Fetching integrations",
"SEARCH_PLACEHOLDER": "Search integrations...",
"NO_RESULTS": "No integrations found matching your search",
"CAPTAIN": {
"DISABLED": "Captain is not enabled on your account.",
"CLICK_HERE_TO_CONFIGURE": "Click here to configure",
@@ -28,6 +31,9 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"COUNT": "{n} webhook | {n} webhooks",
"SEARCH_PLACEHOLDER": "Search webhooks...",
"NO_RESULTS": "No webhooks found matching your search",
"FORM": {
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
@@ -104,6 +110,7 @@
}
},
"SLACK": {
"HEADER": "Slack",
"DELETE": "Delete",
"DELETE_CONFIRMATION": {
"TITLE": "Delete the integration",
@@ -223,10 +230,17 @@
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LEARN_MORE": "Learn more about Dashboard Apps",
"COUNT": "{n} dashboard app | {n} dashboard apps",
"SEARCH_PLACEHOLDER": "Search dashboard apps...",
"NO_RESULTS": "No dashboard apps found matching your search",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": { "NAME": "Name", "ENDPOINT": "Endpoint" },
"TABLE_HEADER": {
"NAME": "Name",
"ENDPOINT": "Endpoint",
"ACTIONS": "Actions"
},
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
@@ -262,6 +276,7 @@
}
},
"LINEAR": {
"HEADER": "Linear",
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
"LOADING": "Fetching linear issues...",
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
@@ -356,6 +371,7 @@
}
},
"NOTION": {
"HEADER": "Notion",
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",

View File

@@ -5,6 +5,9 @@
"LOADING": "Fetching labels",
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
"LEARN_MORE": "Learn more about labels",
"COUNT": "{n} label | {n} labels",
"SEARCH_PLACEHOLDER": "Search labels...",
"NO_RESULTS": "No labels found matching your search",
"SEARCH_404": "There are no items matching this query",
"LIST": {
"404": "There are no labels available in this account.",
@@ -13,7 +16,8 @@
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"COLOR": "Color"
"COLOR": "Color",
"ACTION": "Actions"
}
},
"FORM": {

View File

@@ -3,9 +3,12 @@
"HEADER": "Macros",
"DESCRIPTION": "A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click.",
"LEARN_MORE": "Learn more about macros",
"COUNT": "{n} macro | {n} macros",
"HEADER_BTN_TXT": "Add a new macro",
"HEADER_BTN_TXT_SAVE": "Save macro",
"LOADING": "Fetching macros",
"SEARCH_PLACEHOLDER": "Search macros...",
"NO_RESULTS": "No macros found matching your search",
"ERROR": "Something went wrong. Please try again",
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
"ADD": {
@@ -29,7 +32,8 @@
"NAME": "Name",
"CREATED BY": "Created by",
"LAST_UPDATED_BY": "Last updated by",
"VISIBILITY": "Visibility"
"VISIBILITY": "Visibility",
"ACTIONS": "Actions"
},
"404": "No macros found"
},

View File

@@ -1,7 +1,7 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",

View File

@@ -5,7 +5,12 @@
"ADD_ACTION_LONG": "Create a new SLA Policy",
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"COUNT": "{n} SLA | {n} SLAs",
"LOADING": "Fetching SLAs",
"SEARCH_PLACEHOLDER": "Search SLA...",
"SEARCH": {
"NO_RESULTS": "No SLA found matching your search"
},
"PAYWALL": {
"TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
@@ -20,14 +25,18 @@
},
"LIST": {
"404": "There are no SLAs available in this account.",
"TABLE_HEADER": {
"SLA": "SLA",
"BUSINESS_HOURS": "Business hours"
},
"EMPTY": {
"TITLE_1": "Enterprise P0",
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
"TITLE_2": "Enterprise P1",
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
},
"BUSINESS_HOURS_ON": "Business hours on",
"BUSINESS_HOURS_OFF": "Business hours off",
"BUSINESS_HOURS_ON": "Turned on",
"BUSINESS_HOURS_OFF": "Turned off",
"RESPONSE_TYPES": {
"FRT": "First response time threshold",
"NRT": "Next response time threshold",

View File

@@ -5,6 +5,9 @@
"LOADING": "Fetching teams",
"DESCRIPTION": "Teams allow you to organize agents into groups based on their responsibilities. An agent can belong to multiple teams. When working collaboratively, you can assign conversations to specific teams.",
"LEARN_MORE": "Learn more about teams",
"COUNT": "{n} team | {n} teams",
"SEARCH_PLACEHOLDER": "Search teams...",
"NO_RESULTS": "No teams found matching your search",
"LIST": {
"404": "There are no teams created on this account.",
"EDIT_TEAM": "Edit team",
@@ -64,8 +67,8 @@
"ERROR_MESSAGE": "Couldn't save the team details. Try again."
},
"AGENTS": {
"AGENT": "AGENT",
"EMAIL": "EMAIL",
"AGENT": "Agent",
"EMAIL": "Email",
"BUTTON_TEXT": "Add agents",
"ADD_AGENTS": "Adding Agents to your Team...",
"SELECT": "select",

View File

@@ -1,206 +1,329 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import WidgetHead from './WidgetHead.vue';
import WidgetBody from './WidgetBody.vue';
import WidgetFooter from './WidgetFooter.vue';
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Code from 'dashboard/components/Code.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
export default {
name: 'Widget',
components: {
WidgetHead,
WidgetBody,
WidgetFooter,
InputRadioGroup,
const props = defineProps({
welcomeHeading: {
type: String,
default: '',
},
props: {
welcomeHeading: {
type: String,
default: '',
},
welcomeTagline: {
type: String,
default: '',
},
websiteName: {
type: String,
required: true,
},
logo: {
type: String,
default: '',
},
isOnline: {
type: Boolean,
default: true,
},
replyTime: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
widgetBubblePosition: {
type: String,
default: '',
},
widgetBubbleLauncherTitle: {
type: String,
default: '',
},
widgetBubbleType: {
type: String,
default: '',
},
welcomeTagline: {
type: String,
default: '',
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
websiteName: {
type: String,
required: true,
},
data() {
return {
widgetScreens: [
{
id: 'default',
title: this.$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_SCREEN.DEFAULT'),
checked: true,
},
{
id: 'chat',
title: this.$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_SCREEN.CHAT'),
checked: false,
},
],
isDefaultScreen: true,
isWidgetVisible: true,
};
logo: {
type: String,
default: '',
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
getWidgetConfig() {
return {
welcomeHeading: this.welcomeHeading,
welcomeTagline: this.welcomeTagline,
websiteName: this.websiteName,
logo: this.logo,
isDefaultScreen: this.isDefaultScreen,
isOnline: this.isOnline,
replyTime: this.replyTimeText,
color: this.color,
};
},
replyTimeText() {
switch (this.replyTime) {
case 'in_a_few_minutes':
return this.$t(
'INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_FEW_MINUTES'
);
case 'in_a_day':
return this.$t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_DAY');
default:
return this.$t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_FEW_HOURS');
}
},
getBubblePositionStyle() {
return {
justifyContent: this.widgetBubblePosition === 'left' ? 'start' : 'end',
};
},
isBubbleExpanded() {
return (
!this.isWidgetVisible && this.widgetBubbleType === 'expanded_bubble'
);
},
getWidgetBubbleLauncherTitle() {
return this.isWidgetVisible || this.widgetBubbleType === 'standard'
? ' '
: this.widgetBubbleLauncherTitle;
},
isOnline: {
type: Boolean,
default: true,
},
methods: {
handleScreenChange(item) {
this.isDefaultScreen = item.id === 'default';
},
toggleWidget() {
this.isWidgetVisible = !this.isWidgetVisible;
this.isDefaultScreen = true;
},
replyTime: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
widgetBubblePosition: {
type: String,
default: '',
},
widgetBubbleLauncherTitle: {
type: String,
default: '',
},
widgetBubbleType: {
type: String,
default: '',
},
webWidgetScript: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const globalConfig = useMapGetter('globalConfig/get');
const isChatMode = ref(false);
const [isWidgetVisible, toggleWidget] = useToggle(true);
const activeTabIndex = ref(0);
const tabs = computed(() => [
{
label: t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.PREVIEW'
),
},
{
label: t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.SCRIPT'
),
},
]);
const isPreviewTab = computed(() => activeTabIndex.value === 0);
const widgetScript = computed(() => {
if (!props.webWidgetScript) return '';
const options = {
position: props.widgetBubblePosition,
type: props.widgetBubbleType,
launcherTitle: props.widgetBubbleLauncherTitle,
};
const script = props.webWidgetScript;
return (
script.substring(0, 13) +
t('INBOX_MGMT.WIDGET_BUILDER.SCRIPT_SETTINGS', {
options: JSON.stringify(options),
}) +
script.substring(13)
);
});
const replyTimeText = computed(() => {
switch (props.replyTime) {
case 'in_a_few_minutes':
return t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_FEW_MINUTES');
case 'in_a_day':
return t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_DAY');
default:
return t('INBOX_MGMT.WIDGET_BUILDER.REPLY_TIME.IN_A_FEW_HOURS');
}
});
const getWidgetConfig = computed(() => ({
welcomeHeading: props.welcomeHeading,
welcomeTagline: props.welcomeTagline,
websiteName: props.websiteName,
logo: props.logo,
isDefaultScreen: !isChatMode.value,
isOnline: props.isOnline,
replyTime: replyTimeText.value,
color: props.color,
}));
const getBubblePositionStyle = computed(() => ({
justifyContent: props.widgetBubblePosition === 'left' ? 'start' : 'end',
}));
const isBubbleExpanded = computed(
() => !isWidgetVisible.value && props.widgetBubbleType === 'expanded_bubble'
);
const getWidgetBubbleLauncherTitle = computed(() =>
isWidgetVisible.value || props.widgetBubbleType === 'standard'
? ' '
: props.widgetBubbleLauncherTitle
);
const handleTabChange = tab => {
activeTabIndex.value = tabs.value.findIndex(item => item.label === tab.label);
};
const handleToggleWidget = () => {
toggleWidget();
if (isWidgetVisible.value) {
isChatMode.value = false;
}
};
</script>
<template>
<div>
<div v-if="isWidgetVisible" class="flex flex-col items-center">
<InputRadioGroup
name="widget-screen"
:items="widgetScreens"
:action="handleScreenChange"
<div class="flex flex-col h-full min-h-0 flex-1">
<div class="flex items-center justify-between mb-6 flex-shrink-0">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChange"
/>
</div>
<div
v-if="isWidgetVisible"
class="widget-wrapper flex flex-col justify-between rounded-lg shadow-md bg-n-slate-2 dark:bg-n-solid-1 h-[31.25rem] w-80"
>
<WidgetHead :config="getWidgetConfig" />
<div>
<WidgetBody
v-if="!getWidgetConfig.isDefaultScreen"
:config="getWidgetConfig"
/>
<WidgetFooter :config="getWidgetConfig" />
<div class="py-2.5 flex justify-center">
<a
class="items-center gap-0.5 text-n-slate-11 cursor-pointer flex filter grayscale opacity-90 hover:grayscale-0 hover:opacity-100 text-xxs"
>
<img
class="max-w-2.5 max-h-2.5"
:src="globalConfig.logoThumbnail"
/>
<span>
{{
replaceInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
)
}}
</span>
</a>
</div>
<div v-if="isPreviewTab" class="flex items-center gap-2">
<span class="text-heading-3 text-n-slate-11">
{{ $t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_SCREEN.CHAT') }}
</span>
<Switch v-model="isChatMode" />
</div>
</div>
<div class="flex mt-4 w-[320px]" :style="getBubblePositionStyle">
<button
class="relative flex items-center justify-center rounded-full cursor-pointer"
:style="{ background: color }"
:class="
isBubbleExpanded
? 'w-auto font-medium text-base text-white dark:text-white h-12 px-4'
: 'w-16 h-16'
"
@click="toggleWidget"
<div class="flex-1 min-h-0 flex flex-col">
<div
v-if="isPreviewTab"
class="flex-1 flex flex-col items-center justify-end pb-4"
>
<img
v-if="!isWidgetVisible"
src="~dashboard/assets/images/bubble-logo.svg"
alt=""
draggable="false"
class="w-6 h-6 mx-auto"
<div
v-if="isWidgetVisible"
class="widget-wrapper flex flex-1 flex-shrink-0 flex-col justify-between rounded-lg shadow-md bg-n-slate-2 dark:bg-n-solid-1 h-[31.25rem] w-80 mb-4"
>
<WidgetHead :config="getWidgetConfig" />
<div>
<WidgetBody
v-if="!getWidgetConfig.isDefaultScreen"
:config="getWidgetConfig"
/>
<WidgetFooter :config="getWidgetConfig" />
<div class="py-2.5 flex justify-center">
<a
class="items-center gap-0.5 text-n-slate-11 cursor-pointer flex filter grayscale opacity-90 hover:grayscale-0 hover:opacity-100 text-xxs"
>
<img
class="max-w-2.5 max-h-2.5"
:src="globalConfig.logoThumbnail"
/>
<span>
{{
replaceInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
)
}}
</span>
</a>
</div>
</div>
</div>
<div class="flex w-[320px]" :style="getBubblePositionStyle">
<button
class="relative flex items-center justify-center rounded-full cursor-pointer"
:style="{ background: props.color }"
:class="
isBubbleExpanded
? 'w-auto font-medium text-base text-white dark:text-white h-12 px-4'
: 'w-16 h-16'
"
@click="handleToggleWidget"
>
<img
v-if="!isWidgetVisible"
src="~dashboard/assets/images/bubble-logo.svg"
alt=""
draggable="false"
class="w-6 h-6 mx-auto"
/>
<div v-if="isBubbleExpanded" class="ltr:pl-2.5 rtl:pr-2.5">
{{ getWidgetBubbleLauncherTitle }}
</div>
<div v-if="isWidgetVisible" class="relative">
<div
class="absolute w-0.5 h-8 rotate-45 -translate-y-1/2 bg-white"
/>
<div
class="absolute w-0.5 h-8 -rotate-45 -translate-y-1/2 bg-white"
/>
</div>
</button>
</div>
</div>
<div
v-else
class="flex-1 p-3 rounded-lg [&_code]:!bg-n-slate-2 bg-n-slate-2 min-w-0 overflow-auto [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_code]:whitespace-pre-wrap"
>
<Code
:script="widgetScript"
lang="html"
class="!text-start"
:codepen-title="`${websiteName} - Chatwoot Widget Test`"
enable-code-pen
/>
<div v-if="isBubbleExpanded" class="ltr:pl-2.5 rtl:pr-2.5">
{{ getWidgetBubbleLauncherTitle }}
</div>
<div v-if="isWidgetVisible" class="relative">
<div class="absolute w-0.5 h-8 rotate-45 -translate-y-1/2 bg-white" />
<div
class="absolute w-0.5 h-8 -rotate-45 -translate-y-1/2 bg-white"
/>
</div>
</button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
// Widget-specific color variables to match actual widget appearance
.widget-wrapper {
// Light mode - widget colors
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
// Dark mode - widget colors
.dark .widget-wrapper {
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
</style>

View File

@@ -16,7 +16,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1 px-6"
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">

View File

@@ -154,7 +154,7 @@ onMounted(() => {
t('COMPANIES.EMPTY_STATE.TITLE')
}}</span>
</div>
<div v-else class="flex flex-col gap-4 p-4">
<div v-else class="flex flex-col gap-4">
<CompaniesCard
v-for="company in companies"
:id="company.id"

View File

@@ -493,7 +493,7 @@ onMounted(async () => {
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"

View File

@@ -79,9 +79,11 @@ export default {
</script>
<template>
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
<div
class="flex items-center justify-between w-full gap-1 h-[3.25rem] ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
<h1 class="text-heading-2 truncate text-n-slate-12 min-w-0">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
@@ -91,7 +93,7 @@ export default {
trailing-icon
slate
xs
faded
:variant="showInboxDisplayMenu ? 'faded' : 'solid'"
@click="openInboxDisplayMenu"
/>
<InboxDisplayMenu
@@ -106,8 +108,8 @@ export default {
<NextButton
icon="i-lucide-sliders-vertical"
slate
xs
faded
sm
:variant="showInboxOptionMenu ? 'faded' : 'ghost'"
@click="openInboxOptionsMenu"
/>
<InboxOptionMenu

View File

@@ -1,17 +1,12 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper.vue';
import SettingsWrapper from '../settings/SettingsWrapper.vue';
import NotificationsView from './components/NotificationsView.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/notifications'),
component: SettingsWrapper,
props: {
headerTitle: '',
icon: '',
showNewButton: false,
},
children: [
{
path: '',

View File

@@ -41,14 +41,14 @@ export default {
<template>
<div
class="flex justify-between items-center h-20 min-h-[3.5rem] px-4 py-2 bg-n-surface-1"
class="flex justify-between items-center h-20 min-h-[3.5rem] px-6 py-2 bg-n-surface-1"
>
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
<BackButton
v-if="showBackButton"
:button-label="backButtonLabel"
:back-url="backUrl"
class="ml-2 mr-4"
class="ltr:mr-4 rtl:ml-4"
/>
<slot />

View File

@@ -20,7 +20,7 @@ defineProps({
</script>
<template>
<div class="flex flex-col w-full h-full gap-8 font-inter">
<div class="flex flex-col w-full h-full gap-4 font-inter">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<main>

View File

@@ -8,13 +8,13 @@ export default {
</script>
<template>
<div class="flex flex-col w-full items-start mb-4">
<h2 class="text-xl font-medium mb-1 text-n-slate-12 break-words">
<div class="flex flex-col gap-1.5 w-full items-start mb-4">
<h2 class="text-heading-1 text-n-slate-12 break-words">
{{ headerTitle }}
</h2>
<p
v-dompurify-html="headerContent"
class="text-sm w-full text-n-slate-11"
class="text-body-main w-full text-n-slate-11"
/>
</div>
</template>

View File

@@ -1,22 +1,26 @@
<script setup>
import { useRoute } from 'vue-router';
defineProps({
keepAlive: {
type: Boolean,
default: true,
},
});
const route = useRoute();
</script>
<template>
<div
class="flex flex-col w-full h-full m-0 p-6 sm:py-8 lg:px-16 overflow-auto bg-n-surface-1 font-inter"
class="flex flex-col w-full h-full m-0 pb-8 pt-4 px-6 overflow-auto bg-n-surface-1"
>
<div class="flex items-start w-full max-w-6xl mx-auto">
<div class="flex items-start w-full max-w-5xl mx-auto">
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
<component :is="Component" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-else />
<component :is="Component" v-else :key="route.fullPath" />
</router-view>
</div>
</div>

View File

@@ -8,7 +8,6 @@ const props = defineProps({
keepAlive: { type: Boolean, default: true },
showBackButton: { type: Boolean, default: false },
backUrl: { type: [String, Object], default: '' },
fullWidth: { type: Boolean, default: false },
});
const { t } = useI18n();
@@ -19,27 +18,21 @@ const showSettingsHeader = computed(
</script>
<template>
<div class="flex flex-1 flex-col m-0 bg-n-surface-1 overflow-auto">
<div
class="mx-auto w-full flex flex-col flex-1"
:class="{ 'max-w-6xl': !fullWidth }"
>
<SettingsHeader
v-if="showSettingsHeader"
:icon="icon"
:header-title="t(headerTitle)"
:show-back-button="showBackButton"
:back-url="backUrl"
class="sticky top-0 z-20"
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
/>
<div class="flex flex-col h-full m-0 bg-n-surface-1 w-full">
<SettingsHeader
v-if="showSettingsHeader"
:icon="icon"
:header-title="t(headerTitle)"
:show-back-button="showBackButton"
:back-url="backUrl"
class="z-20 max-w-7xl w-full mx-auto"
/>
<router-view v-slot="{ Component }" class="px-5 flex-1 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</div>
<router-view v-slot="{ Component }" class="px-4 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</div>
</template>

View File

@@ -146,12 +146,13 @@ export default {
</script>
<template>
<div class="flex flex-col max-w-2xl mx-auto w-full">
<div class="flex flex-col w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<div class="flex-grow flex-shrink min-w-0 mt-3">
<SectionLayout
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
class="!pt-0"
>
<form
v-if="!uiFlags.isFetchingItem"

View File

@@ -129,14 +129,14 @@ const toggleAutoResolve = async () => {
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-base font-medium text-n-slate-12">
<h3 class="text-heading-2 text-n-slate-12">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
</h3>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
<p class="mb-0 text-body-para text-n-slate-11">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
</p>
</div>

View File

@@ -20,8 +20,16 @@ const { t } = useI18n();
}"
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<div
v-if="
title || beta || $slots.title || description || $slots.description
"
class="col-span-3"
>
<h4
v-if="title || beta || $slots.title"
class="text-heading-2 text-n-slate-12 flex items-center gap-2"
>
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
@@ -31,7 +39,10 @@ const { t } = useI18n();
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<p
v-if="description || $slots.description"
class="text-n-slate-11 text-body-main mt-2"
>
<slot name="description">{{ description }}</slot>
</p>
</div>

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -10,6 +11,11 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AgentBotModal from './components/AgentBotModal.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = {
CREATE: 'create',
@@ -23,6 +29,7 @@ const agentBots = useMapGetter('agentBots/getBots');
const uiFlags = useMapGetter('agentBots/getUIFlags');
const selectedBot = ref({});
const searchQuery = ref('');
const loading = ref({});
const modalType = ref(MODAL_TYPES.CREATE);
const agentBotModalRef = ref(null);
@@ -32,11 +39,18 @@ const tableHeaders = computed(() => {
return [
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
t('AGENT_BOTS.LIST.TABLE_HEADER.ACTIONS'),
];
});
const selectedBotName = computed(() => selectedBot.value?.name || '');
const filteredAgentBots = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentBots.value;
return picoSearch(agentBots.value, query, ['name', 'description']);
});
const openAddModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
@@ -86,87 +100,97 @@ onMounted(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="t('AGENT_BOTS.HEADER')"
:description="t('AGENT_BOTS.DESCRIPTION')"
:link-text="t('AGENT_BOTS.LEARN_MORE')"
:search-placeholder="t('AGENT_BOTS.SEARCH_PLACEHOLDER')"
feature-name="agent_bots"
>
<template v-if="agentBots?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('AGENT_BOTS.COUNT', { n: agentBots.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_BOTS.ADD.TITLE')"
size="sm"
@click="openAddModal"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
<tr v-for="bot in agentBots" :key="bot.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
rounded-full
/>
<div>
<span class="block font-medium break-words">
{{ bot.name }}
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
<BaseTable
:headers="tableHeaders"
:items="filteredAgentBots"
:no-data-message="
searchQuery ? t('AGENT_BOTS.NO_RESULTS') : t('AGENT_BOTS.LIST.404')
"
>
<template #row="{ items }">
<BaseTableRow v-for="bot in items" :key="bot.id" :item="bot">
<template #default>
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-12 truncate">
{{ bot.name }}
</span>
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 rounded-md py-0.5 px-1 flex-shrink-0"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
</span>
</div>
<span class="text-body-main text-n-slate-11 block truncate">
{{ bot.description }}
</span>
</span>
<span class="text-sm text-n-slate-11">
{{ bot.description }}
</span>
</div>
</div>
</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<span class="text-body-main text-n-slate-11 truncate block">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<AgentBotModal

View File

@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import {
useStoreGetters,
useStore,
@@ -25,6 +26,7 @@ const showDeletePopup = ref(false);
const showEditPopup = ref(false);
const agentAPI = ref({ message: '' });
const currentAgent = ref({});
const searchQuery = ref('');
const deleteConfirmText = computed(
() => `${t('AGENT_MGMT.DELETE.CONFIRM.YES')} ${currentAgent.value.name}`
@@ -37,6 +39,13 @@ const deleteMessage = computed(() => {
});
const agentList = computed(() => getters['agents/getAgents'].value);
const filteredAgentList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentList.value;
return picoSearch(agentList.value, query, ['name', 'email']);
});
const uiFlags = computed(() => getters['agents/getUIFlags'].value);
const currentUserId = computed(() => getters.getCurrentUserID.value);
const customRoles = useMapGetter('customRole/getCustomRoles');
@@ -144,112 +153,127 @@ const confirmDeletion = () => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('AGENT_MGMT.HEADER')"
:description="$t('AGENT_MGMT.DESCRIPTION')"
:link-text="$t('AGENT_MGMT.LEARN_MORE')"
:search-placeholder="$t('AGENT_MGMT.SEARCH_PLACEHOLDER')"
feature-name="agents"
>
<template v-if="agentList?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('AGENT_MGMT.COUNT', { n: agentList.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="divide-y divide-n-weak">
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Avatar
:src="agent.thumbnail"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
rounded-full
/>
<div>
<span class="block font-medium capitalize">
{{ agent.name }}
</span>
<span>{{ agent.email }}</span>
</div>
</div>
</td>
<td class="relative py-4 ltr:pr-4 rtl:pl-4">
<span
class="block font-medium w-fit"
:class="{
'hover:text-gray-900 group cursor-pointer':
agent.custom_role_id,
}"
>
{{ getAgentRoleName(agent) }}
<div
class="absolute left-0 z-10 hidden max-w-[300px] w-auto bg-white rounded-xl border border-n-weak shadow-lg top-14 md:top-12 dark:bg-n-solid-2"
:class="{ 'group-hover:block': agent.custom_role_id }"
<span
v-if="!filteredAgentList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('AGENT_MGMT.NO_RESULTS') }}
</span>
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
<div
v-for="(agent, index) in filteredAgentList"
:key="agent.email"
class="flex justify-between flex-row items-start gap-4 py-4"
>
<div class="flex items-center gap-4">
<Avatar
:src="agent.thumbnail"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
/>
<div class="flex flex-col gap-1.5 items-start">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ agent.name }}
</span>
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-11">
{{ agent.email }}
</span>
<div class="w-px h-3 bg-n-strong rounded-lg" />
<span
class="block w-fit text-body-main text-n-slate-11 relative"
:class="{
'hover:text-n-slate-12 group cursor-pointer':
agent.custom_role_id,
}"
>
<div class="flex flex-col gap-1 p-4">
<span class="font-semibold">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="pl-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="font-normal"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
{{ getAgentRoleName(agent) }}
<div
class="absolute ltr:left-0 rtl:right-0 z-10 hidden w-[300px] bg-n-alpha-3 backdrop-blur-[100px] rounded-xl outline outline-1 outline-n-container shadow-lg top-14 md:top-12"
:class="{ 'group-hover:block': agent.custom_role_id }"
>
<div class="flex flex-col gap-1 p-4">
<span class="text-heading-3 text-n-slate-12">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="ltr:pl-4 rtl:pr-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="text-body-main text-n-slate-11"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
</div>
</div>
</div>
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span v-if="agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span v-if="!agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</td>
<td class="py-4">
<div class="flex justify-end gap-1">
<Button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(agent)"
/>
<Button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</span>
<div class="w-px h-3 bg-n-strong rounded-lg" />
<span
v-if="agent.confirmed"
class="text-body-main text-n-slate-11"
>
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span
v-if="!agent.confirmed"
class="text-body-main text-n-slate-11"
>
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex justify-end gap-3">
<Button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="openEditPopup(agent)"
/>
<Button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</div>
</div>
</div>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">

View File

@@ -93,7 +93,7 @@ const handleClick = key => {
</template>
<template #body>
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6">
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mt-4">
<AssignmentCard
v-for="item in agentAssignments"
:key="item.key"

View File

@@ -88,9 +88,9 @@ const handleSubmit = async formState => {
</script>
<template>
<SettingsLayout class="xl:px-44">
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>

View File

@@ -251,9 +251,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>

View File

@@ -96,7 +96,7 @@ onMounted(() => {
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
@@ -108,7 +108,7 @@ onMounted(() => {
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-8">
<div class="flex flex-col gap-4 pt-4">
<AssignmentPolicyCard
v-for="policy in agentAssignmentsPolicies"
:key="policy.id"

View File

@@ -67,9 +67,9 @@ const handleSubmit = async formState => {
</script>
<template>
<SettingsLayout class="xl:px-44">
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>

View File

@@ -184,9 +184,12 @@ onMounted(() => store.dispatch('agents/get'));
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>

View File

@@ -94,7 +94,7 @@ onMounted(() => {
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
@@ -106,7 +106,7 @@ onMounted(() => {
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-8">
<div class="flex flex-col gap-4 pt-4">
<AgentCapacityPolicyCard
v-for="policy in agentCapacityPolicies"
:key="policy.id"

View File

@@ -94,7 +94,8 @@ const createOption = (
key,
stateKey,
disabled = false,
disabledMessage = ''
disabledMessage = '',
disabledLabel = ''
) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
@@ -102,6 +103,7 @@ const createOption = (
isActive: state[stateKey] === key,
disabled,
disabledMessage,
disabledLabel,
});
const assignmentOrderOptions = computed(() => {
@@ -116,13 +118,17 @@ const assignmentOrderOptions = computed(() => {
const disabledMessage = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
const disabledLabel = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage
disabledMessage,
disabledLabel
);
});
});
@@ -217,6 +223,7 @@ defineExpose({
:description="option.description"
:is-active="option.isActive"
:disabled="option.disabled"
:disabled-label="option.disabledLabel"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event"
/>

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import EditAttribute from './EditAttribute.vue';
@@ -26,6 +27,7 @@ const inboxes = useMapGetter('inboxes/getInboxes');
const [showAddPopup, toggleAddPopup] = useToggle(false);
const selectedTabIndex = ref(0);
const searchQuery = ref('');
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
@@ -77,6 +79,7 @@ const attributes = computed(() =>
const onClickTabChange = tab => {
selectedTabIndex.value = tab.key;
searchQuery.value = '';
};
const handleEditAttribute = attribute => {
@@ -144,6 +147,16 @@ const derivedAttributes = computed(() =>
badges: buildBadges(attribute),
}))
);
const filteredAttributes = computed(() => {
const query = searchQuery.value.trim();
if (!query) return derivedAttributes.value;
return picoSearch(derivedAttributes.value, query, [
'attribute_display_name',
'attribute_key',
'attribute_description',
]);
});
</script>
<template>
@@ -153,31 +166,48 @@ const derivedAttributes = computed(() =>
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('ATTRIBUTES_MGMT.HEADER')"
:description="$t('ATTRIBUTES_MGMT.DESCRIPTION')"
:link-text="$t('ATTRIBUTES_MGMT.LEARN_MORE')"
:search-placeholder="$t('ATTRIBUTES_MGMT.SEARCH_PLACEHOLDER')"
feature-name="custom_attributes"
>
<template v-if="attributes?.length" #count>
<span class="text-body-main text-n-slate-11 truncate min-w-0">
{{ $t('ATTRIBUTES_MGMT.COUNT', { n: attributes.length }) }}
</span>
</template>
<template #tabs>
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
@tab-changed="onClickTabChange"
/>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col gap-6">
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div v-if="derivedAttributes.length" class="grid gap-3">
<div class="flex flex-col gap-4">
<span
v-if="!filteredAttributes.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('ATTRIBUTES_MGMT.NO_RESULTS') }}
</span>
<div
v-else-if="filteredAttributes.length"
class="flex flex-col divide-y divide-n-weak border-t border-n-weak"
>
<AttributeListItem
v-for="attribute in derivedAttributes"
v-for="attribute in filteredAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"

View File

@@ -2,8 +2,14 @@
import { useAlert } from 'dashboard/composables';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import {
generateTranslationPayload,
generateLogActionKey,
@@ -75,63 +81,68 @@ const tableHeaders = computed(() => {
</script>
<template>
<div class="flex-1 overflow-auto">
<BaseSettingsHeader
:title="$t('AUDIT_LOGS.HEADER')"
:description="$t('AUDIT_LOGS.DESCRIPTION')"
:link-text="$t('AUDIT_LOGS.LEARN_MORE')"
feature-name="audit_logs"
/>
<div class="mt-6 flex-1 text-n-slate-11">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('AUDIT_LOGS.LOADING')"
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('AUDIT_LOGS.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('AUDIT_LOGS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('AUDIT_LOGS.HEADER')"
:description="$t('AUDIT_LOGS.DESCRIPTION')"
:link-text="$t('AUDIT_LOGS.LEARN_MORE')"
feature-name="audit_logs"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base p-8"
>
{{ $t('AUDIT_LOGS.LIST.404') }}
</p>
<div v-else class="min-w-full overflow-x-auto">
<table class="divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
</template>
<template #body>
<div class="flex flex-col">
<BaseTable :headers="tableHeaders" :items="records">
<template #row="{ items }">
<BaseTableRow
v-for="auditLogItem in items"
:key="auditLogItem.id"
:item="auditLogItem"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{ generateLogText(auditLogItem) }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td>
<td class="py-4 w-[8.75rem]">
{{ auditLogItem.remote_address }}
</td>
</tr>
</tbody>
</table>
<TableFooter
<template #default>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ generateLogText(auditLogItem) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-11 whitespace-nowrap"
>
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</span>
</BaseTableCell>
<BaseTableCell class="w-36">
<span class="text-body-main text-n-slate-11">
{{ auditLogItem.remote_address }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<PaginationFooter
:current-page="Number(meta.currentPage)"
:total-count="meta.totalEntries"
:page-size="meta.perPage"
class="border-n-weak border-t !px-0 py-4"
@page-change="onPageChange"
:total-items="meta.totalEntries"
:items-per-page="meta.perPage"
class="!px-0"
@update:current-page="onPageChange"
/>
</div>
</div>
</div>
</template>
</SettingsLayout>
</template>

View File

@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
const props = defineProps({
automation: {
@@ -35,47 +36,58 @@ const automationActive = computed({
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4 min-w-[200px]">{{ automation.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ automation.description }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<ToggleSwitch v-model="automationActive" />
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]"
:title="readableDateWithTime(automation.created_on)"
>
{{ readableDate(automation.created_on) }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-lucide-copy-plus"
xs
faded
:is-loading="loading"
@click="$emit('clone', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', automation)"
/>
</div>
</td>
</tr>
<BaseTableRow :item="automation">
<template #default>
<BaseTableCell class="max-w-0 w-full">
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<ToggleSwitch v-model="automationActive" />
</BaseTableCell>
<BaseTableCell :title="readableDateWithTime(automation.created_on)">
<span class="text-body-main text-n-slate-12 whitespace-nowrap">
{{ readableDate(automation.created_on) }}
</span>
</BaseTableCell>
<BaseTableCell align="end">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-woot-clone"
sm
slate
:is-loading="loading"
@click="$emit('clone', automation)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>

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