feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -15,13 +15,14 @@ import type {
OverviewAnalyticsQueryParams,
ProjectsAnalyticsResponse,
ProjectsAnalyticsQueryParams,
PlacementsAnalyticsQueryParams,
} from "@/lib/types/api";
export const analyticsApi = {
/**
* Get placements analytics
*/
placements: (workspaceId: string, params?: AnalyticsQueryParams) =>
placements: (workspaceId: string, params?: PlacementsAnalyticsQueryParams) =>
api.get<PaginatedResponse<PlacementAnalyticsItem>>(
`/workspaces/${workspaceId}/analytics/placements`,
{ params }

View File

@@ -81,15 +81,20 @@ export const apiRequest = async <T = any>(
options: RequestOptions = {}
): Promise<T> => {
const { params, requireAuth = true, ...fetchOptions } = options;
const body = fetchOptions.body;
// Build URL
const url = buildUrl(`${API_URL}${endpoint}`, params);
const headers: HeadersInit = {
"Content-Type": "application/json",
...fetchOptions.headers,
};
// Don't set Content-Type for FormData - browser will set it with boundary
if (!(body instanceof FormData)) {
(headers as Record<string, string>)["Content-Type"] = "application/json";
}
// Add auth token if required
if (requireAuth) {
const token = getAuthToken();
@@ -99,9 +104,16 @@ export const apiRequest = async <T = any>(
}
try {
// Serialize body to JSON if it's an object (not FormData or string)
let serializedBody = body;
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof String)) {
serializedBody = JSON.stringify(body);
}
const response = await fetch(url, {
...fetchOptions,
headers,
body: serializedBody,
});
// Handle 204 No Content
@@ -145,21 +157,21 @@ export const api = {
apiRequest<T>(endpoint, {
...options,
method: "POST",
body: body ? JSON.stringify(body) : undefined,
body,
}),
put: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
apiRequest<T>(endpoint, {
...options,
method: "PUT",
body: body ? JSON.stringify(body) : undefined,
body,
}),
patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
apiRequest<T>(endpoint, {
...options,
method: "PATCH",
body: body ? JSON.stringify(body) : undefined,
body,
}),
delete: <T = any>(endpoint: string, options?: RequestOptions) =>

View File

@@ -21,6 +21,15 @@ export const creativesApi = {
{ params }
),
/**
* Get list of creatives for a specific project
*/
listByProject: (workspaceId: string, projectId: string) =>
api.get<PaginatedResponse<Creative>>(
`/workspaces/${workspaceId}/creatives`,
{ params: { project_id: projectId } }
),
/**
* Get single creative
*/

View File

@@ -5,6 +5,7 @@
import { api } from "./client";
import type {
Placement,
PlacementWithStats,
PlacementCreateRequest,
PlacementUpdateRequest,
PlacementsQueryParams,
@@ -23,6 +24,13 @@ export const placementsApi = {
{ params }
),
/**
* Get placements for a specific project (grouped by channel UI)
*/
getByProject: (workspaceId: string, projectId: string) =>
api.get<{ placements: Placement[] }>(`/workspaces/${workspaceId}/projects/${projectId}/placements`)
.then((response) => response.placements),
/**
* Get single placement
*/
@@ -35,6 +43,46 @@ export const placementsApi = {
create: (workspaceId: string, data: PlacementCreateRequest) =>
api.post<Placement>(`/workspaces/${workspaceId}/placements`, data),
/**
* Create placements for multiple channels (bulk creation)
*/
createBulk: (
workspaceId: string,
projectId: string,
data: {
creative_id?: string;
channels: Array<{
username: string;
status?: string;
comment?: string;
details?: {
placement_at?: string;
payment_at?: string;
cost?: { type: string; value: number };
cost_before_bargain?: number;
placement_type?: string;
format?: string;
comment?: string;
creative_id?: string;
};
}>;
details?: {
placement_at?: string;
payment_at?: string;
cost?: { type: string; value: number };
cost_before_bargain?: number;
placement_type?: string;
format?: string;
comment?: string;
creative_id?: string;
};
}
) =>
api.post<{ placements: Placement[] }>(
`/workspaces/${workspaceId}/projects/${projectId}/placements`,
data
),
/**
* Update placement
*/

View File

@@ -9,6 +9,10 @@ export interface UpdateProjectInviteLinkTypeRequest {
purchase_invite_type_default: "public" | "approval";
}
export interface MoveProjectRequest {
target_workspace_id: string;
}
export const projectsApi = {
/**
* Get list of projects for workspace
@@ -41,10 +45,31 @@ export const projectsApi = {
`/workspaces/${workspaceId}/projects/${projectId}/archive`
),
/**
* Unarchive project
*/
unarchive: (workspaceId: string, projectId: string) =>
api.post<Project>(
`/workspaces/${workspaceId}/projects/${projectId}/unarchive`
),
/**
* Delete project (soft delete)
*/
delete: (workspaceId: string, projectId: string) =>
api.delete(`/workspaces/${workspaceId}/projects/${projectId}`),
/**
* Move project to another workspace
*/
move: (
workspaceId: string,
projectId: string,
data: MoveProjectRequest
) =>
api.put<Project>(
`/workspaces/${workspaceId}/projects/${projectId}/move`,
data
),
};

View File

@@ -37,5 +37,22 @@ export const workspacesApi = {
*/
delete: (workspaceId: string) =>
api.delete<void>(`${BASE_PATH}/${workspaceId}`),
/**
* Upload workspace avatar
*/
uploadAvatar: (workspaceId: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return api.post<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
/**
* Delete workspace avatar
*/
deleteAvatar: (workspaceId: string) =>
api.delete<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`),
};

View File

@@ -113,14 +113,15 @@ export const demoApi = {
}
if (params?.placement_channel_id) {
items = items.filter(
(p) => p.placement_channel_id === params.placement_channel_id
(p) => p.channel.id === params.placement_channel_id
);
}
if (params?.creative_id) {
items = items.filter((p) => p.creative_id === params.creative_id);
}
// Filter out cancelled/archived placements
if (!params?.include_archived) {
items = items.filter((p) => p.status === "active");
items = items.filter((p) => p.status !== "Отмена" && p.status !== "Неактуально");
}
return Promise.resolve(paginate(items));
},
@@ -145,18 +146,18 @@ export const demoApi = {
// Filter by project if specified
if (params?.project_id) {
const projectPlacements = demoPlacements.filter(
(p) => p.project_id === params.project_id && p.status === "active"
(p) => p.project_id === params.project_id && p.status !== "Отмена" && p.status !== "Неактуально"
);
const totalCost = projectPlacements.reduce(
(sum, p) => sum + (p.cost || 0),
(sum, p) => sum + (p.details?.cost?.value || 0),
0
);
const totalSubs = projectPlacements.reduce(
(sum, p) => sum + (p.subscriptions_count || 0),
(sum, p) => sum + (p.placement_post?.subscriptions_count || 0),
0
);
const totalViews = projectPlacements.reduce(
(sum, p) => sum + (p.views_count || 0),
(sum, p) => sum + (p.placement_post?.views_count || 0),
0
);
@@ -167,6 +168,7 @@ export const demoApi = {
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
chart_data: demoSpendingAnalytics.chart_data.slice(0, 3),
placements_count: 12,
} as SpendingAnalytics);
}
return Promise.resolve(demoSpendingAnalytics);
@@ -187,7 +189,7 @@ export const demoApi = {
const projectPlacementChannelIds = new Set(
demoPlacements
.filter((p) => p.project_id === params.project_id)
.map((p) => p.placement_channel_id)
.map((p) => p.channel.id)
);
items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id));
}

View File

@@ -8,6 +8,7 @@ import { ru } from "date-fns/locale";
import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants";
import {
demoChannels,
demoPlacementChannels,
demoCreatives,
demoPlacements,
demoPurchasePlanChannels,
@@ -124,6 +125,17 @@ export const demoAwareProjectsApi = {
}
return projectsApi.archive(workspaceId, projectId);
},
unarchive: async (
workspaceId: string,
projectId: string
): Promise<Project> => {
if (isDemoWorkspace(workspaceId)) {
const project = demoProjects.find(p => p.id === projectId);
if (!project) throw new Error("Project not found");
return { ...project, status: "active" };
}
return projectsApi.unarchive(workspaceId, projectId);
},
delete: async (
workspaceId: string,
projectId: string
@@ -138,6 +150,19 @@ export const demoAwareProjectsApi = {
}
return projectsApi.delete(workspaceId, projectId);
},
move: async (
workspaceId: string,
projectId: string,
data: { target_workspace_id: string }
): Promise<Project> => {
if (isDemoWorkspace(workspaceId)) {
const project = demoProjects.find(p => p.id === projectId);
if (!project) throw new Error("Project not found");
// In demo mode, just return the project (mock move)
return project;
}
return projectsApi.move(workspaceId, projectId, data);
},
};
// ============================================================================
@@ -234,14 +259,14 @@ export const demoAwarePlacementsApi = {
}
if (params?.placement_channel_id) {
items = items.filter(
(p) => p.placement_channel_id === params.placement_channel_id
(p) => p.channel.id === params.placement_channel_id
);
}
if (params?.creative_id) {
items = items.filter((p) => p.creative_id === params.creative_id);
}
if (!params?.include_archived) {
items = items.filter((p) => p.status === "active");
items = items.filter((p) => p.status !== "Отмена" && p.status !== "Неактуально");
}
return paginate(items, params?.page, params?.size);
}
@@ -255,6 +280,18 @@ export const demoAwarePlacementsApi = {
}
return placementsApi.get(workspaceId, placementId);
},
getByProject: async (workspaceId: string, projectId: string): Promise<Placement[]> => {
if (isDemoWorkspace(workspaceId)) {
// In demo mode, return all placements for demo project
// Filter by demo project id for realistic behavior
return demoPlacements.filter((p) => {
// For demo purposes, we'll just return all placements
// In real usage, this would filter by project_id
return true;
});
}
return placementsApi.getByProject(workspaceId, projectId);
},
};
// ============================================================================
@@ -292,18 +329,18 @@ export const demoAwareAnalyticsApi = {
if (isDemoWorkspace(workspaceId)) {
if (params?.project_id) {
const projectPlacements = demoPlacements.filter(
(p) => p.project_id === params.project_id && p.status === "active"
(p) => p.project_id === params.project_id && p.status !== "Отмена" && p.status !== "Неактуально"
);
const totalCost = projectPlacements.reduce(
(sum, p) => sum + (p.cost || 0),
(sum, p) => sum + (p.details?.cost?.value || 0),
0
);
const totalSubs = projectPlacements.reduce(
(sum, p) => sum + (p.subscriptions_count || 0),
(sum, p) => sum + (p.placement_post?.subscriptions_count || 0),
0
);
const totalViews = projectPlacements.reduce(
(sum, p) => sum + (p.views_count || 0),
(sum, p) => sum + (p.placement_post?.views_count || 0),
0
);
return {
@@ -313,6 +350,7 @@ export const demoAwareAnalyticsApi = {
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
chart_data: demoSpendingAnalytics.chart_data.slice(0, 4),
placements_count: projectPlacements.length,
};
}
return demoSpendingAnalytics;
@@ -345,7 +383,7 @@ export const demoAwareAnalyticsApi = {
const projectPlacementChannelIds = new Set(
demoPlacements
.filter((p) => p.project_id === params.project_id)
.map((p) => p.placement_channel_id)
.map((p) => p.channel.id)
);
items = items.filter((a) =>
projectPlacementChannelIds.has(a.channel_id)
@@ -357,13 +395,35 @@ export const demoAwareAnalyticsApi = {
},
placements: async (
workspaceId: string,
params?: { project_id?: string; page?: number; size?: number }
params?: {
project_id?: string;
placement_channel_id?: string;
creative_id?: string;
date_from?: string;
date_to?: string;
page?: number;
size?: number;
}
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
if (isDemoWorkspace(workspaceId)) {
let items = demoPlacementAnalytics;
if (params?.project_id) {
items = items.filter((p) => p.project_id === params.project_id);
}
if (params?.placement_channel_id) {
items = items.filter((p) => p.placement_channel_id === params.placement_channel_id);
}
if (params?.creative_id) {
items = items.filter((p) => p.creative_id === params.creative_id);
}
if (params?.date_from) {
const from = new Date(params.date_from);
items = items.filter((p) => new Date(p.placement_date) >= from);
}
if (params?.date_to) {
const to = new Date(params.date_to);
items = items.filter((p) => new Date(p.placement_date) <= to);
}
return paginate(items, params?.page, params?.size);
}
return analyticsApi.placements(workspaceId, params);

View File

@@ -165,18 +165,18 @@ export const useDemoSpendingAnalytics = (
if (projectId) {
// Filter by project
const projectPlacements = demoPlacements.filter(
(p) => p.project_id === projectId && p.status === "active"
(p) => p.project_id === projectId && p.status !== "Отмена" && p.status !== "Неактуально"
);
const totalCost = projectPlacements.reduce(
(sum, p) => sum + (p.cost || 0),
(sum, p) => sum + (p.details?.cost?.value || 0),
0
);
const totalSubs = projectPlacements.reduce(
(sum, p) => sum + (p.subscriptions_count || 0),
(sum, p) => sum + (p.placement_post?.subscriptions_count || 0),
0
);
const totalViews = projectPlacements.reduce(
(sum, p) => sum + (p.views_count || 0),
(sum, p) => sum + (p.placement_post?.views_count || 0),
0
);
@@ -188,6 +188,7 @@ export const useDemoSpendingAnalytics = (
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
chart_data: demoSpendingAnalytics.chart_data.slice(0, 3),
placements_count: 12,
},
isDemo: true,
};
@@ -250,7 +251,7 @@ export const useDemoChannelAnalytics = (
const projectPlacementChannelIds = new Set(
demoPlacements
.filter((p) => p.project_id === projectId)
.map((p) => p.placement_channel_id)
.map((p) => p.channel.id)
);
items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id));
}

View File

@@ -8,6 +8,7 @@ import type {
Channel,
Creative,
Placement,
PlacementChannel,
PurchasePlanChannel,
WorkspaceMember,
Permission,
@@ -187,139 +188,6 @@ export const demoCreatives: Creative[] = [
},
];
// ============================================================================
// Placements
// ============================================================================
export const demoPlacements: Placement[] = [
{
id: "plac-0001-0000-0000-000000000001",
placement_date: "2024-12-20T12:00:00Z",
cost: 5500,
comment: "Отличное размещение",
ad_post_url: "https://t.me/tech_future/1234",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo1",
status: "active",
subscriptions_count: 156,
views_count: 8500,
views_availability: "available",
last_views_fetch_at: "2024-12-21T10:00:00Z",
created_at: "2024-12-19T15:00:00Z",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0001-0000-0000-000000000001",
placement_channel_title: "Технологии Будущего",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
},
{
id: "plac-0002-0000-0000-000000000002",
placement_date: "2024-12-18T14:00:00Z",
cost: 3200,
comment: null,
ad_post_url: "https://t.me/invest_pro/5678",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo2",
status: "active",
subscriptions_count: 89,
views_count: 4200,
views_availability: "available",
last_views_fetch_at: "2024-12-20T08:00:00Z",
created_at: "2024-12-17T10:00:00Z",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0002-0000-0000-000000000002",
placement_channel_title: "Инвестиции Pro",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
},
{
id: "plac-0003-0000-0000-000000000003",
placement_date: "2024-12-15T10:00:00Z",
cost: 4800,
comment: "Программисты - хорошая аудитория",
ad_post_url: "https://t.me/programmers_hub/9012",
invite_link_type: "public",
invite_link: "https://t.me/+ABC123demo3",
status: "active",
subscriptions_count: 234,
views_count: 12000,
views_availability: "available",
last_views_fetch_at: "2024-12-19T14:00:00Z",
created_at: "2024-12-14T09:00:00Z",
project_id: "proj-0002-0000-0000-000000000002",
project_channel_title: "IT Вакансии",
placement_channel_id: "chan-0003-0000-0000-000000000003",
placement_channel_title: "Программисты",
creative_id: "crea-0003-0000-0000-000000000003",
creative_name: "IT Jobs - Основной",
},
{
id: "plac-0004-0000-0000-000000000004",
placement_date: "2024-12-10T16:00:00Z",
cost: 2500,
comment: null,
ad_post_url: "https://t.me/marketing_pro/3456",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo4",
status: "active",
subscriptions_count: 67,
views_count: 3100,
views_availability: "manual",
last_views_fetch_at: "2024-12-12T11:00:00Z",
created_at: "2024-12-09T14:00:00Z",
project_id: "proj-0003-0000-0000-000000000003",
project_channel_title: "Стартапы и Бизнес",
placement_channel_id: "chan-0004-0000-0000-000000000004",
placement_channel_title: "Маркетинг Pro",
creative_id: "crea-0004-0000-0000-000000000004",
creative_name: "Startups - Нетворкинг",
},
{
id: "plac-0005-0000-0000-000000000005",
placement_date: "2024-12-05T11:00:00Z",
cost: 6200,
comment: "Финансовая аудитория",
ad_post_url: "https://t.me/finance_edu/7890",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo5",
status: "active",
subscriptions_count: 312,
views_count: 15600,
views_availability: "available",
last_views_fetch_at: "2024-12-08T09:00:00Z",
created_at: "2024-12-04T10:00:00Z",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0005-0000-0000-000000000005",
placement_channel_title: "Финансовая грамотность",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Крипто - Агрессивный",
},
{
id: "plac-0006-0000-0000-000000000006",
placement_date: "2024-11-28T13:00:00Z",
cost: 1800,
comment: null,
ad_post_url: "https://t.me/startup_daily/2345",
invite_link_type: "public",
invite_link: "https://t.me/+ABC123demo6",
status: "archived",
subscriptions_count: 45,
views_count: 2100,
views_availability: "unavailable",
last_views_fetch_at: null,
created_at: "2024-11-27T15:00:00Z",
project_id: "proj-0003-0000-0000-000000000003",
project_channel_title: "Стартапы и Бизнес",
placement_channel_id: "chan-0006-0000-0000-000000000006",
placement_channel_title: "Startup Daily",
creative_id: "crea-0004-0000-0000-000000000004",
creative_name: "Startups - Нетворкинг",
},
];
// ============================================================================
// Purchase Plan Channels
// ============================================================================
@@ -363,60 +231,204 @@ export const demoPurchasePlanChannels: Record<string, PurchasePlanChannel[]> = {
},
},
],
"proj-0002-0000-0000-000000000002": [
{
id: "plan-0004-0000-0000-000000000004",
status: "completed",
planned_cost: 4800,
comment: "IT аудитория",
channel: {
id: "chan-0003-0000-0000-000000000003",
telegram_id: -1002345678003,
title: "Программисты",
username: "programmers_hub",
},
},
{
id: "plan-0005-0000-0000-000000000005",
status: "planned",
planned_cost: 3000,
comment: null,
channel: {
id: "chan-0001-0000-0000-000000000001",
telegram_id: -1002345678001,
title: "Технологии Будущего",
username: "tech_future",
},
},
],
"proj-0003-0000-0000-000000000003": [
{
id: "plan-0006-0000-0000-000000000006",
status: "completed",
planned_cost: 2500,
comment: null,
channel: {
id: "chan-0004-0000-0000-000000000004",
telegram_id: -1002345678004,
title: "Маркетинг Pro",
username: "marketing_pro",
},
},
{
id: "plan-0007-0000-0000-000000000007",
status: "completed",
planned_cost: 1800,
comment: "Стартап аудитория",
channel: {
id: "chan-0006-0000-0000-000000000006",
telegram_id: -1002345678006,
title: "Startup Daily",
username: "startup_daily",
},
},
],
};
// ============================================================================
// Placements for Project (New Format)
// ============================================================================
export const demoPlacementChannels: PlacementChannel[] = [
{
id: "chan-0001-0000-0000-000000000001",
telegram_id: -1001234001001,
title: "Технологии Будущего",
username: "tech_future",
subscribers_count: 125000,
},
{
id: "chan-0002-0000-0000-000000000002",
telegram_id: -1001234001002,
title: "Инвестиции Pro",
username: "invest_pro",
subscribers_count: 89000,
},
{
id: "chan-0003-0000-0000-000000000003",
telegram_id: -1001234001003,
title: "Программисты",
username: "programmers",
subscribers_count: 230000,
},
{
id: "chan-0004-0000-0000-000000000004",
telegram_id: -1001234001004,
title: "Маркетинг Pro",
username: "marketing_pro",
subscribers_count: 67000,
},
];
export const demoPlacements: (Placement & { project_id: string })[] = [
{
id: "plac-0001-0000-0000-000000000001",
project_id: "proj-0001-0000-0000-000000000001",
status: "Оплачено",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "crypto_jan",
invite_link: "https://t.me/+abc123",
invite_link_type: "public",
channel: demoPlacementChannels[0],
details: {
placement_at: "2024-12-20T12:00:00Z",
payment_at: "2024-12-19T10:00:00Z",
cost: { type: "fixed", value: 5500 },
cost_before_bargain: 6000,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: {
subscriptions_count: 156,
views_count: 8500,
created_at: "2024-12-20T12:00:00Z",
post: {
id: "post-0001-0000-0000-000000000001",
message_id: 12345,
text: "Привет! Отличные новости...",
url: "https://t.me/tech_future/12345",
deleted_from_channel_at: null,
created_at: "2024-12-20T12:00:00Z",
updated_at: "2024-12-20T12:00:00Z",
},
},
created_at: "2024-12-18T08:00:00Z",
},
{
id: "plac-0002-0000-0000-000000000002",
project_id: "proj-0001-0000-0000-000000000001",
status: "Согласование условий",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "crypto_feb",
invite_link: "https://t.me/+def456",
invite_link_type: "approval",
channel: demoPlacementChannels[1],
details: {
placement_at: "2024-12-25T14:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 3200 },
cost_before_bargain: 3500,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: null,
created_at: "2024-12-22T09:00:00Z",
},
{
id: "plac-0003-0000-0000-000000000003",
project_id: "proj-0001-0000-0000-000000000001",
status: "Оплачено",
creative_id: "crea-0001-0000-0000-000000000001",
comment: null,
invite_link: "https://t.me/+ghi789",
invite_link_type: "public",
channel: demoPlacementChannels[2],
details: {
placement_at: "2024-12-15T10:00:00Z",
payment_at: "2024-12-14T16:00:00Z",
cost: { type: "fixed", value: 4800 },
cost_before_bargain: 5000,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: {
subscriptions_count: 234,
views_count: 12000,
created_at: "2024-12-15T10:00:00Z",
post: {
id: "post-0002-0000-0000-000000000002",
message_id: 67890,
text: "Криптовалюты продолжают расти...",
url: "https://t.me/programmers/67890",
deleted_from_channel_at: null,
created_at: "2024-12-15T10:00:00Z",
updated_at: "2024-12-15T10:00:00Z",
},
},
created_at: "2024-12-12T11:00:00Z",
},
{
id: "plac-0004-0000-0000-000000000004",
project_id: "proj-0001-0000-0000-000000000001",
status: "Ждём ответа",
creative_id: "crea-0001-0000-0000-000000000001",
comment: null,
invite_link: "https://t.me/+jkl012",
invite_link_type: "public",
channel: demoPlacementChannels[3],
details: {
placement_at: "2024-12-28T09:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 2500 },
cost_before_bargain: 2800,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: null,
created_at: "2024-12-24T14:00:00Z",
},
{
id: "plac-0005-0000-0000-000000000005",
project_id: "proj-0002-0000-0000-000000000002",
status: "Написать",
creative_id: "crea-0002-0000-0000-000000000002",
comment: "Новый оффер",
invite_link: null,
invite_link_type: "approval",
channel: demoPlacementChannels[0],
details: {
placement_at: null,
payment_at: null,
cost: { type: "fixed", value: 6000 },
cost_before_bargain: null,
placement_type: "self_promo",
format: "Спонсорский",
comment: null,
creative_id: "crea-0002-0000-0000-000000000002",
},
placement_post: null,
created_at: "2024-12-26T08:00:00Z",
},
{
id: "plac-0006-0000-0000-000000000006",
project_id: "proj-0002-0000-0000-000000000002",
status: "Отмена",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "Неактуально",
invite_link: null,
invite_link_type: "public",
channel: demoPlacementChannels[1],
details: {
placement_at: null,
payment_at: null,
cost: null,
cost_before_bargain: null,
placement_type: null,
format: null,
comment: "Клиент отказался",
creative_id: null,
},
placement_post: null,
created_at: "2024-12-10T10:00:00Z",
},
];
// ============================================================================
// Analytics Data
// ============================================================================
@@ -427,6 +439,7 @@ export const demoSpendingAnalytics: SpendingAnalytics = {
total_views: 45500,
avg_cpf: 26.58,
avg_cpm: 527.47,
placements_count: 12,
chart_data: [
{
period: "2024-11",
@@ -607,6 +620,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 8500,
cpf: 35.26,
cpm: 647.06,
post_url: "https://t.me/tech_future/1001",
},
{
id: "plac-0002-0000-0000-000000000002",
@@ -622,6 +636,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 4200,
cpf: 35.96,
cpm: 761.9,
post_url: "https://t.me/invest_pro/502",
},
{
id: "plac-0003-0000-0000-000000000003",
@@ -637,6 +652,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 12000,
cpf: 20.51,
cpm: 400.0,
post_url: "https://t.me/programmers_hub/2050",
},
{
id: "plac-0004-0000-0000-000000000004",
@@ -652,6 +668,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 3100,
cpf: 37.31,
cpm: 806.45,
post_url: "https://t.me/marketing_pro/803",
},
{
id: "plac-0005-0000-0000-000000000005",
@@ -667,6 +684,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 15600,
cpf: 19.87,
cpm: 397.44,
post_url: "https://t.me/finance_edu/1204",
},
];

View File

@@ -42,10 +42,12 @@ export interface PaginationParams {
export interface Workspace {
id: string; // UUID
name: string;
avatar_url?: string | null;
}
export interface WorkspaceCreateRequest {
name: string;
avatar_file?: File | null;
}
export interface WorkspaceUpdateRequest {
@@ -215,7 +217,18 @@ export interface CreativesQueryParams extends PaginationParams {
// Placements
// ----------------------------------------------------------------------------
export type PlacementStatus = "active" | "archived";
export type PlacementStatus =
| "Без статуса"
| "Написать"
| "Ждём ответа"
| "Согласование условий"
| "Оплатить"
| "Оплачено"
| "Отмена"
| "Не подходит цена"
| "Неактуально"
| "Не отвечает";
export type InviteLinkType = "public" | "approval";
export type PostViewsAvailability =
| "unknown"
@@ -223,26 +236,79 @@ export type PostViewsAvailability =
| "unavailable"
| "manual";
export interface Placement {
id: string; // UUID
project_id: string;
project_channel_title: string;
placement_channel_id: string;
placement_channel_title: string;
creative_id: string;
creative_name: string;
placement_date: string; // ISO 8601
cost: number | null;
export interface PlacementChannel {
id: string;
telegram_id: number;
title: string;
username: string | null;
subscribers_count?: number;
}
export interface PlacementCostInfo {
type: "fixed" | "cpm";
value: number;
}
export interface PlacementDetails {
placement_at: string | null; // ISO 8601
payment_at: string | null; // ISO 8601
cost: PlacementCostInfo | null;
cost_before_bargain: number | null;
placement_type: string | null;
format: string | null;
comment: string | null;
ad_post_url: string | null;
invite_link_type: InviteLinkType;
invite_link: string;
status: PlacementStatus;
creative_id: string | null;
}
export interface PlacementPost {
subscriptions_count: number;
views_count?: number | null;
views_availability?: PostViewsAvailability;
last_views_fetch_at?: string | null;
views_count: number | null;
created_at: string;
post: {
id: string;
message_id: number;
text: string;
url: string | null;
deleted_from_channel_at: string | null;
created_at: string;
updated_at: string;
} | null;
}
export interface Placement {
id: string;
status: PlacementStatus;
creative_id: string | null;
comment: string | null;
invite_link: string | null;
invite_link_type: InviteLinkType;
channel: PlacementChannel;
details: PlacementDetails | null;
placement_post: PlacementPost | null;
created_at: string;
}
export interface PlacementWithStats extends Placement {
cpf: number | null;
cpm: number | null;
}
export interface PlacementsGroupedByChannel {
channel: PlacementChannel;
placements: PlacementWithStats[];
total_cost: number;
total_subscriptions: number;
total_views: number;
}
export interface ProjectPlacementsSummary {
total_placements: number;
total_channels: number;
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
}
export interface PlacementCreateRequest {
@@ -306,6 +372,15 @@ export interface PlacementAnalyticsItem {
views_count: number;
cpf: number;
cpm: number;
post_url?: string | null;
}
export interface PlacementsAnalyticsQueryParams extends PaginationParams {
project_id?: string;
placement_channel_id?: string;
creative_id?: string;
date_from?: string;
date_to?: string;
}
// Creatives Analytics (matches actual API response)
@@ -350,6 +425,7 @@ export interface SpendingAnalytics {
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
placements_count: number;
}
export interface AnalyticsQueryParams extends PaginationParams {

View File

@@ -0,0 +1,53 @@
// ============================================================================
// Channel URL Parser Utility
// Parses various URL formats and usernames to extract channel username
// ============================================================================
export function parseChannelInput(input: string): string | null {
if (!input || typeof input !== "string") return null;
let cleaned = input.trim();
// Remove @ prefix if present
cleaned = cleaned.replace(/^@/, "");
// Patterns for different URL formats
const patterns: RegExp[] = [
/telemetr\.me\/content\/([a-zA-Z0-9_]+)/, // telemetr.me/content/rian_ru
/telemetr\.me\/([a-zA-Z0-9_]+)/, // telemetr.me/rian_ru (alternative)
/t\.me\/([a-zA-Z0-9_]+)/, // t.me/rian_ru or https://t.me/rian_ru
/tgstat\.ru\/channel\/@?([a-zA-Z0-9_]+)/, // tgstat.ru/channel/@rian_ru or tgstat.ru/channel/rian_ru
/tg\.me\/([a-zA-Z0-9_]+)/, // tg.me/rian_ru
];
for (const pattern of patterns) {
const match = cleaned.match(pattern);
if (match && match[1]) {
return match[1];
}
}
// If it's just a plain username (alphanumeric + underscore)
if (/^[a-zA-Z0-9_]+$/.test(cleaned)) {
return cleaned;
}
return null;
}
// Check if input is a valid channel identifier
export function isValidChannelInput(input: string): boolean {
return parseChannelInput(input) !== null;
}
// Get platform hint from URL (for analytics links)
export function getChannelAnalyticsUrls(username: string): {
telemetr: string;
tgstat: string;
} {
const cleanUsername = username.replace(/^@/, "");
return {
telemetr: `https://telemetr.me/channel/${cleanUsername}`,
tgstat: `https://tgstat.ru/channel/@${cleanUsername}`,
};
}