feat(реклама-фронт): шаг «Бюджет» (недельный/дневной, только правда без прогнозов)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user