improvement for the backend api
This commit is contained in:
@@ -22,22 +22,18 @@ export const authApi = {
|
||||
/**
|
||||
* Complete auth with one-time token from bot
|
||||
*/
|
||||
complete: async (token: string): Promise<AuthCompleteResponse> => {
|
||||
const data: AuthCompleteRequest = { token };
|
||||
const response = await api.post<AuthCompleteResponse>(
|
||||
"/auth/complete",
|
||||
data,
|
||||
complete: async (token: string): Promise<{ access_token: string }> => {
|
||||
// GET request with token as query parameter
|
||||
const response = await api.get<{ access_token: string }>(
|
||||
`/auth/complete?token=${token}`,
|
||||
{
|
||||
requireAuth: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Save tokens to storage
|
||||
saveAuthTokens(response.access_token, response.refresh_token);
|
||||
|
||||
// Save user to storage
|
||||
// Save access token to storage
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response.user));
|
||||
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.access_token);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
@@ -5,33 +5,20 @@
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
TargetChannel,
|
||||
TargetChannelDetail,
|
||||
TargetChannelQueryParams,
|
||||
TargetChannelUpdateRequest,
|
||||
ListResponse,
|
||||
TargetChannelsListResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const channelsApi = {
|
||||
/**
|
||||
* Get list of target channels
|
||||
* GET /api/v1/target_channels
|
||||
*/
|
||||
list: (params?: TargetChannelQueryParams) =>
|
||||
api.get<ListResponse<TargetChannel>>("/target-channels", { params }),
|
||||
|
||||
/**
|
||||
* Get target channel details
|
||||
*/
|
||||
get: (id: string) => api.get<TargetChannelDetail>(`/target-channels/${id}`),
|
||||
|
||||
/**
|
||||
* Update target channel (is_active)
|
||||
*/
|
||||
update: (id: string, data: TargetChannelUpdateRequest) =>
|
||||
api.patch<TargetChannel>(`/target-channels/${id}`, data),
|
||||
list: () => api.get<TargetChannelsListResponse>("/target_channels"),
|
||||
|
||||
/**
|
||||
* Delete (disconnect) target channel
|
||||
* DELETE /api/v1/target_channels/{channel_id}
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/target-channels/${id}`),
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/target_channels/${id}`),
|
||||
};
|
||||
|
||||
@@ -89,8 +89,14 @@ const routeMockRequest = async (
|
||||
if (endpoint === "/auth/init") {
|
||||
return mockApiHandlers.authInit();
|
||||
}
|
||||
if (endpoint === "/auth/complete") {
|
||||
return mockApiHandlers.authComplete(body);
|
||||
if (endpoint.startsWith("/auth/complete")) {
|
||||
// Extract token from query params
|
||||
const token =
|
||||
params.token ||
|
||||
(endpoint.includes("?")
|
||||
? new URLSearchParams(endpoint.split("?")[1]).get("token")
|
||||
: null);
|
||||
return mockApiHandlers.authComplete({ token });
|
||||
}
|
||||
if (endpoint === "/auth/refresh") {
|
||||
return mockApiHandlers.authRefresh(body);
|
||||
@@ -100,53 +106,51 @@ const routeMockRequest = async (
|
||||
}
|
||||
|
||||
// Target Channels
|
||||
if (endpoint === "/target-channels" && method === "GET") {
|
||||
return mockApiHandlers.getTargetChannels(params);
|
||||
if (endpoint === "/target_channels" && method === "GET") {
|
||||
return mockApiHandlers.getTargetChannels();
|
||||
}
|
||||
if (endpoint.startsWith("/target-channels/") && method === "GET") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getTargetChannel(id);
|
||||
}
|
||||
if (endpoint.startsWith("/target-channels/") && method === "PATCH") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updateTargetChannel(id, body);
|
||||
}
|
||||
if (endpoint.startsWith("/target-channels/") && method === "DELETE") {
|
||||
if (endpoint.startsWith("/target_channels/") && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deleteTargetChannel(id);
|
||||
}
|
||||
|
||||
// External Channels
|
||||
if (endpoint === "/external-channels" && method === "GET") {
|
||||
return mockApiHandlers.getExternalChannels(params);
|
||||
if (endpoint.startsWith("/external_channels/target/") && method === "GET") {
|
||||
const targetChannelId = endpoint.split("/")[3];
|
||||
return mockApiHandlers.getExternalChannels(targetChannelId);
|
||||
}
|
||||
if (endpoint === "/external-channels" && method === "POST") {
|
||||
if (endpoint === "/external_channels" && method === "POST") {
|
||||
return mockApiHandlers.createExternalChannel(body);
|
||||
}
|
||||
if (
|
||||
endpoint.startsWith("/external-channels/") &&
|
||||
!endpoint.includes("/import") &&
|
||||
endpoint.startsWith("/external_channels/") &&
|
||||
!endpoint.includes("/target/") &&
|
||||
!endpoint.includes("/links") &&
|
||||
method === "GET"
|
||||
) {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getExternalChannel(id);
|
||||
}
|
||||
if (endpoint.startsWith("/external-channels/") && method === "PATCH") {
|
||||
if (
|
||||
endpoint.startsWith("/external_channels/") &&
|
||||
endpoint.includes("/links") &&
|
||||
method === "PATCH"
|
||||
) {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updateExternalChannelLinks(id, body);
|
||||
}
|
||||
if (
|
||||
endpoint.startsWith("/external_channels/") &&
|
||||
!endpoint.includes("/links") &&
|
||||
method === "PATCH"
|
||||
) {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updateExternalChannel(id, body);
|
||||
}
|
||||
if (endpoint.startsWith("/external-channels/") && method === "DELETE") {
|
||||
if (endpoint.startsWith("/external_channels/") && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deleteExternalChannel(id);
|
||||
}
|
||||
if (endpoint === "/external-channels/import" && method === "POST") {
|
||||
// FormData для импорта
|
||||
const formData = body as any;
|
||||
return mockApiHandlers.importExternalChannels(
|
||||
formData.file,
|
||||
formData.target_channel_id
|
||||
);
|
||||
}
|
||||
|
||||
// Creatives
|
||||
if (endpoint === "/creatives" && method === "GET") {
|
||||
@@ -168,28 +172,36 @@ const routeMockRequest = async (
|
||||
return mockApiHandlers.deleteCreative(id);
|
||||
}
|
||||
|
||||
// Purchases
|
||||
if (endpoint === "/purchases" && method === "GET") {
|
||||
return mockApiHandlers.getPurchases(params);
|
||||
// Placements
|
||||
if (endpoint === "/placements" && method === "GET") {
|
||||
return mockApiHandlers.getPlacements(params);
|
||||
}
|
||||
if (endpoint === "/purchases" && method === "POST") {
|
||||
return mockApiHandlers.createPurchase(body);
|
||||
if (endpoint === "/placements" && method === "POST") {
|
||||
return mockApiHandlers.createPlacement(body);
|
||||
}
|
||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "GET") {
|
||||
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "GET") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getPurchase(id);
|
||||
return mockApiHandlers.getPlacement(id);
|
||||
}
|
||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "PATCH") {
|
||||
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "PATCH") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updatePurchase(id, body);
|
||||
return mockApiHandlers.updatePlacement(id, body);
|
||||
}
|
||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "DELETE") {
|
||||
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deletePurchase(id);
|
||||
return mockApiHandlers.deletePlacement(id);
|
||||
}
|
||||
if (endpoint.includes("/refresh-views") && method === "POST") {
|
||||
if (endpoint.includes("/views/fetch") && method === "POST") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.refreshPurchaseViews(id);
|
||||
return mockApiHandlers.fetchPlacementViews(id);
|
||||
}
|
||||
if (endpoint.includes("/views/history") && method === "GET") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getPlacementViewsHistory(id, params);
|
||||
}
|
||||
if (endpoint.includes("/views/manual") && method === "POST") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.setPlacementViewsManually(id, params?.views_count);
|
||||
}
|
||||
|
||||
// Analytics
|
||||
@@ -264,6 +276,27 @@ export const apiRequest = async <T = any>(
|
||||
|
||||
// Handle errors
|
||||
if (!response.ok) {
|
||||
// FastAPI returns errors in format { "detail": "..." }
|
||||
// Transform to our ApiError format
|
||||
if (data.detail) {
|
||||
throw {
|
||||
error: {
|
||||
code:
|
||||
response.status === 401
|
||||
? "UNAUTHORIZED"
|
||||
: response.status === 403
|
||||
? "FORBIDDEN"
|
||||
: response.status === 404
|
||||
? "NOT_FOUND"
|
||||
: response.status === 422
|
||||
? "VALIDATION_ERROR"
|
||||
: "API_ERROR",
|
||||
message: data.detail,
|
||||
},
|
||||
detail: data.detail,
|
||||
};
|
||||
}
|
||||
// If it's already in our format, throw as is
|
||||
const error: ApiError = data;
|
||||
throw error;
|
||||
}
|
||||
@@ -314,13 +347,7 @@ export const api = {
|
||||
formData: FormData,
|
||||
options?: RequestOptions
|
||||
): Promise<T> => {
|
||||
if (USE_MOCKS) {
|
||||
// Handle mock file upload
|
||||
const file = formData.get("file") as File;
|
||||
const targetChannelId = formData.get("target_channel_id") as string;
|
||||
return mockApiHandlers.importExternalChannels(file, targetChannelId) as T;
|
||||
}
|
||||
|
||||
// File import is now done on client side, no need for mock handling
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const token = getAuthToken();
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
Creative,
|
||||
CreativeDetail,
|
||||
CreativeQueryParams,
|
||||
CreativeCreateRequest,
|
||||
CreativeUpdateRequest,
|
||||
ListResponse,
|
||||
CreativesListResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
@@ -18,12 +17,12 @@ export const creativesApi = {
|
||||
* Get list of creatives
|
||||
*/
|
||||
list: (params?: CreativeQueryParams) =>
|
||||
api.get<ListResponse<Creative>>("/creatives", { params }),
|
||||
api.get<CreativesListResponse>("/creatives", { params }),
|
||||
|
||||
/**
|
||||
* Get creative details
|
||||
*/
|
||||
get: (id: string) => api.get<CreativeDetail>(`/creatives/${id}`),
|
||||
get: (id: string) => api.get<Creative>(`/creatives/${id}`),
|
||||
|
||||
/**
|
||||
* Create new creative
|
||||
|
||||
@@ -5,57 +5,48 @@
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
ExternalChannel,
|
||||
ExternalChannelDetail,
|
||||
ExternalChannelQueryParams,
|
||||
ExternalChannelsListResponse,
|
||||
ExternalChannelCreateRequest,
|
||||
ExternalChannelUpdateRequest,
|
||||
ExternalChannelImportResponse,
|
||||
ListResponse,
|
||||
ExternalChannelLinksUpdateRequest,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const externalChannelsApi = {
|
||||
/**
|
||||
* Get list of external channels
|
||||
* Get list of external channels for target channel
|
||||
* GET /api/v1/external_channels/target/{target_channel_id}
|
||||
*/
|
||||
list: (params?: ExternalChannelQueryParams) =>
|
||||
api.get<ListResponse<ExternalChannel>>("/external-channels", { params }),
|
||||
|
||||
/**
|
||||
* Get external channel details
|
||||
*/
|
||||
get: (id: string) =>
|
||||
api.get<ExternalChannelDetail>(`/external-channels/${id}`),
|
||||
list: (targetChannelId: string) =>
|
||||
api.get<ExternalChannelsListResponse>(
|
||||
`/external_channels/target/${targetChannelId}`
|
||||
),
|
||||
|
||||
/**
|
||||
* Create new external channel
|
||||
* POST /api/v1/external_channels
|
||||
*/
|
||||
create: (data: ExternalChannelCreateRequest) =>
|
||||
api.post<ExternalChannel>("/external-channels", data),
|
||||
api.post<ExternalChannel>("/external_channels", data),
|
||||
|
||||
/**
|
||||
* Update external channel
|
||||
* PATCH /api/v1/external_channels/{channel_id}
|
||||
*/
|
||||
update: (id: string, data: ExternalChannelUpdateRequest) =>
|
||||
api.patch<ExternalChannel>(`/external-channels/${id}`, data),
|
||||
api.patch<ExternalChannel>(`/external_channels/${id}`, data),
|
||||
|
||||
/**
|
||||
* Update external channel links
|
||||
* PATCH /api/v1/external_channels/{channel_id}/links
|
||||
*/
|
||||
updateLinks: (id: string, data: ExternalChannelLinksUpdateRequest) =>
|
||||
api.patch<ExternalChannel>(`/external_channels/${id}/links`, data),
|
||||
|
||||
/**
|
||||
* Delete external channel
|
||||
* DELETE /api/v1/external_channels/{channel_id}
|
||||
*/
|
||||
delete: (id: string) =>
|
||||
api.delete<SuccessResponse>(`/external-channels/${id}`),
|
||||
|
||||
/**
|
||||
* Import external channels from Excel file
|
||||
*/
|
||||
import: (file: File, targetChannelId: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("target_channel_id", targetChannelId);
|
||||
|
||||
return api.upload<ExternalChannelImportResponse>(
|
||||
"/external-channels/import",
|
||||
formData
|
||||
);
|
||||
},
|
||||
api.delete<SuccessResponse>(`/external_channels/${id}`),
|
||||
};
|
||||
|
||||
@@ -7,5 +7,5 @@ export * from "./auth";
|
||||
export * from "./channels";
|
||||
export * from "./external-channels";
|
||||
export * from "./creatives";
|
||||
export * from "./purchases";
|
||||
export * from "./placements";
|
||||
export * from "./analytics";
|
||||
|
||||
73
lib/api/placements.ts
Normal file
73
lib/api/placements.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// ============================================================================
|
||||
// Placements API
|
||||
// ============================================================================
|
||||
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
Placement,
|
||||
PlacementQueryParams,
|
||||
PlacementCreateRequest,
|
||||
PlacementUpdateRequest,
|
||||
PlacementsListResponse,
|
||||
ViewsFetchResponse,
|
||||
ViewsHistoryResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const placementsApi = {
|
||||
/**
|
||||
* Get list of placements
|
||||
*/
|
||||
list: (params?: PlacementQueryParams) =>
|
||||
api.get<PlacementsListResponse>("/placements", { params }),
|
||||
|
||||
/**
|
||||
* Get placement details
|
||||
*/
|
||||
get: (id: string) => api.get<Placement>(`/placements/${id}`),
|
||||
|
||||
/**
|
||||
* Create new placement
|
||||
*/
|
||||
create: (data: PlacementCreateRequest) =>
|
||||
api.post<Placement>("/placements", data),
|
||||
|
||||
/**
|
||||
* Update placement
|
||||
*/
|
||||
update: (id: string, data: PlacementUpdateRequest) =>
|
||||
api.patch<Placement>(`/placements/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete placement
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/placements/${id}`),
|
||||
|
||||
/**
|
||||
* Fetch views for placement
|
||||
*/
|
||||
fetchViews: (id: string) =>
|
||||
api.post<ViewsFetchResponse>(`/placements/${id}/views/fetch`),
|
||||
|
||||
/**
|
||||
* Get views history for placement
|
||||
*/
|
||||
viewsHistory: (
|
||||
id: string,
|
||||
params?: { from_date?: string; to_date?: string }
|
||||
) =>
|
||||
api.get<ViewsHistoryResponse>(`/placements/${id}/views/history`, {
|
||||
params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set views manually
|
||||
*/
|
||||
setViewsManually: (id: string, views_count: number) =>
|
||||
api.post<Placement>(`/placements/${id}/views/manual`, null, {
|
||||
params: { views_count },
|
||||
}),
|
||||
};
|
||||
|
||||
// Keep purchasesApi as an alias for backwards compatibility
|
||||
export const purchasesApi = placementsApi;
|
||||
@@ -1,52 +0,0 @@
|
||||
// ============================================================================
|
||||
// Purchases API
|
||||
// ============================================================================
|
||||
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
Purchase,
|
||||
PurchaseDetail,
|
||||
PurchaseQueryParams,
|
||||
PurchaseCreateRequest,
|
||||
PurchaseCreateResponse,
|
||||
PurchaseUpdateRequest,
|
||||
PurchaseRefreshViewsResponse,
|
||||
ListResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const purchasesApi = {
|
||||
/**
|
||||
* Get list of purchases
|
||||
*/
|
||||
list: (params?: PurchaseQueryParams) =>
|
||||
api.get<ListResponse<Purchase>>("/purchases", { params }),
|
||||
|
||||
/**
|
||||
* Get purchase details
|
||||
*/
|
||||
get: (id: string) => api.get<PurchaseDetail>(`/purchases/${id}`),
|
||||
|
||||
/**
|
||||
* Create new purchase
|
||||
*/
|
||||
create: (data: PurchaseCreateRequest) =>
|
||||
api.post<PurchaseCreateResponse>("/purchases", data),
|
||||
|
||||
/**
|
||||
* Update purchase
|
||||
*/
|
||||
update: (id: string, data: PurchaseUpdateRequest) =>
|
||||
api.patch<Purchase>(`/purchases/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete purchase
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/purchases/${id}`),
|
||||
|
||||
/**
|
||||
* Refresh views count for purchase
|
||||
*/
|
||||
refreshViews: (id: string) =>
|
||||
api.post<PurchaseRefreshViewsResponse>(`/purchases/${id}/refresh-views`),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TargetChannel, TargetChannelDetail } from "@/lib/types/api";
|
||||
import { TargetChannel } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Target Channels Data
|
||||
@@ -6,74 +6,24 @@ import { TargetChannel, TargetChannelDetail } from "@/lib/types/api";
|
||||
|
||||
export const mockTargetChannels: TargetChannel[] = [
|
||||
{
|
||||
id: "tc1",
|
||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||
telegram_id: -1001234567890,
|
||||
title: "Мой Главный Канал",
|
||||
username: "my_main_channel",
|
||||
description: "Основной канал для продвижения продукта",
|
||||
is_active: true,
|
||||
created_at: "2024-01-10T10:00:00.000Z",
|
||||
updated_at: "2024-01-10T10:00:00.000Z",
|
||||
total_purchases: 15,
|
||||
total_subscriptions: 1250,
|
||||
avg_cpf: 45.5,
|
||||
},
|
||||
{
|
||||
id: "tc2",
|
||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
||||
telegram_id: -1009876543210,
|
||||
title: "Тестовый Канал",
|
||||
username: "test_channel_promo",
|
||||
description: "Канал для тестирования рекламных креативов",
|
||||
is_active: true,
|
||||
created_at: "2024-02-15T12:00:00.000Z",
|
||||
updated_at: "2024-02-15T12:00:00.000Z",
|
||||
total_purchases: 8,
|
||||
total_subscriptions: 450,
|
||||
avg_cpf: 52.3,
|
||||
},
|
||||
{
|
||||
id: "tc3",
|
||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
||||
telegram_id: -1005555555555,
|
||||
title: "Новости и Обновления",
|
||||
username: null,
|
||||
description: "Приватный канал для новостей",
|
||||
is_active: false,
|
||||
created_at: "2024-03-01T08:00:00.000Z",
|
||||
updated_at: "2024-03-20T14:30:00.000Z",
|
||||
total_purchases: 3,
|
||||
total_subscriptions: 120,
|
||||
avg_cpf: 67.8,
|
||||
},
|
||||
];
|
||||
|
||||
export const getMockTargetChannelDetail = (
|
||||
id: string
|
||||
): TargetChannelDetail | null => {
|
||||
const channel = mockTargetChannels.find((c) => c.id === id);
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
...channel,
|
||||
recent_purchases: [], // Will be populated from purchases mock
|
||||
stats_by_period: [
|
||||
{
|
||||
period: "day",
|
||||
subscriptions: 25,
|
||||
cost: 1200,
|
||||
cpf: 48.0,
|
||||
},
|
||||
{
|
||||
period: "week",
|
||||
subscriptions: 180,
|
||||
cost: 8500,
|
||||
cpf: 47.2,
|
||||
},
|
||||
{
|
||||
period: "month",
|
||||
subscriptions: 750,
|
||||
cost: 35000,
|
||||
cpf: 46.7,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Creative, CreativeDetail } from "@/lib/types/api";
|
||||
import { Creative } from "@/lib/types/api";
|
||||
import { mockTargetChannels } from "./channels";
|
||||
|
||||
// ============================================================================
|
||||
@@ -11,61 +11,39 @@ export const mockCreatives: Creative[] = [
|
||||
name: 'Креатив "Присоединяйся"',
|
||||
text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}",
|
||||
target_channel_id: "tc1",
|
||||
is_archived: false,
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
created_at: "2024-01-15T10:00:00.000Z",
|
||||
updated_at: "2024-01-15T10:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
total_purchases: 10,
|
||||
total_subscriptions: 850,
|
||||
avg_cpf: 42.3,
|
||||
status: "active",
|
||||
placements_count: 10,
|
||||
},
|
||||
{
|
||||
id: "cr2",
|
||||
name: 'Креатив "Эксклюзив"',
|
||||
text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}",
|
||||
target_channel_id: "tc1",
|
||||
is_archived: false,
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
created_at: "2024-01-20T12:00:00.000Z",
|
||||
updated_at: "2024-01-20T12:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
total_purchases: 5,
|
||||
total_subscriptions: 400,
|
||||
avg_cpf: 48.5,
|
||||
status: "active",
|
||||
placements_count: 5,
|
||||
},
|
||||
{
|
||||
id: "cr3",
|
||||
name: 'Креатив "Обучение"',
|
||||
text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}",
|
||||
target_channel_id: "tc2",
|
||||
is_archived: false,
|
||||
target_channel_title: mockTargetChannels[1].title,
|
||||
created_at: "2024-02-05T09:00:00.000Z",
|
||||
updated_at: "2024-02-05T09:00:00.000Z",
|
||||
target_channel: mockTargetChannels[1],
|
||||
total_purchases: 8,
|
||||
total_subscriptions: 450,
|
||||
avg_cpf: 52.3,
|
||||
status: "active",
|
||||
placements_count: 8,
|
||||
},
|
||||
{
|
||||
id: "cr4",
|
||||
name: "Старый креатив (архив)",
|
||||
text: "Устаревший текст с предложением. {link}",
|
||||
target_channel_id: "tc1",
|
||||
is_archived: true,
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
created_at: "2023-12-01T10:00:00.000Z",
|
||||
updated_at: "2024-01-10T15:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
total_purchases: 2,
|
||||
total_subscriptions: 80,
|
||||
avg_cpf: 75.0,
|
||||
status: "archived",
|
||||
placements_count: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const getMockCreativeDetail = (id: string): CreativeDetail | null => {
|
||||
const creative = mockCreatives.find((c) => c.id === id);
|
||||
if (!creative) return null;
|
||||
|
||||
return {
|
||||
...creative,
|
||||
purchases: [], // Will be populated from purchases mock
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api";
|
||||
import { ExternalChannel } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Channels Data
|
||||
@@ -6,90 +6,43 @@ import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api";
|
||||
|
||||
export const mockExternalChannels: ExternalChannel[] = [
|
||||
{
|
||||
id: "ec1",
|
||||
id: "550e8400-e29b-41d4-a716-446655440020",
|
||||
telegram_id: -1001111111111,
|
||||
title: "Канал о Маркетинге",
|
||||
username: "marketing_pro",
|
||||
link: "https://t.me/marketing_pro",
|
||||
subscribers_count: 50000,
|
||||
description: "Лучшие практики маркетинга",
|
||||
created_at: "2024-01-05T10:00:00.000Z",
|
||||
updated_at: "2024-01-05T10:00:00.000Z",
|
||||
target_channels: ["tc1", "tc2"],
|
||||
total_purchases: 5,
|
||||
avg_cpf: 42.5,
|
||||
avg_cpm: 850.0,
|
||||
},
|
||||
{
|
||||
id: "ec2",
|
||||
id: "550e8400-e29b-41d4-a716-446655440021",
|
||||
telegram_id: -1002222222222,
|
||||
title: "Бизнес и Стартапы",
|
||||
username: "business_startups",
|
||||
link: "https://t.me/business_startups",
|
||||
subscribers_count: 120000,
|
||||
description: "Новости мира бизнеса",
|
||||
created_at: "2024-01-08T11:00:00.000Z",
|
||||
updated_at: "2024-01-08T11:00:00.000Z",
|
||||
target_channels: ["tc1"],
|
||||
total_purchases: 8,
|
||||
avg_cpf: 55.8,
|
||||
avg_cpm: 1200.0,
|
||||
},
|
||||
{
|
||||
id: "ec3",
|
||||
id: "550e8400-e29b-41d4-a716-446655440022",
|
||||
telegram_id: -1003333333333,
|
||||
title: "IT и Технологии",
|
||||
username: "it_tech_news",
|
||||
link: "https://t.me/it_tech_news",
|
||||
subscribers_count: 85000,
|
||||
description: "Все о новых технологиях",
|
||||
created_at: "2024-01-12T09:00:00.000Z",
|
||||
updated_at: "2024-01-12T09:00:00.000Z",
|
||||
target_channels: ["tc1", "tc2"],
|
||||
total_purchases: 3,
|
||||
avg_cpf: 38.2,
|
||||
avg_cpm: 750.0,
|
||||
},
|
||||
{
|
||||
id: "ec4",
|
||||
telegram_id: null,
|
||||
id: "550e8400-e29b-41d4-a716-446655440023",
|
||||
telegram_id: -1004444444444,
|
||||
title: "Крипто Инвестиции",
|
||||
username: "crypto_invest",
|
||||
link: "https://t.me/crypto_invest",
|
||||
subscribers_count: 200000,
|
||||
description: null,
|
||||
created_at: "2024-02-01T10:00:00.000Z",
|
||||
updated_at: "2024-02-01T10:00:00.000Z",
|
||||
target_channels: ["tc2"],
|
||||
total_purchases: 2,
|
||||
avg_cpf: 68.5,
|
||||
avg_cpm: 1500.0,
|
||||
},
|
||||
{
|
||||
id: "ec5",
|
||||
id: "550e8400-e29b-41d4-a716-446655440024",
|
||||
telegram_id: -1005555555555,
|
||||
title: "Продажи и Переговоры",
|
||||
username: "sales_expert",
|
||||
link: "https://t.me/sales_expert",
|
||||
subscribers_count: 35000,
|
||||
description: "Экспертиза в продажах",
|
||||
created_at: "2024-02-10T14:00:00.000Z",
|
||||
updated_at: "2024-02-10T14:00:00.000Z",
|
||||
target_channels: ["tc1"],
|
||||
total_purchases: 4,
|
||||
avg_cpf: 45.0,
|
||||
avg_cpm: 900.0,
|
||||
},
|
||||
];
|
||||
|
||||
export const getMockExternalChannelDetail = (
|
||||
id: string
|
||||
): ExternalChannelDetail | null => {
|
||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
...channel,
|
||||
purchases: [], // Will be populated from purchases mock
|
||||
};
|
||||
};
|
||||
|
||||
213
lib/mocks/data/placements.ts
Normal file
213
lib/mocks/data/placements.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Placement, Subscription, ViewsSnapshot } from "@/lib/types/api";
|
||||
import { mockTargetChannels } from "./channels";
|
||||
import { mockExternalChannels } from "./external-channels";
|
||||
import { mockCreatives } from "./creatives";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Placements Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockPlacements: Placement[] = [
|
||||
{
|
||||
id: "p1",
|
||||
target_channel_id: "tc1",
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
external_channel_id: "ec1",
|
||||
external_channel_title: mockExternalChannels[0].title,
|
||||
creative_id: "cr1",
|
||||
creative_name: mockCreatives[0].name,
|
||||
placement_date: "2024-03-15T10:00:00.000Z",
|
||||
cost: 5000,
|
||||
comment: "Тестовая закупка на утро",
|
||||
ad_post_url: "https://t.me/marketing_pro/1234",
|
||||
invite_link_type: "approval",
|
||||
invite_link: "https://t.me/+AbCdEfGhIjKlMnOp",
|
||||
status: "active",
|
||||
subscriptions_count: 125,
|
||||
views_count: 12500,
|
||||
views_availability: "available",
|
||||
last_views_fetch_at: "2024-03-16T10:00:00.000Z",
|
||||
created_at: "2024-03-14T15:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
target_channel_id: "tc1",
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
external_channel_id: "ec2",
|
||||
external_channel_title: mockExternalChannels[1].title,
|
||||
creative_id: "cr2",
|
||||
creative_name: mockCreatives[1].name,
|
||||
placement_date: "2024-03-18T14:00:00.000Z",
|
||||
cost: 12000,
|
||||
comment: "Большой канал, ожидаем хороших результатов",
|
||||
ad_post_url: "https://t.me/business_startups/5678",
|
||||
invite_link_type: "approval",
|
||||
invite_link: "https://t.me/+QrStUvWxYzAbCdEf",
|
||||
status: "active",
|
||||
subscriptions_count: 210,
|
||||
views_count: 28000,
|
||||
views_availability: "available",
|
||||
last_views_fetch_at: "2024-03-19T10:00:00.000Z",
|
||||
created_at: "2024-03-17T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
target_channel_id: "tc2",
|
||||
target_channel_title: mockTargetChannels[1].title,
|
||||
external_channel_id: "ec3",
|
||||
external_channel_title: mockExternalChannels[2].title,
|
||||
creative_id: "cr3",
|
||||
creative_name: mockCreatives[2].name,
|
||||
placement_date: "2024-03-20T16:00:00.000Z",
|
||||
cost: 3500,
|
||||
comment: null,
|
||||
ad_post_url: "https://t.me/it_tech_news/9101",
|
||||
invite_link_type: "public",
|
||||
invite_link: "https://t.me/+GhIjKlMnOpQrStUv",
|
||||
status: "active",
|
||||
subscriptions_count: 75,
|
||||
views_count: 8500,
|
||||
views_availability: "available",
|
||||
last_views_fetch_at: "2024-03-21T10:00:00.000Z",
|
||||
created_at: "2024-03-19T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
target_channel_id: "tc1",
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
external_channel_id: "ec1",
|
||||
external_channel_title: mockExternalChannels[0].title,
|
||||
creative_id: "cr1",
|
||||
creative_name: mockCreatives[0].name,
|
||||
placement_date: "2024-03-22T08:00:00.000Z",
|
||||
cost: 4500,
|
||||
comment: "Повтор в этом же канале",
|
||||
ad_post_url: "https://t.me/marketing_pro/1290",
|
||||
invite_link_type: "approval",
|
||||
invite_link: "https://t.me/+XyZaBcDeFgHiJkLm",
|
||||
status: "active",
|
||||
subscriptions_count: 95,
|
||||
views_count: null,
|
||||
views_availability: "unavailable",
|
||||
last_views_fetch_at: "2024-03-23T09:00:00.000Z",
|
||||
created_at: "2024-03-21T14:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "p5",
|
||||
target_channel_id: "tc1",
|
||||
target_channel_title: mockTargetChannels[0].title,
|
||||
external_channel_id: "ec2",
|
||||
external_channel_title: mockExternalChannels[1].title,
|
||||
creative_id: "cr1",
|
||||
creative_name: mockCreatives[0].name,
|
||||
placement_date: "2024-02-10T10:00:00.000Z",
|
||||
cost: 10000,
|
||||
comment: "Старая закупка (архив)",
|
||||
ad_post_url: "https://t.me/business_startups/4567",
|
||||
invite_link_type: "public",
|
||||
invite_link: "https://t.me/+OldLinkArch12345",
|
||||
status: "archived",
|
||||
subscriptions_count: 150,
|
||||
views_count: 20000,
|
||||
views_availability: "available",
|
||||
last_views_fetch_at: "2024-02-12T10:00:00.000Z",
|
||||
created_at: "2024-02-09T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
// Keep old name for backward compatibility
|
||||
export const mockPurchases = mockPlacements;
|
||||
|
||||
// ============================================================================
|
||||
// Mock Subscriptions
|
||||
// ============================================================================
|
||||
|
||||
export const mockSubscriptions: Subscription[] = [
|
||||
{
|
||||
id: "s1",
|
||||
purchase_id: "p1",
|
||||
telegram_user_id: 123456789,
|
||||
user_first_name: "Иван",
|
||||
user_last_name: "Иванов",
|
||||
user_username: "ivan_ivanov",
|
||||
subscribed_at: "2024-03-15T11:35:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "join_request",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
purchase_id: "p1",
|
||||
telegram_user_id: 987654321,
|
||||
user_first_name: "Мария",
|
||||
user_last_name: null,
|
||||
user_username: "maria123",
|
||||
subscribed_at: "2024-03-15T12:05:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "join_request",
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
purchase_id: "p1",
|
||||
telegram_user_id: 456789123,
|
||||
user_first_name: "Алексей",
|
||||
user_last_name: "Петров",
|
||||
user_username: null,
|
||||
subscribed_at: "2024-03-15T13:20:00.000Z",
|
||||
is_active: false,
|
||||
event_type: "join_request",
|
||||
},
|
||||
{
|
||||
id: "s4",
|
||||
purchase_id: "p2",
|
||||
telegram_user_id: 789123456,
|
||||
user_first_name: "Елена",
|
||||
user_last_name: "Сидорова",
|
||||
user_username: "elena_s",
|
||||
subscribed_at: "2024-03-18T14:30:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "chat_member",
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Mock Views History
|
||||
// ============================================================================
|
||||
|
||||
export const mockViewsHistory: ViewsSnapshot[] = [
|
||||
{
|
||||
id: "v1",
|
||||
purchase_id: "p1",
|
||||
views: 10000,
|
||||
fetched_at: "2024-03-15T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v2",
|
||||
purchase_id: "p1",
|
||||
views: 11500,
|
||||
fetched_at: "2024-03-15T18:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v3",
|
||||
purchase_id: "p1",
|
||||
views: 12500,
|
||||
fetched_at: "2024-03-16T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v4",
|
||||
purchase_id: "p2",
|
||||
views: 25000,
|
||||
fetched_at: "2024-03-18T15:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v5",
|
||||
purchase_id: "p2",
|
||||
views: 27000,
|
||||
fetched_at: "2024-03-18T20:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v6",
|
||||
purchase_id: "p2",
|
||||
views: 28000,
|
||||
fetched_at: "2024-03-19T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
@@ -1,264 +0,0 @@
|
||||
import {
|
||||
Purchase,
|
||||
PurchaseDetail,
|
||||
Subscription,
|
||||
ViewsSnapshot,
|
||||
} from "@/lib/types/api";
|
||||
import { mockTargetChannels } from "./channels";
|
||||
import { mockExternalChannels } from "./external-channels";
|
||||
import { mockCreatives } from "./creatives";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Purchases Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockPurchases: Purchase[] = [
|
||||
{
|
||||
id: "p1",
|
||||
target_channel_id: "tc1",
|
||||
external_channel_id: "ec1",
|
||||
creative_id: "cr1",
|
||||
scheduled_date: "2024-03-15T10:00:00.000Z",
|
||||
actual_date: "2024-03-15T11:30:00.000Z",
|
||||
cost: 5000,
|
||||
comment: "Тестовая закупка на утро",
|
||||
post_link: "https://t.me/marketing_pro/1234",
|
||||
invite_link: "https://t.me/+AbCdEfGhIjKlMnOp",
|
||||
invite_link_type: "public",
|
||||
is_archived: false,
|
||||
created_at: "2024-03-14T15:00:00.000Z",
|
||||
updated_at: "2024-03-15T11:30:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
external_channel: mockExternalChannels[0],
|
||||
creative: mockCreatives[0],
|
||||
subscriptions_count: 125,
|
||||
views_count: 12500,
|
||||
cpf: 40.0,
|
||||
cpm: 400.0,
|
||||
conversion_rate: 1.0,
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
target_channel_id: "tc1",
|
||||
external_channel_id: "ec2",
|
||||
creative_id: "cr2",
|
||||
scheduled_date: "2024-03-18T14:00:00.000Z",
|
||||
actual_date: "2024-03-18T14:15:00.000Z",
|
||||
cost: 12000,
|
||||
comment: "Большой канал, ожидаем хороших результатов",
|
||||
post_link: "https://t.me/business_startups/5678",
|
||||
invite_link: "https://t.me/+QrStUvWxYzAbCdEf",
|
||||
invite_link_type: "public",
|
||||
is_archived: false,
|
||||
created_at: "2024-03-17T10:00:00.000Z",
|
||||
updated_at: "2024-03-18T14:15:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
external_channel: mockExternalChannels[1],
|
||||
creative: mockCreatives[1],
|
||||
subscriptions_count: 210,
|
||||
views_count: 28000,
|
||||
cpf: 57.1,
|
||||
cpm: 428.6,
|
||||
conversion_rate: 0.75,
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
target_channel_id: "tc2",
|
||||
external_channel_id: "ec3",
|
||||
creative_id: "cr3",
|
||||
scheduled_date: "2024-03-20T16:00:00.000Z",
|
||||
actual_date: null,
|
||||
cost: 3500,
|
||||
comment: null,
|
||||
post_link: "https://t.me/it_tech_news/9101",
|
||||
invite_link: "https://t.me/+GhIjKlMnOpQrStUv",
|
||||
invite_link_type: "private",
|
||||
is_archived: false,
|
||||
created_at: "2024-03-19T12:00:00.000Z",
|
||||
updated_at: "2024-03-19T12:00:00.000Z",
|
||||
target_channel: mockTargetChannels[1],
|
||||
external_channel: mockExternalChannels[2],
|
||||
creative: mockCreatives[2],
|
||||
subscriptions_count: 85,
|
||||
views_count: 8500,
|
||||
cpf: 41.2,
|
||||
cpm: 411.8,
|
||||
conversion_rate: 1.0,
|
||||
},
|
||||
{
|
||||
id: "p4",
|
||||
target_channel_id: "tc1",
|
||||
external_channel_id: "ec5",
|
||||
creative_id: "cr1",
|
||||
scheduled_date: "2024-03-22T12:00:00.000Z",
|
||||
actual_date: null,
|
||||
cost: 4500,
|
||||
comment: "Повторная закупка в том же канале",
|
||||
post_link: null,
|
||||
invite_link: "https://t.me/+WxYzAbCdEfGhIjKl",
|
||||
invite_link_type: "public",
|
||||
is_archived: false,
|
||||
created_at: "2024-03-21T09:00:00.000Z",
|
||||
updated_at: "2024-03-21T09:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
external_channel: mockExternalChannels[4],
|
||||
creative: mockCreatives[0],
|
||||
subscriptions_count: 95,
|
||||
views_count: null,
|
||||
cpf: 47.4,
|
||||
cpm: null,
|
||||
conversion_rate: null,
|
||||
},
|
||||
{
|
||||
id: "p5",
|
||||
target_channel_id: "tc1",
|
||||
external_channel_id: "ec1",
|
||||
creative_id: "cr1",
|
||||
scheduled_date: "2024-02-10T10:00:00.000Z",
|
||||
actual_date: "2024-02-10T10:30:00.000Z",
|
||||
cost: 5500,
|
||||
comment: "Старая закупка для истории",
|
||||
post_link: "https://t.me/marketing_pro/1100",
|
||||
invite_link: "https://t.me/+OldInviteLink123",
|
||||
invite_link_type: "public",
|
||||
is_archived: true,
|
||||
created_at: "2024-02-09T15:00:00.000Z",
|
||||
updated_at: "2024-02-20T10:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
external_channel: mockExternalChannels[0],
|
||||
creative: mockCreatives[0],
|
||||
subscriptions_count: 140,
|
||||
views_count: 15000,
|
||||
cpf: 39.3,
|
||||
cpm: 366.7,
|
||||
conversion_rate: 0.93,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock Subscriptions
|
||||
export const mockSubscriptions: Subscription[] = [
|
||||
{
|
||||
id: "s1",
|
||||
purchase_id: "p1",
|
||||
telegram_user_id: 111111111,
|
||||
user_first_name: "Иван",
|
||||
user_last_name: "Петров",
|
||||
user_username: "user1",
|
||||
subscribed_at: "2024-03-15T11:35:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "chat_member",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
purchase_id: "p1",
|
||||
telegram_user_id: 222222222,
|
||||
user_first_name: "Мария",
|
||||
user_last_name: "Сидорова",
|
||||
user_username: "user2",
|
||||
subscribed_at: "2024-03-15T11:40:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "chat_member",
|
||||
},
|
||||
{
|
||||
id: "s3",
|
||||
purchase_id: "p1",
|
||||
telegram_user_id: 333333333,
|
||||
user_first_name: "Алексей",
|
||||
user_last_name: null,
|
||||
user_username: null,
|
||||
subscribed_at: "2024-03-15T12:00:00.000Z",
|
||||
is_active: false,
|
||||
event_type: "join_request",
|
||||
},
|
||||
{
|
||||
id: "s4",
|
||||
purchase_id: "p2",
|
||||
telegram_user_id: 444444444,
|
||||
user_first_name: "Елена",
|
||||
user_last_name: "Кузнецова",
|
||||
user_username: "user4",
|
||||
subscribed_at: "2024-03-18T14:20:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "chat_member",
|
||||
},
|
||||
{
|
||||
id: "s5",
|
||||
purchase_id: "p2",
|
||||
telegram_user_id: 555555555,
|
||||
user_first_name: "Дмитрий",
|
||||
user_last_name: "Смирнов",
|
||||
user_username: "user5",
|
||||
subscribed_at: "2024-03-18T14:25:00.000Z",
|
||||
is_active: true,
|
||||
event_type: "chat_member",
|
||||
},
|
||||
];
|
||||
|
||||
// Mock Views Snapshots
|
||||
export const mockViewsSnapshots: ViewsSnapshot[] = [
|
||||
{
|
||||
id: "v1",
|
||||
purchase_id: "p1",
|
||||
views: 5000,
|
||||
fetched_at: "2024-03-15T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v2",
|
||||
purchase_id: "p1",
|
||||
views: 8500,
|
||||
fetched_at: "2024-03-15T18:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v3",
|
||||
purchase_id: "p1",
|
||||
views: 10200,
|
||||
fetched_at: "2024-03-16T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v4",
|
||||
purchase_id: "p1",
|
||||
views: 11800,
|
||||
fetched_at: "2024-03-16T18:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v5",
|
||||
purchase_id: "p1",
|
||||
views: 12500,
|
||||
fetched_at: "2024-03-17T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v6",
|
||||
purchase_id: "p2",
|
||||
views: 15000,
|
||||
fetched_at: "2024-03-18T15:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v7",
|
||||
purchase_id: "p2",
|
||||
views: 22000,
|
||||
fetched_at: "2024-03-18T20:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v8",
|
||||
purchase_id: "p2",
|
||||
views: 26500,
|
||||
fetched_at: "2024-03-19T09:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "v9",
|
||||
purchase_id: "p2",
|
||||
views: 28000,
|
||||
fetched_at: "2024-03-19T18:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const getMockPurchaseDetail = (id: string): PurchaseDetail | null => {
|
||||
const purchase = mockPurchases.find((p) => p.id === id);
|
||||
if (!purchase) return null;
|
||||
|
||||
return {
|
||||
...purchase,
|
||||
subscriptions: mockSubscriptions.filter((s) => s.purchase_id === id),
|
||||
views_history: mockViewsSnapshots.filter((v) => v.purchase_id === id),
|
||||
};
|
||||
};
|
||||
@@ -6,20 +6,14 @@ import { User } from "@/lib/types/api";
|
||||
|
||||
export const mockUsers: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
telegram_id: 123456789,
|
||||
username: "ivan_test",
|
||||
first_name: "Иван",
|
||||
last_name: "Иванов",
|
||||
created_at: "2024-01-15T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
telegram_id: 987654321,
|
||||
username: "maria_test",
|
||||
first_name: "Мария",
|
||||
last_name: "Петрова",
|
||||
created_at: "2024-02-01T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
mockCurrentUser,
|
||||
mockTokens,
|
||||
mockTargetChannels,
|
||||
getMockTargetChannelDetail,
|
||||
mockExternalChannels,
|
||||
getMockExternalChannelDetail,
|
||||
mockCreatives,
|
||||
getMockCreativeDetail,
|
||||
mockPurchases,
|
||||
getMockPurchaseDetail,
|
||||
mockPlacements,
|
||||
mockSubscriptions,
|
||||
mockViewsHistory,
|
||||
mockAnalyticsOverview,
|
||||
mockCostsReportByDay,
|
||||
mockCostsReportByWeek,
|
||||
@@ -28,22 +26,21 @@ import type {
|
||||
TargetChannel,
|
||||
ExternalChannel,
|
||||
Creative,
|
||||
Purchase,
|
||||
PurchaseCreateRequest,
|
||||
PurchaseCreateResponse,
|
||||
Placement,
|
||||
PlacementCreateRequest,
|
||||
PlacementUpdateRequest,
|
||||
CreativeCreateRequest,
|
||||
ExternalChannelCreateRequest,
|
||||
PurchaseRefreshViewsResponse,
|
||||
ExternalChannelImportResponse,
|
||||
ViewsFetchResponse,
|
||||
ViewsHistoryResponse,
|
||||
AuthCompleteRequest,
|
||||
AuthCompleteResponse,
|
||||
AuthRefreshRequest,
|
||||
AuthRefreshResponse,
|
||||
AuthInitResponse,
|
||||
TargetChannelUpdateRequest,
|
||||
ExternalChannelUpdateRequest,
|
||||
CreativeUpdateRequest,
|
||||
PurchaseUpdateRequest,
|
||||
ExternalChannelsListResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
// Simulate network delay
|
||||
@@ -75,15 +72,21 @@ export const mockApiHandlers = {
|
||||
};
|
||||
},
|
||||
|
||||
async authComplete(data: AuthCompleteRequest): Promise<AuthCompleteResponse> {
|
||||
async authComplete(
|
||||
data: AuthCompleteRequest | { token: string | null }
|
||||
): Promise<{ access_token: string }> {
|
||||
await delay();
|
||||
if (!data.token) {
|
||||
throw mockError("VALIDATION_ERROR", "Token is required");
|
||||
}
|
||||
|
||||
// Save mock user to localStorage for later retrieval
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("tgex_user", JSON.stringify(mockCurrentUser));
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: mockTokens.access_token,
|
||||
refresh_token: mockTokens.refresh_token,
|
||||
user: mockCurrentUser,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -105,44 +108,10 @@ export const mockApiHandlers = {
|
||||
// --------------------------------------------------------------------------
|
||||
// Target Channels
|
||||
// --------------------------------------------------------------------------
|
||||
async getTargetChannels(params?: {
|
||||
is_active?: boolean;
|
||||
}): Promise<ListResponse<TargetChannel>> {
|
||||
async getTargetChannels() {
|
||||
await delay();
|
||||
let filtered = [...mockTargetChannels];
|
||||
|
||||
if (params?.is_active !== undefined) {
|
||||
filtered = filtered.filter((c) => c.is_active === params.is_active);
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getTargetChannel(id: string) {
|
||||
await delay();
|
||||
const detail = getMockTargetChannelDetail(id);
|
||||
if (!detail) {
|
||||
throw mockError("NOT_FOUND", "Target channel not found");
|
||||
}
|
||||
return detail;
|
||||
},
|
||||
|
||||
async updateTargetChannel(
|
||||
id: string,
|
||||
data: TargetChannelUpdateRequest
|
||||
): Promise<TargetChannel> {
|
||||
await delay();
|
||||
const channel = mockTargetChannels.find((c) => c.id === id);
|
||||
if (!channel) {
|
||||
throw mockError("NOT_FOUND", "Target channel not found");
|
||||
}
|
||||
return {
|
||||
...channel,
|
||||
is_active: data.is_active,
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channels: mockTargetChannels,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -158,41 +127,24 @@ export const mockApiHandlers = {
|
||||
// --------------------------------------------------------------------------
|
||||
// External Channels
|
||||
// --------------------------------------------------------------------------
|
||||
async getExternalChannels(params?: {
|
||||
target_channel_id?: string;
|
||||
search?: string;
|
||||
}): Promise<ListResponse<ExternalChannel>> {
|
||||
async getExternalChannels(
|
||||
targetChannelId: string
|
||||
): Promise<ExternalChannelsListResponse> {
|
||||
await delay();
|
||||
let filtered = [...mockExternalChannels];
|
||||
|
||||
if (params?.target_channel_id) {
|
||||
filtered = filtered.filter((c) =>
|
||||
c.target_channels.includes(params.target_channel_id!)
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(search) ||
|
||||
c.username?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Возвращаем все каналы для указанного target channel
|
||||
// В реальном API это будет фильтрация по связям через промежуточную таблицу
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
external_channels: mockExternalChannels,
|
||||
};
|
||||
},
|
||||
|
||||
async getExternalChannel(id: string) {
|
||||
async getExternalChannel(id: string): Promise<ExternalChannel> {
|
||||
await delay();
|
||||
const detail = getMockExternalChannelDetail(id);
|
||||
if (!detail) {
|
||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||
if (!channel) {
|
||||
throw mockError("NOT_FOUND", "External channel not found");
|
||||
}
|
||||
return detail;
|
||||
return channel;
|
||||
},
|
||||
|
||||
async createExternalChannel(
|
||||
@@ -201,18 +153,11 @@ export const mockApiHandlers = {
|
||||
await delay(500);
|
||||
const newChannel: ExternalChannel = {
|
||||
id: generateId(),
|
||||
telegram_id: null,
|
||||
telegram_id: data.telegram_id,
|
||||
title: data.title,
|
||||
username: data.username || null,
|
||||
link: data.link,
|
||||
subscribers_count: data.subscribers_count || null,
|
||||
description: data.description || null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channels: data.target_channel_ids,
|
||||
total_purchases: 0,
|
||||
avg_cpf: null,
|
||||
avg_cpm: null,
|
||||
};
|
||||
mockExternalChannels.push(newChannel);
|
||||
return newChannel;
|
||||
@@ -230,7 +175,6 @@ export const mockApiHandlers = {
|
||||
return {
|
||||
...channel,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -244,42 +188,20 @@ export const mockApiHandlers = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async importExternalChannels(
|
||||
file: File,
|
||||
targetChannelId: string
|
||||
): Promise<ExternalChannelImportResponse> {
|
||||
await delay(1500); // Longer delay for file processing
|
||||
|
||||
// Mock: simulate importing 3-5 channels
|
||||
const importCount = Math.floor(Math.random() * 3) + 3;
|
||||
const imported: ExternalChannel[] = [];
|
||||
|
||||
for (let i = 0; i < importCount; i++) {
|
||||
const newChannel: ExternalChannel = {
|
||||
id: generateId(),
|
||||
telegram_id: null,
|
||||
title: `Импортированный канал ${i + 1}`,
|
||||
username: `imported_channel_${i + 1}`,
|
||||
link: `https://t.me/imported_channel_${i + 1}`,
|
||||
subscribers_count: Math.floor(Math.random() * 100000) + 10000,
|
||||
description: "Импортировано из Excel",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channels: [targetChannelId],
|
||||
total_purchases: 0,
|
||||
avg_cpf: null,
|
||||
avg_cpm: null,
|
||||
};
|
||||
imported.push(newChannel);
|
||||
mockExternalChannels.push(newChannel);
|
||||
async updateExternalChannelLinks(
|
||||
id: string,
|
||||
data: {
|
||||
add_target_channel_ids?: string[];
|
||||
remove_target_channel_ids?: string[];
|
||||
}
|
||||
|
||||
return {
|
||||
imported: importCount,
|
||||
skipped: 1,
|
||||
errors: ["Строка 15: некорректный формат ссылки"],
|
||||
channels: imported,
|
||||
};
|
||||
): Promise<ExternalChannel> {
|
||||
await delay();
|
||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||
if (!channel) {
|
||||
throw mockError("NOT_FOUND", "External channel not found");
|
||||
}
|
||||
// В упрощенной версии просто возвращаем канал
|
||||
return channel;
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -287,8 +209,8 @@ export const mockApiHandlers = {
|
||||
// --------------------------------------------------------------------------
|
||||
async getCreatives(params?: {
|
||||
target_channel_id?: string;
|
||||
is_archived?: boolean;
|
||||
}): Promise<ListResponse<Creative>> {
|
||||
include_archived?: boolean;
|
||||
}) {
|
||||
await delay();
|
||||
let filtered = [...mockCreatives];
|
||||
|
||||
@@ -298,36 +220,27 @@ export const mockApiHandlers = {
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.is_archived !== undefined) {
|
||||
filtered = filtered.filter((c) => c.is_archived === params.is_archived);
|
||||
if (!params?.include_archived) {
|
||||
filtered = filtered.filter((c) => c.status !== "archived");
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
creatives: filtered,
|
||||
};
|
||||
},
|
||||
|
||||
async getCreative(id: string) {
|
||||
async getCreative(id: string): Promise<Creative> {
|
||||
await delay();
|
||||
const detail = getMockCreativeDetail(id);
|
||||
if (!detail) {
|
||||
const creative = mockCreatives.find((c) => c.id === id);
|
||||
if (!creative) {
|
||||
throw mockError("NOT_FOUND", "Creative not found");
|
||||
}
|
||||
return detail;
|
||||
return creative;
|
||||
},
|
||||
|
||||
async createCreative(data: CreativeCreateRequest): Promise<Creative> {
|
||||
await delay(500);
|
||||
|
||||
// Validate {link} placeholder
|
||||
if (!data.text.includes("{link}")) {
|
||||
throw mockError(
|
||||
"VALIDATION_ERROR",
|
||||
"Creative text must contain {link} placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
const targetChannel = mockTargetChannels.find(
|
||||
(c) => c.id === data.target_channel_id
|
||||
);
|
||||
@@ -340,13 +253,10 @@ export const mockApiHandlers = {
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
target_channel_id: data.target_channel_id,
|
||||
is_archived: false,
|
||||
target_channel_title: targetChannel.title,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channel: targetChannel,
|
||||
total_purchases: 0,
|
||||
total_subscriptions: 0,
|
||||
avg_cpf: null,
|
||||
status: "active",
|
||||
placements_count: 0,
|
||||
};
|
||||
mockCreatives.push(newCreative);
|
||||
return newCreative;
|
||||
@@ -372,7 +282,6 @@ export const mockApiHandlers = {
|
||||
return {
|
||||
...creative,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -383,10 +292,10 @@ export const mockApiHandlers = {
|
||||
throw mockError("NOT_FOUND", "Creative not found");
|
||||
}
|
||||
|
||||
if (creative.total_purchases > 0) {
|
||||
if (creative.placements_count > 0) {
|
||||
throw mockError(
|
||||
"CREATIVE_IN_USE",
|
||||
"Cannot delete creative with active purchases"
|
||||
"Cannot delete creative with active placements"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,20 +305,16 @@ export const mockApiHandlers = {
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Purchases
|
||||
// Placements (Purchases)
|
||||
// --------------------------------------------------------------------------
|
||||
async getPurchases(params?: {
|
||||
async getPlacements(params?: {
|
||||
target_channel_id?: string;
|
||||
external_channel_id?: string;
|
||||
creative_id?: string;
|
||||
is_archived?: boolean;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
}): Promise<ListResponse<Purchase>> {
|
||||
include_archived?: boolean;
|
||||
}) {
|
||||
await delay();
|
||||
let filtered = [...mockPurchases];
|
||||
let filtered = [...mockPlacements];
|
||||
|
||||
if (params?.target_channel_id) {
|
||||
filtered = filtered.filter(
|
||||
@@ -427,62 +332,25 @@ export const mockApiHandlers = {
|
||||
filtered = filtered.filter((p) => p.creative_id === params.creative_id);
|
||||
}
|
||||
|
||||
if (params?.is_archived !== undefined) {
|
||||
filtered = filtered.filter((p) => p.is_archived === params.is_archived);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (params?.sort) {
|
||||
filtered.sort((a, b) => {
|
||||
let aVal: any;
|
||||
let bVal: any;
|
||||
|
||||
switch (params.sort) {
|
||||
case "date":
|
||||
aVal = a.actual_date || a.scheduled_date || a.created_at;
|
||||
bVal = b.actual_date || b.scheduled_date || b.created_at;
|
||||
break;
|
||||
case "cost":
|
||||
aVal = a.cost || 0;
|
||||
bVal = b.cost || 0;
|
||||
break;
|
||||
case "cpf":
|
||||
aVal = a.cpf || 0;
|
||||
bVal = b.cpf || 0;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.subscriptions_count;
|
||||
bVal = b.subscriptions_count;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (params.order === "desc") {
|
||||
return bVal > aVal ? 1 : -1;
|
||||
}
|
||||
return aVal > bVal ? 1 : -1;
|
||||
});
|
||||
if (!params?.include_archived) {
|
||||
filtered = filtered.filter((p) => p.status !== "archived");
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
placements: filtered,
|
||||
};
|
||||
},
|
||||
|
||||
async getPurchase(id: string) {
|
||||
async getPlacement(id: string): Promise<Placement> {
|
||||
await delay();
|
||||
const detail = getMockPurchaseDetail(id);
|
||||
if (!detail) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
const placement = mockPlacements.find((p) => p.id === id);
|
||||
if (!placement) {
|
||||
throw mockError("NOT_FOUND", "Placement not found");
|
||||
}
|
||||
return detail;
|
||||
return placement;
|
||||
},
|
||||
|
||||
async createPurchase(
|
||||
data: PurchaseCreateRequest
|
||||
): Promise<PurchaseCreateResponse> {
|
||||
async createPlacement(data: PlacementCreateRequest): Promise<Placement> {
|
||||
await delay(800);
|
||||
|
||||
const targetChannel = mockTargetChannels.find(
|
||||
@@ -502,85 +370,111 @@ export const mockApiHandlers = {
|
||||
.toString(36)
|
||||
.substring(2, 15)}`;
|
||||
|
||||
const newPurchase: Purchase = {
|
||||
const newPlacement: Placement = {
|
||||
id: generateId(),
|
||||
target_channel_id: data.target_channel_id,
|
||||
target_channel_title: targetChannel.title,
|
||||
external_channel_id: data.external_channel_id,
|
||||
external_channel_title: externalChannel.title,
|
||||
creative_id: data.creative_id,
|
||||
scheduled_date: data.scheduled_date || null,
|
||||
actual_date: data.actual_date || null,
|
||||
creative_name: creative.name,
|
||||
placement_date: data.placement_date,
|
||||
cost: data.cost || null,
|
||||
comment: data.comment || null,
|
||||
post_link: data.post_link || null,
|
||||
ad_post_url: data.ad_post_url || null,
|
||||
invite_link_type: data.invite_link_type || "public",
|
||||
invite_link: inviteLink,
|
||||
invite_link_type: data.invite_link_type,
|
||||
is_archived: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channel: targetChannel,
|
||||
external_channel: externalChannel,
|
||||
creative: creative,
|
||||
status: "active",
|
||||
subscriptions_count: 0,
|
||||
views_count: null,
|
||||
cpf: null,
|
||||
cpm: null,
|
||||
conversion_rate: null,
|
||||
views_availability: "unknown",
|
||||
last_views_fetch_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockPurchases.push(newPurchase);
|
||||
|
||||
// Format message with invite link
|
||||
const formattedMessage = creative.text.replace("{link}", inviteLink);
|
||||
|
||||
return {
|
||||
...newPurchase,
|
||||
formatted_message: formattedMessage,
|
||||
};
|
||||
mockPlacements.push(newPlacement);
|
||||
return newPlacement;
|
||||
},
|
||||
|
||||
async updatePurchase(
|
||||
async updatePlacement(
|
||||
id: string,
|
||||
data: PurchaseUpdateRequest
|
||||
): Promise<Purchase> {
|
||||
data: PlacementUpdateRequest
|
||||
): Promise<Placement> {
|
||||
await delay();
|
||||
const purchase = mockPurchases.find((p) => p.id === id);
|
||||
if (!purchase) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
const placement = mockPlacements.find((p) => p.id === id);
|
||||
if (!placement) {
|
||||
throw mockError("NOT_FOUND", "Placement not found");
|
||||
}
|
||||
|
||||
return {
|
||||
...purchase,
|
||||
...placement,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async deletePurchase(id: string): Promise<SuccessResponse> {
|
||||
async deletePlacement(id: string): Promise<SuccessResponse> {
|
||||
await delay();
|
||||
const index = mockPurchases.findIndex((p) => p.id === id);
|
||||
const index = mockPlacements.findIndex((p) => p.id === id);
|
||||
if (index === -1) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
throw mockError("NOT_FOUND", "Placement not found");
|
||||
}
|
||||
mockPurchases.splice(index, 1);
|
||||
mockPlacements.splice(index, 1);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async refreshPurchaseViews(
|
||||
id: string
|
||||
): Promise<PurchaseRefreshViewsResponse> {
|
||||
async fetchPlacementViews(id: string): Promise<ViewsFetchResponse> {
|
||||
await delay(1000);
|
||||
const purchase = mockPurchases.find((p) => p.id === id);
|
||||
if (!purchase) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
const placement = mockPlacements.find((p) => p.id === id);
|
||||
if (!placement) {
|
||||
throw mockError("NOT_FOUND", "Placement not found");
|
||||
}
|
||||
|
||||
// Mock: add some random views
|
||||
const newViews =
|
||||
(purchase.views_count || 0) + Math.floor(Math.random() * 500);
|
||||
(placement.views_count || 0) + Math.floor(Math.random() * 500);
|
||||
|
||||
return {
|
||||
views: newViews,
|
||||
placement_id: id,
|
||||
views_count: newViews,
|
||||
views_availability: "available",
|
||||
fetched_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
};
|
||||
},
|
||||
|
||||
async getPlacementViewsHistory(
|
||||
id: string,
|
||||
params?: { from_date?: string; to_date?: string }
|
||||
): Promise<ViewsHistoryResponse> {
|
||||
await delay();
|
||||
const placement = mockPlacements.find((p) => p.id === id);
|
||||
if (!placement) {
|
||||
throw mockError("NOT_FOUND", "Placement not found");
|
||||
}
|
||||
|
||||
const filteredHistory = mockViewsHistory.filter(
|
||||
(v) => v.purchase_id === id
|
||||
);
|
||||
return {
|
||||
histories: filteredHistory,
|
||||
};
|
||||
},
|
||||
|
||||
async setPlacementViewsManually(
|
||||
id: string,
|
||||
views_count: number
|
||||
): Promise<Placement> {
|
||||
await delay();
|
||||
const placement = mockPlacements.find((p) => p.id === id);
|
||||
if (!placement) {
|
||||
throw mockError("NOT_FOUND", "Placement not found");
|
||||
}
|
||||
|
||||
return {
|
||||
...placement,
|
||||
views_count,
|
||||
views_availability: "manual",
|
||||
last_views_fetch_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ export * from "./data/users";
|
||||
export * from "./data/channels";
|
||||
export * from "./data/external-channels";
|
||||
export * from "./data/creatives";
|
||||
export * from "./data/purchases";
|
||||
export * from "./data/placements";
|
||||
export * from "./data/analytics";
|
||||
|
||||
198
lib/types/api.ts
198
lib/types/api.ts
@@ -7,12 +7,9 @@
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
id: string; // UUID
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string;
|
||||
last_name: string | null;
|
||||
created_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface AuthInitResponse {
|
||||
@@ -42,189 +39,172 @@ export interface AuthRefreshResponse {
|
||||
// Target Channel
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Основная структура как возвращает API
|
||||
export interface TargetChannel {
|
||||
id: string;
|
||||
id: string; // UUID
|
||||
telegram_id: number;
|
||||
title: string;
|
||||
username: string | null; // @channel_name
|
||||
description: string | null;
|
||||
username: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Stats
|
||||
total_purchases: number;
|
||||
total_subscriptions: number;
|
||||
avg_cpf: number | null; // Average Cost Per Follower
|
||||
}
|
||||
|
||||
// Расширенная версия с дополнительными полями (пока не реализовано на бекенде)
|
||||
export interface TargetChannelDetail extends TargetChannel {
|
||||
recent_purchases: Purchase[]; // last 5
|
||||
stats_by_period: {
|
||||
period: "day" | "week" | "month";
|
||||
subscriptions: number;
|
||||
cost: number;
|
||||
cpf: number;
|
||||
}[];
|
||||
// В будущем можно добавить статистику
|
||||
}
|
||||
|
||||
export interface TargetChannelUpdateRequest {
|
||||
is_active: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface TargetChannelsListResponse {
|
||||
target_channels: TargetChannel[];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// External Channel
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Основная структура как возвращает API
|
||||
export interface ExternalChannel {
|
||||
id: string;
|
||||
telegram_id: number | null;
|
||||
id: string; // UUID
|
||||
telegram_id: number;
|
||||
title: string;
|
||||
username: string | null;
|
||||
link: string; // t.me link or custom
|
||||
subscribers_count: number | null;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Relations
|
||||
target_channels: string[]; // IDs of target channels
|
||||
// Stats
|
||||
total_purchases: number;
|
||||
avg_cpf: number | null;
|
||||
avg_cpm: number | null;
|
||||
}
|
||||
|
||||
export interface ExternalChannelDetail extends ExternalChannel {
|
||||
purchases: Purchase[];
|
||||
subscribers_count: number | null;
|
||||
}
|
||||
|
||||
export interface ExternalChannelCreateRequest {
|
||||
telegram_id: number;
|
||||
title: string;
|
||||
username?: string;
|
||||
link: string;
|
||||
subscribers_count?: number;
|
||||
description?: string;
|
||||
target_channel_ids: string[]; // Привязка к целевым каналам
|
||||
username?: string | null;
|
||||
description?: string | null;
|
||||
subscribers_count?: number | null;
|
||||
target_channel_ids: string[]; // UUID[]
|
||||
}
|
||||
|
||||
export interface ExternalChannelUpdateRequest {
|
||||
title?: string;
|
||||
username?: string;
|
||||
link?: string;
|
||||
subscribers_count?: number;
|
||||
description?: string;
|
||||
target_channel_ids?: string[];
|
||||
username?: string | null;
|
||||
description?: string | null;
|
||||
subscribers_count?: number | null;
|
||||
}
|
||||
|
||||
export interface ExternalChannelImportResponse {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
channels: ExternalChannel[];
|
||||
export interface ExternalChannelLinksUpdateRequest {
|
||||
add_target_channel_ids?: string[];
|
||||
remove_target_channel_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ExternalChannelsListResponse {
|
||||
external_channels: ExternalChannel[];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Creative
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type CreativeStatus = "active" | "archived";
|
||||
|
||||
export interface Creative {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string; // Template with {link} placeholder
|
||||
text: string;
|
||||
target_channel_id: string;
|
||||
is_archived: boolean;
|
||||
target_channel_title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Relations
|
||||
target_channel: TargetChannel;
|
||||
// Stats
|
||||
total_purchases: number;
|
||||
total_subscriptions: number;
|
||||
avg_cpf: number | null;
|
||||
status: CreativeStatus;
|
||||
placements_count: number;
|
||||
}
|
||||
|
||||
export interface CreativeDetail extends Creative {
|
||||
purchases: Purchase[];
|
||||
export interface CreativesListResponse {
|
||||
creatives: Creative[];
|
||||
}
|
||||
|
||||
export interface CreativeCreateRequest {
|
||||
name: string;
|
||||
text: string; // Must contain {link} placeholder
|
||||
text: string;
|
||||
target_channel_id: string;
|
||||
}
|
||||
|
||||
export interface CreativeUpdateRequest {
|
||||
name?: string;
|
||||
text?: string;
|
||||
is_archived?: boolean;
|
||||
status?: CreativeStatus;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Purchase
|
||||
// Placement (Purchase)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type InviteLinkType = "public" | "private";
|
||||
export type InviteLinkType = "public" | "approval";
|
||||
export type PlacementStatus = "active" | "archived";
|
||||
export type ViewsAvailability =
|
||||
| "unknown"
|
||||
| "available"
|
||||
| "unavailable"
|
||||
| "manual";
|
||||
|
||||
export interface Purchase {
|
||||
export interface Placement {
|
||||
id: string;
|
||||
target_channel_id: string;
|
||||
target_channel_title: string;
|
||||
external_channel_id: string;
|
||||
external_channel_title: string;
|
||||
creative_id: string;
|
||||
// Purchase details
|
||||
scheduled_date: string | null; // ISO 8601
|
||||
actual_date: string | null;
|
||||
cost: number | null; // in rubles
|
||||
creative_name: string;
|
||||
placement_date: string;
|
||||
cost: number | null;
|
||||
comment: string | null;
|
||||
post_link: string | null; // Link to ad post in external channel
|
||||
invite_link: string; // Generated invite link
|
||||
invite_link_type: InviteLinkType; // with bot approval or not
|
||||
is_archived: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Relations
|
||||
target_channel: TargetChannel;
|
||||
external_channel: ExternalChannel;
|
||||
creative: Creative;
|
||||
// Stats
|
||||
ad_post_url: string | null;
|
||||
invite_link_type: InviteLinkType;
|
||||
invite_link: string;
|
||||
status: PlacementStatus;
|
||||
subscriptions_count: number;
|
||||
views_count: number | null;
|
||||
cpf: number | null; // Cost Per Follower
|
||||
cpm: number | null; // Cost Per Mile (1000 views)
|
||||
conversion_rate: number | null; // subscriptions / views * 100
|
||||
views_availability: ViewsAvailability;
|
||||
last_views_fetch_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PurchaseDetail extends Purchase {
|
||||
subscriptions: Subscription[];
|
||||
views_history: ViewsSnapshot[];
|
||||
export interface PlacementsListResponse {
|
||||
placements: Placement[];
|
||||
}
|
||||
|
||||
export interface PurchaseCreateRequest {
|
||||
export interface PlacementCreateRequest {
|
||||
target_channel_id: string;
|
||||
external_channel_id: string;
|
||||
creative_id: string;
|
||||
scheduled_date?: string;
|
||||
actual_date?: string;
|
||||
cost?: number;
|
||||
comment?: string;
|
||||
post_link?: string;
|
||||
invite_link_type: InviteLinkType;
|
||||
placement_date: string;
|
||||
cost?: number | null;
|
||||
comment?: string | null;
|
||||
ad_post_url?: string | null;
|
||||
invite_link_type?: InviteLinkType;
|
||||
}
|
||||
|
||||
export interface PurchaseCreateResponse extends Purchase {
|
||||
formatted_message: string; // Creative text with invite_link inserted
|
||||
export interface PlacementUpdateRequest {
|
||||
placement_date?: string;
|
||||
cost?: number | null;
|
||||
comment?: string | null;
|
||||
ad_post_url?: string | null;
|
||||
status?: PlacementStatus;
|
||||
}
|
||||
|
||||
export interface PurchaseUpdateRequest {
|
||||
scheduled_date?: string;
|
||||
actual_date?: string;
|
||||
cost?: number;
|
||||
comment?: string;
|
||||
post_link?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
// Legacy aliases for backwards compatibility
|
||||
export type Purchase = Placement;
|
||||
export type PurchaseCreateRequest = PlacementCreateRequest;
|
||||
export type PurchaseUpdateRequest = PlacementUpdateRequest;
|
||||
|
||||
export interface PurchaseRefreshViewsResponse {
|
||||
views: number;
|
||||
export interface ViewsFetchResponse {
|
||||
placement_id: string;
|
||||
views_count: number | null;
|
||||
views_availability: ViewsAvailability;
|
||||
fetched_at: string;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface ViewsHistoryResponse {
|
||||
histories: ViewsSnapshot[];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -372,7 +352,7 @@ export interface CreativeQueryParams {
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
export interface PurchaseQueryParams {
|
||||
export interface PlacementQueryParams {
|
||||
target_channel_id?: string;
|
||||
external_channel_id?: string;
|
||||
creative_id?: string;
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
// ============================================================================
|
||||
|
||||
export const API_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "https://api.tgex.app/v1";
|
||||
export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true";
|
||||
export const BOT_USERNAME = process.env.NEXT_PUBLIC_BOT_USERNAME || "tgex_bot";
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||
export const USE_MOCKS =
|
||||
process.env.NEXT_PUBLIC_USE_MOCKS?.toLowerCase() === "true" || false;
|
||||
export const BOT_USERNAME =
|
||||
process.env.NEXT_PUBLIC_BOT_USERNAME || "your_bot_username";
|
||||
|
||||
// Local Storage Keys
|
||||
export const STORAGE_KEYS = {
|
||||
@@ -76,7 +78,7 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
export const ROUTES = {
|
||||
HOME: "/",
|
||||
LOGIN: "/login",
|
||||
AUTH_COMPLETE: "/auth-complete",
|
||||
AUTH_COMPLETE: "/auth/complete",
|
||||
|
||||
CHANNELS: "/channels",
|
||||
CHANNEL_DETAIL: (id: string) => `/channels/${id}`,
|
||||
|
||||
191
lib/utils/file-parser.ts
Normal file
191
lib/utils/file-parser.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// ============================================================================
|
||||
// File Parser Utilities
|
||||
// ============================================================================
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import Papa from "papaparse";
|
||||
|
||||
export interface ParsedChannel {
|
||||
telegram_id?: number;
|
||||
title: string;
|
||||
username?: string;
|
||||
description?: string;
|
||||
subscribers_count?: number;
|
||||
row: number; // Номер строки для отчетов об ошибках
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
channels: ParsedChannel[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит Excel файл (xlsx)
|
||||
*/
|
||||
export const parseExcelFile = async (file: File): Promise<ParseResult> => {
|
||||
const errors: string[] = [];
|
||||
const channels: ParsedChannel[] = [];
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
// Пропускаем заголовок (первая строка)
|
||||
const rows = jsonData.slice(1) as any[][];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const rowNumber = index + 2; // +2 потому что пропустили заголовок и индекс с 0
|
||||
|
||||
// Пропускаем пустые строки
|
||||
if (!row || row.length === 0 || !row[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel: ParsedChannel = {
|
||||
row: rowNumber,
|
||||
title: String(row[0] || "").trim(),
|
||||
username: row[1] ? String(row[1]).trim().replace("@", "") : undefined,
|
||||
telegram_id: row[2] ? parseInt(String(row[2])) : undefined,
|
||||
subscribers_count: row[3] ? parseInt(String(row[3])) : undefined,
|
||||
description: row[4] ? String(row[4]).trim() : undefined,
|
||||
};
|
||||
|
||||
if (!channel.title) {
|
||||
errors.push(`Строка ${rowNumber}: отсутствует название канала`);
|
||||
return;
|
||||
}
|
||||
|
||||
channels.push(channel);
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Строка ${rowNumber}: ошибка парсинга - ${
|
||||
err instanceof Error ? err.message : "неизвестная ошибка"
|
||||
}`
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Ошибка чтения файла: ${
|
||||
err instanceof Error ? err.message : "неизвестная ошибка"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return { channels, errors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Парсит CSV файл
|
||||
*/
|
||||
export const parseCsvFile = async (file: File): Promise<ParseResult> => {
|
||||
return new Promise((resolve) => {
|
||||
const errors: string[] = [];
|
||||
const channels: ParsedChannel[] = [];
|
||||
|
||||
Papa.parse(file, {
|
||||
header: false,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
const rows = results.data as string[][];
|
||||
|
||||
// Пропускаем заголовок
|
||||
rows.slice(1).forEach((row, index) => {
|
||||
const rowNumber = index + 2;
|
||||
|
||||
if (!row || row.length === 0 || !row[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel: ParsedChannel = {
|
||||
row: rowNumber,
|
||||
title: (row[0] || "").trim(),
|
||||
username: row[1] ? row[1].trim().replace("@", "") : undefined,
|
||||
telegram_id: row[2] ? parseInt(row[2]) : undefined,
|
||||
subscribers_count: row[3] ? parseInt(row[3]) : undefined,
|
||||
description: row[4] ? row[4].trim() : undefined,
|
||||
};
|
||||
|
||||
if (!channel.title) {
|
||||
errors.push(`Строка ${rowNumber}: отсутствует название канала`);
|
||||
return;
|
||||
}
|
||||
|
||||
channels.push(channel);
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Строка ${rowNumber}: ошибка парсинга - ${
|
||||
err instanceof Error ? err.message : "неизвестная ошибка"
|
||||
}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
resolve({ channels, errors });
|
||||
},
|
||||
error: (err) => {
|
||||
errors.push(`Ошибка парсинга CSV: ${err.message}`);
|
||||
resolve({ channels: [], errors });
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Универсальная функция парсинга файла
|
||||
*/
|
||||
export const parseChannelsFile = async (file: File): Promise<ParseResult> => {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
|
||||
if (extension === "xlsx" || extension === "xls") {
|
||||
return parseExcelFile(file);
|
||||
} else if (extension === "csv") {
|
||||
return parseCsvFile(file);
|
||||
} else {
|
||||
return {
|
||||
channels: [],
|
||||
errors: [
|
||||
"Неподдерживаемый формат файла. Поддерживаются только .xlsx, .xls и .csv",
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Скачать шаблон для импорта
|
||||
*/
|
||||
export const downloadTemplate = (format: "xlsx" | "csv") => {
|
||||
const headers = [
|
||||
"Название канала*",
|
||||
"Username (без @)",
|
||||
"Telegram ID",
|
||||
"Подписчиков",
|
||||
"Описание",
|
||||
];
|
||||
const example = [
|
||||
"Пример канала",
|
||||
"example_channel",
|
||||
"-1001234567890",
|
||||
"10000",
|
||||
"Описание канала",
|
||||
];
|
||||
|
||||
if (format === "xlsx") {
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, example]);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Channels");
|
||||
XLSX.writeFile(wb, "channels_template.xlsx");
|
||||
} else {
|
||||
const csv = Papa.unparse([headers, example]);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = "channels_template.csv";
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user