Files
portal/app/resources/js/views/settings/NotificationsTab.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

292 lines
10 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.
<script setup lang="ts">
/**
* Settings → Уведомления. Матрица 7 событий × 3 канала (inapp/push/email)
* + sound_enabled. Соответствует schema.sql:699 users.notification_preferences.
*
* События (точно по schema-default):
* new_lead / 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: '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: '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" data-tour="notifications-matrix">
<h3 class="text-subtitle-2 mb-3">События × каналы</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Выберите, о каких событиях и какими каналами вас уведомлять.
</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"
data-tour="notifications-sound"
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 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;
}
/* Телефон: колонки каналов по 110/130px не влезали — Email уезжал за край,
а название события сжималось в узкую колонку и текст крошился по одному слову.
Ужимаем колонки-галочки до ширины чекбокса и уменьшаем отступы, чтобы событию
осталась почти вся ширина. Заголовки каналов переносим по буквам. */
@media (max-width: 599.98px) {
.prefs-head,
.prefs-row {
grid-template-columns: 1fr 46px 46px;
}
.prefs-cell {
padding: 8px 6px;
}
.ch-col {
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.15;
}
/* Заголовки каналов — мельче, чтобы «EMAIL» помещался в строку, а не ломался. */
.prefs-head .ch-col {
font-size: 9px;
letter-spacing: 0.02em;
padding-inline: 3px;
}
}
</style>