feat: translate conversations, reply box and message components

This commit is contained in:
Abhinav Raut
2025-03-31 23:14:24 +05:30
parent e19f817c5f
commit cd4b9a9c23
17 changed files with 209 additions and 134 deletions

View File

@@ -114,7 +114,7 @@
<Editor
v-model:htmlContent="action.value[0]"
@update:htmlContent="(value) => handleEditorChange(value, index)"
:placeholder="t('admin.macro.message_content.placeholder')"
:placeholder="t('editor.placeholder')"
/>
</div>
</div>

View File

@@ -75,7 +75,7 @@
<!-- Plain text input -->
<Input
type="text"
:placeholder="t('form.fields.setValue')"
:placeholder="t('form.field.setValue')"
v-if="inputType(index) === 'text'"
v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"
@@ -84,7 +84,7 @@
<!-- Number input -->
<Input
type="number"
:placeholder="t('form.fields.setValue')"
:placeholder="t('form.field.setValue')"
v-if="inputType(index) === 'number'"
v-model="rule.value"
@update:modelValue="(value) => handleValueChange(value, index)"

View File

@@ -19,7 +19,7 @@
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="t('admin.macro.message_content.placeholder')"
:placeholder="t('editor.placeholder')"
/>
</div>
</FormControl>

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-screen w-full flex items-center justify-center min-w-[400px]">
<p>Select a conversation from the left panel.</p>
<p>{{ $t('conversation.placeholder') }}</p>
</div>
</template>

View File

@@ -2,17 +2,17 @@
<Dialog :open="dialogOpen" @update:open="dialogOpen = false">
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogTitle>{{ $t('conversation.newConversation') }}</DialogTitle>
</DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
<FormField name="contact_email">
<FormItem class="relative">
<FormLabel>Email</FormLabel>
<FormLabel>{{ $t('form.field.email') }}</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Search contact by email or type new email"
:placeholder="t('conversation.searchContact')"
v-model="emailQuery"
@input="handleSearchContacts"
autocomplete="off"
@@ -38,9 +38,9 @@
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>First Name</FormLabel>
<FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="First Name" v-bind="componentField" required />
<Input type="text" placeholder="" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
@@ -48,9 +48,9 @@
<FormField v-slot="{ componentField }" name="last_name">
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="Last Name" v-bind="componentField" required />
<Input type="text" placeholder="" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
@@ -58,9 +58,9 @@
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Subject</FormLabel>
<FormLabel>{{ $t('form.field.subject') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="Subject" v-bind="componentField" required />
<Input type="text" placeholder="" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
@@ -68,11 +68,11 @@
<FormField v-slot="{ componentField }" name="inbox_id">
<FormItem>
<FormLabel>Inbox</FormLabel>
<FormLabel>{{ $t('form.field.inbox') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select an inbox" />
<SelectValue :placeholder="t('form.field.selectInbox')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -94,13 +94,12 @@
<!-- Set assigned team -->
<FormField v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Assign team (optional)</FormLabel>
<FormLabel>{{ $t('form.field.assignTeamOptional') }}</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
placeholder="Search team"
defaultLabel="Assign team"
:placeholder="t('form.field.selectTeam')"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
@@ -118,11 +117,13 @@
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3" v-if="selected">
<div class="w-7 h-7 flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-7 h-7 flex items-center justify-center" v-if="selected">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || 'Select team' }}</span>
<span class="text-sm">{{
selected?.label || t('form.field.selectTeam')
}}</span>
</div>
</template>
</ComboBox>
@@ -134,13 +135,12 @@
<!-- Set assigned agent -->
<FormField v-slot="{ componentField }" name="agent_id">
<FormItem>
<FormLabel>Assign agent (optional)</FormLabel>
<FormLabel>{{ $t('form.field.assignAgentOptional') }}</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
placeholder="Search agent"
defaultLabel="Assign agent"
:placeholder="t('form.field.selectAgent')"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
@@ -176,7 +176,9 @@
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || 'Assign agent' }}</span>
<span class="text-sm">{{
selected?.label || t('form.field.selectAgent')
}}</span>
</div>
</template>
</ComboBox>
@@ -191,13 +193,13 @@
class="flex-1 min-h-0 flex flex-col"
>
<FormItem class="flex flex-col flex-1">
<FormLabel>Message</FormLabel>
<FormLabel>{{ $t('form.field.message') }}</FormLabel>
<FormControl class="flex-1 min-h-0 flex flex-col">
<div class="flex-1 min-h-0 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
:placeholder="t('editor.placeholder')"
class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
/>
</div>
@@ -208,7 +210,9 @@
</div>
<DialogFooter class="mt-4 pt-2 border-t shrink-0">
<Button type="submit" :disabled="loading" :isLoading="loading"> Submit </Button>
<Button type="submit" :disabled="loading" :isLoading="loading">
{{ $t('globals.buttons.submit') }}
</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -247,6 +251,7 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useI18n } from 'vue-i18n'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import api from '@/api'
@@ -256,6 +261,7 @@ const dialogOpen = defineModel({
})
const inboxStore = useInboxStore()
const { t } = useI18n()
const uStore = useUsersStore()
const teamStore = useTeamStore()
const emitter = useEmitter()
@@ -265,16 +271,26 @@ const emailQuery = ref('')
let timeoutId = null
const formSchema = z.object({
subject: z.string().min(3, 'Subject must be at least 3 characters'),
content: z.string().min(1, 'Message cannot be empty'),
subject: z.string().min(
3,
t('form.error.min', {
min: 3
})
),
content: z.string().min(
1,
t('globals.messages.cannotBeEmpty', {
name: t('globals.entities.message')
})
),
inbox_id: z.any().refine((val) => inboxStore.options.some((option) => option.value === val), {
message: 'Inbox is required'
message: t('globals.messages.required')
}),
team_id: z.any().optional(),
agent_id: z.any().optional(),
contact_email: z.string().email('Invalid email address'),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required')
contact_email: z.string().email(t('globals.messages.invalidEmailAddress')),
first_name: z.string().min(1, t('globals.messages.required')),
last_name: z.string().min(1, t('globals.messages.required'))
})
const form = useForm({
@@ -310,7 +326,6 @@ const handleSearchContacts = async () => {
searchResults.value = [...resp.data.data]
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -335,7 +350,6 @@ const createConversation = form.handleSubmit(async (values) => {
emailQuery.value = ''
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -40,6 +40,7 @@
<script setup>
import { X, Users, User, MessageSquare, Tags, Flag } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useI18n } from 'vue-i18n'
defineProps({
actions: {
@@ -52,6 +53,7 @@ defineProps({
}
})
const { t } = useI18n()
const getIcon = (type) =>
({
assign_team: Users,
@@ -71,17 +73,17 @@ const getDisplayValue = (action) => {
const getTooltip = (action) => {
switch (action.type) {
case 'assign_team':
return `Assign to team: ${getDisplayValue(action)}`
return `${t('globals.messages.assign_team')}: ${getDisplayValue(action)}`
case 'assign_user':
return `Assign to user: ${getDisplayValue(action)}`
return `${t('globals.messages.assign_user')}: ${getDisplayValue(action)}`
case 'set_status':
return `Set status to: ${getDisplayValue(action)}`
return `${t('globals.messages.set_status')}: ${getDisplayValue(action)}`
case 'set_priority':
return `Set priority to: ${getDisplayValue(action)}`
return `${t('globals.messages.set_priority')}: ${getDisplayValue(action)}`
case 'set_tags':
return `Set tags: ${getDisplayValue(action)}`
return `${t('globals.messages.set_tags')}: ${getDisplayValue(action)}`
default:
return `Action: ${action.type}, Value: ${getDisplayValue(action)}`
return `${t('globals.entities.action')}: ${action.type}, ${t('globals.entities.value')}: ${getDisplayValue(action)}`
}
}
</script>

View File

@@ -2,18 +2,22 @@
<Dialog :open="openAIKeyPrompt" @update:open="openAIKeyPrompt = false">
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-2">
<DialogTitle>Enter OpenAI API Key</DialogTitle>
<DialogTitle>{{ $t('ai.enterOpenAIAPIKey') }}</DialogTitle>
<DialogDescription>
OpenAI API key is not set or invalid. Please enter a valid API key to use AI features.
{{
$t('ai.apiKey.description', {
provider: 'OpenAI'
})
}}
</DialogDescription>
</DialogHeader>
<Form v-slot="{ handleSubmit }" as="" keep-values :validation-schema="formSchema">
<form id="apiKeyForm" @submit="handleSubmit($event, updateProvider)">
<FormField v-slot="{ componentField }" name="apiKey">
<FormItem>
<FormLabel>API Key</FormLabel>
<FormLabel>{{ $t('globals.entities.apiKey') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="Enter your API key" v-bind="componentField" />
<Input type="text" placeholder="sk-am1RLw7XUWGX.." v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -26,7 +30,7 @@
:is-loading="isOpenAIKeyUpdating"
:disabled="isOpenAIKeyUpdating"
>
Save
{{ $t('globals.buttons.save') }}
</Button>
</DialogFooter>
</Form>
@@ -136,7 +140,7 @@ import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useUserStore } from '@/stores/user'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button'
import {
@@ -167,6 +171,7 @@ const formSchema = toTypedSchema(
})
)
const { t } = useI18n()
const conversationStore = useConversationStore()
const emitter = useEmitter()
const userStore = useUserStore()
@@ -205,7 +210,6 @@ const fetchAiPrompts = async () => {
aiPrompts.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -234,7 +238,6 @@ const handleAiPromptSelected = async (key) => {
openAIKeyPrompt.value = true
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -251,12 +254,12 @@ const updateProvider = async (values) => {
await api.updateAIProvider({ api_key: values.apiKey, provider: 'openai' })
openAIKeyPrompt.value = false
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'API key saved successfully.'
description: t('globals.messages.savedSuccessfully', {
name: t('globals.entities.apiKey')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -288,7 +291,6 @@ const handleFileUpload = (event) => {
.catch((error) => {
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -316,7 +318,6 @@ const handleInlineImageUpload = (event) => {
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -398,7 +399,6 @@ const processSend = async () => {
} catch (error) {
hasAPIErrored = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -13,14 +13,14 @@
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
{{ $t('replyBox.reply') }}
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
{{ $t('replyBox.privateNote') }}
</TabsTrigger>
</TabsList>
</Tabs>
@@ -46,7 +46,7 @@
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
:placeholder="t('replyBox.emailAddresess')"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
@@ -63,7 +63,7 @@
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
:placeholder="t('replyBox.emailAddresess')"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
@@ -146,6 +146,7 @@ import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { useI18n } from 'vue-i18n'
// Define models for two-way binding
const messageType = defineModel('messageType', { default: 'reply' })
@@ -198,11 +199,11 @@ const emit = defineEmits([
const conversationStore = useConversationStore()
const emitter = useEmitter()
const { t } = useI18n()
const insertContent = ref(null)
const setInlineImage = ref(null)
const editorPlaceholder =
'Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.'
const editorPlaceholder = t('replyBox.editor.placeholder')
const toggleBcc = async () => {
showBcc.value = !showBcc.value
@@ -255,13 +256,13 @@ const validateEmails = (field) => {
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
(error) => !error.startsWith(`${t('replyBox.invalidEmailsIn')} ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
`${t('replyBox.invalidEmailsIn')} ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
@@ -274,9 +275,8 @@ const handleSend = async () => {
validateEmails('bcc')
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
description: t('replyBox.correctEmailErrors')
})
return
}

View File

@@ -35,9 +35,9 @@
<Smile class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
>Send</Button
>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">
{{ $t('globals.buttons.send') }}
</Button>
</div>
</template>

View File

@@ -40,23 +40,27 @@
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest activity</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('newest')">Newest activity</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_first')"
>Started first</DropdownMenuItem
>
<DropdownMenuItem @click="handleSortChange('started_last')"
>Started last</DropdownMenuItem
>
<DropdownMenuItem @click="handleSortChange('waiting_longest')"
>Waiting longest</DropdownMenuItem
>
<DropdownMenuItem @click="handleSortChange('next_sla_target')"
>Next SLA target</DropdownMenuItem
>
<DropdownMenuItem @click="handleSortChange('priority_first')"
>Priority first</DropdownMenuItem
>
<DropdownMenuItem @click="handleSortChange('oldest')">
{{ $t('conversation.sort.oldestActivity') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('newest')">
{{ $t('conversation.sort.newestActivity') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_first')">
{{ $t('conversation.sort.startedFirst') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_last')">
{{ $t('conversation.sort.startedLast') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('waiting_longest')">
{{ $t('conversation.sort.waitingLongest') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('next_sla_target')">
{{ $t('conversation.sort.nextSLATarget') }}
</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('priority_first')">
{{ $t('conversation.sort.priorityFirst') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -67,8 +71,8 @@
v-if="!hasConversations && !hasErrored && !isLoading"
key="empty"
class="px-4 py-8"
title="No conversations found"
message="Try adjusting filters"
:title="t('conversation.noConversationsFound')"
:message="t('conversation.tryAdjustingFilters')"
:icon="MessageCircleQuestion"
/>
@@ -77,7 +81,7 @@
v-if="conversationStore.conversations.errorMessage"
key="error"
class="px-4 py-8"
title="Could not fetch conversations"
:title="t('conversation.couldNotFetch')"
:message="conversationStore.conversations.errorMessage"
:icon="MessageCircleWarning"
/>
@@ -126,13 +130,13 @@
class="transition-all duration-200 ease-in-out transform hover:scale-105"
>
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
{{ isLoading ? t('globals.entities.loading') : t('globals.entities.loadMore') }}
</Button>
<p
class="text-sm text-gray-500"
v-else-if="conversationStore.conversationsList.length > 10"
>
All conversations loaded
{{ $t('conversation.allLoaded') }}
</p>
</div>
</div>
@@ -154,11 +158,13 @@ import { SidebarTrigger } from '@/components/ui/sidebar'
import EmptyList from '@/features/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/features/conversation/list/ConversationListItem.vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ConversationListItemSkeleton from '@/features/conversation/list/ConversationListItemSkeleton.vue'
const conversationStore = useConversationStore()
const route = useRoute()
let reFetchInterval = ref(null)
const { t } = useI18n()
const title = computed(() => {
const typeValue = route.meta?.type?.(route)

View File

@@ -39,7 +39,7 @@
@click="toggleQuote"
class="text-xs cursor-pointer text-muted-foreground px-2 py-1 w-max hover:bg-muted hover:text-primary rounded-md transition-all"
>
{{ showQuotedText ? 'Hide quoted text' : 'Show quoted text' }}
{{ showQuotedText ? t('conversation.hideQuotedText') : t('conversation.showQuotedText') }}
</div>
<!-- Attachments -->
@@ -73,6 +73,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Letter } from 'vue-letter'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useI18n } from 'vue-i18n'
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
const props = defineProps({
@@ -82,6 +83,7 @@ const props = defineProps({
const convStore = useConversationStore()
const settingsStore = useAppSettingsStore()
const showQuotedText = ref(false)
const { t } = useI18n()
const getAvatar = computed(() => {
return convStore.current?.contact?.avatar_url || ''

View File

@@ -16,7 +16,7 @@
class="transition-all duration-200 hover:bg-gray-100 dark:hover:bg-gray-700 hover:scale-105 active:scale-95"
>
<RefreshCw size="17" class="mr-2" />
Load more
{{ $t('globals.entities.loadMore') }}
</Button>
</div>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Subject</p>
<p class="font-medium">{{ $t('form.field.subject') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ conversation.subject || '-' }}
@@ -8,14 +8,14 @@
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Reference number</p>
<p class="font-medium">{{ $t('form.field.referenceNumber') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ conversation.reference_number }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">Initiated at</p>
<p class="font-medium">{{ $t('form.field.initiatedAt') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-if="conversation.created_at">
{{ format(conversation.created_at, 'PPpp') }}
@@ -25,7 +25,7 @@
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<p class="font-medium">{{ $t('form.field.firstReplyAt') }}</p>
<SlaBadge
v-if="conversation.first_response_deadline_at"
:dueAt="conversation.first_response_deadline_at"
@@ -44,8 +44,8 @@
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaBadge
<p class="font-medium">{{ $t('form.field.resolvedAt') }}</p>
<SlaBadge
v-if="conversation.resolution_deadline_at"
:dueAt="conversation.resolution_deadline_at"
:actualAt="conversation.resolved_at"
@@ -62,7 +62,7 @@
</div>
<div class="flex flex-col gap-1 mb-5" v-if="conversation.closed_at">
<p class="font-medium">Closed at</p>
<p class="font-medium">{{ $t('form.field.closedAt') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ format(conversation.closed_at, 'PPpp') }}
@@ -70,7 +70,7 @@
</div>
<div class="flex flex-col gap-1 mb-5">
<p class="font-medium">SLA policy</p>
<p class="font-medium">{{ $t('form.field.slaPolicy') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>
<p v-if="conversation.sla_policy_name">

View File

@@ -8,14 +8,13 @@
>
<AccordionItem value="Actions" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
Actions
{{ $t('conversation.sidebar.action', 2) }}
</AccordionTrigger>
<AccordionContent class="space-y-4 p-4">
<ComboBox
v-model="assignedUserID"
:items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
placeholder="Search agent"
defaultLabel="Assign agent"
:placeholder="t('form.field.selectAgent')"
@select="selectAgent"
>
<template #item="{ item }">
@@ -46,7 +45,7 @@
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || 'Assign agent' }}</span>
<span class="text-sm">{{ selected?.label || t('form.field.assignAgent') }}</span>
</div>
</template>
</ComboBox>
@@ -54,8 +53,7 @@
<ComboBox
v-model="assignedTeamID"
:items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
placeholder="Search team"
defaultLabel="Assign team"
:placeholder="t('form.field.selectTeam')"
@select="selectTeam"
>
<template #item="{ item }">
@@ -74,11 +72,11 @@
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3" v-if="selected">
<div class="w-7 h-7 flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-7 h-7 flex items-center justify-center" v-if="selected">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || 'Select team' }}</span>
<span class="text-sm">{{ selected?.label || t('form.field.assignTeam') }}</span>
</div>
</template>
</ComboBox>
@@ -86,8 +84,7 @@
<ComboBox
v-model="priorityID"
:items="priorityOptions"
:defaultLabel="'Select priority'"
placeholder="Select priority"
:placeholder="t('form.field.selectPriority')"
@select="selectPriority"
>
<template #item="{ item }">
@@ -109,7 +106,7 @@
>
<component :is="getPriorityIcon(selected?.value)" size="14" />
</div>
<span class="text-sm">{{ selected?.label || 'Select priority' }}</span>
<span class="text-sm">{{ selected?.label || t('form.field.selectPriority') }}</span>
</div>
</template>
</ComboBox>
@@ -118,14 +115,14 @@
v-if="conversationStore.current"
v-model="conversationStore.current.tags"
:items="tags.map((tag) => ({ label: tag, value: tag }))"
placeholder="Select tags"
:placeholder="t('form.field.selectTag', 2)"
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="Information" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
Information
{{ $t('conversation.sidebar.information') }}
</AccordionTrigger>
<AccordionContent class="p-4">
<ConversationInfo />
@@ -134,7 +131,7 @@
<AccordionItem value="Previous conversations" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
Previous conversations
{{ $t('conversation.sidebar.previousConvo') }}
</AccordionTrigger>
<AccordionContent class="p-4">
<div
@@ -144,7 +141,7 @@
"
class="text-center text-sm text-muted-foreground py-4"
>
No previous conversations
{{ $t('conversation.sidebar.noPreviousConvo') }}
</div>
<div v-else class="space-y-3">
<router-link
@@ -201,6 +198,7 @@ import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const emitter = useEmitter()
@@ -208,6 +206,7 @@ const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const tags = ref([])
const { t } = useI18n()
let isConversationChange = false
// Watch for changes in the current conversation and set the flag
@@ -264,7 +263,6 @@ const fetchTags = async () => {
tags.value = resp.data.data.map((item) => item.name)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -40,7 +40,7 @@
<Skeleton class="w-32 h-4" />
</span>
<span v-else>
{{ conversation?.contact?.phone_number || 'Not available' }}
{{ conversation?.contact?.phone_number || t('conversation.sidebar.notAvailable') }}
</span>
</div>
</div>
@@ -55,8 +55,10 @@ import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useConversationStore } from '@/stores/conversation'
import { Skeleton } from '@/components/ui/skeleton'
import {useI18n} from 'vue-i18n'
const conversationStore = useConversationStore()
const emitter = useEmitter()
const conversation = computed(() => conversationStore.current)
const { t } = useI18n()
</script>

View File

@@ -91,7 +91,7 @@
v-model="componentField.modelValue"
@update:modelValue="handleChange"
:items="conversationEventOptions"
:placeholder="t('form.fields.selectEvents')"
:placeholder="t('form.field.selectEvents')"
>
</SelectTag>
</FormControl>

View File

@@ -39,6 +39,7 @@
"globals.entities.macro": "Macro | Macros",
"globals.entities.macroAction": "Macro Action | Macro Actions",
"globals.entities.action": "Action | Actions",
"globals.entities.value": "Value | Values",
"globals.entities.event": "Event | Events",
"globals.entities.automation": "Automation | Automations",
"globals.entities.oidc": "OIDC | OIDCs",
@@ -53,6 +54,9 @@
"globals.entities.day": "Day | Days",
"globals.entities.filter": "Filter | Filters",
"globals.entities.profile": "Profile | Profiles",
"globals.entities.apiKey": "API key | API keys",
"globals.entities.loading": "Loading...",
"globals.entities.loadMore": "Load more",
"globals.messages.adjustFilters": "Try adjusting your filters",
"globals.messages.errorUploadingFile": "Error uploading file",
"globals.messages.errorUpdating": "Error updating {name}",
@@ -112,6 +116,7 @@
"globals.messages.somethingWentWrong": "Something went wrong",
"globals.messages.done": "Done",
"globals.messages.emptyState": "Nothing here",
"globals.messages.cannotBeEmpty": "{name} cannot be empty",
"globals.messages.pressEnterToSelectAValue": "Press enter to select a value",
"globals.messages.caseSensitiveMatch": "Case sensitive match",
"globals.messages.viewDetails": "View details",
@@ -144,7 +149,6 @@
"app.couldNotReload": "Could not reload {name}, Please restart the app",
"app.badRequest": "Bad request",
"email.invalidFromAddress": "Invalid from email address format, make sure it's a valid email address in the format `Name <mail@example.com>`",
"ai.apiKeyNotSet": "{provider} API Key is not set, Please ask your administrator to set it up",
"inbox.emptyIMAP": "Empty IMAP config",
"inbox.emptySMTP": "Empty SMTP config",
"template.defaultTemplateAlreadyExists": "Default template already exists",
@@ -159,13 +163,6 @@
"macro.partiallyApplied": "Macro partially applied",
"macro.applied": "Macro applied",
"sla.firstResponseTimeAfterResolution": "First response time cannot be after resolution time",
"conversation.resolveWithoutAssignee": "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve",
"conversation.notMemberOfTeam": "You're not a member of this team, Please refresh the page and try again",
"conversation.viewPermissionDenied": "You do not have access to view this view",
"conversation.errorGeneratingMessageID": "Error generating message ID",
"conversation.invalidSnoozeDuration": "Invalid snooze duration",
"conversation.errorUnassigningOpenConversations": "Error unassigning open conversations",
"conversation.errorRemovingConversationAssignee": "Error removing conversation assignee",
"conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting",
"conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
"csat.alreadySubmitted": "CSAT already submitted",
@@ -236,12 +233,19 @@
"navigation.edit": "Edit",
"navigation.delete": "Delete",
"form.field.name": "Name",
"form.field.inbox": "Inbox",
"form.field.provider": "Provider",
"form.field.providerURL": "Provider URL",
"form.field.clientID": "Client ID",
"form.field.clientSecret": "Client Secret",
"form.field.callbackURL": "Callback URL",
"form.field.subject": "Subject",
"form.field.referenceNumber": "Reference Number",
"form.field.initiatedAt": "Initiated at",
"form.field.firstReplyAt": "First Reply At",
"form.field.resolvedAt": "Resolved At",
"form.field.closedAt": "Closed At",
"form.field.slaPolicy": "SLA Policy",
"form.field.isDefault": "Is default",
"form.field.body": "Body",
"form.field.default": "Default",
@@ -276,15 +280,23 @@
"form.field.selectRoles": "Select roles",
"form.field.selectField": "Select field",
"form.field.selectTeam": "Select team",
"form.field.selectTag": "Select tag",
"form.field.selectPriority": "Select priority",
"form.field.selectTag": "Select tag | Select tags",
"form.field.selectAction": "Select action",
"form.field.selectProvider": "Select provider",
"form.field.selectUser": "Select user",
"form.field.selectAgent": "Select agent",
"form.field.selectValue": "Select value",
"form.field.selectTeams": "Select teams",
"form.field.selectType": "Select type",
"form.fields.setValue": "Set value",
"form.fields.selectEvents": "Select events",
"form.field.selectInbox": "Select an inbox",
"form.field.assignTeamOptional": "Assign team (optional)",
"form.field.assignAgentOptional": "Assign agent (optional)",
"form.field.assignAgent": "Assign agent",
"form.field.assignTeam": "Assign team",
"form.field.message": "Message",
"form.field.setValue": "Set value",
"form.field.selectEvents": "Select events",
"form.field.selectOperator": "Select operator",
"form.field.value": "Value",
"form.error.min": "Must be at least {min} characters",
@@ -378,7 +390,6 @@
"admin.conversation_tags.name.valid": "Tag name should at least 3 characters",
"admin.conversation_tags.delete_confirmation": "This action cannot be undone. This will permanently delete this tag, and remove it from all conversations.",
"admin.macro.message_content": "Response to be sent when macro is used (optional)",
"admin.macro.message_content.placeholder": "Shift + Enter to add a new line",
"admin.macro.actions": "Actions (optional)",
"admin.macro.visibility": "Visibility",
"admin.macro.visibility.all": "All users",
@@ -531,6 +542,8 @@
"globals.buttons.save": "Save",
"globals.buttons.save_changes": "Save changes",
"globals.buttons.cancel": "Cancel",
"globals.buttons.submit": "Submit",
"globals.buttons.send": "Send",
"globals.buttons.update": "Update",
"globals.buttons.delete": "Delete",
"globals.buttons.create": "Create",
@@ -578,5 +591,43 @@
"account.chooseAFile": "Choose a file...",
"account.removeAvatar": "Remove avatar",
"account.cropAvatar": "Crop avatar",
"account.avatarRemoved": "Avatar removed"
"account.avatarRemoved": "Avatar removed",
"conversation.resolveWithoutAssignee": "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve",
"conversation.notMemberOfTeam": "You're not a member of this team, Please refresh the page and try again",
"conversation.viewPermissionDenied": "You do not have access to view this view",
"conversation.errorGeneratingMessageID": "Error generating message ID",
"conversation.invalidSnoozeDuration": "Invalid snooze duration",
"conversation.errorUnassigningOpenConversations": "Error unassigning open conversations",
"conversation.errorRemovingConversationAssignee": "Error removing conversation assignee",
"conversation.placeholder": "Select a conversation from the left panel.",
"conversation.newConversation": "New conversation",
"conversation.searchContact": "Search contact by email or type new email",
"conversation.sort.oldestActivity": "Oldest activity",
"conversation.sort.newestActivity": "Newest activity",
"conversation.sort.startedFirst": "Started first",
"conversation.sort.startedLast": "Started last",
"conversation.sort.waitingLongest": "Waiting longest",
"conversation.sort.nextSLATarget": "Next SLA target",
"conversation.sort.priorityFirst": "Priority first",
"conversation.noConversationsFound": "No conversations found",
"conversation.tryAdjustingFilters": "Try adjusting filters",
"conversation.couldNotFetch": "Could not fetch conversations",
"conversation.allLoaded": "All conversations loaded",
"conversation.showQuotedText": "Show quoted text",
"conversation.hideQuotedText": "Hide quoted text",
"conversation.sidebar.action": "Action | Actions",
"conversation.sidebar.information": "Information",
"conversation.sidebar.previousConvo": "Previous conversastions",
"conversation.sidebar.noPreviousConvo": "No previous conversations",
"conversation.sidebar.notAvailable": "Not available",
"editor.placeholder": "Shift + Enter to add a new line",
"ai.apiKeyNotSet": "{provider} API Key is not set. Please ask administrator to set it up",
"ai.enterOpenAIAPIKey": "Enter OpenAI API Key",
"ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.",
"replyBox.reply": "Reply",
"replyBox.privateNote": "Private note",
"replyBox.emailAddresess": "Email addresses separated by comma",
"replyBox.editor.placeholder": "Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.",
"replyBox.invalidEmailsIn": "Invalid email(s) in",
"replyBox.correctEmailErrors": "Please correct the email errors before sending."
}