Files
DLE/frontend/src/views/contacts/ContactDetailsView.vue

848 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
This software is proprietary and confidential.
Unauthorized copying, modification, or distribution is prohibited.
For licensing inquiries: info@hb3-accelerator.com
Website: https://hb3-accelerator.com
GitHub: https://github.com/HB3-ACCELERATOR
-->
<template>
<BaseLayout>
<div v-if="!isAdmin" class="empty-table-placeholder">Нет доступа</div>
<div v-else class="contact-details-page">
<div v-if="isLoading">Загрузка...</div>
<div v-else-if="!contact">Контакт не найден</div>
<div v-else class="contact-details-content">
<div class="contact-details-header">
<h2>Детали контакта</h2>
<button class="close-btn" @click="goBack">×</button>
</div>
<div class="contact-info-block">
<div>
<strong>Имя:</strong>
<template v-if="isAdmin">
<input v-model="editableName" class="edit-input" @blur="saveName" @keyup.enter="saveName" />
<span v-if="isSavingName" class="saving">Сохранение...</span>
</template>
<template v-else>
{{ contact.name }}
</template>
</div>
<div><strong>Email:</strong> {{ contact.email || '-' }}</div>
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div>
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div>
<div>
<strong>Язык:</strong>
<div class="multi-select">
<div class="selected-langs">
<span v-for="lang in selectedLanguages" :key="lang" class="lang-tag">
{{ getLanguageLabel(lang) }}
<span class="remove-tag" @click="removeLanguage(lang)">×</span>
</span>
<input
v-model="langInput"
@focus="showLangDropdown = true"
@input="showLangDropdown = true"
@keydown.enter.prevent="addLanguageFromInput"
class="lang-input"
placeholder="Добавить язык..."
/>
</div>
<ul v-if="showLangDropdown" class="lang-dropdown">
<li
v-for="lang in filteredLanguages"
:key="lang.value"
@mousedown.prevent="addLanguage(lang.value)"
:class="{ selected: selectedLanguages.includes(lang.value) }"
>
{{ lang.label }}
</li>
</ul>
</div>
<span v-if="isSavingLangs" class="saving">Сохранение...</span>
</div>
<div><strong>Дата создания:</strong> {{ formatDate(contact.created_at) }}</div>
<div><strong>Дата последнего сообщения:</strong> {{ formatDate(lastMessageDate) }}</div>
<div class="user-tags-block">
<strong>Теги пользователя:</strong>
<span v-for="tag in userTags" :key="tag.id" class="user-tag">
{{ tag.name }}
<span class="remove-tag" @click="removeUserTag(tag.id)">×</span>
</span>
<button class="add-tag-btn" @click="openTagModal">Добавить тег</button>
</div>
<div class="block-user-section">
<strong>Статус блокировки:</strong>
<span v-if="contact.is_blocked" class="blocked-status">Заблокирован</span>
<span v-else class="unblocked-status">Не заблокирован</span>
<template v-if="isAdmin">
<el-button
v-if="!contact.is_blocked"
type="danger"
size="small"
@click="blockUser"
style="margin-left: 1em;"
>Заблокировать</el-button>
<el-button
v-else
type="success"
size="small"
@click="unblockUser"
style="margin-left: 1em;"
>Разблокировать</el-button>
</template>
</div>
<div class="delete-actions">
<button class="delete-history-btn" @click="deleteMessagesHistory">Удалить историю сообщений</button>
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div>
</div>
<div class="messages-block">
<h3>Чат с пользователем</h3>
<ChatInterface
:messages="messages"
:isLoading="isLoadingMessages"
:attachments="chatAttachments"
:newMessage="chatNewMessage"
:isAdmin="isAdmin"
@send-message="handleSendMessage"
@update:newMessage="val => chatNewMessage = val"
@update:attachments="val => chatAttachments = val"
@ai-reply="handleAiReply"
/>
</div>
<el-dialog v-model="showTagModal" title="Добавить тег пользователю">
<div v-if="allTags.length">
<el-select
v-model="selectedTags"
multiple
filterable
placeholder="Выберите теги"
@change="addTagsToUser"
>
<el-option
v-for="tag in allTags"
:key="tag.id"
:label="tag.name"
:value="tag.id"
/>
</el-select>
<div style="margin-top: 1em; color: #888; font-size: 0.95em;">
<strong>Существующие теги:</strong>
<span v-for="tag in allTags" :key="'list-' + tag.id" style="margin-right: 0.7em;">
{{ tag.name }}<span v-if="tag.description"> ({{ tag.description }})</span>
</span>
</div>
</div>
<div style="margin-top: 1em;">
<el-input v-model="newTagName" placeholder="Новый тег" />
<el-input v-model="newTagDescription" placeholder="Описание" />
<el-button type="primary" @click="createTag">Создать тег</el-button>
</div>
</el-dialog>
</div>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import Message from '../../components/Message.vue';
import ChatInterface from '../../components/ChatInterface.vue';
import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js';
import { useAuthContext } from '@/composables/useAuth';
import { ElMessageBox } from 'element-plus';
import tablesService from '../../services/tablesService';
import { useTagsWebSocket } from '../../composables/useTagsWebSocket';
const route = useRoute();
const router = useRouter();
const userId = computed(() => route.params.id);
const contact = ref(null);
const isLoading = ref(true);
const isLoadingMessages = ref(false);
const lastMessageDate = ref(null);
const editableName = ref('');
const isSavingName = ref(false);
const isSavingLangs = ref(false);
const userTags = ref([]);
const allTags = ref([]);
const selectedTags = ref([]);
const showTagModal = ref(false);
const newTagName = ref('');
const newTagDescription = ref('');
const messages = ref([]);
const chatAttachments = ref([]);
const chatNewMessage = ref('');
const { isAdmin } = useAuthContext();
const isAiLoading = ref(false);
const conversationId = ref(null);
// id таблицы тегов (будет найден или создан)
const tagsTableId = ref(null);
// WebSocket для тегов
const { onTagsUpdate } = useTagsWebSocket();
let unsubscribeFromTags = null;
async function ensureTagsTable() {
// Получаем все пользовательские таблицы
const tables = await tablesService.getTables();
let tagsTable = tables.find(t => t.name === 'Теги клиентов');
if (!tagsTable) {
// Если таблицы нет — создаём
tagsTable = await tablesService.createTable({
name: 'Теги клиентов',
description: 'Справочник тегов для контактов',
isRagSourceId: 2 // не источник для RAG по умолчанию
});
// Добавляем столбцы параллельно
await Promise.all([
tablesService.addColumn(tagsTable.id, { name: 'Название', type: 'text' }),
tablesService.addColumn(tagsTable.id, { name: 'Описание', type: 'text' })
]);
} else {
// Проверяем, есть ли нужные столбцы, если таблица уже была создана
const table = await tablesService.getTable(tagsTable.id);
const hasName = table.columns.some(col => col.name === 'Название');
const hasDesc = table.columns.some(col => col.name === 'Описание');
// Добавляем недостающие столбцы параллельно
const addColumnPromises = [];
if (!hasName) addColumnPromises.push(tablesService.addColumn(tagsTable.id, { name: 'Название', type: 'text' }));
if (!hasDesc) addColumnPromises.push(tablesService.addColumn(tagsTable.id, { name: 'Описание', type: 'text' }));
if (addColumnPromises.length > 0) {
await Promise.all(addColumnPromises);
}
}
tagsTableId.value = tagsTable.id;
return tagsTable.id;
}
async function loadAllTags() {
// Убедимся, что таблица тегов есть
const tableId = await ensureTagsTable();
// Загружаем все строки из таблицы тегов
const table = await tablesService.getTable(tableId);
// Ожидаем, что первый столбец — название тега, второй — описание (если есть)
const nameCol = table.columns[0];
const descCol = table.columns[1];
allTags.value = table.rows.map(row => {
const nameCell = table.cellValues.find(c => c.row_id === row.id && c.column_id === nameCol.id);
const descCell = descCol ? table.cellValues.find(c => c.row_id === row.id && c.column_id === descCol.id) : null;
return {
id: row.id,
name: nameCell ? nameCell.value : '',
description: descCell ? descCell.value : ''
};
});
}
function openTagModal() {
showTagModal.value = true;
loadAllTags();
}
function toggleSidebar() {
isSidebarOpen.value = !isSidebarOpen.value;
}
// --- Языки ---
const allLanguages = [
{ value: 'ru', label: 'Русский' },
{ value: 'en', label: 'English' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'zh', label: '中文' },
{ value: 'ar', label: 'العربية' },
{ value: 'pt', label: 'Português' },
{ value: 'it', label: 'Italiano' },
{ value: 'ja', label: '日本語' },
{ value: 'tr', label: 'Türkçe' },
{ value: 'pl', label: 'Polski' },
{ value: 'uk', label: 'Українська' },
{ value: 'other', label: 'Другое' }
];
const selectedLanguages = ref([]);
const langInput = ref('');
const showLangDropdown = ref(false);
const filteredLanguages = computed(() => {
const input = langInput.value.toLowerCase();
return allLanguages.filter(
l => !selectedLanguages.value.includes(l.value) && l.label.toLowerCase().includes(input)
);
});
function getLanguageLabel(val) {
const found = allLanguages.find(l => l.value === val);
return found ? found.label : val;
}
function addLanguage(lang) {
if (!selectedLanguages.value.includes(lang)) {
selectedLanguages.value.push(lang);
saveLanguages();
}
langInput.value = '';
showLangDropdown.value = false;
}
function addLanguageFromInput() {
const found = filteredLanguages.value[0];
if (found) addLanguage(found.value);
}
function removeLanguage(lang) {
selectedLanguages.value = selectedLanguages.value.filter(l => l !== lang);
saveLanguages();
}
function saveLanguages() {
isSavingLangs.value = true;
contactsService.updateContact(contact.value.id, { language: selectedLanguages.value })
.then(() => reloadContact())
.finally(() => { isSavingLangs.value = false; });
}
// --- Имя ---
function saveName() {
if (editableName.value !== contact.value.name) {
isSavingName.value = true;
contactsService.updateContact(contact.value.id, { name: editableName.value })
.then(() => reloadContact())
.finally(() => { isSavingName.value = false; });
}
}
// --- Удаление ---
async function deleteMessagesHistory() {
if (!contact.value || !contact.value.id) return;
try {
const confirmed = await ElMessageBox.confirm(
'Вы действительно хотите удалить всю историю сообщений этого пользователя? Это действие необратимо.',
'Подтверждение удаления',
{
confirmButtonText: 'Удалить',
cancelButtonText: 'Отмена',
type: 'warning'
}
);
if (confirmed) {
const result = await messagesService.deleteMessagesHistory(contact.value.id);
if (result.success) {
ElMessageBox.alert(
`История сообщений успешно удалена. Удалено сообщений: ${result.deletedMessages}, бесед: ${result.deletedConversations}`,
'Успех',
{ type: 'success' }
);
// Обновляем список сообщений
await loadMessages();
} else {
throw new Error('Не удалось удалить историю сообщений');
}
}
} catch (e) {
if (e !== 'cancel') {
ElMessageBox.alert(
'Ошибка при удалении истории сообщений: ' + (e?.response?.data?.error || e?.message || e),
'Ошибка',
{ type: 'error' }
);
}
}
}
function deleteContact() {
router.push({ name: 'contact-delete-confirm', params: { id: contact.value.id } });
}
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleString();
}
async function loadMessages() {
if (!contact.value || !contact.value.id) return;
isLoadingMessages.value = true;
try {
// Получаем conversationId для контакта
const conv = await messagesService.getConversationByUserId(contact.value.id);
conversationId.value = conv?.id || null;
if (conversationId.value) {
messages.value = await messagesService.getMessagesByConversationId(conversationId.value);
if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else {
lastMessageDate.value = null;
}
} else {
messages.value = [];
lastMessageDate.value = null;
}
} catch (e) {
messages.value = [];
lastMessageDate.value = null;
} finally {
isLoadingMessages.value = false;
}
}
async function reloadContact() {
isLoading.value = true;
try {
contact.value = await contactsService.getContactById(userId.value);
editableName.value = contact.value?.name || '';
selectedLanguages.value = Array.isArray(contact.value?.preferred_language)
? contact.value.preferred_language
: (contact.value?.preferred_language ? [contact.value.preferred_language] : []);
} catch (e) {
contact.value = null;
} finally {
isLoading.value = false;
}
}
function goBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push({ name: 'crm' });
}
}
async function handleSendMessage({ message, attachments }) {
if (!contact.value || !contact.value.id) return;
if (contact.value.is_blocked) {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Пользователь заблокирован. Отправка сообщений невозможна.', 'Ошибка', { type: 'error' });
} else {
alert('Пользователь заблокирован. Отправка сообщений невозможна.');
}
return;
}
// Проверка наличия хотя бы одного идентификатора
const hasAnyId = contact.value.email || contact.value.telegram || contact.value.wallet;
if (!hasAnyId) {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('У пользователя нет ни одного идентификатора (email, telegram, wallet). Сообщение не может быть отправлено.', 'Ошибка', { type: 'warning' });
} else {
alert('У пользователя нет ни одного идентификатора (email, telegram, wallet). Сообщение не может быть отправлено.');
}
return;
}
try {
const result = await messagesService.broadcastMessage({
userId: contact.value.id,
message,
attachments
});
// Формируем текст результата для отображения админу
let resultText = '';
if (result && Array.isArray(result.results)) {
resultText = 'Результат рассылки по каналам:';
for (const r of result.results) {
resultText += `\n${r.channel}: ${(r.status === 'sent' || r.status === 'saved') ? 'Успех' : 'Ошибка'}${r.error ? ' (' + r.error + ')' : ''}`;
}
} else {
resultText = 'Не удалось получить подробный ответ от сервера.';
}
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert(resultText, 'Результат рассылки', { type: 'info' });
} else {
alert(resultText);
}
await loadMessages();
} catch (e) {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' });
} else {
alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e));
}
}
}
async function handleAiReply(selectedMessages = []) {
// console.log('[AI-ASSISTANT] Кнопка нажата, messages:', messages.value);
if (isAiLoading.value) {
// console.log('[AI-ASSISTANT] Уже идёт генерация, выход');
return;
}
if (!Array.isArray(selectedMessages) || selectedMessages.length === 0) {
alert('Выберите хотя бы одно сообщение пользователя для генерации ответа.');
return;
}
isAiLoading.value = true;
try {
// Генерируем черновик ответа через новый endpoint
const draftResp = await messagesService.generateAiDraft(conversationId.value, selectedMessages);
if (draftResp && draftResp.success && draftResp.aiMessage) {
chatNewMessage.value = draftResp.aiMessage;
// console.log('[AI-ASSISTANT] Черновик сгенерирован:', draftResp.aiMessage);
} else {
alert('Не удалось сгенерировать ответ ИИ.');
}
} catch (e) {
alert('Ошибка генерации ответа ИИ: ' + (e?.message || e));
} finally {
isAiLoading.value = false;
// console.log('[AI-ASSISTANT] Генерация завершена');
}
}
function showBlockStatusMessage(msg, type = 'info') {
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert(msg, 'Статус блокировки', { type });
} else {
alert(msg);
}
}
async function blockUser() {
if (!contact.value) return;
try {
await contactsService.blockContact(contact.value.id);
contact.value.is_blocked = true;
showBlockStatusMessage('Пользователь заблокирован', 'success');
} catch (e) {
showBlockStatusMessage('Ошибка блокировки пользователя', 'error');
}
}
async function unblockUser() {
if (!contact.value) return;
try {
await contactsService.unblockContact(contact.value.id);
contact.value.is_blocked = false;
showBlockStatusMessage('Пользователь разблокирован', 'success');
} catch (e) {
showBlockStatusMessage('Ошибка разблокировки пользователя', 'error');
}
}
// --- Теги ---
async function createTag() {
if (!newTagName.value) return;
const tableId = await ensureTagsTable();
const table = await tablesService.getTable(tableId);
const nameCol = table.columns[0];
const descCol = table.columns[1];
// 1. Создаём строку
const newRow = await tablesService.addRow(tableId);
// console.log('DEBUG newRow:', newRow);
if (!newRow || !newRow.id) {
// console.error('Ошибка: не удалось получить id новой строки', newRow);
alert('Ошибка: не удалось получить id новой строки. См. консоль.');
return;
}
const newRowId = newRow.id;
// 2. Сохраняем имя
await tablesService.saveCell({
table_id: tableId,
row_id: newRowId,
column_id: nameCol.id,
value: newTagName.value
});
// 3. Сохраняем описание (если есть столбец)
if (descCol && newTagDescription.value) {
await tablesService.saveCell({
table_id: tableId,
row_id: newRowId,
column_id: descCol.id,
value: newTagDescription.value
});
}
// 4. Обновляем список тегов
await loadAllTags();
// 5. Автоматически выбираем новый тег для пользователя
selectedTags.value = [...selectedTags.value, newRowId];
await addTagsToUser();
// 6. Очищаем поля
newTagName.value = '';
newTagDescription.value = '';
}
async function loadUserTags() {
if (!contact.value || !contact.value.id) {
userTags.value = [];
return;
}
// Получаем id тегов пользователя
const tagIds = await contactsService.getContactTags(contact.value.id);
if (!Array.isArray(tagIds) || tagIds.length === 0) {
userTags.value = [];
return;
}
// Загружаем справочник тегов
await loadAllTags();
// Сопоставляем id с объектами тегов
userTags.value = allTags.value.filter(tag => tagIds.includes(tag.id));
}
// После добавления/удаления тегов всегда обновляем userTags
async function addTagsToUser() {
if (!contact.value || !contact.value.id) return;
if (!selectedTags.value || selectedTags.value.length === 0) return;
try {
await contactsService.addTagsToContact(contact.value.id, selectedTags.value);
await loadUserTags();
showTagModal.value = false;
ElMessageBox.alert('Теги успешно добавлены.', 'Успех', { type: 'success' });
} catch (e) {
ElMessageBox.alert('Ошибка добавления тегов: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' });
}
}
async function removeUserTag(tagId) {
if (!contact.value || !contact.value.id) return;
try {
await contactsService.removeTagFromContact(contact.value.id, tagId);
await loadUserTags();
ElMessageBox.alert('Тег успешно удален.', 'Успех', { type: 'success' });
} catch (e) {
ElMessageBox.alert('Ошибка удаления тега: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' });
}
}
onMounted(async () => {
await reloadContact();
await loadUserTags();
await loadMessages();
// Подписываемся на обновления тегов
unsubscribeFromTags = onTagsUpdate(async () => {
// console.log('[ContactDetailsView] Получено обновление тегов, перезагружаем списки тегов');
await loadAllTags();
await loadUserTags();
});
});
onUnmounted(() => {
// Отписываемся от WebSocket при размонтировании
if (unsubscribeFromTags) {
unsubscribeFromTags();
}
});
watch(userId, async () => {
await reloadContact();
await loadUserTags();
await loadMessages();
});
</script>
<style scoped>
.contact-details-page {
padding: 32px 0;
}
.contact-details-content {
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
width: 100%;
margin-top: 40px;
position: relative;
overflow-x: auto;
}
.contact-details-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #bbb;
transition: color 0.2s;
}
.close-btn:hover {
color: #333;
}
.contact-info-block {
margin-bottom: 18px;
font-size: 1.08rem;
line-height: 1.7;
}
.edit-input {
border: 1px solid #ccc;
border-radius: 6px;
padding: 4px 10px;
font-size: 1rem;
margin-left: 8px;
min-width: 120px;
}
.saving {
color: #17a2b8;
font-size: 0.95rem;
margin-left: 8px;
}
.delete-actions {
display: flex;
gap: 12px;
margin-top: 18px;
}
.delete-history-btn {
background: #ff9800;
color: #fff;
border: none;
border-radius: 6px;
padding: 7px 18px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.delete-history-btn:hover {
background: #f57c00;
}
.delete-btn {
background: #dc3545;
color: #fff;
border: none;
border-radius: 6px;
padding: 7px 18px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.delete-btn:hover {
background: #b52a37;
}
.multi-select {
position: relative;
display: inline-block;
min-width: 220px;
}
.selected-langs {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 36px;
background: #f5f7fa;
border-radius: 6px;
padding: 4px 8px;
border: 1px solid #ccc;
}
.lang-tag {
background: #e6f7ff;
color: #138496;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.97rem;
display: flex;
align-items: center;
}
.remove-tag {
margin-left: 4px;
cursor: pointer;
color: #888;
font-weight: bold;
}
.remove-tag:hover {
color: #dc3545;
}
.lang-input {
border: none;
outline: none;
background: transparent;
font-size: 1rem;
min-width: 80px;
margin-left: 4px;
}
.lang-dropdown {
position: absolute;
left: 0;
top: 100%;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
z-index: 10;
min-width: 180px;
max-height: 180px;
overflow-y: auto;
margin-top: 2px;
padding: 0;
list-style: none;
}
.lang-dropdown li {
padding: 7px 14px;
cursor: pointer;
font-size: 1rem;
}
.lang-dropdown li.selected {
background: #e6f7ff;
color: #138496;
}
.lang-dropdown li:hover {
background: #f0f0f0;
}
.messages-block {
background: #f8fafc;
border-radius: 10px;
padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.messages-list {
max-height: 350px;
overflow-y: auto;
margin-top: 10px;
}
.loading, .empty {
color: #888;
text-align: center;
margin: 20px 0;
}
.user-tags-block {
margin: 1em 0;
}
.user-tag {
display: inline-block;
background: #e0f7fa;
color: #00796b;
border-radius: 6px;
padding: 0.2em 0.7em;
margin-right: 0.5em;
font-size: 0.95em;
}
.add-tag-btn {
margin-left: 1em;
background: #2ecc40;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.3em 1em;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.add-tag-btn:hover {
background: #27ae38;
}
.block-user-section {
margin-top: 1em;
margin-bottom: 1em;
}
.blocked-status {
color: #d32f2f;
font-weight: bold;
}
.unblocked-status {
color: #388e3c;
font-weight: bold;
}
</style>