feat(реклама показы): поле «адрес сайта» в мастере кампании + валидация
Задача 8d. Клиент вписывает в мастере адрес сайта landing_url, куда ведёт баннер по клику. Поле на шаге «Баннеры», проверка перед переходом дальше непустой и начинается с http, сохранение через patchCampaign, показ в сводке шага «Проверка и отправка». Бэкенд store/update валидируют url до 1024 символов. Так кампания не застрянет на запуске без адреса. Реклама-модуль 192/192 зелёный на чистой базе, мастер 39/39. Правки ортогональны деньгам. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,7 @@ class AdvertisingCampaignController extends Controller
|
||||
'snapshot_to' => ['nullable', 'date', 'after_or_equal:snapshot_from'],
|
||||
'run_days' => ['nullable', 'integer', 'min:1', 'max:365'],
|
||||
'client_cpm_rub' => ['nullable', 'numeric', 'gt:0'],
|
||||
'landing_url' => ['nullable', 'url', 'max:1024'],
|
||||
]);
|
||||
|
||||
$mode = $data['mode'] ?? AdCampaign::MODE_AUTO;
|
||||
@@ -86,6 +87,7 @@ class AdvertisingCampaignController extends Controller
|
||||
'estimated_impressions' => $data['estimated_impressions'] ?? null,
|
||||
'budget_rub' => $data['budget_rub'] ?? null,
|
||||
'client_cpm_rub' => $data['client_cpm_rub'] ?? null,
|
||||
'landing_url' => $data['landing_url'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($campaign, 201);
|
||||
@@ -138,6 +140,7 @@ class AdvertisingCampaignController extends Controller
|
||||
'snapshot_to' => ['sometimes', 'nullable', 'date', 'after_or_equal:snapshot_from'],
|
||||
'run_days' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:365'],
|
||||
'client_cpm_rub' => ['sometimes', 'nullable', 'numeric', 'gt:0'],
|
||||
'landing_url' => ['sometimes', 'nullable', 'url', 'max:1024'],
|
||||
]);
|
||||
|
||||
// Свой список номеров — только в ручном режиме. Итоговый режим = то, что пришло в
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface Campaign {
|
||||
run_days?: number | null;
|
||||
/** Клиентская цена за 1000 показов, ₽ — правится клиентом, дефолт с сервера. */
|
||||
client_cpm_rub?: string | null;
|
||||
/** Адрес сайта, куда ведёт баннер по клику. */
|
||||
landing_url?: string | null;
|
||||
}
|
||||
|
||||
/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */
|
||||
@@ -100,6 +102,8 @@ export interface CampaignCreate {
|
||||
run_days?: number | null;
|
||||
/** Клиентская цена за 1000 показов, ₽ — правится клиентом на шаге 2. */
|
||||
client_cpm_rub?: string | null;
|
||||
/** Адрес сайта, куда ведёт баннер по клику. */
|
||||
landing_url?: string | null;
|
||||
}
|
||||
|
||||
/** Объявление кампании (ответ POST /api/advertising/campaigns/{id}/ads). */
|
||||
|
||||
@@ -80,6 +80,7 @@ const form = reactive<CampaignCreate>({
|
||||
snapshot_to: null,
|
||||
run_days: 14,
|
||||
client_cpm_rub: null,
|
||||
landing_url: null,
|
||||
});
|
||||
|
||||
const draftId = ref<number | null>(null);
|
||||
@@ -184,6 +185,7 @@ async function loadForEdit(id: number): Promise<void> {
|
||||
form.snapshot_to = detail.campaign.snapshot_to ?? null;
|
||||
form.run_days = detail.campaign.run_days ?? 14;
|
||||
form.client_cpm_rub = detail.campaign.client_cpm_rub ?? null;
|
||||
form.landing_url = detail.campaign.landing_url ?? null;
|
||||
draftId.value = detail.campaign.id;
|
||||
await refreshAudienceSize();
|
||||
await loadBanners();
|
||||
@@ -449,8 +451,16 @@ async function approve(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Уходя с шага 3 — баннеры должны быть утверждены, иначе блокируем переход. */
|
||||
/**
|
||||
* Уходя с шага 3 — сперва адрес сайта (баннер должен вести куда-то, иначе запускатор
|
||||
* кампании упадёт непонятной ошибкой), затем баннеры должны быть утверждены.
|
||||
*/
|
||||
function checkBannersStep(): boolean {
|
||||
const url = form.landing_url?.trim() ?? '';
|
||||
if (!url || !url.startsWith('http')) {
|
||||
bannerStepError.value = 'Укажите адрес сайта, куда ведёт реклама (начинается с http…).';
|
||||
return false;
|
||||
}
|
||||
if (!bannersApproved.value) {
|
||||
bannerStepError.value = 'Загрузите хотя бы один баннер и нажмите «Утвердить».';
|
||||
return false;
|
||||
@@ -513,6 +523,16 @@ async function goNext(): Promise<void> {
|
||||
if (step.value === 3) {
|
||||
const ok = checkBannersStep();
|
||||
if (!ok) return;
|
||||
if (draftId.value === null) {
|
||||
bannerStepError.value = 'Черновик кампании ещё не создан.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await patchCampaign(draftId.value, { landing_url: form.landing_url });
|
||||
} catch (e) {
|
||||
bannerStepError.value = extractErrorMessage(e, 'Не удалось сохранить адрес сайта.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
step.value = Math.min(stepTitles.length, step.value + 1);
|
||||
}
|
||||
@@ -833,6 +853,17 @@ defineExpose({
|
||||
размера (JPG, PNG или GIF, до {{ humanMax }}). Мы ничего не обрезаем.
|
||||
</p>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.landing_url"
|
||||
label="Адрес сайта, куда ведёт реклама"
|
||||
hint="Например https://ваш-сайт.ру — сюда попадёт человек, кликнувший по баннеру"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-4"
|
||||
data-testid="landing-url-input"
|
||||
/>
|
||||
|
||||
<v-row dense class="mb-4">
|
||||
<v-col v-for="slot in bannerSet?.slots ?? []" :key="`${slot.width}x${slot.height}`" cols="6" sm="4" md="3">
|
||||
<v-card
|
||||
@@ -995,6 +1026,10 @@ defineExpose({
|
||||
formatPlain(Number(audienceSize?.cost_rub ?? 0))
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-link-variant">
|
||||
<v-list-item-title>Адрес сайта</v-list-item-title>
|
||||
<v-list-item-subtitle data-testid="summary-landing-url">{{ form.landing_url }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-checkbox
|
||||
|
||||
@@ -156,6 +156,69 @@ it('updates a campaign partially', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a campaign with a valid landing_url and returns it', function () {
|
||||
$response = $this->postJson('/api/advertising/campaigns', [
|
||||
'name' => 'Кампания с адресом',
|
||||
'audience_days' => 10,
|
||||
'landing_url' => 'https://liderra.ru/promo',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('landing_url', 'https://liderra.ru/promo');
|
||||
|
||||
$this->assertDatabaseHas('ad_campaigns', [
|
||||
'id' => $response->json('id'),
|
||||
'landing_url' => 'https://liderra.ru/promo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an invalid landing_url with 422 on store', function () {
|
||||
$response = $this->postJson('/api/advertising/campaigns', [
|
||||
'name' => 'Кампания с плохим адресом',
|
||||
'audience_days' => 10,
|
||||
'landing_url' => 'не-адрес',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['landing_url']);
|
||||
});
|
||||
|
||||
it('updates landing_url on an existing campaign', function () {
|
||||
$campaign = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Кампания под адрес',
|
||||
'audience_days' => 10,
|
||||
'weekly_budget_rub' => '1000.00',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
|
||||
'landing_url' => 'https://liderra.ru/promo2',
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('landing_url', 'https://liderra.ru/promo2');
|
||||
|
||||
$this->assertDatabaseHas('ad_campaigns', [
|
||||
'id' => $campaign->id,
|
||||
'landing_url' => 'https://liderra.ru/promo2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an invalid landing_url with 422 on update', function () {
|
||||
$campaign = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'name' => 'Кампания под плохой адрес',
|
||||
'audience_days' => 10,
|
||||
'weekly_budget_rub' => '1000.00',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [
|
||||
'landing_url' => 'не-адрес',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['landing_url']);
|
||||
});
|
||||
|
||||
it('reports a small audience as not enough with a hint', function () {
|
||||
$campaign = AdCampaign::create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
|
||||
@@ -169,6 +169,7 @@ async function gotoStep3(w: ReturnType<typeof mount>): Promise<void> {
|
||||
async function gotoStep4(w: ReturnType<typeof mount>): Promise<void> {
|
||||
await gotoStep3(w);
|
||||
(w.vm as unknown as { bannersApprovedAt: string | null }).bannersApprovedAt = '2026-07-26T00:00:00Z';
|
||||
(w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/promo';
|
||||
await w.vm.$nextTick();
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await flush();
|
||||
@@ -417,6 +418,56 @@ describe('CampaignWizard.vue (Ч.5b3c — показы, шаг 3 «Баннер
|
||||
const errorText = w.find('[data-testid="wizard-step-3"]').text();
|
||||
expect(errorText).toContain('150 КБ');
|
||||
});
|
||||
|
||||
it('пустой адрес сайта блокирует переход на шаг 4 и показывает подсказку про http', async () => {
|
||||
fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED);
|
||||
approveBannersMock.mockResolvedValue({ approved_at: '2026-07-26T00:00:00Z' });
|
||||
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep3(w);
|
||||
|
||||
await w.find('[data-testid="banner-approve-btn"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true);
|
||||
const err = w.find('[data-testid="banner-step-error"]');
|
||||
expect(err.exists()).toBe(true);
|
||||
expect(err.text()).toContain('адрес сайта');
|
||||
// gotoStep3 уже сохранил шаги 1/2 через patchCampaign (2 вызова) — блокировка на шаге 3
|
||||
// не должна добавить третий (с landing_url).
|
||||
expect(patchCampaignMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('заполненный адрес сайта сохраняется через patchCampaign при переходе на шаг 4', async () => {
|
||||
fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED);
|
||||
approveBannersMock.mockResolvedValue({ approved_at: '2026-07-26T00:00:00Z' });
|
||||
|
||||
const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
await gotoStep3(w);
|
||||
|
||||
await w.find('[data-testid="banner-approve-btn"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
(w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/promo';
|
||||
await w.vm.$nextTick();
|
||||
|
||||
await w.find('[data-testid="wizard-next"]').trigger('click');
|
||||
await flush();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
expect(patchCampaignMock).toHaveBeenCalledWith(99, { landing_url: 'https://liderra.ru/promo' });
|
||||
expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CampaignWizard.vue (Ч.5b — показы, шаг 4 «Проверка и отправка»)', () => {
|
||||
|
||||
Reference in New Issue
Block a user