feat: all project setup

This commit is contained in:
ivannoskov
2025-11-10 12:07:24 +03:00
parent fbfd7719bb
commit b0a9934220
99 changed files with 19597 additions and 0 deletions

43
lib/api/analytics.ts Normal file
View File

@@ -0,0 +1,43 @@
// ============================================================================
// Analytics API
// ============================================================================
import { api } from "./client";
import type {
AnalyticsOverview,
CostsReport,
AnalyticsChannelData,
AnalyticsCreativeData,
} from "@/lib/types/api";
export const analyticsApi = {
/**
* Get overview analytics
*/
overview: (params?: { target_channel_id?: string }) =>
api.get<AnalyticsOverview>("/analytics/overview", { params }),
/**
* Get costs report
*/
costs: (params: {
period?: "day" | "week" | "month";
target_channel_id?: string;
}) => api.get<CostsReport>("/analytics/costs", { params }),
/**
* Get channels analytics
*/
channels: (params?: { target_channel_id?: string }) =>
api.get<{ data: AnalyticsChannelData[] }>("/analytics/channels", {
params,
}),
/**
* Get creatives analytics
*/
creatives: (params?: { target_channel_id?: string }) =>
api.get<{ data: AnalyticsCreativeData[] }>("/analytics/creatives", {
params,
}),
};

109
lib/api/auth.ts Normal file
View File

@@ -0,0 +1,109 @@
// ============================================================================
// Auth API
// ============================================================================
import { api, saveAuthTokens, clearAuthTokens } from "./client";
import { STORAGE_KEYS } from "@/lib/utils/constants";
import type {
User,
AuthInitResponse,
AuthCompleteRequest,
AuthCompleteResponse,
AuthRefreshRequest,
AuthRefreshResponse,
} from "@/lib/types/api";
export const authApi = {
/**
* Initialize Telegram auth flow
*/
init: () => api.get<AuthInitResponse>("/auth/init", { requireAuth: false }),
/**
* 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,
{
requireAuth: false,
}
);
// Save tokens to storage
saveAuthTokens(response.access_token, response.refresh_token);
// Save user to storage
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response.user));
}
return response;
},
/**
* Refresh access token
*/
refresh: async (): Promise<string> => {
if (typeof window === "undefined") {
throw new Error("Cannot refresh token on server");
}
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
if (!refreshToken) {
throw new Error("No refresh token available");
}
const data: AuthRefreshRequest = { refresh_token: refreshToken };
const response = await api.post<AuthRefreshResponse>(
"/auth/refresh",
data,
{
requireAuth: false,
}
);
// Update access token
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.access_token);
return response.access_token;
},
/**
* Get current user info
*/
me: () => api.get<User>("/auth/me"),
/**
* Logout (clear tokens)
*/
logout: () => {
clearAuthTokens();
},
/**
* Get user from storage (no API call)
*/
getUserFromStorage: (): User | null => {
if (typeof window === "undefined") return null;
const userJson = localStorage.getItem(STORAGE_KEYS.USER);
if (!userJson) return null;
try {
return JSON.parse(userJson);
} catch {
return null;
}
},
/**
* Check if user is authenticated
*/
isAuthenticated: (): boolean => {
if (typeof window === "undefined") return false;
return !!localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
},
};

37
lib/api/channels.ts Normal file
View File

@@ -0,0 +1,37 @@
// ============================================================================
// Target Channels API
// ============================================================================
import { api } from "./client";
import type {
TargetChannel,
TargetChannelDetail,
TargetChannelQueryParams,
TargetChannelUpdateRequest,
ListResponse,
SuccessResponse,
} from "@/lib/types/api";
export const channelsApi = {
/**
* Get list of 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),
/**
* Delete (disconnect) target channel
*/
delete: (id: string) => api.delete<SuccessResponse>(`/target-channels/${id}`),
};

347
lib/api/client.ts Normal file
View File

@@ -0,0 +1,347 @@
// ============================================================================
// API Client with Mock Support
// ============================================================================
import { API_URL, USE_MOCKS, STORAGE_KEYS } from "@/lib/utils/constants";
import { mockApiHandlers } from "@/lib/mocks/handlers";
import type { ApiError } from "@/lib/types/api";
// ============================================================================
// Types
// ============================================================================
interface RequestOptions extends RequestInit {
params?: Record<string, any>;
requireAuth?: boolean;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Build URL with query parameters
*/
const buildUrl = (endpoint: string, params?: Record<string, any>): string => {
if (!params || Object.keys(params).length === 0) {
return endpoint;
}
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${endpoint}?${queryString}` : endpoint;
};
/**
* Get auth token from storage
*/
const getAuthToken = (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
};
/**
* Clear auth tokens from storage
*/
export const clearAuthTokens = (): void => {
if (typeof window === "undefined") return;
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER);
};
/**
* Save auth tokens to storage
*/
export const saveAuthTokens = (
accessToken: string,
refreshToken: string
): void => {
if (typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
};
// ============================================================================
// Mock Router
// ============================================================================
/**
* Route request to appropriate mock handler
*/
const routeMockRequest = async (
endpoint: string,
options: RequestOptions
): Promise<any> => {
const method = options.method || "GET";
const params = options.params || {};
const body = options.body ? JSON.parse(options.body as string) : undefined;
console.log(`[MOCK API] ${method} ${endpoint}`, { params, body });
// Auth endpoints
if (endpoint === "/auth/init") {
return mockApiHandlers.authInit();
}
if (endpoint === "/auth/complete") {
return mockApiHandlers.authComplete(body);
}
if (endpoint === "/auth/refresh") {
return mockApiHandlers.authRefresh(body);
}
if (endpoint === "/auth/me") {
return mockApiHandlers.authMe();
}
// Target Channels
if (endpoint === "/target-channels" && method === "GET") {
return mockApiHandlers.getTargetChannels(params);
}
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") {
const id = endpoint.split("/")[2];
return mockApiHandlers.deleteTargetChannel(id);
}
// External Channels
if (endpoint === "/external-channels" && method === "GET") {
return mockApiHandlers.getExternalChannels(params);
}
if (endpoint === "/external-channels" && method === "POST") {
return mockApiHandlers.createExternalChannel(body);
}
if (
endpoint.startsWith("/external-channels/") &&
!endpoint.includes("/import") &&
method === "GET"
) {
const id = endpoint.split("/")[2];
return mockApiHandlers.getExternalChannel(id);
}
if (endpoint.startsWith("/external-channels/") && method === "PATCH") {
const id = endpoint.split("/")[2];
return mockApiHandlers.updateExternalChannel(id, body);
}
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") {
return mockApiHandlers.getCreatives(params);
}
if (endpoint === "/creatives" && method === "POST") {
return mockApiHandlers.createCreative(body);
}
if (endpoint.startsWith("/creatives/") && method === "GET") {
const id = endpoint.split("/")[2];
return mockApiHandlers.getCreative(id);
}
if (endpoint.startsWith("/creatives/") && method === "PATCH") {
const id = endpoint.split("/")[2];
return mockApiHandlers.updateCreative(id, body);
}
if (endpoint.startsWith("/creatives/") && method === "DELETE") {
const id = endpoint.split("/")[2];
return mockApiHandlers.deleteCreative(id);
}
// Purchases
if (endpoint === "/purchases" && method === "GET") {
return mockApiHandlers.getPurchases(params);
}
if (endpoint === "/purchases" && method === "POST") {
return mockApiHandlers.createPurchase(body);
}
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "GET") {
const id = endpoint.split("/")[2];
return mockApiHandlers.getPurchase(id);
}
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "PATCH") {
const id = endpoint.split("/")[2];
return mockApiHandlers.updatePurchase(id, body);
}
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "DELETE") {
const id = endpoint.split("/")[2];
return mockApiHandlers.deletePurchase(id);
}
if (endpoint.includes("/refresh-views") && method === "POST") {
const id = endpoint.split("/")[2];
return mockApiHandlers.refreshPurchaseViews(id);
}
// Analytics
if (endpoint === "/analytics/overview") {
return mockApiHandlers.getAnalyticsOverview(params);
}
if (endpoint === "/analytics/costs") {
return mockApiHandlers.getAnalyticsCosts(params);
}
if (endpoint === "/analytics/channels") {
return mockApiHandlers.getAnalyticsChannels(params);
}
if (endpoint === "/analytics/creatives") {
return mockApiHandlers.getAnalyticsCreatives(params);
}
throw {
error: {
code: "NOT_IMPLEMENTED",
message: `Mock handler not implemented for ${method} ${endpoint}`,
},
};
};
// ============================================================================
// API Client
// ============================================================================
/**
* Main API request function with mock support
*/
export const apiRequest = async <T = any>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> => {
const { params, requireAuth = true, ...fetchOptions } = options;
// Use mocks if enabled
if (USE_MOCKS) {
try {
const result = await routeMockRequest(endpoint, options);
return result as T;
} catch (error) {
throw error;
}
}
// Real API request
const url = buildUrl(`${API_URL}${endpoint}`, params);
const headers: HeadersInit = {
"Content-Type": "application/json",
...fetchOptions.headers,
};
// Add auth token if required
if (requireAuth) {
const token = getAuthToken();
if (token) {
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
}
}
try {
const response = await fetch(url, {
...fetchOptions,
headers,
});
// Parse response
const data = await response.json();
// Handle errors
if (!response.ok) {
const error: ApiError = data;
throw error;
}
return data as T;
} catch (error: any) {
// Network error or parsing error
if (!error.error) {
throw {
error: {
code: "NETWORK_ERROR",
message: error.message || "Network error occurred",
},
} as ApiError;
}
throw error;
}
};
// ============================================================================
// Convenience Methods
// ============================================================================
export const api = {
get: <T = any>(endpoint: string, options?: RequestOptions) =>
apiRequest<T>(endpoint, { ...options, method: "GET" }),
post: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
apiRequest<T>(endpoint, {
...options,
method: "POST",
body: JSON.stringify(body),
}),
patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
apiRequest<T>(endpoint, {
...options,
method: "PATCH",
body: JSON.stringify(body),
}),
delete: <T = any>(endpoint: string, options?: RequestOptions) =>
apiRequest<T>(endpoint, { ...options, method: "DELETE" }),
// Special method for file upload
upload: async <T = any>(
endpoint: string,
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;
}
const url = `${API_URL}${endpoint}`;
const token = getAuthToken();
const headers: HeadersInit = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(url, {
method: "POST",
headers,
body: formData,
...options,
});
const data = await response.json();
if (!response.ok) {
throw data as ApiError;
}
return data as T;
},
};

44
lib/api/creatives.ts Normal file
View File

@@ -0,0 +1,44 @@
// ============================================================================
// Creatives API
// ============================================================================
import { api } from "./client";
import type {
Creative,
CreativeDetail,
CreativeQueryParams,
CreativeCreateRequest,
CreativeUpdateRequest,
ListResponse,
SuccessResponse,
} from "@/lib/types/api";
export const creativesApi = {
/**
* Get list of creatives
*/
list: (params?: CreativeQueryParams) =>
api.get<ListResponse<Creative>>("/creatives", { params }),
/**
* Get creative details
*/
get: (id: string) => api.get<CreativeDetail>(`/creatives/${id}`),
/**
* Create new creative
*/
create: (data: CreativeCreateRequest) =>
api.post<Creative>("/creatives", data),
/**
* Update creative
*/
update: (id: string, data: CreativeUpdateRequest) =>
api.patch<Creative>(`/creatives/${id}`, data),
/**
* Delete creative
*/
delete: (id: string) => api.delete<SuccessResponse>(`/creatives/${id}`),
};

View File

@@ -0,0 +1,61 @@
// ============================================================================
// External Channels API
// ============================================================================
import { api } from "./client";
import type {
ExternalChannel,
ExternalChannelDetail,
ExternalChannelQueryParams,
ExternalChannelCreateRequest,
ExternalChannelUpdateRequest,
ExternalChannelImportResponse,
ListResponse,
SuccessResponse,
} from "@/lib/types/api";
export const externalChannelsApi = {
/**
* Get list of external channels
*/
list: (params?: ExternalChannelQueryParams) =>
api.get<ListResponse<ExternalChannel>>("/external-channels", { params }),
/**
* Get external channel details
*/
get: (id: string) =>
api.get<ExternalChannelDetail>(`/external-channels/${id}`),
/**
* Create new external channel
*/
create: (data: ExternalChannelCreateRequest) =>
api.post<ExternalChannel>("/external-channels", data),
/**
* Update external channel
*/
update: (id: string, data: ExternalChannelUpdateRequest) =>
api.patch<ExternalChannel>(`/external-channels/${id}`, data),
/**
* Delete external channel
*/
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
);
},
};

11
lib/api/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// ============================================================================
// API Export
// ============================================================================
export * from "./client";
export * from "./auth";
export * from "./channels";
export * from "./external-channels";
export * from "./creatives";
export * from "./purchases";
export * from "./analytics";

52
lib/api/purchases.ts Normal file
View File

@@ -0,0 +1,52 @@
// ============================================================================
// 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`),
};

277
lib/mocks/data/analytics.ts Normal file
View File

@@ -0,0 +1,277 @@
import type {
AnalyticsOverview,
CostsReport,
AnalyticsChannelData,
AnalyticsCreativeData,
} from "@/lib/types/api";
// ============================================================================
// Mock Analytics Data
// ============================================================================
export const mockAnalyticsOverview: AnalyticsOverview = {
totalPurchases: 25,
purchasesChange: 5,
totalFollowers: 2500,
followersChange: 320,
averageCpf: 50.0,
cpfChange: -5.2,
averageCpm: 450.0,
cpmChange: -3.8,
topChannelsByCpf: [
{
channel_id: "ec3",
channel_name: "IT и Технологии",
total_purchases: 3,
avg_cpf: 38.2,
},
{
channel_id: "ec1",
channel_name: "Канал о Маркетинге",
total_purchases: 5,
avg_cpf: 40.0,
},
{
channel_id: "ec5",
channel_name: "Продажи и Переговоры",
total_purchases: 4,
avg_cpf: 45.0,
},
{
channel_id: "ec2",
channel_name: "Бизнес и Стартапы",
total_purchases: 8,
avg_cpf: 55.8,
},
{
channel_id: "ec4",
channel_name: "Крипто Инвестиции",
total_purchases: 2,
avg_cpf: 68.5,
},
],
topCreativesByCpf: [
{
creative_id: "cr1",
creative_name: 'Креатив "Присоединяйся"',
total_subscriptions: 1180,
avg_cpf: 42.4,
},
{
creative_id: "cr2",
creative_name: 'Креатив "Эксклюзив"',
total_subscriptions: 577,
avg_cpf: 48.5,
},
{
creative_id: "cr3",
creative_name: 'Креатив "Обучение"',
total_subscriptions: 803,
avg_cpf: 52.3,
},
],
};
// Mock data for costs report
export const mockCostsReportByDay: CostsReport = {
totalCost: 76500,
averageCost: 7650,
periods: [
{
date: "2024-02-20",
total_cost: 5000,
purchases_count: 1,
avg_cost: 5000,
},
{
date: "2024-02-22",
total_cost: 3500,
purchases_count: 1,
avg_cost: 3500,
},
{
date: "2024-02-25",
total_cost: 7000,
purchases_count: 2,
avg_cost: 3500,
},
{
date: "2024-02-28",
total_cost: 4500,
purchases_count: 1,
avg_cost: 4500,
},
{
date: "2024-03-02",
total_cost: 8500,
purchases_count: 2,
avg_cost: 4250,
},
{
date: "2024-03-05",
total_cost: 6000,
purchases_count: 1,
avg_cost: 6000,
},
{
date: "2024-03-08",
total_cost: 12000,
purchases_count: 3,
avg_cost: 4000,
},
{
date: "2024-03-12",
total_cost: 5500,
purchases_count: 1,
avg_cost: 5500,
},
{
date: "2024-03-15",
total_cost: 15000,
purchases_count: 3,
avg_cost: 5000,
},
{
date: "2024-03-18",
total_cost: 9500,
purchases_count: 2,
avg_cost: 4750,
},
],
};
export const mockCostsReportByWeek: CostsReport = {
totalCost: 125000,
averageCost: 15625,
periods: [
{
date: "2024-02-19",
total_cost: 15500,
purchases_count: 4,
avg_cost: 3875,
},
{
date: "2024-02-26",
total_cost: 13000,
purchases_count: 3,
avg_cost: 4333,
},
{
date: "2024-03-04",
total_cost: 26500,
purchases_count: 6,
avg_cost: 4417,
},
{
date: "2024-03-11",
total_cost: 33000,
purchases_count: 7,
avg_cost: 4714,
},
{
date: "2024-03-18",
total_cost: 24500,
purchases_count: 5,
avg_cost: 4900,
},
],
};
export const mockCostsReportByMonth: CostsReport = {
totalCost: 125000,
averageCost: 62500,
periods: [
{
date: "2024-02-01",
total_cost: 20000,
purchases_count: 5,
avg_cost: 4000,
},
{
date: "2024-03-01",
total_cost: 105000,
purchases_count: 20,
avg_cost: 5250,
},
],
};
// Mock data for channels analytics
export const mockAnalyticsChannelsData: AnalyticsChannelData[] = [
{
channel_id: "ec1",
channel_name: "Канал о Маркетинге",
purchases_count: 5,
total_cost: 25000,
total_subscriptions: 625,
avg_cpf: 40.0,
avg_cpm: 380.0,
},
{
channel_id: "ec2",
channel_name: "Бизнес и Стартапы",
purchases_count: 8,
total_cost: 48000,
total_subscriptions: 860,
avg_cpf: 55.8,
avg_cpm: 450.0,
},
{
channel_id: "ec3",
channel_name: "IT и Технологии",
purchases_count: 3,
total_cost: 12000,
total_subscriptions: 314,
avg_cpf: 38.2,
avg_cpm: 360.0,
},
{
channel_id: "ec4",
channel_name: "Крипто Инвестиции",
purchases_count: 2,
total_cost: 18000,
total_subscriptions: 263,
avg_cpf: 68.5,
avg_cpm: 520.0,
},
{
channel_id: "ec5",
channel_name: "Продажи и Переговоры",
purchases_count: 4,
total_cost: 16000,
total_subscriptions: 356,
avg_cpf: 45.0,
avg_cpm: 410.0,
},
];
// Mock data for creatives analytics
export const mockAnalyticsCreativesData: AnalyticsCreativeData[] = [
{
creative_id: "cr1",
creative_name: 'Креатив "Присоединяйся"',
purchases_count: 10,
total_cost: 50000,
total_subscriptions: 1180,
avg_cpf: 42.4,
conversion_rate: 0.95,
},
{
creative_id: "cr2",
creative_name: 'Креатив "Эксклюзив"',
purchases_count: 5,
total_cost: 28000,
total_subscriptions: 577,
avg_cpf: 48.5,
conversion_rate: 0.88,
},
{
creative_id: "cr3",
creative_name: 'Креатив "Обучение"',
purchases_count: 8,
total_cost: 42000,
total_subscriptions: 803,
avg_cpf: 52.3,
conversion_rate: 0.92,
},
];

View File

@@ -0,0 +1,79 @@
import { TargetChannel, TargetChannelDetail } from "@/lib/types/api";
// ============================================================================
// Mock Target Channels Data
// ============================================================================
export const mockTargetChannels: TargetChannel[] = [
{
id: "tc1",
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",
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",
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,
},
],
};
};

View File

@@ -0,0 +1,71 @@
import { Creative, CreativeDetail } from "@/lib/types/api";
import { mockTargetChannels } from "./channels";
// ============================================================================
// Mock Creatives Data
// ============================================================================
export const mockCreatives: Creative[] = [
{
id: "cr1",
name: 'Креатив "Присоединяйся"',
text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}",
target_channel_id: "tc1",
is_archived: false,
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,
},
{
id: "cr2",
name: 'Креатив "Эксклюзив"',
text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}",
target_channel_id: "tc1",
is_archived: false,
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,
},
{
id: "cr3",
name: 'Креатив "Обучение"',
text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}",
target_channel_id: "tc2",
is_archived: false,
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,
},
{
id: "cr4",
name: "Старый креатив (архив)",
text: "Устаревший текст с предложением. {link}",
target_channel_id: "tc1",
is_archived: true,
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,
},
];
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
};
};

View File

@@ -0,0 +1,95 @@
import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api";
// ============================================================================
// Mock External Channels Data
// ============================================================================
export const mockExternalChannels: ExternalChannel[] = [
{
id: "ec1",
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",
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",
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,
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",
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
};
};

264
lib/mocks/data/purchases.ts Normal file
View File

@@ -0,0 +1,264 @@
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),
};
};

32
lib/mocks/data/users.ts Normal file
View File

@@ -0,0 +1,32 @@
import { User } from "@/lib/types/api";
// ============================================================================
// Mock Users Data
// ============================================================================
export const mockUsers: User[] = [
{
id: "1",
telegram_id: 123456789,
username: "ivan_test",
first_name: "Иван",
last_name: "Иванов",
created_at: "2024-01-15T10:00:00.000Z",
},
{
id: "2",
telegram_id: 987654321,
username: "maria_test",
first_name: "Мария",
last_name: "Петрова",
created_at: "2024-02-01T10:00:00.000Z",
},
];
export const mockCurrentUser = mockUsers[0];
// Mock tokens
export const mockTokens = {
access_token: "mock_access_token_123456789",
refresh_token: "mock_refresh_token_123456789",
};

636
lib/mocks/handlers.ts Normal file
View File

@@ -0,0 +1,636 @@
// ============================================================================
// Mock API Handlers
// ============================================================================
import {
mockCurrentUser,
mockTokens,
mockTargetChannels,
getMockTargetChannelDetail,
mockExternalChannels,
getMockExternalChannelDetail,
mockCreatives,
getMockCreativeDetail,
mockPurchases,
getMockPurchaseDetail,
mockAnalyticsOverview,
mockCostsReportByDay,
mockCostsReportByWeek,
mockCostsReportByMonth,
mockAnalyticsChannelsData,
mockAnalyticsCreativesData,
} from "./index";
import type {
ListResponse,
SuccessResponse,
ApiError,
TargetChannel,
ExternalChannel,
Creative,
Purchase,
PurchaseCreateRequest,
PurchaseCreateResponse,
CreativeCreateRequest,
ExternalChannelCreateRequest,
PurchaseRefreshViewsResponse,
ExternalChannelImportResponse,
AuthCompleteRequest,
AuthCompleteResponse,
AuthRefreshRequest,
AuthRefreshResponse,
AuthInitResponse,
TargetChannelUpdateRequest,
ExternalChannelUpdateRequest,
CreativeUpdateRequest,
PurchaseUpdateRequest,
} from "@/lib/types/api";
// Simulate network delay
const delay = (ms: number = 300) =>
new Promise((resolve) => setTimeout(resolve, ms));
// Helper to generate mock error
const mockError = (code: string, message: string): ApiError => ({
error: { code, message },
});
// Helper to generate IDs
let idCounter = 1000;
const generateId = () => `mock_${idCounter++}`;
// ============================================================================
// Mock API Handlers
// ============================================================================
export const mockApiHandlers = {
// --------------------------------------------------------------------------
// Auth
// --------------------------------------------------------------------------
async authInit(): Promise<AuthInitResponse> {
await delay();
return {
bot_username: "tgex_bot",
start_param: "login",
};
},
async authComplete(data: AuthCompleteRequest): Promise<AuthCompleteResponse> {
await delay();
if (!data.token) {
throw mockError("VALIDATION_ERROR", "Token is required");
}
return {
access_token: mockTokens.access_token,
refresh_token: mockTokens.refresh_token,
user: mockCurrentUser,
};
},
async authRefresh(data: AuthRefreshRequest): Promise<AuthRefreshResponse> {
await delay();
if (!data.refresh_token) {
throw mockError("VALIDATION_ERROR", "Refresh token is required");
}
return {
access_token: `${mockTokens.access_token}_refreshed`,
};
},
async authMe() {
await delay();
return mockCurrentUser;
},
// --------------------------------------------------------------------------
// Target Channels
// --------------------------------------------------------------------------
async getTargetChannels(params?: {
is_active?: boolean;
}): Promise<ListResponse<TargetChannel>> {
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(),
};
},
async deleteTargetChannel(id: string): Promise<SuccessResponse> {
await delay();
const channel = mockTargetChannels.find((c) => c.id === id);
if (!channel) {
throw mockError("NOT_FOUND", "Target channel not found");
}
return { success: true };
},
// --------------------------------------------------------------------------
// External Channels
// --------------------------------------------------------------------------
async getExternalChannels(params?: {
target_channel_id?: string;
search?: string;
}): Promise<ListResponse<ExternalChannel>> {
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)
);
}
return {
data: filtered,
total: filtered.length,
};
},
async getExternalChannel(id: string) {
await delay();
const detail = getMockExternalChannelDetail(id);
if (!detail) {
throw mockError("NOT_FOUND", "External channel not found");
}
return detail;
},
async createExternalChannel(
data: ExternalChannelCreateRequest
): Promise<ExternalChannel> {
await delay(500);
const newChannel: ExternalChannel = {
id: generateId(),
telegram_id: null,
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;
},
async updateExternalChannel(
id: string,
data: ExternalChannelUpdateRequest
): Promise<ExternalChannel> {
await delay();
const channel = mockExternalChannels.find((c) => c.id === id);
if (!channel) {
throw mockError("NOT_FOUND", "External channel not found");
}
return {
...channel,
...data,
updated_at: new Date().toISOString(),
};
},
async deleteExternalChannel(id: string): Promise<SuccessResponse> {
await delay();
const index = mockExternalChannels.findIndex((c) => c.id === id);
if (index === -1) {
throw mockError("NOT_FOUND", "External channel not found");
}
mockExternalChannels.splice(index, 1);
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);
}
return {
imported: importCount,
skipped: 1,
errors: ["Строка 15: некорректный формат ссылки"],
channels: imported,
};
},
// --------------------------------------------------------------------------
// Creatives
// --------------------------------------------------------------------------
async getCreatives(params?: {
target_channel_id?: string;
is_archived?: boolean;
}): Promise<ListResponse<Creative>> {
await delay();
let filtered = [...mockCreatives];
if (params?.target_channel_id) {
filtered = filtered.filter(
(c) => c.target_channel_id === params.target_channel_id
);
}
if (params?.is_archived !== undefined) {
filtered = filtered.filter((c) => c.is_archived === params.is_archived);
}
return {
data: filtered,
total: filtered.length,
};
},
async getCreative(id: string) {
await delay();
const detail = getMockCreativeDetail(id);
if (!detail) {
throw mockError("NOT_FOUND", "Creative not found");
}
return detail;
},
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
);
if (!targetChannel) {
throw mockError("NOT_FOUND", "Target channel not found");
}
const newCreative: Creative = {
id: generateId(),
name: data.name,
text: data.text,
target_channel_id: data.target_channel_id,
is_archived: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
target_channel: targetChannel,
total_purchases: 0,
total_subscriptions: 0,
avg_cpf: null,
};
mockCreatives.push(newCreative);
return newCreative;
},
async updateCreative(
id: string,
data: CreativeUpdateRequest
): Promise<Creative> {
await delay();
const creative = mockCreatives.find((c) => c.id === id);
if (!creative) {
throw mockError("NOT_FOUND", "Creative not found");
}
if (data.text && !data.text.includes("{link}")) {
throw mockError(
"VALIDATION_ERROR",
"Creative text must contain {link} placeholder"
);
}
return {
...creative,
...data,
updated_at: new Date().toISOString(),
};
},
async deleteCreative(id: string): Promise<SuccessResponse> {
await delay();
const creative = mockCreatives.find((c) => c.id === id);
if (!creative) {
throw mockError("NOT_FOUND", "Creative not found");
}
if (creative.total_purchases > 0) {
throw mockError(
"CREATIVE_IN_USE",
"Cannot delete creative with active purchases"
);
}
const index = mockCreatives.findIndex((c) => c.id === id);
mockCreatives.splice(index, 1);
return { success: true };
},
// --------------------------------------------------------------------------
// Purchases
// --------------------------------------------------------------------------
async getPurchases(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>> {
await delay();
let filtered = [...mockPurchases];
if (params?.target_channel_id) {
filtered = filtered.filter(
(p) => p.target_channel_id === params.target_channel_id
);
}
if (params?.external_channel_id) {
filtered = filtered.filter(
(p) => p.external_channel_id === params.external_channel_id
);
}
if (params?.creative_id) {
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;
});
}
return {
data: filtered,
total: filtered.length,
};
},
async getPurchase(id: string) {
await delay();
const detail = getMockPurchaseDetail(id);
if (!detail) {
throw mockError("NOT_FOUND", "Purchase not found");
}
return detail;
},
async createPurchase(
data: PurchaseCreateRequest
): Promise<PurchaseCreateResponse> {
await delay(800);
const targetChannel = mockTargetChannels.find(
(c) => c.id === data.target_channel_id
);
const externalChannel = mockExternalChannels.find(
(c) => c.id === data.external_channel_id
);
const creative = mockCreatives.find((c) => c.id === data.creative_id);
if (!targetChannel || !externalChannel || !creative) {
throw mockError("NOT_FOUND", "One of the required entities not found");
}
// Generate mock invite link
const inviteLink = `https://t.me/+${Math.random()
.toString(36)
.substring(2, 15)}`;
const newPurchase: Purchase = {
id: generateId(),
target_channel_id: data.target_channel_id,
external_channel_id: data.external_channel_id,
creative_id: data.creative_id,
scheduled_date: data.scheduled_date || null,
actual_date: data.actual_date || null,
cost: data.cost || null,
comment: data.comment || null,
post_link: data.post_link || null,
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,
subscriptions_count: 0,
views_count: null,
cpf: null,
cpm: null,
conversion_rate: null,
};
mockPurchases.push(newPurchase);
// Format message with invite link
const formattedMessage = creative.text.replace("{link}", inviteLink);
return {
...newPurchase,
formatted_message: formattedMessage,
};
},
async updatePurchase(
id: string,
data: PurchaseUpdateRequest
): Promise<Purchase> {
await delay();
const purchase = mockPurchases.find((p) => p.id === id);
if (!purchase) {
throw mockError("NOT_FOUND", "Purchase not found");
}
return {
...purchase,
...data,
updated_at: new Date().toISOString(),
};
},
async deletePurchase(id: string): Promise<SuccessResponse> {
await delay();
const index = mockPurchases.findIndex((p) => p.id === id);
if (index === -1) {
throw mockError("NOT_FOUND", "Purchase not found");
}
mockPurchases.splice(index, 1);
return { success: true };
},
async refreshPurchaseViews(
id: string
): Promise<PurchaseRefreshViewsResponse> {
await delay(1000);
const purchase = mockPurchases.find((p) => p.id === id);
if (!purchase) {
throw mockError("NOT_FOUND", "Purchase not found");
}
// Mock: add some random views
const newViews =
(purchase.views_count || 0) + Math.floor(Math.random() * 500);
return {
views: newViews,
fetched_at: new Date().toISOString(),
};
},
// --------------------------------------------------------------------------
// Analytics
// --------------------------------------------------------------------------
async getAnalyticsOverview(params?: {
target_channel_id?: string;
date_from?: string;
date_to?: string;
}) {
await delay();
return mockAnalyticsOverview;
},
async getAnalyticsCosts(params?: {
period?: "day" | "week" | "month";
target_channel_id?: string;
}) {
await delay();
const period = params?.period || "week";
if (period === "day") {
return mockCostsReportByDay;
} else if (period === "month") {
return mockCostsReportByMonth;
}
return mockCostsReportByWeek;
},
async getAnalyticsChannels(params?: {
target_channel_id?: string;
date_from?: string;
date_to?: string;
type?: "external" | "target";
}) {
await delay();
return {
data: mockAnalyticsChannelsData,
};
},
async getAnalyticsCreatives(params?: {
target_channel_id?: string;
date_from?: string;
date_to?: string;
}) {
await delay();
return {
data: mockAnalyticsCreativesData,
};
},
};

10
lib/mocks/index.ts Normal file
View File

@@ -0,0 +1,10 @@
// ============================================================================
// Mock Data Export
// ============================================================================
export * from "./data/users";
export * from "./data/channels";
export * from "./data/external-channels";
export * from "./data/creatives";
export * from "./data/purchases";
export * from "./data/analytics";

410
lib/types/api.ts Normal file
View File

@@ -0,0 +1,410 @@
// ============================================================================
// API Types - Generated from API_DOCUMENTATION.md
// ============================================================================
// ----------------------------------------------------------------------------
// User & Auth
// ----------------------------------------------------------------------------
export interface User {
id: string;
telegram_id: number;
username: string | null;
first_name: string;
last_name: string | null;
created_at: string; // ISO 8601
}
export interface AuthInitResponse {
bot_username: string;
start_param: string; // "login"
}
export interface AuthCompleteRequest {
token: string; // one-time token from bot
}
export interface AuthCompleteResponse {
access_token: string;
refresh_token: string;
user: User;
}
export interface AuthRefreshRequest {
refresh_token: string;
}
export interface AuthRefreshResponse {
access_token: string;
}
// ----------------------------------------------------------------------------
// Target Channel
// ----------------------------------------------------------------------------
export interface TargetChannel {
id: string;
telegram_id: number;
title: string;
username: string | null; // @channel_name
description: 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;
}
// ----------------------------------------------------------------------------
// External Channel
// ----------------------------------------------------------------------------
export interface ExternalChannel {
id: string;
telegram_id: number | null;
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[];
}
export interface ExternalChannelCreateRequest {
title: string;
username?: string;
link: string;
subscribers_count?: number;
description?: string;
target_channel_ids: string[]; // Привязка к целевым каналам
}
export interface ExternalChannelUpdateRequest {
title?: string;
username?: string;
link?: string;
subscribers_count?: number;
description?: string;
target_channel_ids?: string[];
}
export interface ExternalChannelImportResponse {
imported: number;
skipped: number;
errors: string[];
channels: ExternalChannel[];
}
// ----------------------------------------------------------------------------
// Creative
// ----------------------------------------------------------------------------
export interface Creative {
id: string;
name: string;
text: string; // Template with {link} placeholder
target_channel_id: string;
is_archived: boolean;
created_at: string;
updated_at: string;
// Relations
target_channel: TargetChannel;
// Stats
total_purchases: number;
total_subscriptions: number;
avg_cpf: number | null;
}
export interface CreativeDetail extends Creative {
purchases: Purchase[];
}
export interface CreativeCreateRequest {
name: string;
text: string; // Must contain {link} placeholder
target_channel_id: string;
}
export interface CreativeUpdateRequest {
name?: string;
text?: string;
is_archived?: boolean;
}
// ----------------------------------------------------------------------------
// Purchase
// ----------------------------------------------------------------------------
export type InviteLinkType = "public" | "private";
export interface Purchase {
id: string;
target_channel_id: string;
external_channel_id: string;
creative_id: string;
// Purchase details
scheduled_date: string | null; // ISO 8601
actual_date: string | null;
cost: number | null; // in rubles
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
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
}
export interface PurchaseDetail extends Purchase {
subscriptions: Subscription[];
views_history: ViewsSnapshot[];
}
export interface PurchaseCreateRequest {
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;
}
export interface PurchaseCreateResponse extends Purchase {
formatted_message: string; // Creative text with invite_link inserted
}
export interface PurchaseUpdateRequest {
scheduled_date?: string;
actual_date?: string;
cost?: number;
comment?: string;
post_link?: string;
is_archived?: boolean;
}
export interface PurchaseRefreshViewsResponse {
views: number;
fetched_at: string;
}
// ----------------------------------------------------------------------------
// Subscription
// ----------------------------------------------------------------------------
export interface Subscription {
id: string;
purchase_id: string;
telegram_user_id: number;
user_first_name: string;
user_last_name: string | null;
user_username: string | null;
subscribed_at: string; // ISO 8601
is_active: boolean;
event_type: "chat_member" | "join_request";
}
// ----------------------------------------------------------------------------
// Views Snapshot
// ----------------------------------------------------------------------------
export interface ViewsSnapshot {
id: string;
purchase_id: string;
views: number;
fetched_at: string; // ISO 8601
}
// ----------------------------------------------------------------------------
// Analytics
// ----------------------------------------------------------------------------
export interface AnalyticsOverview {
totalPurchases: number;
purchasesChange: number;
totalFollowers: number;
followersChange: number;
averageCpf: number;
cpfChange: number;
averageCpm: number;
cpmChange: number;
topChannelsByCpf: {
channel_id: string;
channel_name: string;
total_purchases: number;
avg_cpf: number;
}[];
topCreativesByCpf: {
creative_id: string;
creative_name: string;
total_subscriptions: number;
avg_cpf: number;
}[];
}
export type GroupByPeriod = "day" | "week" | "month" | "quarter" | "year";
export interface AnalyticsCostsDataPoint {
period: string; // ISO 8601 date (start of period)
cost: number;
purchases_count: number;
subscriptions: number;
}
export interface AnalyticsCostsResponse {
data: AnalyticsCostsDataPoint[];
}
export interface CostsReport {
totalCost: number;
averageCost: number;
periods: {
date: string;
total_cost: number;
purchases_count: number;
avg_cost: number;
}[];
}
export interface AnalyticsChannelData {
channel_id: string;
channel_name: string;
purchases_count: number;
total_cost: number;
total_subscriptions: number;
avg_cpf: number;
avg_cpm: number;
}
export interface AnalyticsChannelsResponse {
data: AnalyticsChannelData[];
}
export interface AnalyticsCreativeData {
creative_id: string;
creative_name: string;
purchases_count: number;
total_cost: number;
total_subscriptions: number;
avg_cpf: number;
conversion_rate: number;
}
export interface AnalyticsCreativesResponse {
data: AnalyticsCreativeData[];
}
// ----------------------------------------------------------------------------
// Common API Responses
// ----------------------------------------------------------------------------
export interface ListResponse<T> {
data: T[];
total: number;
}
export interface SuccessResponse {
success: boolean;
}
export interface ApiError {
error: {
code: string;
message: string;
details?: any;
};
}
// ----------------------------------------------------------------------------
// Query Parameters
// ----------------------------------------------------------------------------
export interface TargetChannelQueryParams {
is_active?: boolean;
}
export interface ExternalChannelQueryParams {
target_channel_id?: string;
search?: string;
}
export interface CreativeQueryParams {
target_channel_id?: string;
is_archived?: boolean;
}
export interface PurchaseQueryParams {
target_channel_id?: string;
external_channel_id?: string;
creative_id?: string;
is_archived?: boolean;
date_from?: string; // ISO 8601
date_to?: string; // ISO 8601
sort?: "date" | "cost" | "cpf" | "subscriptions";
order?: "asc" | "desc";
}
export interface AnalyticsOverviewQueryParams {
target_channel_id?: string;
date_from?: string;
date_to?: string;
}
export interface AnalyticsCostsQueryParams {
target_channel_id?: string;
date_from: string; // required
date_to: string; // required
group_by: GroupByPeriod;
}
export interface AnalyticsChannelsQueryParams {
target_channel_id?: string;
date_from?: string;
date_to?: string;
type: "external" | "target";
}
export interface AnalyticsCreativesQueryParams {
target_channel_id?: string;
date_from?: string;
date_to?: string;
}

21
lib/types/common.ts Normal file
View File

@@ -0,0 +1,21 @@
// ============================================================================
// Common Types
// ============================================================================
export type LoadingState = "idle" | "loading" | "success" | "error";
export interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export interface PaginationParams {
page?: number;
limit?: number;
}
export interface SortParams {
sort_by?: string;
order?: "asc" | "desc";
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

102
lib/utils/constants.ts Normal file
View File

@@ -0,0 +1,102 @@
// ============================================================================
// Constants
// ============================================================================
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";
// Local Storage Keys
export const STORAGE_KEYS = {
ACCESS_TOKEN: "tgex_access_token",
REFRESH_TOKEN: "tgex_refresh_token",
USER: "tgex_user",
} as const;
// API Endpoints
export const API_ENDPOINTS = {
// Auth
AUTH_INIT: "/auth/init",
AUTH_COMPLETE: "/auth/complete",
AUTH_REFRESH: "/auth/refresh",
AUTH_ME: "/auth/me",
// Target Channels
TARGET_CHANNELS: "/target-channels",
TARGET_CHANNEL: (id: string) => `/target-channels/${id}`,
// External Channels
EXTERNAL_CHANNELS: "/external-channels",
EXTERNAL_CHANNEL: (id: string) => `/external-channels/${id}`,
EXTERNAL_CHANNELS_IMPORT: "/external-channels/import",
// Creatives
CREATIVES: "/creatives",
CREATIVE: (id: string) => `/creatives/${id}`,
// Purchases
PURCHASES: "/purchases",
PURCHASE: (id: string) => `/purchases/${id}`,
PURCHASE_REFRESH_VIEWS: (id: string) => `/purchases/${id}/refresh-views`,
// Analytics
ANALYTICS_OVERVIEW: "/analytics/overview",
ANALYTICS_COSTS: "/analytics/costs",
ANALYTICS_CHANNELS: "/analytics/channels",
ANALYTICS_CREATIVES: "/analytics/creatives",
} as const;
// Error Codes
export const ERROR_CODES = {
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
NOT_FOUND: "NOT_FOUND",
VALIDATION_ERROR: "VALIDATION_ERROR",
INTERNAL_ERROR: "INTERNAL_ERROR",
CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
CHANNEL_ALREADY_EXISTS: "CHANNEL_ALREADY_EXISTS",
INSUFFICIENT_PERMISSIONS: "INSUFFICIENT_PERMISSIONS",
CREATIVE_IN_USE: "CREATIVE_IN_USE",
INVALID_INVITE_LINK: "INVALID_INVITE_LINK",
} as const;
// Date Formats
export const DATE_FORMATS = {
DISPLAY: "dd.MM.yyyy",
DISPLAY_WITH_TIME: "dd.MM.yyyy HH:mm",
API: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
} as const;
// Pagination
export const DEFAULT_PAGE_SIZE = 20;
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
// Routes
export const ROUTES = {
HOME: "/",
LOGIN: "/login",
AUTH_COMPLETE: "/auth-complete",
CHANNELS: "/channels",
CHANNEL_DETAIL: (id: string) => `/channels/${id}`,
EXTERNAL_CHANNELS: "/external-channels",
EXTERNAL_CHANNEL_DETAIL: (id: string) => `/external-channels/${id}`,
EXTERNAL_CHANNELS_IMPORT: "/external-channels/import",
CREATIVES: "/creatives",
CREATIVE_NEW: "/creatives/new",
CREATIVE_DETAIL: (id: string) => `/creatives/${id}`,
CREATIVE_EDIT: (id: string) => `/creatives/${id}/edit`,
PURCHASES: "/purchases",
PURCHASE_NEW: "/purchases/new",
PURCHASE_DETAIL: (id: string) => `/purchases/${id}`,
PURCHASE_EDIT: (id: string) => `/purchases/${id}/edit`,
ANALYTICS: "/analytics",
ANALYTICS_COSTS: "/analytics/costs",
ANALYTICS_CHANNELS: "/analytics/channels",
ANALYTICS_CREATIVES: "/analytics/creatives",
} as const;

164
lib/utils/format.ts Normal file
View File

@@ -0,0 +1,164 @@
// ============================================================================
// Formatting Utilities
// ============================================================================
/**
* Форматирование числа с разделением тысяч
*/
export const formatNumber = (
value: number | null | undefined,
showSign: boolean = false
): string => {
if (value === null || value === undefined) return "—";
const sign = showSign && value > 0 ? "+" : "";
return `${sign}${new Intl.NumberFormat("ru-RU").format(value)}`;
};
/**
* Форматирование валюты (рубли)
*/
export const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
/**
* Форматирование CPF/CPM с двумя знаками после запятой
*/
export const formatMetric = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
/**
* Форматирование процента
*/
export const formatPercent = (
value: number | null | undefined,
showSign: boolean = false
): string => {
if (value === null || value === undefined) return "—";
const sign = showSign && value > 0 ? "+" : "";
return `${sign}${formatMetric(value)}%`;
};
/**
* Форматирование даты
*/
export const formatDate = (
date: string | Date | null | undefined,
includeTime: boolean = false
): string => {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
if (includeTime) {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
};
/**
* Форматирование относительной даты (сколько прошло)
*/
export const formatRelativeDate = (date: string | Date): string => {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const diff = now.getTime() - d.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 7) {
return formatDate(d);
} else if (days > 0) {
return `${days} ${pluralize(days, "день", "дня", "дней")} назад`;
} else if (hours > 0) {
return `${hours} ${pluralize(hours, "час", "часа", "часов")} назад`;
} else if (minutes > 0) {
return `${minutes} ${pluralize(
minutes,
"минута",
"минуты",
"минут"
)} назад`;
} else {
return "только что";
}
};
/**
* Плюрализация русских слов
*/
export const pluralize = (
count: number,
one: string,
few: string,
many: string
): string => {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) {
return one;
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
return few;
} else {
return many;
}
};
/**
* Сокращение больших чисел (1000 -> 1K)
*/
export const formatCompactNumber = (
value: number | null | undefined
): string => {
if (value === null || value === undefined) return "—";
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
} else if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`;
}
return formatNumber(value);
};
/**
* Форматирование username (с @)
*/
export const formatUsername = (username: string | null | undefined): string => {
if (!username) return "—";
return username.startsWith("@") ? username : `@${username}`;
};
/**
* Обрезка длинного текста
*/
export const truncate = (text: string, maxLength: number = 50): string => {
if (text.length <= maxLength) return text;
return `${text.substring(0, maxLength)}...`;
};