feat(реклама): поле «Цена за клик» в мастере кампании (T13) + чистка мёртвого блока

Шаг 3 «Бюджет» получил поле цены за клик (дефолт 10.00₽, как на бэке в
CampaignLauncher), значение уходит в PATCH при сохранении бюджета и
префиллится при открытии мастера на правку — иначе правка бюджета у
существующей кампании тихо сбрасывала бы её цену за клик на дефолт.
Сводка шага 4 показывает цену за клик и ссылку первого объявления.
Убран мёртвый блок «Бюджет в день» — поле упразднено ранее, а условие
на него всё ещё висело в шаблоне.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-25 21:07:18 +03:00
parent d6cd122f23
commit 03f202260f
3 changed files with 82 additions and 3 deletions
+1
View File
@@ -34,6 +34,7 @@ export interface Campaign {
*/
use_uploaded_list?: boolean;
daily_budget_rub?: string | null;
click_bid_rub?: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */
@@ -72,6 +72,7 @@ const form = reactive<CampaignCreate & { ads: AdCreative[] }>({
use_uploaded_list: false,
weekly_budget_rub: '1000.00',
daily_budget_rub: null,
click_bid_rub: '10.00',
ads: [],
});
@@ -140,6 +141,7 @@ async function loadForEdit(id: number): Promise<void> {
form.use_uploaded_list = detail.campaign.use_uploaded_list ?? false;
form.weekly_budget_rub = detail.campaign.weekly_budget_rub;
form.daily_budget_rub = detail.campaign.daily_budget_rub ?? null;
form.click_bid_rub = detail.campaign.click_bid_rub ?? '10.00';
form.ads = detail.ads ?? [];
draftId.value = detail.campaign.id;
await refreshAudienceSize();
@@ -200,6 +202,7 @@ async function saveBudgetStep(): Promise<boolean> {
weekly_budget_rub: form.weekly_budget_rub,
audience_days: form.audience_days,
use_uploaded_list: form.use_uploaded_list,
click_bid_rub: form.click_bid_rub,
});
return true;
} catch (e) {
@@ -404,6 +407,19 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budg
data-testid="budget-weekly"
/>
<v-text-field
v-model="form.click_bid_rub"
type="number"
label="Цена за клик"
suffix="₽"
variant="outlined"
density="comfortable"
class="mb-2"
hint="Сколько максимум готовы платить за один переход"
persistent-hint
data-testid="budget-click-bid"
/>
<p v-if="budgetError" class="text-body-2 text-error" data-testid="wizard-budget-error">
{{ budgetError }}
</p>
@@ -430,9 +446,17 @@ defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budg
formatPlain(Number(form.weekly_budget_rub))
}}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="form.daily_budget_rub" prepend-icon="mdi-calendar-today-outline">
<v-list-item-title>Бюджет в день</v-list-item-title>
<v-list-item-subtitle class="num">{{ form.daily_budget_rub }} </v-list-item-subtitle>
<v-list-item prepend-icon="mdi-cursor-default-click-outline">
<v-list-item-title>Цена за клик</v-list-item-title>
<v-list-item-subtitle class="num">{{
formatPlain(Number(form.click_bid_rub))
}}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="form.ads[0]?.href" prepend-icon="mdi-link-variant">
<v-list-item-title>Сайт</v-list-item-title>
<v-list-item-subtitle data-testid="launch-summary-site">{{
form.ads[0]?.href
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
@@ -226,6 +226,44 @@ describe('CampaignWizard.vue (B2-7 — шаг 3 «Бюджет»)', () => {
expect(note.exists()).toBe(true);
expect(note.text()).toContain('340');
});
it('T13 — на шаге 3 есть поле «Цена за клик» с дефолтом 10.00', 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 clickBidInput = w.find('[data-testid="budget-click-bid"] input');
expect(clickBidInput.exists()).toBe(true);
expect((clickBidInput.element as HTMLInputElement).value).toBe('10.00');
});
it('T13 — правка цены за клик и «Далее» на шаге 3 уходит в patchCampaign click_bid_rub', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, click_bid_rub: '15.00' });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
await gotoStep3(w);
const clickBidInput = w.find('[data-testid="budget-click-bid"] input');
await clickBidInput.setValue('15');
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({ click_bid_rub: '15' }));
expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true);
});
});
describe('CampaignWizard.vue (B2-8 — шаг 4 «Проверка и запуск»)', () => {
@@ -330,6 +368,22 @@ describe('CampaignWizard.vue (B2-8 — шаг 4 «Проверка и запус
expect(errorEl.text()).toContain('Аудитория слишком мала для запуска.');
expect(w.emitted('launched')).toBeFalsy();
});
it('T13 — сводка шага 4 показывает цену за клик', async () => {
createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null });
patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN, weekly_budget_rub: '1000.00' });
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
await gotoStep4(w);
const summary = w.find('[data-testid="launch-summary"]');
expect(summary.text()).toContain('Цена за клик');
expect(summary.text()).toContain('10');
});
});
describe('CampaignWizard.vue (B2-9 — правка на ходу, Р30)', () => {