feat(реклама-фронт): мастер запуска — каркас v-stepper + шаг «Кого рекламируем» с живым счётчиком

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-25 01:28:15 +03:00
parent 7cd689f897
commit 480ac8f6be
4 changed files with 366 additions and 3 deletions
@@ -0,0 +1,232 @@
<script setup lang="ts">
/**
* Мастер запуска кампании «Яндекс Аудитория» (Часть B2, задача B2-5).
*
* Каркас — v-stepper на 4 шага (Р29/Р42 — макет свой, визуальный язык строго
* по дизайн-системе портала: Vuetify 3, токены темы, mdi-иконки). Сейчас
* наполнен только шаг 1 «Кого рекламируем» (Р7, Р16, Р17, Р21–Р23); шаги
* 2–4 — пустые заглушки, их наполнят задачи B2-6 (Объявления), B2-7 (Бюджет)
* и B2-8 (Проверка и запуск).
*
* ЧЕРНОВИК: при монтировании мастера сразу создаём черновик кампании
* (POST /api/advertising/campaigns, api/advertising.ts::createCampaign) —
* без id не работает живой счётчик аудитории (GET .../audience-size требует
* campaign id). Имя черновика — авто, «Реклама от ДД.ММ.ГГГГ» (дата создания).
*
* ⚠️ Брошенные черновики (клиент закрыл мастер, не дойдя до запуска) пока
* НЕ чистятся и НЕ переиспользуются — они остаются в статусе draft. Чистка/
* переиспользование черновиков — следующий инкремент (эндпоинта удаления
* в API B1 нет, выдумывать его здесь нельзя).
*/
import { reactive, ref, computed, watch, onMounted } from 'vue';
import { createCampaign, fetchAudienceSize, type CampaignCreate, type AudienceSize } from '../../api/advertising';
import { extractErrorMessage } from '../../api/client';
/** Объявление кампании — наполнит шаг 2 (B2-6); сейчас только хранится в форме. */
interface WizardAd {
title: string;
text: string;
href: string;
title2?: string;
}
const step = ref(1);
const stepTitles = ['Кого рекламируем', 'Объявления', 'Бюджет', 'Проверка и запуск'];
/** Общая форма мастера — B2-6/7/8 продолжат работать с этим же реактивным объектом. */
const form = reactive<CampaignCreate & { ads: WizardAd[] }>({
name: '',
audience_days: 10,
use_uploaded_list: false,
weekly_budget_rub: '1000.00',
daily_budget_rub: null,
ads: [],
});
const draftId = ref<number | null>(null);
const draftError = ref<string | null>(null);
const audienceSize = ref<AudienceSize | null>(null);
const audienceLoading = ref(false);
const audienceError = ref<string | null>(null);
/** Достаточно ли аудитории для запуска — понадобится шагу 4 (B2-8) для блокировки кнопки. */
const canLaunch = computed(() => audienceSize.value?.enough ?? false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** «Реклама от 25.07.2026» — имя черновика по умолчанию, дата создания. */
function autoName(): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, '0');
return `Реклама от ${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`;
}
/** Живой счётчик аудитории — вынесен отдельно от debounce, чтобы тест мог звать напрямую. */
async function refreshAudienceSize(): Promise<void> {
if (draftId.value === null) return;
audienceLoading.value = true;
audienceError.value = null;
try {
audienceSize.value = await fetchAudienceSize(draftId.value, form.audience_days);
} catch (e) {
audienceSize.value = null;
audienceError.value = extractErrorMessage(e, 'Не удалось посчитать размер аудитории.');
} finally {
audienceLoading.value = false;
}
}
async function createDraft(): Promise<void> {
draftError.value = null;
try {
form.name = autoName();
const campaign = await createCampaign({
name: form.name,
audience_days: form.audience_days,
use_uploaded_list: false,
weekly_budget_rub: form.weekly_budget_rub,
});
draftId.value = campaign.id;
await refreshAudienceSize();
} catch (e) {
draftError.value = extractErrorMessage(e, 'Не удалось создать черновик кампании.');
}
}
/** Ползунок дней меняется часто — считаем счётчик не на каждый пиксель, а с задержкой ~400мс. */
function scheduleAudienceRefresh(): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
void refreshAudienceSize();
}, 400);
}
watch(() => form.audience_days, scheduleAudienceRefresh);
function goBack(): void {
step.value = Math.max(1, step.value - 1);
}
function goNext(): void {
step.value = Math.min(stepTitles.length, step.value + 1);
}
/**
* VStepper's `#item` slot типизирован как `StepperItem = string | Record<string, any>`
* (Vuetify 3.12) — читаем `value` через явный каст, чтобы не городить `as any` в шаблоне.
*/
function stepValue(item: unknown): number {
return Number((item as { value: number }).value);
}
onMounted(() => {
void createDraft();
});
defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
</script>
<template>
<div class="campaign-wizard" data-testid="wizard">
<p v-if="draftError" class="text-body-2 text-error mb-4" data-testid="wizard-draft-error">
{{ draftError }}
</p>
<v-stepper v-model="step" :items="stepTitles" hide-actions flat>
<template #item="itemSlot">
<div v-if="stepValue(itemSlot) === 1" class="pa-4" data-testid="wizard-step-1">
<h2 class="text-subtitle-1 font-weight-medium mb-1">Кого рекламируем</h2>
<p class="text-body-2 text-medium-emphasis mb-4">
Покажем рекламу тем, кто недавно оставил заявку выберите, за сколько дней брать контакты.
</p>
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-body-2">За сколько дней брать свежие сделки</span>
<span class="text-body-2 font-weight-medium num">{{ form.audience_days }} дн.</span>
</div>
<v-slider
v-model="form.audience_days"
:min="1"
:max="90"
:step="1"
color="primary"
class="mb-2"
data-testid="audience-days-slider"
/>
<div class="d-flex align-center ga-2 mb-3" data-testid="audience-size-result">
<v-progress-circular v-if="audienceLoading" indeterminate size="18" width="2" color="primary" />
<template v-else-if="audienceSize">
<v-icon
:icon="audienceSize.enough ? 'mdi-check-circle' : 'mdi-alert-circle-outline'"
:color="audienceSize.enough ? 'success' : 'warning'"
size="20"
/>
<span class="text-body-2">Наберётся ~{{ audienceSize.size }} человек</span>
</template>
<span v-else-if="audienceError" class="text-body-2 text-error">{{ audienceError }}</span>
</div>
<v-alert
v-if="audienceSize && !audienceSize.enough"
type="warning"
variant="tonal"
density="comfortable"
icon="mdi-alert-circle-outline"
class="mb-4"
data-testid="audience-hint"
>
{{ audienceSize.hint || 'Маловато для запуска — увеличьте число дней' }}
</v-alert>
<v-switch
v-model="form.use_uploaded_list"
color="primary"
label="Использовать мой загруженный список номеров"
hide-details
data-testid="use-list-switch"
/>
</div>
<div v-else-if="stepValue(itemSlot) === 2" class="pa-4" data-testid="wizard-step-2">
<p class="text-body-2 text-medium-emphasis"> скоро </p>
</div>
<div v-else-if="stepValue(itemSlot) === 3" class="pa-4" data-testid="wizard-step-3">
<p class="text-body-2 text-medium-emphasis"> скоро </p>
</div>
<div v-else-if="stepValue(itemSlot) === 4" class="pa-4" data-testid="wizard-step-4">
<p class="text-body-2 text-medium-emphasis"> скоро </p>
</div>
</template>
<template #default>
<v-divider />
<div class="d-flex justify-space-between pa-4">
<v-btn variant="outlined" :disabled="step === 1" data-testid="wizard-back" @click="goBack">
Назад
</v-btn>
<v-btn
color="primary"
variant="flat"
:disabled="step === stepTitles.length"
data-testid="wizard-next"
@click="goNext"
>
Далее
</v-btn>
</div>
</template>
</v-stepper>
</div>
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>
@@ -2,12 +2,13 @@
/**
* «Яндекс Аудитория» — экран рекламного канала (Часть B2, задача B2-2).
* Вкладка «Мои кампании» (B2-4) — CampaignList.vue. Вкладка «Новая реклама» —
* пока плейсхолдер (мастер создания — следующая задача); api-слой готов
* в resources/js/api/advertising.ts.
* мастер CampaignWizard.vue (B2-5, каркас + шаг 1; шаги 2–4 — заглушки,
* наполнят B2-6/7/8); api-слой готов в resources/js/api/advertising.ts.
*/
import { ref } from 'vue';
import AdWalletHeader from '../../components/advertising/AdWalletHeader.vue';
import CampaignList from '../../components/advertising/CampaignList.vue';
import CampaignWizard from '../../components/advertising/CampaignWizard.vue';
const tab = ref('campaigns');
// id редактируемой кампании — пробрасывается из CampaignList (событие edit),
@@ -42,7 +43,7 @@ function onEdit(id: number): void {
<CampaignList @new="tab = 'new'" @edit="onEdit" />
</v-tabs-window-item>
<v-tabs-window-item value="new">
<p class="text-body-2 text-medium-emphasis" data-testid="tab-new-placeholder"> скоро </p>
<CampaignWizard />
</v-tabs-window-item>
</v-tabs-window>
</div>
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
const { createCampaignMock, fetchAudienceSizeMock } = vi.hoisted(() => ({
createCampaignMock: vi.fn(),
fetchAudienceSizeMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
createCampaign: createCampaignMock,
fetchAudienceSize: fetchAudienceSizeMock,
}));
import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue';
const vuetify = createVuetify();
async function flush(): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
}
const DRAFT_CAMPAIGN = {
id: 99,
name: 'Реклама от 25.07.2026',
status: 'draft' as const,
weekly_budget_rub: '1000.00',
audience_days: 10,
launched_at: null,
};
describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () => {
beforeEach(() => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
});
it('при монтировании создаёт черновик кампании один раз и показывает шаг 1', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(createCampaignMock).toHaveBeenCalledTimes(1);
expect(w.find('[data-testid="wizard"]').exists()).toBe(true);
expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
// Заголовки шагов — строго как в задаче.
const text = w.text();
expect(text).toContain('Кого рекламируем');
expect(text).toContain('Объявления');
expect(text).toContain('Бюджет');
expect(text).toContain('Проверка и запуск');
});
it('пересчёт счётчика зовёт fetchAudienceSize(id, days); ниже 100 показывает подсказку', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({
size: 42,
min: 100,
enough: false,
hint: 'Маловато — увеличьте до 20 дней',
});
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
// Черновик создан с id 99 и дефолтными 10 днями — счётчик уже посчитан один раз.
expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, 10);
const hint = w.find('[data-testid="audience-hint"]');
expect(hint.exists()).toBe(true);
expect(hint.text()).toContain('Маловато');
// Меняем срез дней и зовём пересчёт напрямую (без ожидания реального debounce).
(w.vm as unknown as { form: { audience_days: number } }).form.audience_days = 4;
await (w.vm as unknown as { refreshAudienceSize: () => Promise<void> }).refreshAudienceSize();
await w.vm.$nextTick();
expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, 4);
});
it('достаточно аудитории — зелёная галочка и результат без подсказки', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const result = w.find('[data-testid="audience-size-result"]');
expect(result.text()).toContain('Наберётся ~120');
expect(w.find('[data-testid="audience-hint"]').exists()).toBe(false);
});
it('кнопки «Назад»/«Далее» переключают шаги мастера', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
await w.find('[data-testid="wizard-next"]').trigger('click');
await w.vm.$nextTick();
expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true);
await w.find('[data-testid="wizard-back"]').trigger('click');
await w.vm.$nextTick();
expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
});
});
@@ -8,11 +8,25 @@ vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
// CampaignWizard (B2-5) теперь рендерится во вкладке «Новая реклама» и на
// монтировании создаёт черновик кампании + считает счётчик аудитории —
// мокаем и то, и другое, чтобы не ходить в сеть.
vi.mock('../../resources/js/api/advertising', () => ({
fetchWallet: vi.fn(() =>
Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
),
fetchCampaigns: vi.fn(() => Promise.resolve([])),
createCampaign: vi.fn(() =>
Promise.resolve({
id: 99,
name: 'Реклама от 25.07.2026',
status: 'draft',
weekly_budget_rub: '1000.00',
audience_days: 10,
launched_at: null,
}),
),
fetchAudienceSize: vi.fn(() => Promise.resolve({ size: 120, min: 100, enough: true, hint: null })),
}));
import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';