419 lines
21 KiB
TypeScript
419 lines
21 KiB
TypeScript
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);
|
||
}
|