feat: global ui & functional update
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
112
lib/types/api.ts
112
lib/types/api.ts
@@ -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 {
|
||||
|
||||
53
lib/utils/channel-parser.ts
Normal file
53
lib/utils/channel-parser.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user