Files
portal/app/resources/js/views/settings/NotificationsTab.vue
T

269 lines
9.4 KiB
Vue
Raw Normal View History

<script setup lang="ts">
/**
* Settings → Уведомления. Матрица 8 событий × 3 канала (inapp/push/email)
* + sound_enabled. Соответствует schema.sql:699 users.notification_preferences.
*
* События (точно по 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 записываются для будущей активации без миграции данных.
*/
import { computed, ref, watch } from 'vue';
import { useAuthStore } from '../../stores/auth';
import {
type NotificationChannel,
type NotificationEventKey,
type NotificationPreferences,
updateNotificationPreferences,
} from '../../api/auth';
interface NotifEvent {
id: NotificationEventKey;
label: string;
description?: string;
}
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: 'Новости платформы, скидки, новые функции.' },
];
interface ChannelKey {
id: NotificationChannel;
label: string;
description: string;
}
const CHANNELS: ChannelKey[] = [
{ id: 'inapp', label: 'В приложении', description: 'Колокольчик в интерфейсе.' },
{ id: 'push', label: 'Push', description: 'Web-push в браузер (включится в Post-MVP).' },
{ id: 'email', label: 'Email', description: 'На адрес в профиле.' },
];
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();
}
const canSave = computed(() => dirty.value && !saving.value && auth.isAuthenticated);
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Уведомления</h2>
<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>
<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">
Матрица 8 событий × 3 каналов (соответствует
<code>users.notification_preferences</code> в БД). Push активируется в Post-MVP.
</p>
<div class="prefs-table">
<div class="prefs-head">
<div class="prefs-cell event-col">Событие</div>
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col" :title="ch.description">
{{ ch.label }}
</div>
</div>
<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}`"
/>
</div>
</div>
</div>
</v-card>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">Звуковые алерты</h3>
<v-switch
v-model="soundEnabled"
color="primary"
hide-details
data-testid="sound-enabled-switch"
label="Воспроизводить звук при новом in-app уведомлении"
/>
</v-card>
<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>
</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;
}
.event-col {
color: #081319;
font-size: 13px;
}
.event-desc {
font-size: 11px;
color: #66635c;
margin-top: 2px;
}
.ch-col {
text-align: center;
display: flex;
justify-content: center;
}
.actions-row {
display: flex;
gap: 12px;
margin-top: 8px;
}
</style>