feat(реклама-фронт): мастер запуска — каркас v-stepper + шаг «Кого рекламируем» с живым счётчиком
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user