Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Muhsin Keloth
2025-08-14 13:56:49 +05:30
committed by GitHub
3651 changed files with 197936 additions and 49575 deletions
@@ -0,0 +1,39 @@
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
title: { type: String, required: true },
isOpen: { type: Boolean, default: false },
});
const isExpanded = ref(props.isOpen);
const toggleAccordion = () => {
isExpanded.value = !isExpanded.value;
};
watch(
() => props.isOpen,
newValue => {
isExpanded.value = newValue;
}
);
</script>
<template>
<div class="border rounded-lg border-n-slate-4">
<button
class="flex items-center justify-between w-full p-4 text-left"
@click="toggleAccordion"
>
<span class="text-sm font-medium text-n-slate-12">{{ title }}</span>
<span
class="w-5 h-5 transition-transform duration-200 i-lucide-chevron-down"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<div v-if="isExpanded" class="p-4 pt-0">
<slot />
</div>
</div>
</template>
@@ -76,8 +76,8 @@ const campaignStatus = computed(() => {
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium);
});
</script>
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -0,0 +1,37 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -21,11 +21,11 @@ const addCampaign = async campaignDetails => {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
useAlert(t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
@@ -40,9 +40,9 @@ const handleSubmit = campaignDetails => {
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
</h3>
<LiveChatCampaignForm
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -39,9 +39,9 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
</h3>
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -0,0 +1,50 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
>
<div class="p-6 flex flex-col gap-6">
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</div>
</template>
@@ -0,0 +1,266 @@
<script setup>
import { reactive, computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const templateParserRef = ref(null);
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
return templateParserRef.value?.v$?.$invalid === false || true;
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
// Extract template content - this should be the template message body
const templateContent = parserData?.renderedTemplate || '';
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: parserData?.processedParams || {},
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
/>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -4,6 +4,10 @@ defineProps({
type: String,
default: 'col',
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
@@ -18,10 +22,11 @@ const handleClick = () => {
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
class="flex w-full gap-3 py-5"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-6',
]"
@click="handleClick"
>
<slot />
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</template>
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -16,6 +17,7 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
@@ -91,7 +93,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
@@ -149,15 +158,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
/>
<template #after>
<transition
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
enter-from-class="overflow-hidden opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
leave-to-class="overflow-hidden opacity-0 max-h-0"
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div v-show="isExpanded" class="w-full">
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
@@ -176,8 +185,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</transition>
</div>
</template>
</CardLayout>
</template>
@@ -8,17 +8,17 @@ import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
isUpdating: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['goToContactsList']);
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
const { t } = useI18n();
const slots = useSlots();
@@ -45,9 +45,17 @@ const breadcrumbItems = computed(() => {
return items;
});
const isContactBlocked = computed(() => {
return props.selectedContact?.blocked;
});
const handleBreadcrumbClick = () => {
emit('goToContactsList');
};
const toggleBlock = () => {
emit('toggleBlock', isContactBlocked.value);
};
</script>
<template>
@@ -57,23 +65,41 @@ const handleBreadcrumbClick = () => {
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full mx-auto max-w-[650px]">
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
<div class="flex items-center gap-2">
<Button
:label="
!isContactBlocked
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]">
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot name="default" />
</div>
</main>
@@ -81,7 +107,7 @@ const handleBreadcrumbClick = () => {
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description="
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm"
/>
@@ -50,6 +50,7 @@ const { t } = useI18n();
</div>
<ComboBox
id="inbox"
use-api-results
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
@@ -218,10 +218,13 @@ const resetForm = () => {
Object.assign(state, defaultState);
};
watch(() => props.contactData, prepareStateBasedOnProps, {
immediate: true,
deep: true,
});
watch(
() => props.contactData?.id,
id => {
if (id) prepareStateBasedOnProps();
},
{ immediate: true }
);
// Expose state to parent component for avatar upload
defineExpose({
@@ -247,10 +250,9 @@ defineExpose({
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@update:model-value="handleCountrySelection"
/>
@@ -266,7 +268,9 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
!isDetailsView
? '[&:not(.error,.focus)]:!outline-transparent'
: ''
}`"
class="w-full"
@input="
@@ -303,7 +307,7 @@ defineExpose({
v-model="
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
"
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
class="w-auto min-w-[100px] text-sm bg-transparent outline-none reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
:placeholder="item.placeholder"
:size="item.placeholder.length"
@input="emit('update', state)"
@@ -51,17 +51,18 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
type="submit"
:label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
"
color="blue"
:disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact"
@click="handleDialogConfirm"
/>
</div>
</template>
@@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
buttonLabel: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
isSegmentsView: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -60,7 +34,7 @@ const emit = defineEmits([
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -85,7 +59,7 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
@@ -105,7 +79,12 @@ const emit = defineEmits([
<slot name="filter" />
</div>
<Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
@@ -113,7 +92,7 @@ const emit = defineEmits([
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView"
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
@@ -36,6 +36,7 @@ const props = defineProps({
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -277,6 +278,7 @@ defineExpose({
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@@ -31,10 +31,6 @@ const sortMenus = [
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6"
class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
const props = defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
@@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
@@ -57,7 +70,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:show-search="isNotSegmentView && !isActiveView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@@ -66,19 +79,16 @@ const openFilter = () => {
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
@@ -87,8 +87,10 @@ useKeyboardEvents(keyboardEvents);
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="mx-6 py-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@delete="onDelete"
/>
</div>
@@ -1,6 +1,8 @@
<script setup>
import { useTemplateRef, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useToggle } from '@vueuse/core';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -14,39 +16,63 @@ const props = defineProps({
type: String,
required: true,
},
allowDelete: {
type: Boolean,
default: false,
},
collapsible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['delete']);
const noteContentRef = useTemplateRef('noteContentRef');
const needsCollapse = ref(false);
const [isExpanded, toggleExpanded] = useToggle();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const handleDelete = () => {
emit('delete', props.note.id);
};
onMounted(() => {
if (props.collapsible) {
// Check if content height exceeds approximately 4 lines
// Assuming line height is ~1.625 and font size is ~14px
const threshold = 14 * 1.625 * 4; // ~84px
needsCollapse.value = noteContentRef.value?.clientHeight > threshold;
}
});
</script>
<template>
<div
class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="note?.user?.name || 'Bot'"
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
:src="
note?.user?.name
? note?.user?.thumbnail
: '/assets/images/chatwoot_bot.png'
"
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium">{{ writtenBy }}</span>
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
<span class="font-medium text-n-slate-12">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
@@ -56,8 +82,28 @@ const handleDelete = () => {
/>
</div>
<p
ref="noteContentRef"
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
:class="{
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
}"
/>
<p v-if="collapsible && needsCollapse">
<Button
variant="faded"
color="blue"
size="xs"
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="() => toggleExpanded()"
>
<template v-if="isExpanded">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.COLLAPSE') }}
</template>
<template v-else>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EXPAND') }}
</template>
</Button>
</p>
</div>
</template>
@@ -71,6 +71,7 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const lastActivityAt = computed(() => {
@@ -0,0 +1,87 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
const { updateUISettings } = useUISettings();
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
};
const keyboardEvents = {
'Alt+KeyO': {
action: toggleConversationSidebarToggle,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
v-if="showCopilotTab"
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
@@ -4,38 +4,14 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
modelValue: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
maxLength: { type: Number, default: 200 },
showCharacterCount: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
message: { type: String, default: '' },
messageType: {
type: String,
default: 'info',
@@ -43,6 +19,8 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
@@ -58,7 +36,7 @@ const messageClass = computed(() => {
case 'error':
return 'text-n-ruby-9 dark:text-n-ruby-9';
case 'success':
return 'text-green-500 dark:text-green-400';
return 'text-n-teal-10 dark:text-n-teal-10';
default:
return 'text-n-slate-11 dark:text-n-slate-11';
}
@@ -120,6 +98,8 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -22,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
@@ -35,12 +35,12 @@ defineProps({
<div class="flex flex-col items-center justify-center gap-6">
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
{{ title }}
</h2>
<p
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -70,7 +70,7 @@ const category = {
<div
v-for="(article, index) in articles"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<ArticleCard
:id="article.id"
@@ -32,7 +32,7 @@ const categories = [
<div
v-for="(category, index) in categories"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<CategoryCard
:id="category.id"
@@ -121,11 +121,7 @@ const handleAction = ({ action, value }) => {
</div>
<span
class="text-sm line-clamp-3"
:class="
hasDescription
? 'text-slate-500 dark:text-slate-400'
: 'text-slate-400 dark:text-slate-700'
"
:class="hasDescription ? 'text-n-slate-11' : 'text-n-slate-9'"
>
{{ description }}
</span>
@@ -8,7 +8,7 @@ import ArticleEmptyState from './ArticleEmptyState.vue';
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Article Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<div class="w-full h-full px-20 mx-auto bg-n-background">
<ArticleEmptyState />
</div>
</Variant>
@@ -8,7 +8,7 @@ import PortalEmptyState from './PortalEmptyState.vue';
:layout="{ type: 'single', width: '1100px' }"
>
<Variant title="Portal Empty State">
<div class="w-full h-full px-20 mx-auto bg-white dark:bg-slate-900">
<div class="w-full h-full px-20 mx-auto bg-n-background">
<PortalEmptyState />
</div>
</Variant>
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto lg:px-6">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
@@ -14,7 +14,7 @@ const locales = [
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Locale Card">
<div class="px-10 py-4 bg-white dark:bg-slate-900">
<div class="px-10 py-4 bg-n-background">
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
<LocaleCard
:locale="locale.name"
@@ -55,9 +55,7 @@ const handleAction = ({ action, value }) => {
<CardLayout>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
>
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ locale }} ({{ localeCode }})
</span>
<span
@@ -69,9 +67,7 @@ const handleAction = ({ action, value }) => {
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
<span class="text-sm text-n-slate-11 whitespace-nowrap">
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
@@ -79,10 +75,8 @@ const handleAction = ({ action, value }) => {
)
}}
</span>
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
<span
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
<div class="w-px h-3 bg-n-weak" />
<span class="text-sm text-n-slate-11 whitespace-nowrap">
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
@@ -127,7 +127,7 @@ const previewArticle = () => {
<style lang="scss" scoped>
::v-deep {
.ProseMirror .empty-node::before {
@apply text-slate-200 dark:text-slate-500 text-base;
@apply text-n-slate-10 text-base;
}
.ProseMirror-menubar-wrapper {
@@ -142,7 +142,7 @@ const previewArticle = () => {
.editor-root .has-selection {
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
@@ -161,6 +161,10 @@ const previewArticle = () => {
}
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
@@ -206,7 +206,7 @@ onMounted(() => {
/>
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="w-px h-3 bg-n-weak" />
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
@@ -239,7 +239,7 @@ onMounted(() => {
</OnClickOutside>
</div>
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
<div class="w-px h-3 bg-n-weak" />
<div class="relative">
<OnClickOutside @trigger="openProperties = false">
<Button
@@ -128,7 +128,7 @@ const updateArticleStatus = async ({ value }) => {
<div class="flex items-center gap-4">
<span
v-if="isUpdating || isSaved"
class="text-xs font-medium transition-all duration-300 text-slate-500 dark:text-slate-400"
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
>
{{ statusText }}
</span>
@@ -52,7 +52,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
@@ -75,7 +75,7 @@ onMounted(() => {
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-n-slate-12"
>
{{
t(
@@ -90,9 +90,9 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[220px]"
class="w-[13.75rem]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]"
custom-text-area-class="max-h-[9.375rem]"
auto-height
min-height="3rem"
/>
@@ -108,12 +108,12 @@ onMounted(() => {
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[120px]"
custom-label-class="min-w-[7.5rem]"
/>
</div>
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-n-slate-12"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
@@ -126,7 +126,7 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[224px]"
class="w-[14rem]"
/>
</div>
</div>
@@ -64,7 +64,7 @@ const articles = [
<template>
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
<Variant title="All Articles">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<div class="w-full min-h-screen bg-n-background">
<ArticlesPage :articles="articles" />
</div>
</Variant>
@@ -201,7 +201,7 @@ const categories = [
<template>
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
<Variant title="All Categories">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<div class="w-full min-h-screen bg-n-background">
<CategoriesPage :categories="categories" />
</div>
</Variant>
@@ -90,9 +90,9 @@ const handleCategory = async formData => {
<template>
<div
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
<h3 class="text-base font-medium text-n-slate-12">
{{
t(
`HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.${mode.toUpperCase()}`
@@ -200,8 +200,9 @@ defineExpose({ state, isSubmitDisabled });
:label="state.icon"
color="slate"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -245,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -147,10 +147,8 @@ const handleBreadcrumbClick = () => {
/>
</OnClickOutside>
</div>
<div class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800" />
<span
class="min-w-0 text-sm font-medium truncate text-slate-800 dark:text-slate-100"
>
<div class="w-px h-3.5 rounded my-auto bg-n-weak" />
<span class="min-w-0 text-sm font-medium truncate text-n-slate-12">
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
n: categoriesCount,
@@ -95,7 +95,7 @@ defineExpose({ dialogRef });
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</Dialog>
@@ -44,7 +44,7 @@ const locales = [
<template>
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
<Variant title="All Locales">
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
<div class="w-full min-h-screen bg-n-background">
<LocalesPage :locales="locales" />
</div>
</Variant>
@@ -35,7 +35,7 @@ const localeCount = computed(() => props.locales?.length);
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
@@ -1,6 +1,9 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { isValidDomain } from '@chatwoot/utils';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -26,6 +29,20 @@ const formState = reactive({
customDomain: props.customDomain,
});
const rules = {
customDomain: {
isValidDomain: helpers.withMessage(
() =>
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
),
isValidDomain
),
},
};
const v$ = useVuelidate(rules, formState);
watch(
() => props.customDomain,
newVal => {
@@ -33,7 +50,10 @@ watch(
}
);
const handleDialogConfirm = () => {
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('addCustomDomain', formState.customDomain);
};
@@ -67,6 +87,11 @@ defineExpose({ dialogRef });
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
:message="
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
"
:message-type="v$.customDomain.$error ? 'error' : 'info'"
@blur="v$.customDomain.$touch()"
/>
</Dialog>
</template>
@@ -1,9 +1,15 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import { email, required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
@@ -12,10 +18,20 @@ const props = defineProps({
},
});
const emit = defineEmits(['confirm']);
const emit = defineEmits(['send', 'close']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
@@ -25,10 +41,34 @@ const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(subdomainCNAME.value);
useAlert(
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
)
);
};
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
const resetForm = () => {
v$.value.$reset();
state.email = '';
};
const onClose = () => {
resetForm();
emit('close');
};
const handleSend = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('send', state.email);
onClose();
};
defineExpose({ dialogRef });
@@ -37,42 +77,103 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
@confirm="handleDialogConfirm"
:show-confirm-button="false"
@close="resetForm"
>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<NextButton
icon="i-lucide-x"
sm
ghost
slate
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
@click="onClose"
/>
<div class="flex flex-col gap-6 divide-y divide-n-strong">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
<div class="flex flex-col gap-6 pt-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
)
}}
</p>
</div>
<form
class="flex items-start gap-3 w-full"
@submit.prevent="handleSend"
>
<Input
v-model="state.email"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
)
"
:message="
v$.email.$error
? t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
)
: ''
"
:message-type="v$.email.$error ? 'error' : 'info'"
class="w-full"
@blur="v$.email.$touch()"
/>
<NextButton
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
)
"
type="submit"
class="flex-shrink-0"
/>
</form>
</div>
</div>
</Dialog>
</template>
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { shouldBeUrl } from 'shared/helpers/Validators';
import { required, minLength, helpers, url } from '@vuelidate/validators';
import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -61,8 +61,17 @@ const liveChatWidgets = computed(() => {
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
homePageLink: { shouldBeUrl },
slug: {
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
homePageLink: { url },
};
const v$ = useVuelidate(rules, state);
@@ -71,9 +80,9 @@ const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const slugError = computed(() => {
return v$.value.slug.$errors[0]?.$message || '';
});
const homePageLinkError = computed(() =>
v$.value.homePageLink.$error
@@ -202,7 +211,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.NAME.LABEL') }}
</label>
@@ -220,7 +229,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HEADER_TEXT.LABEL') }}
</label>
@@ -236,7 +245,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap text-n-slate-12 py-2.5"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.PAGE_TITLE.LABEL') }}
</label>
@@ -252,7 +261,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap text-slate-900 py-2.5 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap text-n-slate-12 py-2.5"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.HOME_PAGE_LINK.LABEL') }}
</label>
@@ -272,7 +281,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.SLUG.LABEL') }}
</label>
@@ -290,7 +299,7 @@ const handleAvatarDelete = () => {
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
@@ -303,12 +312,14 @@ const handleAvatarDelete = () => {
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button]:!outline-n-weak"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.BRAND_COLOR.LABEL') }}
</label>
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
@@ -11,11 +12,52 @@ const props = defineProps({
type: Object,
required: true,
},
isFetchingStatus: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['updatePortalConfiguration']);
const emit = defineEmits([
'updatePortalConfiguration',
'refreshStatus',
'sendCnameInstructions',
]);
const SSL_STATUS = {
LIVE: ['active', 'staging_active'],
PENDING: [
'provisioned',
'pending',
'initializing',
'pending_validation',
'pending_deployment',
'pending_issuance',
'holding_deployment',
'holding_validation',
'pending_expiration',
'pending_cleanup',
'pending_deletion',
'staging_deployment',
'backup_issued',
],
ERROR: [
'blocked',
'inactive',
'moved',
'expired',
'deleted',
'timed_out_initializing',
'timed_out_validation',
'timed_out_issuance',
'timed_out_deployment',
'timed_out_deletion',
'deactivating',
],
};
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
@@ -25,6 +67,45 @@ const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
const verificationErrors = computed(
() => sslSettings.value.verification_errors || ''
);
const isLive = computed(() =>
SSL_STATUS.LIVE.includes(sslSettings.value.status)
);
const isPending = computed(() =>
SSL_STATUS.PENDING.includes(sslSettings.value.status)
);
const isError = computed(() =>
SSL_STATUS.ERROR.includes(sslSettings.value.status)
);
const statusText = computed(() => {
if (isLive.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
);
if (isPending.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
);
if (isError.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
);
return '';
});
const statusColors = computed(() => {
if (isLive.value)
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
if (isError.value)
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
});
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div class="flex items-center">
<div v-if="customDomainAddress" class="flex items-center gap-3">
<div
v-if="statusText && isOnChatwootCloud"
v-tooltip="verificationErrors"
class="flex items-center gap-3 flex-shrink-0"
>
<span
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
:class="statusColors.bubble"
/>
<span
:class="statusColors.text"
class="text-sm leading-[16px] font-medium"
>
{{ statusText }}
</span>
</div>
<div
v-if="statusText && isOnChatwootCloud"
class="w-px h-3 bg-n-weak"
/>
<Button
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
<Button
v-if="isOnChatwootCloud"
slate
sm
link
icon="i-lucide-refresh-ccw"
:class="isFetchingStatus && 'animate-spin'"
@click="onClickRefreshSSLStatus"
/>
</div>
<Button
v-else
:label="
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@confirm="closeDNSConfigurationDialog"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
/>
</div>
</template>
@@ -5,7 +5,7 @@ import PortalSettings from './PortalSettings.vue';
<template>
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
<Variant title="Default">
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
<div class="w-[1000px] min-h-screen bg-n-background">
<PortalSettings />
</div>
</Variant>
@@ -26,6 +26,8 @@ const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
'refreshStatus',
'sendCnameInstructions',
]);
const { t } = useI18n();
@@ -36,6 +38,7 @@ const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const fetchSSLStatus = () => {
emit('refreshStatus');
};
const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
@@ -74,20 +85,23 @@ const handleDeletePortal = () => {
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="w-full h-px bg-n-weak" />
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
@@ -6,8 +6,9 @@ import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { required, minLength, helpers } from '@vuelidate/validators';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import { isValidSlug } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -31,7 +32,16 @@ const state = reactive({
const rules = {
name: { required, minLength: minLength(2) },
slug: { required },
slug: {
required: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
required
),
isValidSlug: helpers.withMessage(
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
isValidSlug
),
},
};
const v$ = useVuelidate(rules, state);
@@ -40,9 +50,9 @@ const nameError = computed(() =>
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
);
const slugError = computed(() =>
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
);
const slugError = computed(() => {
return v$.value.slug.$errors[0]?.$message || '';
});
const isSubmitDisabled = computed(() => v$.value.$invalid);
@@ -131,6 +141,7 @@ defineExpose({ dialogRef });
:message="
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
"
@blur="v$.name.$touch()"
/>
<Input
id="portal-slug"
@@ -140,6 +151,8 @@ defineExpose({ dialogRef });
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
:message-type="slugError ? 'error' : 'info'"
:message="slugError || buildPortalURL(state.slug)"
@input="v$.slug.$touch()"
@blur="v$.slug.$touch()"
/>
</div>
</Dialog>
@@ -32,7 +32,7 @@ const portals = [
:layout="{ type: 'grid', width: '510px' }"
>
<Variant title="Portal Switcher">
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
<div class="h-[500px] p-4 bg-n-slate-2 dark:bg-n-background">
<PortalSwitcher
:portals="portals"
header="Choose a Portal"
@@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -100,7 +100,7 @@ const redirectToPortalHomePage = () => {
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-slate-900 dark:text-slate-50 w-fit hover:underline"
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
@click="redirectToPortalHomePage"
>
{{ t('HELP_CENTER.PORTAL_SWITCHER.PORTALS') }}
@@ -115,7 +115,7 @@ const redirectToPortalHomePage = () => {
@click="onClickPreviewPortal"
/>
</div>
<p class="text-sm text-slate-600 dark:text-slate-300">
<p class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SWITCHER.CREATE_PORTAL') }}
</p>
</div>
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
const inbox = computed(() => props.stateInbox);
const inboxIcon = computed(() => {
const { phoneNumber, channelType } = inbox.value;
return getInboxIconByType(channelType, phoneNumber);
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const hasSlaThreshold = computed(() => {
@@ -63,11 +63,12 @@ const lastActivityAt = computed(() => {
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
icon: isUnread.value ? 'mail' : 'mail-unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
]);
const messageClasses = computed(() => ({
@@ -153,7 +154,7 @@ onBeforeMount(contextMenuActions.close);
<template>
<div
role="button"
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer"
@contextmenu="contextMenuActions.open($event)"
@click="emit('click')"
>
@@ -232,7 +233,7 @@ onBeforeMount(contextMenuActions.close);
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm text-n-slate-10">
<span class="text-xs text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
@@ -13,7 +14,9 @@ import {
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -26,13 +29,26 @@ const props = defineProps({
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
@@ -47,6 +63,7 @@ const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
@@ -59,6 +76,8 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
@@ -104,7 +123,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
// Merge the processed contactableInboxes with the inboxesList
selectedContact.value.contactInboxes = mergeInboxDetails(
contactableInboxes,
inboxesList.value
);
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
@@ -124,9 +148,14 @@ const clearSelectedContact = () => {
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
selectedContact.value = null;
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
@@ -159,12 +188,19 @@ const toggle = () => {
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
currentContact.contactInboxes || []
);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
...currentContact,
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
},
@@ -172,7 +208,15 @@ watch(
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!viewInModal.value) return;
handleClickOutside();
};
onMounted(() => resetContacts());
@@ -195,12 +239,12 @@ useKeyboardEvents(keyboardEvents);
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
:class="{
'z-40': showComposeNewConversation,
'z-50': showComposeNewConversation && !viewInModal,
}"
>
<slot
@@ -208,29 +252,37 @@ useKeyboardEvents(keyboardEvents);
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
<div
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</div>
</template>
@@ -25,6 +25,7 @@ const props = defineProps({
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
});
const emit = defineEmits([
@@ -148,11 +149,12 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
@@ -170,7 +172,7 @@ useKeyboardEvents(keyboardEvents);
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
@@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
/>
</div>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
@@ -232,4 +234,20 @@ useKeyboardEvents(keyboardEvents);
.emoji-dialog::before {
@apply hidden;
}
// The <label> tag inside the file-upload component overlaps the button due to its position.
// This causes the button's hover state to not work, as it's positioned below the label (z-index).
// Increasing the button's z-index would break the file upload functionality.
// This style ensures the label remains clickable while preserving the button's hover effect.
:deep() {
.file-uploads.file-uploads-html5 {
label {
@apply hover:cursor-pointer;
}
&:hover button {
@apply dark:bg-n-solid-2 bg-n-alpha-2;
}
}
}
</style>
@@ -47,17 +47,17 @@ const removeAttachment = id => {
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[72px] h-[72px]"
class="relative group/image w-[4.5rem] h-[4.5rem]"
>
<img
class="object-cover w-[72px] h-[72px] rounded-lg"
class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
:src="attachment.thumb"
/>
<Button
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
class="absolute top-1 ltr:right-1 rtl:left-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
@@ -69,7 +69,7 @@ const removeAttachment = id => {
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
class="max-w-[18.75rem] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
>
<ContactSelector
:contacts="contacts"
@@ -336,6 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:inbox-id="targetInbox?.id"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@@ -113,7 +113,8 @@ const handleInput = value => {
</div>
<div
v-else-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 min-h-7 min-w-0"
:class="!contactId ? 'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1' : 'px-3'"
>
<span class="text-sm truncate text-n-slate-12">
{{
@@ -123,6 +124,7 @@ const handleInput = value => {
}}
</span>
<Button
v-if="!contactId"
variant="ghost"
icon="i-lucide-x"
color="slate"
@@ -52,7 +52,7 @@ const targetInboxLabel = computed(() => {
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -1,23 +1,25 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
messageTemplates: {
type: Array,
default: () => [],
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
const searchQuery = ref('');
const selectedTemplate = ref(null);
@@ -25,19 +27,7 @@ const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
const filteredTemplates = computed(() => {
@@ -84,10 +74,13 @@ const handleSendMessage = template => {
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<Icon
icon="i-lucide-search"
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
@@ -96,13 +89,13 @@ const handleSendMessage = template => {
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
@@ -111,12 +104,12 @@ const handleSendMessage = template => {
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplateParser
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@@ -0,0 +1,64 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</WhatsAppTemplateParser>
</div>
</div>
</template>
@@ -106,7 +106,7 @@ onMounted(() => {
<template>
<div
class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
@@ -138,7 +138,8 @@ onMounted(() => {
class="flex items-center w-full gap-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
:title="key"
>
{{ key }}
</span>
@@ -36,10 +36,11 @@ const transformInbox = ({
email,
channelType,
phoneNumber,
medium,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
icon: getInboxIconByType(channelType, medium, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
@@ -87,6 +88,21 @@ export const processContactableInboxes = inboxes => {
}));
};
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
if (!inboxesData || !inboxesData.length) {
return [];
}
return inboxesData.map(inboxData => {
const matchingInbox =
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
return {
...camelcaseKeys(matchingInbox, { deep: true }),
...inboxData,
};
});
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
@@ -110,6 +110,153 @@ describe('composeConversationHelper', () => {
});
});
describe('mergeInboxDetails', () => {
it('returns empty array if inboxesData is empty or null', () => {
expect(helpers.mergeInboxDetails(null)).toEqual([]);
expect(helpers.mergeInboxDetails([])).toEqual([]);
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
});
it('merges inbox data with matching inboxes from the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 2, sourceId: 'source2' },
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
channel_id: 10,
phone_number: null,
},
{
id: 2,
name: 'Inbox 2',
channel_type: 'Channel::Whatsapp',
channel_id: 20,
phone_number: '+1234567890',
},
{
id: 3,
name: 'Inbox 3',
channel_type: 'Channel::Api',
channel_id: 30,
phone_number: null,
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
channelId: 10,
phoneNumber: null,
});
expect(result[1]).toMatchObject({
id: 2,
sourceId: 'source2',
name: 'Inbox 2',
channelType: 'Channel::Whatsapp',
channelId: 20,
phoneNumber: '+1234567890',
});
});
it('handles inboxes not found in the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
});
expect(result[1]).toMatchObject({
id: 99,
sourceId: 'source99',
});
expect(result[1].name).toBeUndefined();
expect(result[1].channelType).toBeUndefined();
});
it('camelcases properties from inboxesList', () => {
const inboxesData = [{ id: 1, sourceId: 'source1' }];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
avatar_url: 'https://example.com/avatar.png',
working_hours: [
{
day_of_week: 1,
closed_all_day: false,
},
],
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
avatarUrl: 'https://example.com/avatar.png',
});
expect(result[0].workingHours[0]).toMatchObject({
dayOfWeek: 1,
closedAllDay: false,
});
});
it('preserves original properties when they conflict with inboxesList', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1', name: 'Original Name' },
];
const inboxesList = [
{
id: 1,
name: 'List Name',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0].name).toBe('Original Name');
expect(result[0].channelType).toBe('Channel::Email');
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
@@ -0,0 +1,29 @@
<script setup>
import SidebarActionsHeader from './SidebarActionsHeader.vue';
</script>
<template>
<Story
title="Components/SidebarActionsHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<SidebarActionsHeader title="Default State" />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<SidebarActionsHeader
title="With New Conversation Button"
:buttons="[
{
key: 'new_conversation',
icon: 'i-lucide-plus',
},
]"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Button from './button/Button.vue';
defineProps({
title: {
type: String,
required: true,
},
buttons: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = button => {
emit('click', button.key);
};
</script>
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<Button
v-for="button in buttons"
:key="button.key"
v-tooltip="button.tooltip"
:icon="button.icon"
ghost
sm
@click="handleButtonClick(button)"
/>
<Button
v-tooltip="$t('GENERAL.CLOSE')"
icon="i-lucide-x"
ghost
sm
@click="$emit('close')"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<!--
* Preserves RTL/LTR context when teleporting content
* Ensures direction-specific classes (ltr:tailwind-class, rtl:tailwind-class) work correctly
* when content is teleported outside the app's container with [dir] attribute
-->
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
to: {
type: String,
default: 'body',
},
});
const isRTL = useMapGetter('accounts/isRTL');
const contentDirection = computed(() => (isRTL.value ? 'rtl' : 'ltr'));
</script>
<template>
<Teleport :to="to">
<div :dir="contentDirection">
<slot />
</div>
</Teleport>
</template>
@@ -5,7 +5,7 @@ import Avatar from './Avatar.vue';
<template>
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
<Variant title="Default">
<div class="flex p-4 space-x-4 bg-white dark:bg-slate-900">
<div class="flex p-4 space-x-4 bg-n-background">
<Avatar
name=""
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
@@ -16,7 +16,7 @@ import Avatar from './Avatar.vue';
</Variant>
<Variant title="Different Shapes">
<div class="gap-4 p-4 bg-white dark:bg-slate-900">
<div class="gap-4 p-4 bg-n-background">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya"
name=""
@@ -34,7 +34,7 @@ import Avatar from './Avatar.vue';
</Variant>
<Variant title="Different Sizes">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-4 p-4 bg-n-background">
<Avatar
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
:size="48"
@@ -57,7 +57,7 @@ import Avatar from './Avatar.vue';
</Variant>
<Variant title="With Status">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-4 p-4 bg-n-background">
<Avatar
src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix"
status="online"
@@ -77,7 +77,7 @@ import Avatar from './Avatar.vue';
</Variant>
<Variant title="With Custom Icon">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-4 p-4 bg-n-background">
<Avatar name="Custom Icon" icon-name="i-lucide-user" :size="48" />
<Avatar
name="Custom Industry"
@@ -88,7 +88,7 @@ import Avatar from './Avatar.vue';
</Variant>
<Variant title="Upload States">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-4 p-4 bg-n-background">
<!-- Empty state with upload -->
<Avatar name="Upload New" allow-upload :size="48" />
@@ -103,7 +103,7 @@ import Avatar from './Avatar.vue';
</Variant>
<Variant title="Name Initials">
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-4 p-4 bg-n-background">
<Avatar name="Catherine" :size="48" />
<Avatar name="John Doe" :size="48" />
<Avatar name="Rose Doe John" :size="48" />
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { removeEmoji } from 'shared/helpers/emoji';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
@@ -33,10 +34,18 @@ const props = defineProps({
validator: value =>
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
},
inbox: {
type: Object,
default: null,
},
iconName: {
type: String,
default: null,
},
hideOfflineStatus: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['upload', 'delete']);
@@ -66,11 +75,11 @@ const AVATAR_COLORS = {
default: { bg: '#E8E8E8', text: '#60646C' },
};
const STATUS_CLASSES = {
const STATUS_CLASSES = computed(() => ({
online: 'bg-n-teal-10',
busy: 'bg-n-amber-10',
offline: 'bg-n-slate-10',
};
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
}));
const showDefaultAvatar = computed(() => !props.src && !props.name);
@@ -174,21 +183,31 @@ watch(
</script>
<template>
<span class="relative inline-flex group/avatar z-0" :style="containerStyles">
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles"
>
<!-- Status Badge -->
<slot name="badge" :size="size">
<div
v-if="status"
v-if="status && STATUS_CLASSES[status]"
class="absolute z-20 border rounded-full border-n-slate-3"
:style="badgeStyles"
:class="STATUS_CLASSES[status]"
/>
<div
v-if="inbox && !(status && STATUS_CLASSES[status])"
:style="badgeStyles"
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
>
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
</div>
</slot>
<!-- Delete Avatar Button -->
<div
v-if="src && allowUpload"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 ltr:-right-2 rtl:-left-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleDismiss"
>
<Icon icon="i-lucide-x" class="text-n-slate-11 size-4" />
@@ -232,31 +251,40 @@ watch(
<!-- Fallback Icon if no name or image -->
<Icon
v-else
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
:title="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
icon="i-lucide-user"
:style="iconStyles"
/>
</template>
<!-- Upload Overlay and Input -->
<div
v-if="allowUpload"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
<slot
v-if="allowUpload || $slots.overlay"
name="overlay"
:size="size"
:handle-upload="handleUploadAvatar"
:file-input-ref="fileInput"
:handle-image-upload="handleImageUpload"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
<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"
@click="handleUploadAvatar"
>
<Icon
icon="i-lucide-upload"
class="text-white"
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
/>
<input
v-if="allowUpload"
ref="fileInput"
type="file"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
class="hidden"
@change="handleImageUpload"
/>
</div>
</slot>
</span>
</span>
</template>
@@ -1,3 +1,5 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup>
import { computed } from 'vue';
@@ -30,11 +32,11 @@ const bannerClass = computed(() => {
const buttonClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-4 text-n-blue-11',
slate: 'bg-n-slate-4 hover:bg-n-slate-5 text-n-slate-11',
amber: 'bg-n-amber-4 hover:bg-n-amber-5 text-n-amber-11',
teal: 'bg-n-teal-4 hover:bg-n-teal-5 text-n-teal-11',
ruby: 'bg-n-ruby-4 hover:bg-n-ruby-5 text-n-ruby-11',
blue: 'bg-n-blue-4 hover:bg-n-blue-5 text-n-blue-11',
};
return classMap[props.color];
@@ -29,32 +29,32 @@ const longBreadcrumb = ref([
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Single Item">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<div class="w-full p-4 bg-n-background">
<Breadcrumb :items="singleItem" />
</div>
</Variant>
<Variant title="Two Items">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<div class="w-full p-4 bg-n-background">
<Breadcrumb :items="twoItems" />
</div>
</Variant>
<Variant title="Three Items with Count">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<div class="w-full p-4 bg-n-background">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</Variant>
<Variant title="Long Breadcrumb">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<div class="w-full p-4 bg-n-background">
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
</div>
</Variant>
<Variant title="RTL Support">
<div dir="rtl">
<div class="w-full p-4 bg-white dark:bg-slate-900">
<div class="w-full p-4 bg-n-background">
<Breadcrumb :items="threeItems" count-label="articles" />
</div>
</div>
@@ -15,8 +15,8 @@ const emit = defineEmits(['click']);
const { t } = useI18n();
const onClick = event => {
emit('click', event);
const onClick = (item, index) => {
emit('click', item, index);
};
</script>
@@ -24,24 +24,25 @@ const onClick = event => {
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
<ol class="flex items-center mb-0">
<li v-for="(item, index) in items" :key="index" class="flex items-center">
<Icon
v-if="index > 0"
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<!-- Render as button for all except the last item -->
<button
v-if="index === 0"
v-if="index !== items.length - 1"
class="inline-flex items-center justify-center min-w-0 gap-2 p-0 text-sm font-medium transition-all duration-200 ease-in-out border-0 rounded-lg text-n-slate-11 hover:text-n-slate-12 outline-transparent max-w-56"
@click="onClick"
@click="onClick(item, index)"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
</button>
<template v-else>
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 mx-2 size-4 text-n-slate-11 dark:text-n-slate-11"
/>
<span
class="text-sm truncate text-slate-900 dark:text-slate-50 max-w-56"
>
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</template>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 max-w-56">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
</ol>
</nav>
@@ -11,7 +11,7 @@ const SIZES = ['default', 'sm', 'lg'];
<Story title="Components/Button" :layout="{ type: 'grid', width: '800px' }">
<!-- Basic Variants -->
<Variant title="Basic Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<Button
v-for="variant in VARIANTS"
:key="variant"
@@ -23,7 +23,7 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Colors -->
<Variant title="Color Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<Button
v-for="color in COLORS"
:key="color"
@@ -35,16 +35,14 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Sizes -->
<Variant title="Size Variants">
<div
class="flex flex-wrap items-center gap-2 p-4 bg-white dark:bg-slate-900"
>
<div class="flex flex-wrap items-center gap-2 p-4 bg-n-background">
<Button v-for="size in SIZES" :key="size" :label="size" :size="size" />
</div>
</Variant>
<!-- Icons -->
<Variant title="Icons">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<Button label="Leading Icon" icon="i-lucide-plus" />
<Button label="Trailing Icon" icon="i-lucide-plus" trailing-icon />
<Button icon="i-lucide-plus" />
@@ -53,7 +51,7 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Loading State -->
<Variant title="Loading State">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<Button label="Loading" is-loading />
<Button label="Loading" variant="outline" is-loading />
<Button is-loading icon="i-lucide-plus" />
@@ -62,7 +60,7 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Disabled State -->
<Variant title="Disabled State">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<Button label="Disabled" disabled />
<Button label="Disabled Outline" variant="outline" disabled />
<Button label="Disabled Icon" icon="delete" disabled />
@@ -78,7 +76,7 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Color Combinations -->
<Variant title="Color & Variant Combinations">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<template v-for="color in COLORS" :key="color">
<Button
v-for="variant in VARIANTS"
@@ -93,7 +91,7 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Icon Positions -->
<Variant title="Icon Positions & Sizes">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<template v-for="size in SIZES" :key="size">
<Button
:label="`${size} Leading`"
@@ -113,7 +111,7 @@ const SIZES = ['default', 'sm', 'lg'];
<!-- Ghost & Link Variants -->
<Variant title="Ghost & Link Variants">
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
<Button label="Ghost Button" variant="ghost" color="slate" />
<Button
label="Ghost with Icon"
@@ -99,45 +99,58 @@ const computedJustify = computed(() => {
const STYLE_CONFIG = {
colors: {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
solid:
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-brand',
ghost:
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
solid:
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 focus-visible:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:underline outline-transparent',
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 focus-visible:bg-n-ruby-9/20 outline-transparent',
outline:
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
ghost:
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
},
amber: {
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
solid:
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 focus-visible:bg-n-amber-10 outline-transparent',
faded:
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 focus-visible:bg-n-amber-9/20 outline-transparent',
outline:
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 focus-visible:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:enabled:underline focus-visible:underline outline-transparent',
ghost:
'text-n-amber-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
slate: {
solid:
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10 focus-visible:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 focus-visible:text-n-slate-12 hover:enabled:underline focus-visible:underline outline-transparent',
ghost:
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
outline:
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
ghost:
'text-n-teal-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
},
sizes: {
@@ -171,13 +184,13 @@ const STYLE_CONFIG = {
center: 'justify-center',
end: 'justify-end',
},
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
};
const variantClasses = computed(() => {
const variantMap = {
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-2`,
outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded,
solid: STYLE_CONFIG.colors[computedColor.value].solid,
@@ -0,0 +1,41 @@
<script setup>
import ConfirmButton from './ConfirmButton.vue';
import { ref } from 'vue';
const count = ref(0);
const incrementCount = () => {
count.value += 1;
};
</script>
<template>
<Story
title="Components/ConfirmButton"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic">
<div class="grid gap-2 p-4 bg-n-background">
<p>{{ count }}</p>
<ConfirmButton
label="Delete"
confirm-label="Confirm?"
@click="incrementCount"
/>
</div>
</Variant>
<Variant title="Color Change">
<div class="grid gap-2 p-4 bg-n-background">
<p>{{ count }}</p>
<ConfirmButton
label="Archive"
confirm-label="Confirm?"
color="slate"
confirm-color="amber"
@click="incrementCount"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref, computed } from 'vue';
import Button from './Button.vue';
const props = defineProps({
label: { type: [String, Number], default: '' },
confirmLabel: { type: [String, Number], default: '' },
color: { type: String, default: 'blue' },
confirmColor: { type: String, default: 'ruby' },
confirmHint: { type: String, default: '' },
variant: { type: String, default: null },
size: { type: String, default: null },
justify: { type: String, default: null },
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const isConfirmMode = ref(false);
const isClicked = ref(false);
const currentLabel = computed(() => {
return isConfirmMode.value ? props.confirmLabel : props.label;
});
const currentColor = computed(() => {
return isConfirmMode.value ? props.confirmColor : props.color;
});
const resetConfirmMode = () => {
isConfirmMode.value = false;
isClicked.value = false;
};
const handleClick = () => {
if (!isConfirmMode.value) {
isConfirmMode.value = true;
} else {
isClicked.value = true;
emit('click');
setTimeout(resetConfirmMode, 400);
}
};
</script>
<template>
<div
class="relative"
:class="{
'animate-bounce-complete': isClicked,
}"
>
<Button
type="button"
:label="currentLabel"
:color="currentColor"
:variant="variant"
:size="size"
:justify="justify"
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
@click="handleClick"
@blur="resetConfirmMode"
>
<template v-if="$slots.default" #default>
<slot />
</template>
<template v-if="$slots.icon" #icon>
<slot name="icon" />
</template>
</Button>
<div
v-if="isConfirmMode && confirmHint"
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
>
{{ confirmHint }}
</div>
</div>
</template>
<style scoped>
@keyframes bounce-complete {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.animate-bounce-complete {
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
</style>
@@ -1,12 +1,13 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue';
const { featureFlag } = defineProps({
const props = defineProps({
currentPage: {
type: Number,
default: 1,
@@ -23,6 +24,10 @@ const { featureFlag } = defineProps({
type: String,
default: '',
},
backUrl: {
type: [String, Object],
default: '',
},
buttonPolicy: {
type: Array,
default: () => [],
@@ -39,6 +44,10 @@ const { featureFlag } = defineProps({
type: Boolean,
default: false,
},
showKnowMore: {
type: Boolean,
default: true,
},
isEmpty: {
type: Boolean,
default: false,
@@ -50,10 +59,10 @@ const { featureFlag } = defineProps({
});
const emit = defineEmits(['click', 'close', 'update:currentPage']);
const { isCloudFeatureEnabled } = useAccount();
const { shouldShowPaywall } = usePolicy();
const showPaywall = computed(() => {
return !isCloudFeatureEnabled(featureFlag);
return shouldShowPaywall(props.featureFlag);
});
const handleButtonClick = () => {
@@ -67,17 +76,29 @@ const handlePageChange = event => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[960px] mx-auto">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
<slot name="headerTitle" />
</span>
<div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :to="backUrl" />
<slot name="headerTitle">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
</slot>
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div>
<div
v-if="!showPaywall"
v-if="!showPaywall && buttonLabel"
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
@@ -95,9 +116,9 @@ const handlePageChange = event => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="controls" />
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
@@ -0,0 +1,91 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 z-10 bg-n-background">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -0,0 +1,21 @@
<script setup>
import AddNewRulesDialog from './AddNewRulesDialog.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesDialog"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-4 py-4 bg-n-background h-[200px]">
<AddNewRulesDialog
button-label="Add a guardrail"
placeholder="Type in another guardrail..."
confirm-label="Create"
cancel-label="Cancel"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
confirmLabel: {
type: String,
default: '',
},
cancelLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const [showPopover, togglePopover] = useToggle();
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="buttonLabel"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
>
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<div class="flex gap-2 justify-between">
<Button
:label="cancelLabel"
sm
link
slate
class="h-10 hover:!no-underline"
@click="onClickCancel"
/>
<Button :label="confirmLabel" sm @click="onClickAdd" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import AddNewRulesInput from './AddNewRulesInput.vue';
</script>
<template>
<Story
title="Captain/Assistant/AddNewRulesInput"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div class="px-6 py-4 bg-n-background">
<AddNewRulesInput
placeholder="Type in another response guideline..."
label="Add and save (↵)"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,51 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
defineProps({
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
});
const emit = defineEmits(['add']);
const modelValue = defineModel({
type: String,
default: '',
});
const onClickAdd = () => {
if (!modelValue.value?.trim()) return;
emit('add', modelValue.value.trim());
modelValue.value = '';
};
</script>
<template>
<div
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
>
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
<InlineInput
v-model="modelValue"
:placeholder="placeholder"
@keyup.enter="onClickAdd"
/>
<Button
:label="label"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="onClickAdd"
/>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup>
import { computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { vOnClickOutside } from '@vueuse/components';
import { required, minLength } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const [showPopover, togglePopover] = useToggle();
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const resetState = () => {
Object.assign(state, {
id: '',
title: '',
description: '',
instruction: '',
});
};
const onClickAdd = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
await emit('add', state);
resetState();
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.CREATE')"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="w-[31.25rem] absolute top-10 ltr:left-0 rtl:right-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 z-50"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }}
</h3>
<div class="flex flex-col gap-4">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER'
)
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER'
)
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CREATE')"
class="w-full"
@click="onClickAdd"
/>
</div>
</div>
</div>
</template>
@@ -12,7 +12,7 @@ import { assistantsList } from 'dashboard/components-next/captain/pageComponents
<div
v-for="(assistant, index) in assistantsList"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<AssistantCard
:id="assistant.id"
@@ -76,9 +76,12 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
<router-link
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</span>
</router-link>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
@@ -0,0 +1,111 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue';
import CaptainAssistant from 'dashboard/api/captain/assistant';
const { assistantId } = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const messages = ref([]);
const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
};
const resetConversation = () => {
messages.value = [];
newMessage.value = '';
};
const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return;
const userMessage = {
content: newMessage.value,
sender: 'user',
timestamp: new Date().toISOString(),
};
messages.value.push(userMessage);
const currentMessage = newMessage.value;
newMessage.value = '';
try {
isLoading.value = true;
const { data } = await CaptainAssistant.playground({
assistantId,
messageContent: currentMessage,
messageHistory: formatMessagesForApi(),
});
messages.value.push({
content: data.response,
sender: 'assistant',
timestamp: new Date().toISOString(),
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error getting assistant response:', error);
} finally {
isLoading.value = false;
}
};
</script>
<template>
<div
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
>
<div class="mb-4">
<div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
</h3>
<NextButton
ghost
size="small"
icon="i-lucide-rotate-ccw"
@click="resetConversation"
/>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.PLAYGROUND.DESCRIPTION') }}
</p>
</div>
<MessageList :messages="messages" :is-loading="isLoading" />
<div
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
>
<input
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
/>
<NextButton
ghost
size="small"
:disabled="!newMessage.trim()"
icon="i-lucide-send"
@click="sendMessage"
/>
</div>
<p class="text-xs text-n-slate-11 pt-2 text-center">
{{ t('CAPTAIN.PLAYGROUND.CREDIT_NOTE') }}
</p>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { computed } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
allItems: {
type: Array,
required: true,
},
selectAllLabel: {
type: String,
default: '',
},
selectedCountLabel: {
type: String,
default: '',
},
deleteLabel: {
type: String,
default: 'Delete',
},
});
const emit = defineEmits(['bulkDelete']);
const modelValue = defineModel({
type: Set,
default: () => new Set(),
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
);
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
},
});
</script>
<template>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{ selectedCountLabel }}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
<div v-else class="flex items-center gap-3">
<slot name="default-actions" />
</div>
</transition>
</template>
@@ -12,7 +12,7 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
<div
v-for="(doc, index) in documentsList"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<DocumentCard
:id="doc.id"
@@ -12,7 +12,7 @@ import { inboxes } from 'dashboard/components-next/captain/pageComponents/emptyS
<div
v-for="inbox in inboxes"
:key="inbox.id"
class="px-20 py-4 bg-white dark:bg-slate-900"
class="px-20 py-4 bg-n-background"
>
<InboxCard :id="inbox.id" :inbox="inbox" />
</div>
@@ -57,9 +57,10 @@ const menuItems = computed(() => [
},
]);
const icon = computed(() =>
getInboxIconByType(props.inbox.channel_type, '', 'outline')
);
const icon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'outline');
});
const handleAction = ({ action, value }) => {
toggleDropdown(false);
@@ -0,0 +1,91 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref, watch, nextTick } from 'vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
messages: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const messageContainer = ref(null);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isUserMessage = sender => sender === 'user';
const getMessageAlignment = sender =>
isUserMessage(sender) ? 'justify-end' : 'justify-start';
const getMessageDirection = sender =>
isUserMessage(sender) ? 'flex-row-reverse' : 'flex-row';
const getAvatarName = sender =>
isUserMessage(sender)
? t('CAPTAIN.PLAYGROUND.USER')
: t('CAPTAIN.PLAYGROUND.ASSISTANT');
const getMessageStyle = sender =>
isUserMessage(sender)
? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12';
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
}
};
watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
<div
v-for="(message, index) in messages"
:key="index"
class="flex"
:class="getMessageAlignment(message.sender)"
>
<div
class="flex items-start gap-1.5"
:class="getMessageDirection(message.sender)"
>
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
<div
class="max-w-[80%] rounded-lg p-3 text-sm"
:class="getMessageStyle(message.sender)"
>
<div class="break-words" v-html="formatMessage(message.content)" />
</div>
</div>
</div>
<div v-if="isLoading" class="flex justify-start">
<div class="flex items-start gap-1.5">
<Avatar :name="getAvatarName('assistant')" rounded-full :size="24" />
<div
class="max-w-sm rounded-lg p-3 text-sm bg-n-solid-iris text-n-slate-12"
>
<div class="flex gap-1">
<div class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce" />
<div
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.2s]"
/>
<div
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.4s]"
/>
</div>
</div>
</div>
</div>
</div>
</template>

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