feat(captain-memory): redesign Contact Memories UI with type badges + relative time + fix i18n keys

This commit is contained in:
Rodribm10 2026-04-19 07:38:50 -03:00
parent b07486c430
commit 6ecafd30c6
5 changed files with 221 additions and 41 deletions

View File

@ -423,14 +423,6 @@
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "An error occurred while deleting FAQ, please try again."
}
},
"MEMORY": {
"LOADING": "Loading…",
"EMPTY": "No memories for this contact yet.",
"CONFIRM_DELETE": "Forget this memory?",
"CONFIRM_FORGET_ALL": "Forget ALL memories for this contact? This cannot be undone after 30 days.",
"FORGET": "Forget",
"FORGET_ALL": "Forget all"
}
},
"CAPTAIN_REPORTS": {

View File

@ -407,6 +407,28 @@
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
},
"CONTACT_MEMORIES": {
"LOADING": "Loading memories…",
"EMPTY_TITLE": "No memories yet",
"EMPTY_HINT": "Memories appear here as the customer chats over time.",
"FORGET": "Forget",
"FORGET_ALL": "Forget all memories",
"CONFIRM_DELETE": "Forget this memory?",
"CONFIRM_FORGET_ALL": "Forget ALL memories for this customer? This cannot be undone after 30 days.",
"ERROR_LOADING": "Failed to load memories",
"CONFIDENCE": "Confidence",
"TYPE_LABELS": {
"preferencia": "Preference",
"data_comemorativa": "Date",
"vinculo_social": "Social",
"padrao_comportamental": "Pattern",
"reclamacao": "Complaint",
"feedback_positivo": "Praise",
"restricao": "Restriction",
"vinculo_comercial": "Commercial",
"contexto_pessoal": "Personal"
}
}
},
"SCHEDULED_MESSAGES": {

View File

@ -425,14 +425,6 @@
"SUCCESS_MESSAGE": "FAQ excluída com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao excluir a FAQ, por favor tente novamente."
}
},
"MEMORY": {
"LOADING": "Carregando…",
"EMPTY": "Sem memórias para este contato ainda.",
"CONFIRM_DELETE": "Esquecer esta memória?",
"CONFIRM_FORGET_ALL": "Esquecer TODAS as memórias deste contato? Após 30 dias não é possível desfazer.",
"FORGET": "Esquecer",
"FORGET_ALL": "Esquecer tudo"
}
},
"CAPTAIN_REPORTS": {

View File

@ -396,6 +396,28 @@
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
},
"CONTACT_MEMORIES": {
"LOADING": "Carregando memórias…",
"EMPTY_TITLE": "Nenhuma memória ainda",
"EMPTY_HINT": "Memórias aparecem aqui conforme o cliente conversa.",
"FORGET": "Esquecer",
"FORGET_ALL": "Esquecer todas",
"CONFIRM_DELETE": "Esquecer esta memória?",
"CONFIRM_FORGET_ALL": "Esquecer TODAS as memórias deste cliente? Não pode ser desfeito após 30 dias.",
"ERROR_LOADING": "Falha ao carregar memórias",
"CONFIDENCE": "Confiança",
"TYPE_LABELS": {
"preferencia": "Preferência",
"data_comemorativa": "Data especial",
"vinculo_social": "Vínculo social",
"padrao_comportamental": "Padrão",
"reclamacao": "Reclamação",
"feedback_positivo": "Elogio",
"restricao": "Restrição",
"vinculo_comercial": "Vínculo comercial",
"contexto_pessoal": "Pessoal"
}
}
},
"SCHEDULED_MESSAGES": {

View File

@ -1,65 +1,217 @@
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow } from 'date-fns';
import ContactMemoriesAPI from 'dashboard/api/captain/contactMemories';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
contactId: { type: [String, Number], required: true },
});
const { t } = useI18n();
const memories = ref([]);
const loading = ref(true);
const error = ref(null);
// Tailwind classes for the colored chip per memory type. Kept close to the
// color language already used by the Chatwoot sidebar (n-* tokens for slate
// and Radix-like accents for the accent colors).
const TYPE_STYLES = {
preferencia: 'bg-n-blue-9/10 text-n-blue-11',
contexto_pessoal: 'bg-n-blue-9/10 text-n-blue-11',
data_comemorativa: 'bg-n-iris-9/10 text-n-iris-11',
vinculo_social: 'bg-n-teal-9/10 text-n-teal-11',
vinculo_comercial: 'bg-n-teal-9/10 text-n-teal-11',
feedback_positivo: 'bg-n-teal-9/10 text-n-teal-11',
padrao_comportamental: 'bg-n-slate-9/10 text-n-slate-11',
reclamacao: 'bg-n-ruby-9/10 text-n-ruby-11',
restricao: 'bg-n-amber-9/10 text-n-amber-11',
};
const DEFAULT_TYPE_STYLE = 'bg-n-slate-9/10 text-n-slate-11';
const typeBadgeClasses = memoryType =>
TYPE_STYLES[memoryType] || DEFAULT_TYPE_STYLE;
const typeLabel = memoryType => {
const key = `CONVERSATION_SIDEBAR.CONTACT_MEMORIES.TYPE_LABELS.${memoryType}`;
const translated = t(key);
// Fallback to the raw type when the translation key does not exist so we
// never render an empty chip.
return translated === key ? memoryType : translated;
};
const confidenceBarClass = confidence => {
if (confidence >= 0.9) return 'bg-n-teal-9';
if (confidence >= 0.7) return 'bg-n-amber-9';
return 'bg-n-slate-7';
};
const relativeTime = isoString => {
if (!isoString) return '';
try {
return formatDistanceToNow(new Date(isoString), { addSuffix: true });
} catch (_e) {
return '';
}
};
const hasMemories = computed(() => memories.value.length > 0);
async function load() {
if (!props.contactId) return;
loading.value = true;
error.value = null;
try {
const { data } = await ContactMemoriesAPI.list(props.contactId);
memories.value = data.data;
memories.value = data.data || [];
} catch (e) {
error.value = e.message;
error.value =
e?.message || t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.ERROR_LOADING');
} finally {
loading.value = false;
}
}
async function deleteMemory(id) {
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN.MEMORY.CONFIRM_DELETE'))) return;
const confirmed =
// eslint-disable-next-line no-alert
window.confirm(t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.CONFIRM_DELETE'));
if (!confirmed) return;
await ContactMemoriesAPI.destroy(props.contactId, id);
await load();
}
async function forgetAll() {
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN.MEMORY.CONFIRM_FORGET_ALL'))) return;
const confirmed =
// eslint-disable-next-line no-alert
window.confirm(
t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.CONFIRM_FORGET_ALL')
);
if (!confirmed) return;
await ContactMemoriesAPI.forgetAll(props.contactId);
await load();
}
onMounted(load);
watch(
() => props.contactId,
newId => {
if (newId) load();
}
);
</script>
<template>
<div class="contact-memories">
<div v-if="loading">{{ t('CAPTAIN.MEMORY.LOADING') }}</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!memories.length" class="empty">
{{ t('CAPTAIN.MEMORY.EMPTY') }}
<div class="flex flex-col gap-3 px-4 pt-2 pb-4">
<!-- Loading skeleton -->
<div
v-if="loading"
class="flex items-center justify-center py-6 text-n-slate-11"
>
<Spinner />
</div>
<ul v-else>
<li v-for="m in memories" :key="m.id" class="memory-row">
<span class="type">{{ m.memory_type }}</span>
<span class="content">{{ m.content }}</span>
<span class="confidence">{{ Math.round(m.confidence * 100) }}%</span>
<button @click="deleteMemory(m.id)">
{{ t('CAPTAIN.MEMORY.FORGET') }}
</button>
</li>
</ul>
<button class="danger" @click="forgetAll">
{{ t('CAPTAIN.MEMORY.FORGET_ALL') }}
</button>
<!-- Error -->
<div
v-else-if="error"
class="flex items-start gap-2 rounded-md bg-n-ruby-9/10 px-3 py-2 text-sm text-n-ruby-11"
>
<Icon icon="i-lucide-alert-triangle" class="mt-0.5 flex-shrink-0" />
<span>{{ error }}</span>
</div>
<!-- Empty state -->
<div
v-else-if="!hasMemories"
class="flex flex-col items-center gap-2 py-8 text-center"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-n-alpha-2 text-n-slate-10"
>
<Icon icon="i-lucide-brain" class="text-xl" />
</div>
<p class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.EMPTY_TITLE') }}
</p>
<p class="max-w-[240px] text-xs leading-5 text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.EMPTY_HINT') }}
</p>
</div>
<!-- Memory list -->
<template v-else>
<ul class="flex flex-col gap-2">
<li
v-for="memory in memories"
:key="memory.id"
class="group/memory flex flex-col gap-2 rounded-lg border border-n-strong bg-n-solid-1 p-3 transition-colors hover:border-n-slate-7"
>
<div class="flex items-start justify-between gap-2">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="typeBadgeClasses(memory.memory_type)"
>
{{ typeLabel(memory.memory_type) }}
</span>
<NextButton
variant="ghost"
color="ruby"
size="xs"
icon="i-lucide-trash-2"
class="opacity-0 group-hover/memory:opacity-100 focus-visible:opacity-100"
:aria-label="t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.FORGET')"
@click="deleteMemory(memory.id)"
/>
</div>
<p
class="m-0 text-sm leading-relaxed text-n-slate-12 whitespace-pre-line break-words"
>
{{ memory.content }}
</p>
<div
class="flex items-center justify-between gap-3 pt-1 text-xs text-n-slate-11"
>
<div
class="flex items-center gap-1.5 min-w-0"
:title="`${t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.CONFIDENCE')}: ${Math.round(memory.confidence * 100)}%`"
>
<span
class="relative h-1.5 w-12 overflow-hidden rounded-full bg-n-alpha-2"
>
<span
class="absolute left-0 top-0 h-full rounded-full"
:class="confidenceBarClass(memory.confidence)"
:style="{ width: `${Math.round(memory.confidence * 100)}%` }"
/>
</span>
<span class="tabular-nums">
{{ `${Math.round(memory.confidence * 100)}%` }}
</span>
</div>
<span class="truncate">{{ relativeTime(memory.created_at) }}</span>
</div>
</li>
</ul>
<div class="flex justify-end pt-1">
<NextButton
variant="ghost"
color="ruby"
size="xs"
icon="i-lucide-trash-2"
:label="t('CONVERSATION_SIDEBAR.CONTACT_MEMORIES.FORGET_ALL')"
@click="forgetAll"
/>
</div>
</template>
</div>
</template>