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`),
};