76cb44b91d
Замечание владельца: «мастер шагов вместо простыни» и «живой счётчик охвата вместо кнопки Рассчитать». Форма была одна длинная, охват узнавался только по нажатию кнопки. Экран: - мастер из четырёх шагов: Кому показываем → Объявление → Деньги → Проверка; - живой счётчик охвата на первом шаге, пересчитывается сам при смене источника людей, периода или списка номеров (с задержкой, чтобы не дёргать сервер на каждую букву); - кнопки «Рассчитать» больше нет: «Запустить» создаёт кампанию, прикладывает картинку и отправляет в кабинет одним нажатием; - на шаге «Деньги» рядом смета и остаток баланса — нехватка денег видна ДО запуска, а не отказом после; - сводка на последнем шаге повторяет всё выбранное. Сервер — новая ручка POST /api/telegram/campaigns/estimate: 🔴 Живой счётчик обязан считать на каждое изменение поля, а охват до сих пор возвращала только POST /campaigns — и она СОЗДАЁТ черновик в базе. Через неё счётчик наплодил бы десятки кампаний-призраков в списке клиента. Новая ручка только читает: ни записи, ни денег, ни робота. На это стоит отдельный тест. Ручка считает ТЕМ ЖЕ кодом, что и создание кампании (общее тело выборки из сделок вынесено на оба входа TelegramAudienceService). Отдельный тест сверяет два ответа: разойдись они — клиент видел бы на экране одно число, а платил по другому. 🔴 Запуск запрещён, когда людей меньше 367 — правило площадки, не наше. Тот же предохранитель стоит на сервере (AudienceGateTest); на экране он объясняет заранее, вместо отказа после оплаты. Новых прав в базе ручка не требует: читает строго то же, что уже читает создание кампании, и не пишет никуда. Тесты: +EstimateApiTest (11 на PHP), +telegram-master-shagov (27 на экранах). 14 прежних проверок формы переехали в набор мастера вместе с кодом — сперва переписаны на новом месте и там проверены, потом убраны со старого. Устарели по смыслу только две («Рассчитать создаёт черновик», «правка сбрасывает смету»): считать вручную больше нечего, устареть расчёту негде. Замер: экраны 240 файлов, 1829 тестов, 0 падений (было 239/1813); телеграм- модуль на PHP 292 теста, 0 падений (было 281); статанализ 0 (базовая линия пересобрана — добавлен только новый Pest-файл, удалений нет); типы 6 чужих ошибок вместо 7; pint чист. На боевой НЕ выкачено. Глазами не принято — приёмка в пачке 5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
219 lines
11 KiB
TypeScript
219 lines
11 KiB
TypeScript
import { apiClient, ensureCsrfCookie } from './client';
|
||
|
||
/**
|
||
* API-модуль клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.2).
|
||
*
|
||
* Эндпоинты под [auth:sanctum, tenant], префикс /api/telegram. GET'ы не требуют
|
||
* CSRF-cookie, мутации (POST) — требуют.
|
||
*
|
||
* 🔴 Отличие от СМС-близнеца: создание (create) и запуск (launch) РАЗДЕЛЕНЫ.
|
||
* create возвращает ЧЕРНОВИК со сметой и числом кандидатов; launch переводит его
|
||
* в очередь и запускает робота кабинета МТС (в песочнице — только черновик).
|
||
*/
|
||
|
||
/** Способ выбора аудитории: свежие сделки за период / своя база / свой список. */
|
||
export type TelegramAudienceKind = 'deals' | 'base' | 'list';
|
||
|
||
/** Кампания клиентской Telegram-рекламы (строка списка, POST-ответ и часть GET .../{id}). */
|
||
export interface TelegramCampaign {
|
||
id: number;
|
||
status: string;
|
||
status_reason?: string | null;
|
||
ad_text: string;
|
||
ad_link: string;
|
||
media_path: string | null;
|
||
/** Документ модератору, приложённый при пересдаче отклонённой кампании (задача 3.6). */
|
||
moderator_file_path?: string | null;
|
||
ord_category: string;
|
||
budget_cap_rub: string;
|
||
audience_kind: TelegramAudienceKind;
|
||
audience_params: Record<string, unknown> | null;
|
||
planned_count: number;
|
||
matched_count: number | null;
|
||
estimated_cost_rub: string;
|
||
actual_cost_rub: string | null;
|
||
/** null = авто-кампания (накопитель), иначе id создавшего менеджера. */
|
||
created_by?: number | null;
|
||
created_at?: string;
|
||
}
|
||
|
||
/** Ответ GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
|
||
export interface TelegramIndex {
|
||
campaigns: TelegramCampaign[];
|
||
sandbox: boolean;
|
||
balance_rub: string;
|
||
frozen_rub: string;
|
||
}
|
||
|
||
/** Тело POST /api/telegram/campaigns — создание черновика кампании. */
|
||
export interface TelegramCreatePayload {
|
||
ad_text: string;
|
||
ad_link: string;
|
||
audience_kind: TelegramAudienceKind;
|
||
budget_cap_rub: string;
|
||
audience_days?: number;
|
||
ord_category?: string;
|
||
phones?: string[];
|
||
/** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */
|
||
ad_headline?: string;
|
||
}
|
||
|
||
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
|
||
export async function fetchTelegram(): Promise<TelegramIndex> {
|
||
const { data } = await apiClient.get<TelegramIndex>('/api/telegram/campaigns');
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/telegram/campaigns — создать ЧЕРНОВИК кампании (деньги/робот не трогаются). */
|
||
export async function createTelegram(payload: TelegramCreatePayload): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<TelegramCampaign>('/api/telegram/campaigns', payload);
|
||
return data;
|
||
}
|
||
|
||
/** Ответ оценки охвата: сколько людей и во сколько обойдётся, плюс порог площадки. */
|
||
export interface TelegramOcenka {
|
||
planned_count: number;
|
||
estimated_cost_rub: string;
|
||
/** Минимум кандидатов у МТС Маркетолог — правило площадки, не наше. */
|
||
min_count: number;
|
||
enough: boolean;
|
||
}
|
||
|
||
/** Что спрашиваем у оценки — только про аудиторию, объявление на охват не влияет. */
|
||
export interface TelegramOcenkaPayload {
|
||
audience_kind: TelegramAudienceKind;
|
||
audience_days?: number;
|
||
phones?: string[];
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/campaigns/estimate — сколько людей увидит рекламу и во сколько
|
||
* это обойдётся, БЕЗ создания кампании.
|
||
*
|
||
* 🔴 Не путать с `createTelegram`: та тоже возвращает охват, но СОЗДАЁТ черновик в базе.
|
||
* Живой счётчик дёргается на каждое изменение поля, и через неё он засорил бы клиенту
|
||
* список кампаний десятками призраков.
|
||
*/
|
||
export async function estimateTelegram(payload: TelegramOcenkaPayload): Promise<TelegramOcenka> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<TelegramOcenka>('/api/telegram/campaigns/estimate', payload);
|
||
return data;
|
||
}
|
||
|
||
/** GET /api/telegram/campaigns/{id} — детали кампании. */
|
||
export async function fetchTelegramCampaign(id: number): Promise<{ campaign: TelegramCampaign }> {
|
||
const { data } = await apiClient.get<{ campaign: TelegramCampaign }>(`/api/telegram/campaigns/${id}`);
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/telegram/campaigns/{id}/launch — запустить черновик (draft → queued + робот). */
|
||
export async function launchTelegram(id: number): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/launch`);
|
||
return data;
|
||
}
|
||
|
||
/** Тело POST /api/telegram/campaigns/{id}/resubmit — правки + опц. документ модератору. */
|
||
export interface TelegramResubmitPayload {
|
||
ad_text: string;
|
||
ad_link: string;
|
||
ord_category?: string;
|
||
/** Лицензия/договор для модератора МТС (png/jpg/pdf ≤10 МБ). */
|
||
moderator_file?: File | null;
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/campaigns/{id}/resubmit — пересдать ОТКЛОНЁННУЮ кампанию
|
||
* (rejected → queued). Отправляем multipart, т.к. можно приложить документ модератору.
|
||
*/
|
||
export async function resubmitTelegram(id: number, payload: TelegramResubmitPayload): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('ad_text', payload.ad_text);
|
||
form.append('ad_link', payload.ad_link);
|
||
if (payload.ord_category) {
|
||
form.append('ord_category', payload.ord_category);
|
||
}
|
||
if (payload.moderator_file) {
|
||
form.append('moderator_file', payload.moderator_file);
|
||
}
|
||
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/resubmit`, form);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Что кабинет МТС принимает в поле медиа (снято глазами 02.08.2026). Список идёт
|
||
* в атрибут accept у полей загрузки — чтобы клиенту в проводнике не показывали
|
||
* заведомо негодные файлы. Настоящая проверка — на сервере (правило MtsMedia).
|
||
*/
|
||
export const MEDIA_ACCEPT =
|
||
'image/jpeg,image/png,video/mp4,video/webm,video/quicktime,video/x-matroska,video/mpeg,video/x-m4v,video/x-ms-wmv';
|
||
|
||
/** Требования МТС к медиа человеческим языком — подсказка под полем загрузки. */
|
||
export const MEDIA_TREBOVANIYA =
|
||
'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880. Видео: до 20 МБ, 3–55 секунд, от 640×360.';
|
||
|
||
/**
|
||
* Показ с медиа дороже обычного — цены кабинета МТС на 02.08.2026. Клиент должен
|
||
* увидеть это ДО загрузки файла, а не после списания денег.
|
||
*/
|
||
export const MEDIA_CENA = 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.';
|
||
|
||
/**
|
||
* POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ
|
||
* объявления. Требования — см. MEDIA_TREBOVANIYA. Робот отдаёт файл кабинету МТС
|
||
* при запуске.
|
||
*/
|
||
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('media', file);
|
||
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/media`, form);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Правило авто-рекламы Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент
|
||
* из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит.
|
||
*/
|
||
export interface TelegramAutoRule {
|
||
enabled: boolean;
|
||
ad_text: string;
|
||
ad_link: string;
|
||
/** Заголовок объявления — МТС требует его, когда реклама ведёт на САЙТ (до 40 знаков). */
|
||
ad_headline: string;
|
||
/** Картинка/видео объявления; грузится отдельно (uploadAutoRuleMedia), в PUT не ходит. */
|
||
media_path?: string | null;
|
||
ord_category: string;
|
||
budget_cap_rub: string;
|
||
daily_limit_rub: string;
|
||
/** Сколько кандидатов накопить перед отправкой пачки; null → порог по умолчанию (367). */
|
||
batch_threshold: number | null;
|
||
}
|
||
|
||
/** GET /api/telegram/auto-rule — правило авто-рекламы тенанта (или дефолты). */
|
||
export async function fetchAutoRule(): Promise<TelegramAutoRule> {
|
||
const { data } = await apiClient.get<TelegramAutoRule>('/api/telegram/auto-rule');
|
||
return data;
|
||
}
|
||
|
||
/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */
|
||
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* POST /api/telegram/auto-rule/media — приложить картинку/видео к авто-правилу.
|
||
* Оно повторяется в каждой авто-пачке. Правило должно быть уже сохранено — иначе 422.
|
||
*/
|
||
export async function uploadAutoRuleMedia(file: File): Promise<TelegramAutoRule> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('media', file);
|
||
const { data } = await apiClient.post<TelegramAutoRule>('/api/telegram/auto-rule/media', form);
|
||
return data;
|
||
}
|