522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
// ============================================================================
|
|
// Demo-Aware API Wrapper
|
|
// ============================================================================
|
|
// This wrapper checks if the workspace is demo and returns mock data instead
|
|
|
|
import { format, getISOWeek } from "date-fns";
|
|
import { ru } from "date-fns/locale";
|
|
import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants";
|
|
import {
|
|
demoChannels,
|
|
demoCreatives,
|
|
demoPlacements,
|
|
demoPurchasePlanChannels,
|
|
demoSpendingAnalytics,
|
|
demoCreativeAnalytics,
|
|
demoChannelAnalytics,
|
|
demoProjects,
|
|
demoWorkspace,
|
|
demoMember,
|
|
demoOverviewAnalytics,
|
|
demoPlacementAnalytics,
|
|
} from "./mock-data";
|
|
import type {
|
|
PaginatedResponse,
|
|
Channel,
|
|
Creative,
|
|
Placement,
|
|
PurchasePlanChannel,
|
|
SpendingAnalytics,
|
|
CreativeAnalyticsItem,
|
|
ChannelAnalyticsItem,
|
|
Project,
|
|
Workspace,
|
|
WorkspaceMember,
|
|
DateGrouping,
|
|
OverviewAnalytics,
|
|
PlacementAnalyticsItem,
|
|
ProjectsAnalyticsResponse,
|
|
ProjectsAnalyticsQueryParams,
|
|
} from "@/lib/types/api";
|
|
import {
|
|
workspacesApi,
|
|
projectsApi,
|
|
channelsApi,
|
|
creativesApi,
|
|
placementsApi,
|
|
purchasePlanApi,
|
|
analyticsApi,
|
|
membersApi,
|
|
} from "@/lib/api";
|
|
|
|
// ============================================================================
|
|
// Helper
|
|
// ============================================================================
|
|
|
|
const paginate = <T>(items: T[], page = 1, size = 50): PaginatedResponse<T> => ({
|
|
items: items.slice((page - 1) * size, page * size),
|
|
total: items.length,
|
|
page,
|
|
size,
|
|
pages: Math.ceil(items.length / size),
|
|
});
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Workspaces API
|
|
// ============================================================================
|
|
|
|
export const demoAwareWorkspacesApi = {
|
|
list: async (params?: { page?: number; size?: number }) => {
|
|
// Always include demo workspace in list for demo mode
|
|
return workspacesApi.list(params);
|
|
},
|
|
get: async (workspaceId: string): Promise<Workspace> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
return demoWorkspace;
|
|
}
|
|
// Workspaces API doesn't have get method, fetch from list
|
|
const response = await workspacesApi.list({ size: 100 });
|
|
const workspace = response.items.find(w => w.id === workspaceId);
|
|
if (!workspace) throw new Error("Workspace not found");
|
|
return workspace;
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Projects API
|
|
// ============================================================================
|
|
|
|
export const demoAwareProjectsApi = {
|
|
list: async (
|
|
workspaceId: string,
|
|
params?: { page?: number; size?: number; include_archived?: boolean }
|
|
): Promise<PaginatedResponse<Project>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
let items = demoProjects;
|
|
if (!params?.include_archived) {
|
|
items = items.filter(p => p.status !== "archived");
|
|
}
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return projectsApi.list(workspaceId, params);
|
|
},
|
|
updateInviteLinkType: async (
|
|
workspaceId: string,
|
|
projectId: string,
|
|
data: { purchase_invite_type_default: "public" | "approval" }
|
|
): Promise<Project> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
// In demo mode, just return the project (mock update)
|
|
const project = demoProjects.find(p => p.id === projectId);
|
|
if (!project) throw new Error("Project not found");
|
|
return { ...project, purchase_invite_type_default: data.purchase_invite_type_default };
|
|
}
|
|
return projectsApi.updateInviteLinkType(workspaceId, projectId, data);
|
|
},
|
|
archive: async (
|
|
workspaceId: string,
|
|
projectId: string
|
|
): Promise<Project> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
const project = demoProjects.find(p => p.id === projectId);
|
|
if (!project) throw new Error("Project not found");
|
|
return { ...project, status: "archived" };
|
|
}
|
|
return projectsApi.archive(workspaceId, projectId);
|
|
},
|
|
delete: async (
|
|
workspaceId: string,
|
|
projectId: string
|
|
): Promise<void> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
// In demo mode, just mark as archived (soft delete)
|
|
const project = demoProjects.find(p => p.id === projectId);
|
|
if (project) {
|
|
project.status = "archived";
|
|
}
|
|
return;
|
|
}
|
|
return projectsApi.delete(workspaceId, projectId);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Members API
|
|
// ============================================================================
|
|
|
|
export const demoAwareMembersApi = {
|
|
list: async (
|
|
workspaceId: string,
|
|
params?: { page?: number; size?: number }
|
|
): Promise<PaginatedResponse<WorkspaceMember>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
return paginate([demoMember], params?.page, params?.size);
|
|
}
|
|
return membersApi.list(workspaceId, params);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Channels API
|
|
// ============================================================================
|
|
|
|
export const demoAwareChannelsApi = {
|
|
list: async (params?: {
|
|
page?: number;
|
|
size?: number;
|
|
username?: string;
|
|
}): Promise<PaginatedResponse<Channel>> => {
|
|
// Channels are global, check if we're in demo context via some mechanism
|
|
// For now, always return real data - demo channels are handled per-workspace
|
|
return channelsApi.list(params);
|
|
},
|
|
listForDemo: (): PaginatedResponse<Channel> => {
|
|
return paginate(demoChannels);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Creatives API
|
|
// ============================================================================
|
|
|
|
export const demoAwareCreativesApi = {
|
|
list: async (
|
|
workspaceId: string,
|
|
params?: {
|
|
project_id?: string;
|
|
include_archived?: boolean;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
): Promise<PaginatedResponse<Creative>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
let items = demoCreatives;
|
|
if (params?.project_id) {
|
|
items = items.filter((c) => c.project_id === params.project_id);
|
|
}
|
|
if (!params?.include_archived) {
|
|
items = items.filter((c) => c.status === "active");
|
|
}
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return creativesApi.list(workspaceId, params);
|
|
},
|
|
get: async (workspaceId: string, creativeId: string): Promise<Creative> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
const creative = demoCreatives.find((c) => c.id === creativeId);
|
|
if (!creative) throw new Error("Creative not found");
|
|
return creative;
|
|
}
|
|
return creativesApi.get(workspaceId, creativeId);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Placements API
|
|
// ============================================================================
|
|
|
|
export const demoAwarePlacementsApi = {
|
|
list: async (
|
|
workspaceId: string,
|
|
params?: {
|
|
project_id?: string;
|
|
placement_channel_id?: string;
|
|
creative_id?: string;
|
|
include_archived?: boolean;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
): Promise<PaginatedResponse<Placement>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
let items = demoPlacements;
|
|
if (params?.project_id) {
|
|
items = items.filter((p) => p.project_id === params.project_id);
|
|
}
|
|
if (params?.placement_channel_id) {
|
|
items = items.filter(
|
|
(p) => p.placement_channel_id === params.placement_channel_id
|
|
);
|
|
}
|
|
if (params?.creative_id) {
|
|
items = items.filter((p) => p.creative_id === params.creative_id);
|
|
}
|
|
if (!params?.include_archived) {
|
|
items = items.filter((p) => p.status === "active");
|
|
}
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return placementsApi.list(workspaceId, params);
|
|
},
|
|
get: async (workspaceId: string, placementId: string): Promise<Placement> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
const placement = demoPlacements.find((p) => p.id === placementId);
|
|
if (!placement) throw new Error("Placement not found");
|
|
return placement;
|
|
}
|
|
return placementsApi.get(workspaceId, placementId);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Purchase Plan API
|
|
// ============================================================================
|
|
|
|
export const demoAwarePurchasePlanApi = {
|
|
list: async (
|
|
workspaceId: string,
|
|
projectId: string,
|
|
params?: { page?: number; size?: number }
|
|
): Promise<PaginatedResponse<PurchasePlanChannel>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
const items = demoPurchasePlanChannels[projectId] || [];
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return purchasePlanApi.list(workspaceId, projectId, params);
|
|
},
|
|
};
|
|
|
|
// ============================================================================
|
|
// Demo-Aware Analytics API
|
|
// ============================================================================
|
|
|
|
export const demoAwareAnalyticsApi = {
|
|
spending: async (
|
|
workspaceId: string,
|
|
params?: {
|
|
project_id?: string;
|
|
date_from?: string;
|
|
date_to?: string;
|
|
grouping?: DateGrouping;
|
|
}
|
|
): Promise<SpendingAnalytics> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
if (params?.project_id) {
|
|
const projectPlacements = demoPlacements.filter(
|
|
(p) => p.project_id === params.project_id && p.status === "active"
|
|
);
|
|
const totalCost = projectPlacements.reduce(
|
|
(sum, p) => sum + (p.cost || 0),
|
|
0
|
|
);
|
|
const totalSubs = projectPlacements.reduce(
|
|
(sum, p) => sum + (p.subscriptions_count || 0),
|
|
0
|
|
);
|
|
const totalViews = projectPlacements.reduce(
|
|
(sum, p) => sum + (p.views_count || 0),
|
|
0
|
|
);
|
|
return {
|
|
total_cost: totalCost,
|
|
total_subscriptions: totalSubs,
|
|
total_views: totalViews,
|
|
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
|
|
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
|
|
chart_data: demoSpendingAnalytics.chart_data.slice(0, 4),
|
|
};
|
|
}
|
|
return demoSpendingAnalytics;
|
|
}
|
|
return analyticsApi.spending(workspaceId, params);
|
|
},
|
|
creatives: async (
|
|
workspaceId: string,
|
|
params?: { project_id?: string; page?: number; size?: number }
|
|
): Promise<PaginatedResponse<CreativeAnalyticsItem>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
let items = demoCreativeAnalytics;
|
|
if (params?.project_id) {
|
|
const projectCreativeIds = demoCreatives
|
|
.filter((c) => c.project_id === params.project_id)
|
|
.map((c) => c.id);
|
|
items = items.filter((a) => projectCreativeIds.includes(a.id));
|
|
}
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return analyticsApi.creatives(workspaceId, params);
|
|
},
|
|
channels: async (
|
|
workspaceId: string,
|
|
params?: { project_id?: string; page?: number; size?: number }
|
|
): Promise<PaginatedResponse<ChannelAnalyticsItem>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
let items = demoChannelAnalytics;
|
|
if (params?.project_id) {
|
|
const projectPlacementChannelIds = new Set(
|
|
demoPlacements
|
|
.filter((p) => p.project_id === params.project_id)
|
|
.map((p) => p.placement_channel_id)
|
|
);
|
|
items = items.filter((a) =>
|
|
projectPlacementChannelIds.has(a.channel_id)
|
|
);
|
|
}
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return analyticsApi.channels(workspaceId, params);
|
|
},
|
|
placements: async (
|
|
workspaceId: string,
|
|
params?: { project_id?: string; page?: number; size?: number }
|
|
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
let items = demoPlacementAnalytics;
|
|
if (params?.project_id) {
|
|
items = items.filter((p) => p.project_id === params.project_id);
|
|
}
|
|
return paginate(items, params?.page, params?.size);
|
|
}
|
|
return analyticsApi.placements(workspaceId, params);
|
|
},
|
|
overview: async (
|
|
workspaceId: string,
|
|
params?: { project_id?: string; date_from?: string; date_to?: string }
|
|
): Promise<OverviewAnalytics> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
return demoOverviewAnalytics;
|
|
}
|
|
return analyticsApi.overview(workspaceId, params);
|
|
},
|
|
projects: async (
|
|
workspaceId: string,
|
|
params: ProjectsAnalyticsQueryParams
|
|
): Promise<ProjectsAnalyticsResponse> => {
|
|
if (isDemoWorkspace(workspaceId)) {
|
|
// Generate mock data based on demoPlacementAnalytics
|
|
const placements = demoPlacementAnalytics;
|
|
let filtered = placements;
|
|
|
|
if (params.project_ids && params.project_ids.length > 0) {
|
|
filtered = filtered.filter((p) => params.project_ids!.includes(p.project_id));
|
|
}
|
|
|
|
if (params.date_from) {
|
|
const from = new Date(params.date_from);
|
|
filtered = filtered.filter((p) => new Date(p.placement_date) >= from);
|
|
}
|
|
|
|
if (params.date_to) {
|
|
const to = new Date(params.date_to);
|
|
filtered = filtered.filter((p) => new Date(p.placement_date) <= to);
|
|
}
|
|
|
|
// Helper to format period label
|
|
const formatPeriodLabel = (period: string, grouping: string): string => {
|
|
try {
|
|
if (grouping === "day") {
|
|
const date = new Date(period + "T00:00:00");
|
|
return format(date, "d MMM", { locale: ru });
|
|
} else if (grouping === "week") {
|
|
return period.replace("W", " нед. ");
|
|
} else if (grouping === "month") {
|
|
const date = new Date(period + "-01");
|
|
return format(date, "MMM yyyy", { locale: ru });
|
|
} else if (grouping === "quarter") {
|
|
return period.replace("Q", "Q");
|
|
}
|
|
return period;
|
|
} catch {
|
|
return period;
|
|
}
|
|
};
|
|
|
|
// Group by period
|
|
const grouped: Record<string, typeof filtered> = {};
|
|
filtered.forEach((item) => {
|
|
const date = new Date(item.placement_date);
|
|
let periodKey: string;
|
|
|
|
switch (params.grouping) {
|
|
case "day":
|
|
periodKey = format(date, "yyyy-MM-dd");
|
|
break;
|
|
case "week":
|
|
const weekNum = getISOWeek(date);
|
|
periodKey = `${date.getFullYear()}-W${weekNum.toString().padStart(2, "0")}`;
|
|
break;
|
|
case "month":
|
|
periodKey = format(date, "yyyy-MM");
|
|
break;
|
|
case "quarter":
|
|
const quarter = Math.floor(date.getMonth() / 3) + 1;
|
|
periodKey = `${date.getFullYear()}-Q${quarter}`;
|
|
break;
|
|
default:
|
|
periodKey = format(date, "yyyy-MM-dd");
|
|
}
|
|
|
|
if (!grouped[periodKey]) {
|
|
grouped[periodKey] = [];
|
|
}
|
|
grouped[periodKey].push(item);
|
|
});
|
|
|
|
// Calculate metrics for each period
|
|
const periods = Object.entries(grouped)
|
|
.map(([period, items]) => {
|
|
const totalCost = items.reduce((sum, item) => sum + (item.cost || 0), 0);
|
|
const totalSubs = items.reduce((sum, item) => sum + (item.subscriptions_count || 0), 0);
|
|
const totalViews = items.reduce((sum, item) => sum + (item.views_count || 0), 0);
|
|
const purchasesCount = items.length;
|
|
|
|
return {
|
|
period,
|
|
period_label: formatPeriodLabel(period, params.grouping),
|
|
metrics: {
|
|
total_cost: totalCost,
|
|
purchases_count: purchasesCount,
|
|
total_subscriptions: totalSubs,
|
|
total_views: totalViews,
|
|
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
|
|
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
|
|
avg_post_cost: purchasesCount > 0 ? totalCost / purchasesCount : null,
|
|
clicks_count: totalSubs, // Using subscriptions as proxy
|
|
reach_volume: totalViews,
|
|
total_discounts: 0,
|
|
avg_discount_percent: 0,
|
|
avg_conversion: 0,
|
|
},
|
|
};
|
|
})
|
|
.sort((a, b) => a.period.localeCompare(b.period));
|
|
|
|
// Calculate totals
|
|
const totals = periods.reduce(
|
|
(acc, period) => {
|
|
acc.total_cost += period.metrics.total_cost;
|
|
acc.purchases_count += period.metrics.purchases_count;
|
|
acc.total_subscriptions += period.metrics.total_subscriptions;
|
|
acc.total_views += period.metrics.total_views;
|
|
acc.clicks_count += period.metrics.clicks_count;
|
|
acc.reach_volume += period.metrics.reach_volume;
|
|
acc.total_discounts += period.metrics.total_discounts || 0;
|
|
return acc;
|
|
},
|
|
{
|
|
total_cost: 0,
|
|
purchases_count: 0,
|
|
total_subscriptions: 0,
|
|
total_views: 0,
|
|
clicks_count: 0,
|
|
reach_volume: 0,
|
|
total_discounts: 0,
|
|
}
|
|
);
|
|
|
|
return {
|
|
periods,
|
|
totals: {
|
|
...totals,
|
|
avg_cpf: totals.total_subscriptions > 0 ? totals.total_cost / totals.total_subscriptions : null,
|
|
avg_cpm: totals.total_views > 0 ? (totals.total_cost / totals.total_views) * 1000 : null,
|
|
avg_post_cost: totals.purchases_count > 0 ? totals.total_cost / totals.purchases_count : null,
|
|
avg_discount_percent: 0,
|
|
avg_conversion: 0,
|
|
},
|
|
};
|
|
}
|
|
return analyticsApi.projects(workspaceId, params);
|
|
},
|
|
};
|
|
|