feat(реклама-фронт): шаг «Бюджет» (недельный/дневной, только правда без прогнозов)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-25 01:46:43 +03:00
parent 34a57d0fcf
commit 11bc282729
2 changed files with 168 additions and 11 deletions
@@ -21,6 +21,7 @@
import { reactive, ref, computed, watch, onMounted } from 'vue';
import {
createCampaign,
patchCampaign,
fetchAudienceSize,
type CampaignCreate,
type AudienceSize,
@@ -118,11 +119,50 @@ function removeAd(index: number): void {
form.ads.splice(index, 1);
}
const budgetError = ref<string | null>(null);
const budgetSaving = ref(false);
function goBack(): void {
step.value = Math.max(1, step.value - 1);
}
function goNext(): void {
/**
* Уходя с шага 3 «Бюджет» — сохраняем черновик (Р37: фронт денег не считает,
* передаёт строки как ввёл пользователь). Пустой недельный бюджет блокирует
* переход; ошибка PATCH — тоже блокирует, введённое не теряется (форма не чистится).
*/
async function saveBudgetStep(): Promise<boolean> {
if (!form.weekly_budget_rub || form.weekly_budget_rub.trim() === '') {
budgetError.value = 'Укажите бюджет в неделю.';
return false;
}
if (draftId.value === null) {
budgetError.value = 'Черновик кампании ещё не создан.';
return false;
}
budgetError.value = null;
budgetSaving.value = true;
try {
await patchCampaign(draftId.value, {
weekly_budget_rub: form.weekly_budget_rub,
daily_budget_rub: form.daily_budget_rub || null,
audience_days: form.audience_days,
use_uploaded_list: form.use_uploaded_list,
});
return true;
} catch (e) {
budgetError.value = extractErrorMessage(e, 'Не удалось сохранить бюджет.');
return false;
} finally {
budgetSaving.value = false;
}
}
async function goNext(): Promise<void> {
if (step.value === 3) {
const saved = await saveBudgetStep();
if (!saved) return;
}
step.value = Math.min(stepTitles.length, step.value + 1);
}
@@ -138,7 +178,7 @@ onMounted(() => {
void createDraft();
});
defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError });
</script>
<template>
@@ -239,7 +279,40 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
</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>
<h2 class="text-subtitle-1 font-weight-medium mb-1">Бюджет</h2>
<p class="text-body-2 text-medium-emphasis mb-4">
Столько максимум спишется за неделю. Платите только за переходы по объявлению.
</p>
<p v-if="audienceSize" class="text-body-2 mb-4" data-testid="budget-audience-note">
Ваша аудитория: ~{{ audienceSize.size }} человек
</p>
<v-text-field
v-model="form.weekly_budget_rub"
type="number"
label="Бюджет в неделю"
suffix="₽"
variant="outlined"
density="comfortable"
class="mb-2"
data-testid="budget-weekly"
/>
<v-text-field
v-model="form.daily_budget_rub"
type="number"
label="Бюджет в день (необязательно)"
suffix="₽"
variant="outlined"
density="comfortable"
class="mb-2"
data-testid="budget-daily"
/>
<p v-if="budgetError" class="text-body-2 text-error" data-testid="wizard-budget-error">
{{ budgetError }}
</p>
</div>
<div v-else-if="stepValue(itemSlot) === 4" class="pa-4" data-testid="wizard-step-4">
@@ -257,6 +330,7 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize });
color="primary"
variant="flat"
:disabled="step === stepTitles.length"
:loading="budgetSaving"
data-testid="wizard-next"
@click="goNext"
>
@@ -2,18 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
const { createCampaignMock, fetchAudienceSizeMock, addCreativeMock, uploadCreativeImageMock } = vi.hoisted(() => ({
createCampaignMock: vi.fn(),
fetchAudienceSizeMock: vi.fn(),
// Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
// который смонтирован внутри мастера.
addCreativeMock: vi.fn(),
uploadCreativeImageMock: vi.fn(),
}));
const { createCampaignMock, fetchAudienceSizeMock, patchCampaignMock, addCreativeMock, uploadCreativeImageMock } =
vi.hoisted(() => ({
createCampaignMock: vi.fn(),
fetchAudienceSizeMock: vi.fn(),
patchCampaignMock: vi.fn(),
// Не используются напрямую этими тестами — нужны CreativeForm.vue (шаг 2, B2-6),
// который смонтирован внутри мастера.
addCreativeMock: vi.fn(),
uploadCreativeImageMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
createCampaign: createCampaignMock,
fetchAudienceSize: fetchAudienceSizeMock,
patchCampaign: patchCampaignMock,
addCreative: addCreativeMock,
uploadCreativeImage: uploadCreativeImageMock,
}));
@@ -39,6 +42,7 @@ describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () =
beforeEach(() => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
});
it('при монтировании создаёт черновик кампании один раз и показывает шаг 1', async () => {
@@ -120,3 +124,82 @@ describe('CampaignWizard.vue (B2-5 — каркас v-stepper + шаг 1)', () =
expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true);
});
});
describe('CampaignWizard.vue (B2-7 — шаг 3 «Бюджет»)', () => {
beforeEach(() => {
createCampaignMock.mockReset();
fetchAudienceSizeMock.mockReset();
patchCampaignMock.mockReset();
});
/** Переходим на шаг 3 кликами «Далее» — так же, как реальный пользователь. */
async function gotoStep3(w: ReturnType<typeof mount>): Promise<void> {
await w.find('[data-testid="wizard-next"]').trigger('click');
await w.vm.$nextTick();
await w.find('[data-testid="wizard-next"]').trigger('click');
await w.vm.$nextTick();
expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true);
}
it('ввод недельного бюджета и «Далее» на шаге 3 сохраняет черновик через patchCampaign', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, weekly_budget_rub: '2500.00' });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
await gotoStep3(w);
const weeklyInput = w.find('[data-testid="budget-weekly"] input');
await weeklyInput.setValue('2500');
await w.vm.$nextTick();
await w.find('[data-testid="wizard-next"]').trigger('click');
await flush();
await w.vm.$nextTick();
expect(patchCampaignMock).toHaveBeenCalledWith(99, expect.objectContaining({ weekly_budget_rub: '2500' }));
// Переход на шаг 4 подтверждает, что сохранение не упало.
expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true);
});
it('пустой недельный бюджет блокирует «Далее» — patchCampaign не зовётся', 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();
await gotoStep3(w);
const weeklyInput = w.find('[data-testid="budget-weekly"] input');
await weeklyInput.setValue('');
await w.vm.$nextTick();
await w.find('[data-testid="wizard-next"]').trigger('click');
await flush();
await w.vm.$nextTick();
expect(patchCampaignMock).not.toHaveBeenCalled();
expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true);
expect(w.find('[data-testid="wizard-budget-error"]').exists()).toBe(true);
});
it('budget-audience-note показывает размер аудитории из счётчика мастера', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 340, min: 100, enough: true, hint: null });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
await gotoStep3(w);
const note = w.find('[data-testid="budget-audience-note"]');
expect(note.exists()).toBe(true);
expect(note.text()).toContain('340');
});
});