Files
portal/app/resources/js/components/layout/AppSidebar.vue
T
Дмитрий 9265f907cc feat(рекламный кошелёк): страница «Рекламный кошелёк» + пресеты суммы, правки вёрстки и обработки оплаты картой
T19: пункт меню «Рекламный кошелёк» (сайдбар + мобильное «Ещё») ведёт на
новый роут /advertising/wallet → AdWalletView.vue — баланс/заморожено/
свободно с короткими подсказками, кнопка «Пополнить», история операций
помечена «появится позже» (клиентского эндпоинта списка операций у
рекламного кошелька нет — не выдумываю).

T20: в AdWalletTopupDialog — чипы-пресеты суммы (1000/3000/5000 + «Своя
сумма»), кнопки в v-card-actions переведены на flex-wrap вместо
v-spacer (чинит тесноту/обрезание «Закрыть»), на узком экране — столбик;
«Оплатить картой» явно выглядит выключенной при невалидной сумме
(variant меняется на outlined).

T21: обработка ответа topupAdvertisingByCard — если ни confirmation_url,
ни ok:true, теперь явная ошибка в диалоге вместо тихого тупика. Редирект
и заглушка-успех (ok:true, с обновлением баланса через emit topped-up)
— поведение сохранено. AdWalletHeader тоже подписан на topped-up.
Живая оплата картой боевым ЮKassa НЕ проверялась (go-live не завершён) —
код корректен на все три исхода ответа, но happy-path в бою не пройден.

Тесты: 10 файлов advertising-* зелёные (86/86), полный npm run test:vue
222/222 файлов без новых падений (1 pre-existing unhandled rejection в
AutopodborManualCompetitorError.spec.ts — не мой). eslint чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 22:00:50 +03:00

361 lines
12 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">
/**
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): sidebar выделен из AppLayout.
* Task 12 (Portal Redesign Quiet Luxury): двухтоновый shell + ⌘K stub + group-eyebrows
* + active-marker pseudo-element + JetBrains Mono badges.
*
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
* Count для «Сделки» — live из API (dealsCount-store, audit B2).
*/
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useAuthStore } from '../../stores/auth';
import { useDealsCountStore } from '../../stores/dealsCount';
import { useCommandPalette } from '../../composables/useCommandPalette';
import { useAutopodborStore } from '../../stores/autopodborStore';
import AdStubDialog from './AdStubDialog.vue';
import { AD_CHANNELS } from './advertisingChannels';
interface NavItem {
title: string;
icon: string;
to?: string;
count?: number;
countKey?: string;
badge?: string;
// stub-пункт: не ведёт на страницу, по клику открывает окно «В разработке».
stub?: boolean;
testid?: string;
}
interface NavGroup {
eyebrow: string;
items: NavItem[];
}
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
// Ярус экрана из AppLayout: на телефоне сайдбар скрыт (навигацию берёт нижняя панель-вкладки).
defineProps<{ tier?: 'phone' | 'tablet' | 'desktop' }>();
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
const { openPalette } = useCommandPalette();
const autopodbor = useAutopodborStore();
// «Рекламные возможности» — раздел-витрина будущих каналов. Пункты пока заглушки:
// клик открывает одно общее окно «В разработке» (AdStubDialog). Список каналов и дату
// релиза держим в advertisingChannels.ts — общий источник с мобильным «Ещё».
const adStubOpen = ref(false);
function openAdStub(): void {
adStubOpen.value = true;
}
onMounted(() => {
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
void autopodbor.loadState().catch(() => {});
});
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
...(autopodbor.enabled
? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }]
: []),
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
{
title: 'Сделки',
icon: 'mdi-format-list-bulleted',
to: '/deals',
count: dealsCount.count ?? undefined,
countKey: 'deals',
},
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
],
},
{
eyebrow: 'Рекламные возможности',
// B2-2: канал с route (сейчас — Яндекс Аудитория) ведёт на реальный экран
// (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub.
// T19: «Рекламный кошелёк» — не канал (в AD_CHANNELS не входит), отдельный
// пункт-ссылка на /advertising/wallet. Число баланса в меню не показываем.
items: [
...AD_CHANNELS.map((c) =>
c.route
? { title: c.title, icon: c.icon, to: c.route, testid: c.testid }
: { title: c.title, icon: c.icon, stub: true, testid: c.testid },
),
{ title: 'Рекламный кошелёк', icon: 'mdi-wallet-outline', to: '/advertising/wallet', testid: 'ad-nav-wallet' },
],
},
{
eyebrow: 'Финансы',
items: [{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' }],
},
{
eyebrow: 'Команда',
items: [
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
{ title: 'Помощь', icon: 'mdi-lifebuoy', to: '/help' },
],
},
]);
function resolveCount(item: NavItem): number {
return item.count ?? 0;
}
defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen" :data-tier="tier">
<div class="ld-sidebar__brand">
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
</div>
<div
class="ld-cmdk-stub"
role="button"
tabindex="0"
data-testid="cmdk-stub"
@click="openPalette"
@keydown.enter="openPalette"
@keydown.space.prevent="openPalette"
>
<span class="ld-cmdk-stub__placeholder">Поиск, команды</span>
<Kbd dark>K</Kbd>
</div>
<nav class="ld-sidebar__nav">
<div v-for="(group, gi) in navGroups" :key="gi" class="ld-nav-group">
<div class="ld-nav-group__eyebrow">{{ group.eyebrow }}</div>
<template v-for="item in group.items" :key="item.to ?? item.testid">
<!-- stub-пункт «Рекламные возможности»: кнопка, открывает окно «В разработке». -->
<button
v-if="item.stub"
type="button"
class="ld-nav-item ld-nav-item--stub"
:data-testid="item.testid"
@click="openAdStub"
>
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
<span class="ld-nav-item__title">{{ item.title }}</span>
</button>
<RouterLink
v-else
:to="item.to!"
class="ld-nav-item"
:class="{ 'ld-nav-item--active': route.path === item.to }"
:data-tour="`nav-${item.to!.replace('/', '')}`"
:data-testid="item.testid"
>
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</span>
<span
v-if="resolveCount(item) > 0"
class="ld-nav-item__badge ld-mono"
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
>{{ resolveCount(item) }}</span
>
</RouterLink>
</template>
</div>
</nav>
<!-- Общее окно-заглушка для всех рекламных пунктов. -->
<AdStubDialog v-model:open="adStubOpen" />
</aside>
</template>
<style scoped>
.ld-sidebar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%);
color: #e8e2d4;
padding: 20px 14px;
width: 232px;
height: 100vh;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
z-index: 1006;
overflow-y: auto;
}
/* Телефон: сайдбара нет — навигацию берёт нижняя панель-вкладки (AppBottomNav). */
.ld-sidebar[data-tier='phone'] {
display: none;
}
/* Иконка пункта — по умолчанию скрыта (десктоп остаётся текстовым, как был). */
.ld-nav-item__icon {
display: none;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
/* Планшет: узкая колонка-иконки (rail). Прячем подписи/поиск/бренд-текст, показываем иконки. */
.ld-sidebar[data-tier='tablet'] {
width: 64px;
padding: 20px 8px;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand-name,
.ld-sidebar[data-tier='tablet'] .ld-cmdk-stub,
.ld-sidebar[data-tier='tablet'] .ld-nav-group__eyebrow,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__title,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__badge,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__new {
display: none;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item__icon {
display: inline-flex;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item {
justify-content: center;
padding: 10px 0;
gap: 0;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand {
height: 22px;
}
.ld-sidebar__brand {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
padding: 0 8px;
margin-bottom: 22px;
}
.ld-sidebar__brand-name {
color: var(--liderra-ivory);
}
.ld-sidebar__brand-dot {
color: var(--liderra-teal);
}
.ld-cmdk-stub {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px 11px;
border-radius: var(--radius-8);
font-size: 12px;
color: #9b9484;
margin-bottom: 18px;
cursor: pointer;
transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-cmdk-stub:hover {
background: rgba(255, 255, 255, 0.1);
}
.ld-sidebar__nav {
flex: 1;
}
.ld-nav-group {
margin-bottom: 6px;
}
.ld-nav-group__eyebrow {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #6b7470;
margin: 14px 8px 4px;
font-weight: 500;
}
.ld-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--radius-6);
font-size: 13px;
color: #b8b0a0;
text-decoration: none;
position: relative;
transition:
color 200ms cubic-bezier(0.16, 1, 0.3, 1),
background 200ms cubic-bezier(0.16, 1, 0.3, 1);
margin-bottom: 1px;
}
.ld-nav-item:hover {
color: #e8e2d4;
background: rgba(255, 255, 255, 0.04);
}
.ld-nav-item--active {
color: var(--liderra-ivory);
background: rgba(15, 110, 86, 0.22);
}
.ld-nav-item--active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--liderra-teal);
border-radius: 2px;
transform-origin: center;
animation: ld-marker-grow 250ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes ld-marker-grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
/* stub-пункт — это <button>; убираем родные стили кнопки, наследуем облик .ld-nav-item. */
.ld-nav-item--stub {
width: 100%;
border: none;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.ld-nav-item__title {
flex: 1;
}
.ld-nav-item__badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
color: #b8b0a0;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum' 1;
}
.ld-nav-item--active .ld-nav-item__badge {
background: rgba(255, 255, 255, 0.1);
color: var(--liderra-ivory);
}
.ld-nav-item__new {
font-size: 9px;
background: var(--liderra-teal);
color: #fff;
border-radius: 4px;
padding: 1px 5px;
letter-spacing: 0.04em;
margin-left: 6px;
}
</style>