2026-05-08 18:51:41 +03:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
/**
|
2026-05-09 11:41:35 +03:00
|
|
|
|
* Settings → Уведомления. Матрица 8 событий × 3 канала (inapp/push/email)
|
|
|
|
|
|
* + sound_enabled. Соответствует schema.sql:699 users.notification_preferences.
|
2026-05-08 18:51:41 +03:00
|
|
|
|
*
|
2026-05-09 11:41:35 +03:00
|
|
|
|
* События (точно по schema-default):
|
|
|
|
|
|
* new_lead / reminder / low_balance / zero_balance /
|
|
|
|
|
|
* topup_success / invoice_paid / new_device_login / marketing.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Каналы (точно по schema):
|
|
|
|
|
|
* inapp (bell-icon в UI) / push (web-push, Post-MVP) / email (Unisender Go).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Push-канал на MVP пока не доставляется (требует ServiceWorker + VAPID), но
|
|
|
|
|
|
* preferences записываются для будущей активации без миграции данных.
|
2026-05-08 18:51:41 +03:00
|
|
|
|
*/
|
2026-05-09 11:41:35 +03:00
|
|
|
|
import { computed, ref, watch } from 'vue';
|
|
|
|
|
|
import { useAuthStore } from '../../stores/auth';
|
|
|
|
|
|
import {
|
|
|
|
|
|
type NotificationChannel,
|
|
|
|
|
|
type NotificationEventKey,
|
|
|
|
|
|
type NotificationPreferences,
|
|
|
|
|
|
updateNotificationPreferences,
|
|
|
|
|
|
} from '../../api/auth';
|
2026-05-08 18:51:41 +03:00
|
|
|
|
|
|
|
|
|
|
interface NotifEvent {
|
2026-05-09 11:41:35 +03:00
|
|
|
|
id: NotificationEventKey;
|
2026-05-08 18:51:41 +03:00
|
|
|
|
label: string;
|
2026-05-09 11:41:35 +03:00
|
|
|
|
description?: string;
|
2026-05-08 18:51:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 11:41:35 +03:00
|
|
|
|
const EVENTS: NotifEvent[] = [
|
|
|
|
|
|
{ id: 'new_lead', label: 'Новый лид', description: 'Поступил новый лид через webhook или manual-создание.' },
|
|
|
|
|
|
{ id: 'reminder', label: 'Напоминание', description: 'Срок касания клиента наступил.' },
|
|
|
|
|
|
{ id: 'low_balance', label: 'Низкий баланс', description: 'Осталось < 3 дней работы по текущим ценам.' },
|
|
|
|
|
|
{ id: 'zero_balance', label: 'Нулевой баланс', description: 'Лиды отклоняются — нет средств для оплаты.' },
|
|
|
|
|
|
{ id: 'topup_success', label: 'Пополнение успешно', description: 'Платёж зачислен на баланс.' },
|
|
|
|
|
|
{ id: 'invoice_paid', label: 'Счёт оплачен', description: 'Тарифный счёт списан.' },
|
|
|
|
|
|
{ id: 'new_device_login', label: 'Новое устройство', description: 'Вход с незнакомого устройства / IP.' },
|
|
|
|
|
|
{ id: 'marketing', label: 'Анонсы и промо', description: 'Новости платформы, скидки, новые функции.' },
|
2026-05-08 18:51:41 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
interface ChannelKey {
|
2026-05-09 11:41:35 +03:00
|
|
|
|
id: NotificationChannel;
|
2026-05-08 18:51:41 +03:00
|
|
|
|
label: string;
|
2026-05-09 11:41:35 +03:00
|
|
|
|
description: string;
|
2026-05-08 18:51:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 11:41:35 +03:00
|
|
|
|
const CHANNELS: ChannelKey[] = [
|
|
|
|
|
|
{ id: 'inapp', label: 'В приложении', description: 'Колокольчик в интерфейсе.' },
|
|
|
|
|
|
{ id: 'push', label: 'Push', description: 'Web-push в браузер (включится в Post-MVP).' },
|
|
|
|
|
|
{ id: 'email', label: 'Email', description: 'На адрес в профиле.' },
|
2026-05-08 18:51:41 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-05-09 11:41:35 +03:00
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
|
|
|
|
|
|
function buildPrefs(): NotificationPreferences {
|
|
|
|
|
|
const userPrefs = auth.user?.notification_preferences ?? {};
|
|
|
|
|
|
const next: NotificationPreferences = {};
|
|
|
|
|
|
for (const e of EVENTS) {
|
|
|
|
|
|
next[e.id] = {};
|
|
|
|
|
|
for (const c of CHANNELS) {
|
|
|
|
|
|
const stored = userPrefs[e.id]?.[c.id];
|
|
|
|
|
|
next[e.id]![c.id] = typeof stored === 'boolean' ? stored : false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return next;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Инициализация синхронно в setup() — иначе v-if="prefs[e.id]" блокирует
|
|
|
|
|
|
// рендер чекбоксов до onMounted, и тесты mount-then-find падают.
|
|
|
|
|
|
const prefs = ref<NotificationPreferences>(buildPrefs());
|
|
|
|
|
|
const soundEnabled = ref<boolean>(auth.user?.sound_enabled ?? true);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const saveSuccess = ref(false);
|
|
|
|
|
|
const saveError = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Snapshot оригинальных значений из auth.user — для определения dirty.
|
|
|
|
|
|
// Replacing с deep-clone чтобы JSON.stringify сравнение работало стабильно.
|
|
|
|
|
|
const originalPrefs = ref<NotificationPreferences>(buildPrefs());
|
|
|
|
|
|
const originalSound = ref<boolean>(auth.user?.sound_enabled ?? true);
|
|
|
|
|
|
|
|
|
|
|
|
const dirty = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
JSON.stringify(prefs.value) !== JSON.stringify(originalPrefs.value) ||
|
|
|
|
|
|
soundEnabled.value !== originalSound.value,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function readFromUser(): void {
|
|
|
|
|
|
prefs.value = buildPrefs();
|
|
|
|
|
|
soundEnabled.value = auth.user?.sound_enabled ?? true;
|
|
|
|
|
|
originalPrefs.value = buildPrefs();
|
|
|
|
|
|
originalSound.value = auth.user?.sound_enabled ?? true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => auth.user?.id, readFromUser);
|
|
|
|
|
|
|
|
|
|
|
|
async function save(): Promise<void> {
|
|
|
|
|
|
if (saving.value) return;
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
saveSuccess.value = false;
|
|
|
|
|
|
saveError.value = false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updatedUser = await updateNotificationPreferences({
|
|
|
|
|
|
prefs: prefs.value,
|
|
|
|
|
|
sound_enabled: soundEnabled.value,
|
|
|
|
|
|
});
|
|
|
|
|
|
// Синхронизируем auth.user без повторного /me-вызова.
|
|
|
|
|
|
auth.user = { ...auth.user!, ...updatedUser };
|
|
|
|
|
|
// Обновляем snapshot — dirty снова false.
|
|
|
|
|
|
originalPrefs.value = buildPrefs();
|
|
|
|
|
|
originalSound.value = auth.user.sound_enabled ?? true;
|
|
|
|
|
|
saveSuccess.value = true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
saveError.value = true;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function reset(): void {
|
|
|
|
|
|
readFromUser();
|
|
|
|
|
|
}
|
2026-05-08 18:51:41 +03:00
|
|
|
|
|
2026-05-09 11:41:35 +03:00
|
|
|
|
const canSave = computed(() => dirty.value && !saving.value && auth.isAuthenticated);
|
2026-05-08 18:51:41 +03:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="tab-content">
|
|
|
|
|
|
<h2 class="tab-title text-h6 mb-4">Уведомления</h2>
|
|
|
|
|
|
|
2026-05-09 11:41:35 +03:00
|
|
|
|
<v-alert
|
|
|
|
|
|
v-if="saveSuccess"
|
|
|
|
|
|
type="success"
|
|
|
|
|
|
variant="tonal"
|
|
|
|
|
|
density="compact"
|
|
|
|
|
|
class="mb-3"
|
|
|
|
|
|
closable
|
|
|
|
|
|
data-testid="notifications-save-success"
|
|
|
|
|
|
@click:close="saveSuccess = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
Настройки сохранены.
|
|
|
|
|
|
</v-alert>
|
|
|
|
|
|
<v-alert
|
|
|
|
|
|
v-if="saveError"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
variant="tonal"
|
|
|
|
|
|
density="compact"
|
|
|
|
|
|
class="mb-3"
|
|
|
|
|
|
closable
|
|
|
|
|
|
data-testid="notifications-save-error"
|
|
|
|
|
|
@click:close="saveError = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
Не удалось сохранить настройки. Попробуйте позже.
|
|
|
|
|
|
</v-alert>
|
|
|
|
|
|
|
2026-05-08 18:51:41 +03:00
|
|
|
|
<v-card variant="outlined" class="pa-4 mb-4">
|
|
|
|
|
|
<h3 class="text-subtitle-2 mb-3">События × каналы</h3>
|
|
|
|
|
|
<p class="text-body-2 text-medium-emphasis mb-3">
|
2026-05-09 11:41:35 +03:00
|
|
|
|
Матрица 8 событий × 3 каналов (соответствует
|
|
|
|
|
|
<code>users.notification_preferences</code> в БД). Push активируется в Post-MVP.
|
2026-05-08 18:51:41 +03:00
|
|
|
|
</p>
|
|
|
|
|
|
<div class="prefs-table">
|
|
|
|
|
|
<div class="prefs-head">
|
|
|
|
|
|
<div class="prefs-cell event-col">Событие</div>
|
2026-05-09 11:41:35 +03:00
|
|
|
|
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col" :title="ch.description">
|
2026-05-08 18:51:41 +03:00
|
|
|
|
{{ ch.label }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-09 11:41:35 +03:00
|
|
|
|
<div v-for="e in EVENTS" :key="e.id" class="prefs-row" :data-event="e.id">
|
|
|
|
|
|
<div class="prefs-cell event-col">
|
|
|
|
|
|
<div>{{ e.label }}</div>
|
|
|
|
|
|
<div v-if="e.description" class="event-desc">{{ e.description }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col">
|
|
|
|
|
|
<v-checkbox
|
|
|
|
|
|
v-if="prefs[e.id]"
|
|
|
|
|
|
v-model="prefs[e.id]![ch.id]"
|
|
|
|
|
|
density="compact"
|
|
|
|
|
|
hide-details
|
|
|
|
|
|
color="primary"
|
|
|
|
|
|
:data-testid="`pref-${e.id}-${ch.id}`"
|
|
|
|
|
|
/>
|
2026-05-08 18:51:41 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
|
2026-05-09 11:41:35 +03:00
|
|
|
|
<v-card variant="outlined" class="pa-4 mb-4">
|
2026-05-08 18:51:41 +03:00
|
|
|
|
<h3 class="text-subtitle-2 mb-3">Звуковые алерты</h3>
|
|
|
|
|
|
<v-switch
|
|
|
|
|
|
v-model="soundEnabled"
|
|
|
|
|
|
color="primary"
|
|
|
|
|
|
hide-details
|
2026-05-09 11:41:35 +03:00
|
|
|
|
data-testid="sound-enabled-switch"
|
|
|
|
|
|
label="Воспроизводить звук при новом in-app уведомлении"
|
2026-05-08 18:51:41 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</v-card>
|
2026-05-09 11:41:35 +03:00
|
|
|
|
|
|
|
|
|
|
<div class="actions-row">
|
|
|
|
|
|
<v-btn color="primary" :loading="saving" :disabled="!canSave" data-testid="save-btn" @click="save">
|
|
|
|
|
|
Сохранить
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
<v-btn variant="outlined" :disabled="!dirty || saving" data-testid="reset-btn" @click="reset">
|
|
|
|
|
|
Отменить
|
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
</div>
|
2026-05-08 18:51:41 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.tab-title {
|
|
|
|
|
|
font-variation-settings: 'opsz' 18;
|
|
|
|
|
|
letter-spacing: -0.005em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.prefs-table {
|
|
|
|
|
|
border: 1px solid #e8e3d6;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.prefs-head,
|
|
|
|
|
|
.prefs-row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 1fr 110px 110px 130px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.prefs-head {
|
|
|
|
|
|
background: #f6f3ec;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
color: #66635c;
|
|
|
|
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.prefs-row {
|
|
|
|
|
|
border-top: 1px solid #f0ede4;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.prefs-cell {
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
}
|
2026-05-09 11:41:35 +03:00
|
|
|
|
|
2026-05-08 18:51:41 +03:00
|
|
|
|
.event-col {
|
|
|
|
|
|
color: #081319;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
2026-05-09 11:41:35 +03:00
|
|
|
|
|
|
|
|
|
|
.event-desc {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: #66635c;
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 18:51:41 +03:00
|
|
|
|
.ch-col {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
2026-05-09 11:41:35 +03:00
|
|
|
|
|
|
|
|
|
|
.actions-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
}
|
2026-05-08 18:51:41 +03:00
|
|
|
|
</style>
|