From 00a89992075338c77a067d32fac75c828a69a74f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?=
Date: Mon, 27 Jul 2026 09:12:50 +0300
Subject: [PATCH] =?UTF-8?q?feat(=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0?=
=?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B):=20=D0=BF=D0=BE=D0=BB?=
=?UTF-8?q?=D0=B5=20=C2=AB=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20=D1=81=D0=B0?=
=?UTF-8?q?=D0=B9=D1=82=D0=B0=C2=BB=20=D0=B2=20=D0=BC=D0=B0=D1=81=D1=82?=
=?UTF-8?q?=D0=B5=D1=80=D0=B5=20=D0=BA=D0=B0=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8?=
=?UTF-8?q?=D0=B8=20+=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8?=
=?UTF-8?q?=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Задача 8d. Клиент вписывает в мастере адрес сайта landing_url, куда ведёт
баннер по клику. Поле на шаге «Баннеры», проверка перед переходом дальше
непустой и начинается с http, сохранение через patchCampaign, показ в
сводке шага «Проверка и отправка». Бэкенд store/update валидируют url
до 1024 символов. Так кампания не застрянет на запуске без адреса.
Реклама-модуль 192/192 зелёный на чистой базе, мастер 39/39. Правки
ортогональны деньгам.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../Api/AdvertisingCampaignController.php | 3 +
app/resources/js/api/advertising.ts | 4 ++
.../components/advertising/CampaignWizard.vue | 37 ++++++++++-
.../AdvertisingCampaignEndpointTest.php | 63 +++++++++++++++++++
...advertising-campaign-wizard-pokazy.spec.ts | 51 +++++++++++++++
5 files changed, 157 insertions(+), 1 deletion(-)
diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
index 33976f37..a8776f44 100644
--- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
+++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
@@ -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'],
]);
// Свой список номеров — только в ручном режиме. Итоговый режим = то, что пришло в
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
index 55f2a237..2060e7dd 100644
--- a/app/resources/js/api/advertising.ts
+++ b/app/resources/js/api/advertising.ts
@@ -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). */
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
index 4360b4aa..40ceb19d 100644
--- a/app/resources/js/components/advertising/CampaignWizard.vue
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -80,6 +80,7 @@ const form = reactive({
snapshot_to: null,
run_days: 14,
client_cpm_rub: null,
+ landing_url: null,
});
const draftId = ref(null);
@@ -184,6 +185,7 @@ async function loadForEdit(id: number): Promise {
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 {
}
}
-/** Уходя с шага 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 {
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 }}). Мы ничего не обрезаем.
+
+
+
+ Адрес сайта
+ {{ form.landing_url }}
+
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,
diff --git a/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts b/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts
index 5c362a64..cb4fe549 100644
--- a/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts
+++ b/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts
@@ -169,6 +169,7 @@ async function gotoStep3(w: ReturnType): Promise {
async function gotoStep4(w: ReturnType): Promise {
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 «Проверка и отправка»)', () => {