Files
portal/app/resources/js/api/advertising.ts
T

419 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-модуль рекламного модуля «Яндекс Аудитория» (Часть B2, Task 1).
*
* Эндпоинты под [auth:sanctum, tenant], префикс /api/advertising (см. Часть B1 —
* docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 12,
* routes/web.php). GET'ы не требуют CSRF-cookie, мутации (POST/PATCH) — требуют.
*/
/** Статус кампании Директа (см. App\Models\AdCampaign константы STATUS_*). */
export type CampaignStatus =
| 'draft'
| 'queued'
// Промежуточный: запуск идёт прямо сейчас. Держится секунды и защищает от двойного
// нажатия «запустить» (иначе в кабинете Яндекса завелись бы две одинаковые кампании).
| 'launching'
| 'pending_moderation'
| 'running'
| 'paused'
| 'rejected'
| 'stopped_no_funds'
| 'completed';
/** Ответ GET /api/advertising/wallet — статус рекламного кошелька тенанта. */
export interface AdWalletStatus {
solvent: boolean;
balance_rub: string;
frozen_rub: string;
free_rub: string;
}
/**
* Кампания «за показы» (строка из GET /api/advertising/campaigns, а также
* POST/PATCH-ответ). `yandex_cost_rub` (наша маржа) сюда НИКОГДА не
* добавляется — модель прячет поле в сериализации (Ч.5b Task 1, `$hidden`),
* клиент его физически не видит.
*/
export interface Campaign {
id: number;
name: string;
status: CampaignStatus;
audience_days: number;
launched_at: string | null;
/** Частота показов на человека за период (Ч.5b). */
frequency?: number;
/** Сколько показов ожидаем — смета с сервера (Ч.5b, из audience-size). */
estimated_impressions?: number;
/** Итоговый бюджет показов клиента — та же смета, что и estimated_impressions. */
budget_rub?: string;
/** Сколько показов фактически откручено на данный момент (Ч.6, CampaignImpressionCharger). */
delivered_impressions?: number;
/**
* Приходят и в POST/PATCH-ответе, и в GET .../{id} (detail, controller::show
* отдаёт модель целиком) — но НЕ в списке GET /campaigns (index явно
* выбирает узкий набор колонок), поэтому опциональны.
*/
use_uploaded_list?: boolean;
/** Legacy-поле модели клик-кампаний — оставлено опциональным, новый мастер его не шлёт/не читает. */
weekly_budget_rub?: string;
/** Пояснение модератора Яндекса — подпись под ярлыком «Отклонено». Полный текст живёт в переписке. */
moderation_reason?: string | null;
/** Режим сбора аудитории — 'auto' (крутится постоянно, окно дней) или 'manual' (разовый снимок + свои номера). */
mode?: 'auto' | 'manual';
/** Начало периода снимка контактов (режим manual). */
snapshot_from?: string | null;
/** Конец периода снимка контактов (режим manual, >= snapshot_from). */
snapshot_to?: string | null;
/** Сколько дней показывать рекламу после снимка (режим manual). */
run_days?: number | null;
/** Клиентская цена за 1000 показов, ₽ — правится клиентом, дефолт с сервера. */
client_cpm_rub?: string | null;
/** Адрес сайта, куда ведёт баннер по клику. */
landing_url?: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */
export interface AudienceSize {
size: number;
min: number;
enough: boolean;
hint: string | null;
frequency?: number;
impressions?: number;
cpm_rub?: string;
cost_rub?: string;
}
/** Тело POST/PATCH /api/advertising/campaigns (создание/правка черновика кампании «за показы»). */
export interface CampaignCreate {
name: string;
audience_days: number;
use_uploaded_list: boolean;
/** Частота показов на человека (Ч.5b, шаг «Как часто показывать»). */
frequency?: number;
frequency_period_days?: number;
/** Смета показов/бюджета — берём из ответа audience-size, фронт не считает сам (Р37). */
estimated_impressions?: number;
budget_rub?: string;
/** Режим сбора аудитории — 'auto' (окно дней, крутится постоянно) или 'manual' (снимок дат + свои номера). */
mode?: 'auto' | 'manual';
/** Начало периода снимка контактов (режим manual, YYYY-MM-DD). */
snapshot_from?: string | null;
/** Конец периода снимка контактов (режим manual, YYYY-MM-DD, >= snapshot_from). */
snapshot_to?: string | null;
/** Сколько дней показывать рекламу после снимка (режим manual, 1..365). */
run_days?: number | null;
/** Клиентская цена за 1000 показов, ₽ — правится клиентом на шаге 2. */
client_cpm_rub?: string | null;
/** Адрес сайта, куда ведёт баннер по клику. */
landing_url?: string | null;
}
/** Объявление кампании (ответ POST /api/advertising/campaigns/{id}/ads). */
export interface AdCreative {
id: number;
campaign_id: number;
title: string;
text: string;
href: string;
title2: string | null;
moderation_status: string;
/** Причина отказа модерации Яндекса — приходит вместе с moderation_status='rejected'. */
moderation_reason?: string | null;
image_normal_hash?: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id} — детали кампании + объявления + расход. */
export interface CampaignDetail {
campaign: Campaign;
ads: AdCreative[];
spent_rub: string;
}
/** GET /api/advertising/wallet — статус рекламного кошелька (баланс/заморожено/свободно). */
export async function fetchWallet(): Promise<AdWalletStatus> {
const { data } = await apiClient.get<AdWalletStatus>('/api/advertising/wallet');
return data;
}
/** GET /api/advertising/campaigns — список кампаний тенанта. */
export async function fetchCampaigns(): Promise<Campaign[]> {
const { data } = await apiClient.get<{ data: Campaign[] }>('/api/advertising/campaigns');
return data.data ?? [];
}
/** GET /api/advertising/campaigns/{id} — детали кампании (объявления + расход). */
export async function fetchCampaign(id: number): Promise<CampaignDetail> {
const { data } = await apiClient.get<CampaignDetail>(`/api/advertising/campaigns/${id}`);
return data;
}
/** POST /api/advertising/campaigns — создать черновик кампании. */
export async function createCampaign(payload: CampaignCreate): Promise<Campaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<Campaign>('/api/advertising/campaigns', payload);
return data;
}
/** PATCH /api/advertising/campaigns/{id} — частичная правка кампании. */
export async function patchCampaign(id: number, payload: Partial<CampaignCreate>): Promise<Campaign> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<Campaign>(`/api/advertising/campaigns/${id}`, payload);
return data;
}
/** Параметры GET /api/advertising/campaigns/{id}/audience-size — оба режима сбора аудитории + клиентская цена. */
export interface AudienceSizeParams {
/** Окно дней (режим auto). */
days?: number;
/** Частота показов на человека — нужна для сметы показов/бюджета на шаге 2. */
frequency?: number;
/** Режим сбора аудитории — определяет, что сервер трактует days vs from/to. */
mode?: 'auto' | 'manual' | string;
/** Начало периода снимка (режим manual). */
from?: string | null;
/** Конец периода снимка (режим manual). */
to?: string | null;
/** Клиентская цена за 1000 показов — если не задана, сервер применяет дефолт. */
cpm?: string | null;
}
/** GET /api/advertising/campaigns/{id}/audience-size — живой счётчик аудитории (+ показы/цена/итого при заданных параметрах). */
export async function fetchAudienceSize(id: number, params: AudienceSizeParams = {}): Promise<AudienceSize> {
const query: Record<string, string | number> = {};
if (params.days !== undefined) query.days = params.days;
if (params.frequency !== undefined) query.frequency = params.frequency;
if (params.mode !== undefined) query.mode = params.mode;
if (params.from) query.from = params.from;
if (params.to) query.to = params.to;
if (params.cpm !== undefined && params.cpm !== null && params.cpm !== '') query.cpm = params.cpm;
const { data } = await apiClient.get<AudienceSize>(`/api/advertising/campaigns/${id}/audience-size`, { params: query });
return data;
}
/**
* Один слот баннера кампании — ровно один из 15 канонических размеров
* (BannerSizes::all()). Клиент грузит СВОЙ готовый файл на КАЖДЫЙ размер —
* автогенерации из одной картинки больше нет. `slots` в GET .../banners
* всегда содержит все 15 размеров (загруженные и пустые вперемешку).
*/
export interface BannerSlot {
width: number;
height: number;
uploaded: boolean;
banner_id: number | null;
bytes: number | null;
included: boolean;
preview_url: string | null;
}
/** Ответ GET /api/advertising/campaigns/{id}/banners. */
export interface BannerSet {
approved_at: string | null;
max_bytes: number;
formats: string[];
slots: BannerSlot[];
}
/** GET набор слотов баннеров кампании (все 15 размеров + момент утверждения). */
export async function fetchBanners(id: number): Promise<BannerSet> {
const { data } = await apiClient.get<BannerSet>(`/api/advertising/campaigns/${id}/banners`);
return data;
}
/** POST /api/advertising/campaigns/{id}/banners — загрузить готовый файл на конкретный размер (multipart width/height/file). */
export async function uploadBanner(id: number, width: number, height: number, file: File): Promise<{ slot: BannerSlot }> {
await ensureCsrfCookie();
const form = new FormData();
form.append('width', String(width));
form.append('height', String(height));
form.append('file', file);
const { data } = await apiClient.post<{ slot: BannerSlot }>(`/api/advertising/campaigns/${id}/banners`, form);
return data;
}
/** PATCH /api/advertising/campaigns/{id}/banners/{bannerId} — включить/выключить размер из показа. */
export async function toggleBannerIncluded(id: number, bannerId: number, included: boolean): Promise<{ slot: BannerSlot }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ slot: BannerSlot }>(`/api/advertising/campaigns/${id}/banners/${bannerId}`, { included });
return data;
}
/** DELETE /api/advertising/campaigns/{id}/banners/{bannerId} — удалить загруженный баннер размера. */
export async function deleteBanner(id: number, bannerId: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/advertising/campaigns/${id}/banners/${bannerId}`);
}
/** POST утвердить набор баннеров. */
export async function approveBanners(id: number): Promise<{ approved_at: string | null }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ approved_at: string | null }>(`/api/advertising/campaigns/${id}/banners/approve`);
return data;
}
/** POST /api/advertising/campaigns/{id}/launch — запустить кампанию (модерация Яндекса). */
export async function launchCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`);
return data;
}
/**
* POST /api/advertising/campaigns/{id}/submit — отправить готовую кампанию «за показы» на запуск.
* Директа сейчас нет (заявка на доступ на рассмотрении, Ч.5b) — переводит кампанию в статус
* `queued` («готова к запуску, ждёт оператора»), реальный запуск в Директ — Часть 4.
*/
export async function submitCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/submit`);
return data;
}
/** POST /api/advertising/campaigns/{id}/pause — поставить кампанию на паузу. */
export async function pauseCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/pause`);
return data;
}
/** POST /api/advertising/campaigns/{id}/resume — возобновить кампанию с паузы. */
export async function resumeCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/resume`);
return data;
}
/**
* POST /api/advertising/campaigns/{id}/revive — вернуть отклонённую кампанию в черновик,
* чтобы клиент переделал картинки и нажал обычное «Запустить». Второго пути запуска нет.
*/
export async function reviveCampaign(id: number): Promise<{ status: CampaignStatus }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/revive`);
return data;
}
/** POST /api/advertising/campaigns/{id}/ads — добавить объявление (текстовый креатив). */
export async function addCreative(
id: number,
payload: { title: string; text: string; href: string; title2?: string },
): Promise<AdCreative> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdCreative>(`/api/advertising/campaigns/${id}/ads`, payload);
return data;
}
/** POST /api/advertising/campaigns/{id}/ads/{adId}/image — загрузить картинку объявления. */
export async function uploadCreativeImage(id: number, adId: number, file: File): Promise<{ hash: string }> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
const { data } = await apiClient.post<{ hash: string }>(`/api/advertising/campaigns/${id}/ads/${adId}/image`, form);
return data;
}
/** DELETE /api/advertising/campaigns/{id} — удалить ЧЕРНОВИК кампании своего тенанта (409 если не draft, 404 чужой). */
export async function deleteCampaign(id: number): Promise<void> {
await ensureCsrfCookie();
await apiClient.delete(`/api/advertising/campaigns/${id}`);
}
/** Ответ POST /api/advertising/campaigns/{id}/phones — сколько номеров распознано / отброшено. */
export interface UploadPhonesResult {
recognized: number;
skipped: number;
}
/**
* POST /api/advertising/campaigns/{id}/phones — загрузить «мой список номеров» кампании
* (файл csv/txt и/или текст, оба необязательны по отдельности — но хотя бы один нужен серверу).
*/
export async function uploadCampaignPhones(
id: number,
payload: { file?: File | null; text?: string },
): Promise<UploadPhonesResult> {
await ensureCsrfCookie();
const form = new FormData();
if (payload.file) form.append('file', payload.file);
if (payload.text && payload.text.trim() !== '') form.append('text', payload.text);
const { data } = await apiClient.post<UploadPhonesResult>(`/api/advertising/campaigns/${id}/phones`, form);
return data;
}
/**
* Счёт на пополнение РЕКЛАМНОГО кошелька (POST /api/billing/invoices, credit_target=advertising).
* Тот же эндпоинт, что и обычный счёт за лиды (api/billing.ts::createInvoice) —
* InvoicePaymentService зачисляет ad_wallets вместо tenants.balance_rub (см.
* app/Http/Controllers/Api/InvoiceController.php::store, min:100/max:1000000).
*/
export interface AdvertisingInvoice {
id: number;
invoice_number: string;
amount_total: string;
pdf_url: string;
}
/** POST /api/billing/invoices с credit_target='advertising' — счёт по реквизитам для пополнения рекламного кошелька. */
export async function createAdvertisingInvoice(amountRub: number): Promise<AdvertisingInvoice> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ invoice: AdvertisingInvoice }>('/api/billing/invoices', {
amount_rub: amountRub,
credit_target: 'advertising',
});
return data.invoice;
}
/**
* Результат POST /api/billing/topup с credit_target='advertising' — две формы (зеркалит
* api/billing.ts::TopupResult / BillingController::topup):
* • реальный шлюз (флаг billing_yookassa_enabled ВКЛ): confirmation_url — редирект на оплату
* ЮKassa, кошелёк зачислится позже по webhook;
* • заглушка (флаг ВЫКЛ): ok:true — AdWalletService зачисляет рекламный кошелёк мгновенно.
*/
export interface AdvertisingCardTopupResult {
confirmation_url?: string;
ok?: boolean;
}
/** POST /api/billing/topup с credit_target='advertising' — оплата картой рекламного кошелька. */
export async function topupAdvertisingByCard(amountRub: string | number): Promise<AdvertisingCardTopupResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<AdvertisingCardTopupResult>('/api/billing/topup', {
amount_rub: amountRub,
credit_target: 'advertising',
});
return data;
}
/** Сообщение ленты кампании — окно передачи между Яндексом и клиентом. */
export interface CampaignMessage {
id: number;
author: 'yandex' | 'client' | 'system';
banner_id: number | null;
body: string;
file_name: string | null;
file_size: number | null;
created_at: string | null;
}
/** GET /api/advertising/campaigns/{id}/messages — лента сообщений кампании. */
export async function fetchCampaignMessages(id: number): Promise<CampaignMessage[]> {
const { data } = await apiClient.get<{ messages: CampaignMessage[] }>(`/api/advertising/campaigns/${id}/messages`);
return data.messages;
}
/**
* POST /api/advertising/campaigns/{id}/messages — ответ клиента, можно с файлом.
* Мутация, поэтому сначала CSRF-cookie — как у всех POST'ов этого модуля.
*/
export async function sendCampaignMessage(id: number, body: string, file: File | null): Promise<void> {
await ensureCsrfCookie();
const form = new FormData();
if (body !== '') form.append('body', body);
if (file !== null) form.append('file', file);
await apiClient.post(`/api/advertising/campaigns/${id}/messages`, form);
}