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 «Проверка и отправка»)', () => {