feat: update analytics
This commit is contained in:
183
ANALYTICS_API_REQUIREMENTS.md
Normal file
183
ANALYTICS_API_REQUIREMENTS.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Требования к API для аналитики по проектам
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /api/v1/workspaces/{workspace_id}/analytics/projects
|
||||
```
|
||||
|
||||
## Параметры запроса (Query Parameters)
|
||||
|
||||
| Параметр | Тип | Обязательный | Описание |
|
||||
|----------|-----|-------------|----------|
|
||||
| `project_ids` | `string[]` | Нет | Массив UUID проектов для фильтрации. Если не указан - все проекты workspace |
|
||||
| `date_from` | `string (ISO 8601)` | Нет | Начало периода (например: `2024-12-01T00:00:00Z`) |
|
||||
| `date_to` | `string (ISO 8601)` | Нет | Конец периода (например: `2024-12-31T23:59:59Z`) |
|
||||
| `grouping` | `enum: "day" \| "week" \| "month" \| "quarter"` | Да | Группировка данных по периодам |
|
||||
| `date_grouping` | `enum: "purchase_date" \| "link_date" \| "placement_date"` | Да | По какой дате группировать размещения |
|
||||
| `metrics` | `string[]` | Нет | Массив метрик для расчета. Если не указан - возвращаются все доступные метрики |
|
||||
|
||||
### Доступные метрики:
|
||||
- `total_cost` - Сумма расходов
|
||||
- `purchases_count` - Количество закупов (размещений)
|
||||
- `total_subscriptions` - Общее количество подписок
|
||||
- `total_views` - Общее количество просмотров
|
||||
- `avg_cpf` - Средний CPF (cost per follower)
|
||||
- `avg_cpm` - Средний CPM (cost per mille)
|
||||
- `avg_post_cost` - Средняя стоимость поста
|
||||
- `clicks_count` - Количество переходов (кликов)
|
||||
- `reach_volume` - Объем охвата
|
||||
- `total_discounts` - Сумма скидок
|
||||
- `avg_discount_percent` - Средний процент скидки
|
||||
- `avg_conversion` - Средняя конверсия закупов
|
||||
|
||||
## Формат ответа
|
||||
|
||||
```json
|
||||
{
|
||||
"periods": [
|
||||
{
|
||||
"period": "2024-12-01",
|
||||
"period_label": "1 дек",
|
||||
"metrics": {
|
||||
"total_cost": 5500.0,
|
||||
"purchases_count": 3,
|
||||
"total_subscriptions": 156,
|
||||
"total_views": 8500,
|
||||
"avg_cpf": 35.26,
|
||||
"avg_cpm": 647.06,
|
||||
"avg_post_cost": 1833.33,
|
||||
"clicks_count": 156,
|
||||
"reach_volume": 8500,
|
||||
"total_discounts": 0.0,
|
||||
"avg_discount_percent": 0.0,
|
||||
"avg_conversion": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"period": "2024-12-02",
|
||||
"period_label": "2 дек",
|
||||
"metrics": {
|
||||
"total_cost": 3200.0,
|
||||
"purchases_count": 2,
|
||||
"total_subscriptions": 89,
|
||||
"total_views": 4200,
|
||||
"avg_cpf": 35.96,
|
||||
"avg_cpm": 761.9,
|
||||
"avg_post_cost": 1600.0,
|
||||
"clicks_count": 89,
|
||||
"reach_volume": 4200,
|
||||
"total_discounts": 0.0,
|
||||
"avg_discount_percent": 0.0,
|
||||
"avg_conversion": 0.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"total_cost": 8700.0,
|
||||
"purchases_count": 5,
|
||||
"total_subscriptions": 245,
|
||||
"total_views": 12700,
|
||||
"avg_cpf": 35.51,
|
||||
"avg_cpm": 685.04,
|
||||
"avg_post_cost": 1740.0,
|
||||
"clicks_count": 245,
|
||||
"reach_volume": 12700,
|
||||
"total_discounts": 0.0,
|
||||
"avg_discount_percent": 0.0,
|
||||
"avg_conversion": 0.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Примеры запросов
|
||||
|
||||
### Пример 1: График расходов по дням за декабрь
|
||||
```http
|
||||
GET /api/v1/workspaces/123e4567-e89b-12d3-a456-426614174000/analytics/projects?grouping=day&date_grouping=placement_date&date_from=2024-12-01T00:00:00Z&date_to=2024-12-31T23:59:59Z&metrics[]=total_cost&project_ids[]=proj-001&project_ids[]=proj-002
|
||||
```
|
||||
|
||||
### Пример 2: Средний CPF по неделям
|
||||
```http
|
||||
GET /api/v1/workspaces/123e4567-e89b-12d3-a456-426614174000/analytics/projects?grouping=week&date_grouping=placement_date&metrics[]=avg_cpf&metrics[]=total_subscriptions
|
||||
```
|
||||
|
||||
### Пример 3: Все метрики по месяцам
|
||||
```http
|
||||
GET /api/v1/workspaces/123e4567-e89b-12d3-a456-426614174000/analytics/projects?grouping=month&date_grouping=purchase_date&date_from=2024-01-01T00:00:00Z&date_to=2024-12-31T23:59:59Z
|
||||
```
|
||||
|
||||
## Формат периода (period)
|
||||
|
||||
В зависимости от `grouping`:
|
||||
- `day`: `"YYYY-MM-DD"` (например: `"2024-12-01"`)
|
||||
- `week`: `"YYYY-Www"` (например: `"2024-W49"`) - ISO week format
|
||||
- `month`: `"YYYY-MM"` (например: `"2024-12"`)
|
||||
- `quarter`: `"YYYY-QN"` (например: `"2024-Q4"`)
|
||||
|
||||
## Формат period_label
|
||||
|
||||
Человекочитаемая метка периода для отображения на графике:
|
||||
- `day`: `"1 дек"` или `"1 дек 2024"`
|
||||
- `week`: `"49 нед. 2024"` или `"1-7 дек"`
|
||||
- `month`: `"дек 2024"`
|
||||
- `quarter`: `"Q4 2024"`
|
||||
|
||||
## Логика расчета метрик
|
||||
|
||||
### Агрегации (SUM):
|
||||
- `total_cost` - сумма всех `cost`
|
||||
- `total_subscriptions` - сумма всех `subscriptions_count`
|
||||
- `total_views` - сумма всех `views_count`
|
||||
- `total_discounts` - сумма всех скидок
|
||||
- `clicks_count` - сумма всех кликов
|
||||
- `reach_volume` - сумма всех просмотров (аналогично `total_views`)
|
||||
|
||||
### Счетчики (COUNT):
|
||||
- `purchases_count` - количество размещений в периоде
|
||||
|
||||
### Средние значения (AVG):
|
||||
- `avg_cpf` = `total_cost / total_subscriptions` (если `total_subscriptions > 0`)
|
||||
- `avg_cpm` = `(total_cost / total_views) * 1000` (если `total_views > 0`)
|
||||
- `avg_post_cost` = `total_cost / purchases_count` (если `purchases_count > 0`)
|
||||
- `avg_discount_percent` = средний процент скидки по размещениям
|
||||
- `avg_conversion` = средняя конверсия закупов
|
||||
|
||||
### Totals (итоговые значения):
|
||||
Все метрики в `totals` рассчитываются как агрегация по всем периодам в ответе.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
### 400 Bad Request
|
||||
```json
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["query", "grouping"],
|
||||
"msg": "Invalid grouping value. Must be one of: day, week, month, quarter",
|
||||
"type": "value_error"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 422 Unprocessable Entity
|
||||
```json
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["query", "date_from"],
|
||||
"msg": "date_from must be before date_to",
|
||||
"type": "value_error"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Если для метрики нет данных (например, `total_discounts`), возвращается `0` или `null`
|
||||
- Если знаменатель равен 0 при расчете средних значений, возвращается `null`
|
||||
- Периоды сортируются по возрастанию (`period`)
|
||||
- Если данных нет для какого-то периода, он не включается в ответ (или включается с нулевыми значениями - на усмотрение бэкенда)
|
||||
|
||||
@@ -4,180 +4,13 @@
|
||||
// Creatives Analytics Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Folder,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import { ProjectSelector } from "@/components/project-selector";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { CreativeAnalyticsItem } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
const getCreativeWord = (count: number): string => {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
|
||||
if (mod100 >= 11 && mod100 <= 14) {
|
||||
return "креативов";
|
||||
}
|
||||
|
||||
if (mod10 === 1) {
|
||||
return "креатив";
|
||||
}
|
||||
|
||||
if (mod10 >= 2 && mod10 <= 4) {
|
||||
return "креатива";
|
||||
}
|
||||
|
||||
return "креативов";
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SortField =
|
||||
| "placements"
|
||||
| "cost"
|
||||
| "subscriptions"
|
||||
| "views"
|
||||
| "cps"
|
||||
| "cpm";
|
||||
type SortOrder = "asc" | "desc" | null;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
import { useParams } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function CreativesAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { getProjectFilterId } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
const [creatives, setCreatives] = useState<CreativeAnalyticsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField | null>("cost");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [workspaceId, projectFilterId]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
size: 100,
|
||||
});
|
||||
setCreatives(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load creatives analytics:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort handler
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortOrder === "asc") setSortOrder("desc");
|
||||
else if (sortOrder === "desc") {
|
||||
setSortField(null);
|
||||
setSortOrder(null);
|
||||
} else setSortOrder("asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
};
|
||||
|
||||
// Sort data
|
||||
const sortedCreatives = [...creatives].sort((a, b) => {
|
||||
if (!sortField || !sortOrder) return 0;
|
||||
|
||||
let aVal = 0;
|
||||
let bVal = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case "placements":
|
||||
aVal = a.placements_count;
|
||||
bVal = b.placements_count;
|
||||
break;
|
||||
case "cost":
|
||||
aVal = a.total_cost;
|
||||
bVal = b.total_cost;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.total_subscriptions;
|
||||
bVal = b.total_subscriptions;
|
||||
break;
|
||||
case "views":
|
||||
aVal = a.total_views;
|
||||
bVal = b.total_views;
|
||||
break;
|
||||
case "cps":
|
||||
aVal = a.avg_cpf ?? 0;
|
||||
bVal = b.avg_cpf ?? 0;
|
||||
break;
|
||||
case "cpm":
|
||||
aVal = a.avg_cpm ?? 0;
|
||||
bVal = b.avg_cpm ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -185,144 +18,28 @@ export default function CreativesAnalyticsPage() {
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||
{ label: "Креативы" },
|
||||
{ label: "По креативам" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Аналитика по креативам
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Сравнение эффективности рекламных материалов
|
||||
Раздел в разработке
|
||||
</p>
|
||||
</div>
|
||||
<ProjectSelector />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sortedCreatives.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Нет данных</h3>
|
||||
<p className="text-muted-foreground text-center">
|
||||
Создайте размещения для отображения статистики по креативам
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<p className="text-muted-foreground">
|
||||
Раздел будет реализован позже
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Креативы</CardTitle>
|
||||
<CardDescription>
|
||||
{sortedCreatives.length} {getCreativeWord(sortedCreatives.length)} с размещениями
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Креатив</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("placements")}
|
||||
>
|
||||
Размещений
|
||||
{getSortIcon("placements")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cost")}
|
||||
>
|
||||
Расходы
|
||||
{getSortIcon("cost")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("subscriptions")}
|
||||
>
|
||||
Подписчики
|
||||
{getSortIcon("subscriptions")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("views")}
|
||||
>
|
||||
Просмотры
|
||||
{getSortIcon("views")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cps")}
|
||||
>
|
||||
Ср. CPS
|
||||
{getSortIcon("cps")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cpm")}
|
||||
>
|
||||
Ср. CPM
|
||||
{getSortIcon("cpm")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedCreatives.map((creative) => (
|
||||
<TableRow key={creative.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{creative.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{creative.placements_count}</TableCell>
|
||||
<TableCell>{formatCurrency(creative.total_cost)}</TableCell>
|
||||
<TableCell>
|
||||
{formatNumber(creative.total_subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell>{formatNumber(creative.total_views)}</TableCell>
|
||||
<TableCell>{formatCurrency(creative.avg_cpf)}</TableCell>
|
||||
<TableCell>{formatCurrency(creative.avg_cpm)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Analytics Overview Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Users,
|
||||
Eye,
|
||||
DollarSign,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import { ProjectSelector } from "@/components/project-selector";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const {
|
||||
selectedProjects,
|
||||
getProjectFilterId,
|
||||
allProjectsSelected,
|
||||
projects
|
||||
} = useWorkspace();
|
||||
|
||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, [workspaceId, projectFilterId]);
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
grouping: "month",
|
||||
});
|
||||
setSpending(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load analytics:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate averages
|
||||
const avgCps =
|
||||
spending && spending.total_subscriptions > 0
|
||||
? spending.total_cost / spending.total_subscriptions
|
||||
: null;
|
||||
const avgCpm =
|
||||
spending && spending.total_views > 0
|
||||
? (spending.total_cost / spending.total_views) * 1000
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Аналитика" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Аналитика</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Обзор эффективности рекламных размещений
|
||||
</p>
|
||||
</div>
|
||||
<ProjectSelector />
|
||||
</div>
|
||||
|
||||
{selectedProjects.length > 1 && !allProjectsSelected && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Выбрано {selectedProjects.length} проекта: {selectedProjects.map(p => p.title).join(", ")}.
|
||||
Показана суммарная аналитика по всем выбранным проектам.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего потрачено
|
||||
</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(spending?.total_cost ?? 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Подписчиков
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(spending?.total_subscriptions ?? 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Средний CPS: {avgCps ? formatCurrency(avgCps) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(spending?.total_views ?? 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Средний CPM: {avgCpm ? formatCurrency(avgCpm) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Периодов данных
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{spending?.chart_data?.length ?? 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">месяцев</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/spending`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Динамика расходов
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
График расходов по времени с группировкой
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/channels`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Аналитика по каналам
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Эффективность каналов размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/creatives`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Аналитика по креативам
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сравнение эффективности креативов
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{spending && spending.chart_data && spending.chart_data.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Последние периоды</CardTitle>
|
||||
<CardDescription>Динамика по периодам</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{spending.chart_data.slice(0, 6).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{item.period}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatNumber(item.subscriptions)} подписчиков •{" "}
|
||||
{formatNumber(item.views)} просмотров
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">
|
||||
{formatCurrency(item.cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1180
app/dashboard/[workspaceId]/analytics/projects/page.tsx
Normal file
1180
app/dashboard/[workspaceId]/analytics/projects/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -424,7 +424,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
items: [
|
||||
{
|
||||
title: "По проектам",
|
||||
url: buildRoute.analytics(workspaceId),
|
||||
url: buildRoute.analyticsProjects(workspaceId),
|
||||
icon: Tv,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
PaginatedResponse,
|
||||
OverviewAnalytics,
|
||||
OverviewAnalyticsQueryParams,
|
||||
ProjectsAnalyticsResponse,
|
||||
ProjectsAnalyticsQueryParams,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const analyticsApi = {
|
||||
@@ -60,4 +62,13 @@ export const analyticsApi = {
|
||||
`/workspaces/${workspaceId}/analytics/overview`,
|
||||
{ params }
|
||||
),
|
||||
|
||||
/**
|
||||
* Get projects analytics (chart data with grouping)
|
||||
*/
|
||||
projects: (workspaceId: string, params: ProjectsAnalyticsQueryParams) =>
|
||||
api.get<ProjectsAnalyticsResponse>(
|
||||
`/workspaces/${workspaceId}/analytics/projects`,
|
||||
{ params }
|
||||
),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// ============================================================================
|
||||
// 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,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
demoWorkspace,
|
||||
demoMember,
|
||||
demoOverviewAnalytics,
|
||||
demoPlacementAnalytics,
|
||||
} from "./mock-data";
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
@@ -31,6 +34,9 @@ import type {
|
||||
WorkspaceMember,
|
||||
DateGrouping,
|
||||
OverviewAnalytics,
|
||||
PlacementAnalyticsItem,
|
||||
ProjectsAnalyticsResponse,
|
||||
ProjectsAnalyticsQueryParams,
|
||||
} from "@/lib/types/api";
|
||||
import {
|
||||
workspacesApi,
|
||||
@@ -349,6 +355,19 @@ export const demoAwareAnalyticsApi = {
|
||||
}
|
||||
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 }
|
||||
@@ -358,5 +377,145 @@ export const demoAwareAnalyticsApi = {
|
||||
}
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
CreativeAnalyticsItem,
|
||||
ChannelAnalyticsItem,
|
||||
OverviewAnalytics,
|
||||
PlacementAnalyticsItem,
|
||||
} from "@/lib/types/api";
|
||||
import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants";
|
||||
|
||||
@@ -590,6 +591,85 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Placements Analytics (Projects Analytics)
|
||||
export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
|
||||
{
|
||||
id: "plac-0001-0000-0000-000000000001",
|
||||
project_id: "proj-0001-0000-0000-000000000001",
|
||||
project_channel_title: "Крипто Новости",
|
||||
placement_channel_id: "chan-0001-0000-0000-000000000001",
|
||||
placement_channel_title: "Технологии Будущего",
|
||||
creative_id: "crea-0001-0000-0000-000000000001",
|
||||
creative_name: "Крипто - Основной",
|
||||
placement_date: "2024-12-20T12:00:00Z",
|
||||
cost: 5500,
|
||||
subscriptions_count: 156,
|
||||
views_count: 8500,
|
||||
cpf: 35.26,
|
||||
cpm: 647.06,
|
||||
},
|
||||
{
|
||||
id: "plac-0002-0000-0000-000000000002",
|
||||
project_id: "proj-0001-0000-0000-000000000001",
|
||||
project_channel_title: "Крипто Новости",
|
||||
placement_channel_id: "chan-0002-0000-0000-000000000002",
|
||||
placement_channel_title: "Инвестиции Pro",
|
||||
creative_id: "crea-0001-0000-0000-000000000001",
|
||||
creative_name: "Крипто - Основной",
|
||||
placement_date: "2024-12-18T14:00:00Z",
|
||||
cost: 3200,
|
||||
subscriptions_count: 89,
|
||||
views_count: 4200,
|
||||
cpf: 35.96,
|
||||
cpm: 761.9,
|
||||
},
|
||||
{
|
||||
id: "plac-0003-0000-0000-000000000003",
|
||||
project_id: "proj-0001-0000-0000-000000000001",
|
||||
project_channel_title: "Крипто Новости",
|
||||
placement_channel_id: "chan-0003-0000-0000-000000000003",
|
||||
placement_channel_title: "Программисты",
|
||||
creative_id: "crea-0001-0000-0000-000000000001",
|
||||
creative_name: "Крипто - Основной",
|
||||
placement_date: "2024-12-15T10:00:00Z",
|
||||
cost: 4800,
|
||||
subscriptions_count: 234,
|
||||
views_count: 12000,
|
||||
cpf: 20.51,
|
||||
cpm: 400.0,
|
||||
},
|
||||
{
|
||||
id: "plac-0004-0000-0000-000000000004",
|
||||
project_id: "proj-0002-0000-0000-000000000002",
|
||||
project_channel_title: "IT Вакансии",
|
||||
placement_channel_id: "chan-0004-0000-0000-000000000004",
|
||||
placement_channel_title: "Маркетинг Pro",
|
||||
creative_id: "crea-0002-0000-0000-000000000002",
|
||||
creative_name: "Крипто - Агрессивный",
|
||||
placement_date: "2024-12-10T16:00:00Z",
|
||||
cost: 2500,
|
||||
subscriptions_count: 67,
|
||||
views_count: 3100,
|
||||
cpf: 37.31,
|
||||
cpm: 806.45,
|
||||
},
|
||||
{
|
||||
id: "plac-0005-0000-0000-000000000005",
|
||||
project_id: "proj-0002-0000-0000-000000000002",
|
||||
project_channel_title: "IT Вакансии",
|
||||
placement_channel_id: "chan-0005-0000-0000-000000000005",
|
||||
placement_channel_title: "Финансовая грамотность",
|
||||
creative_id: "crea-0002-0000-0000-000000000002",
|
||||
creative_name: "Крипто - Агрессивный",
|
||||
placement_date: "2024-12-05T11:00:00Z",
|
||||
cost: 6200,
|
||||
subscriptions_count: 312,
|
||||
views_count: 15600,
|
||||
cpf: 19.87,
|
||||
cpm: 397.44,
|
||||
},
|
||||
];
|
||||
|
||||
export const demoOverviewAnalytics: OverviewAnalytics = {
|
||||
total_cost: { value: 523400.5, delta_percent: 12.4 },
|
||||
total_reach: { value: 380000, delta_percent: -8.1 },
|
||||
|
||||
@@ -291,18 +291,21 @@ export interface ViewsHistoryQueryParams extends PaginationParams {
|
||||
|
||||
export type DateGrouping = "day" | "week" | "month" | "DAY" | "WEEK" | "MONTH";
|
||||
|
||||
// Placements Analytics
|
||||
// Placements Analytics (Projects Analytics)
|
||||
export interface PlacementAnalyticsItem {
|
||||
placement_id: string;
|
||||
id: string; // UUID
|
||||
project_id: string; // UUID
|
||||
project_channel_title: string;
|
||||
placement_channel_id: string; // UUID
|
||||
placement_channel_title: string;
|
||||
placement_channel_username: string | null;
|
||||
creative_id: string; // UUID
|
||||
creative_name: string;
|
||||
placement_date: string;
|
||||
cost: number | null;
|
||||
placement_date: string; // ISO 8601
|
||||
cost: number;
|
||||
subscriptions_count: number;
|
||||
views_count: number | null;
|
||||
cps: number | null; // cost per subscription
|
||||
cpm: number | null; // cost per mille views
|
||||
views_count: number;
|
||||
cpf: number;
|
||||
cpm: number;
|
||||
}
|
||||
|
||||
// Creatives Analytics (matches actual API response)
|
||||
@@ -353,6 +356,42 @@ export interface AnalyticsQueryParams extends PaginationParams {
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
// Projects Analytics Chart API
|
||||
export interface ProjectsAnalyticsMetrics {
|
||||
total_cost: number;
|
||||
purchases_count: number;
|
||||
total_subscriptions: number;
|
||||
total_views: number;
|
||||
avg_cpf: number | null;
|
||||
avg_cpm: number | null;
|
||||
avg_post_cost: number | null;
|
||||
clicks_count: number;
|
||||
reach_volume: number;
|
||||
total_discounts: number | null;
|
||||
avg_discount_percent: number | null;
|
||||
avg_conversion: number | null;
|
||||
}
|
||||
|
||||
export interface ProjectsAnalyticsPeriod {
|
||||
period: string;
|
||||
period_label: string;
|
||||
metrics: ProjectsAnalyticsMetrics;
|
||||
}
|
||||
|
||||
export interface ProjectsAnalyticsResponse {
|
||||
periods: ProjectsAnalyticsPeriod[];
|
||||
totals: ProjectsAnalyticsMetrics;
|
||||
}
|
||||
|
||||
export interface ProjectsAnalyticsQueryParams {
|
||||
project_ids?: string[];
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
grouping: "day" | "week" | "month" | "quarter";
|
||||
date_grouping: "purchase_date" | "link_date" | "placement_date";
|
||||
metrics?: string[];
|
||||
}
|
||||
|
||||
export interface SpendingAnalyticsQueryParams {
|
||||
project_id?: string;
|
||||
date_from?: string;
|
||||
|
||||
@@ -67,11 +67,13 @@ export const buildRoute = {
|
||||
`/dashboard/${workspaceId}/placements/${placementId}`,
|
||||
|
||||
// Analytics
|
||||
analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics`,
|
||||
analyticsSpending: (workspaceId: string) =>
|
||||
`/dashboard/${workspaceId}/analytics/spending`,
|
||||
analytics: (workspaceId: string) => `/dashboard/${workspaceId}/analytics/projects`,
|
||||
analyticsProjects: (workspaceId: string) =>
|
||||
`/dashboard/${workspaceId}/analytics/projects`,
|
||||
analyticsCreatives: (workspaceId: string) =>
|
||||
`/dashboard/${workspaceId}/analytics/creatives`,
|
||||
analyticsSpending: (workspaceId: string) =>
|
||||
`/dashboard/${workspaceId}/analytics/spending`,
|
||||
analyticsChannels: (workspaceId: string) =>
|
||||
`/dashboard/${workspaceId}/analytics/channels`,
|
||||
analyticsPlacements: (workspaceId: string) =>
|
||||
|
||||
BIN
public/logo_full_dark.png
Normal file
BIN
public/logo_full_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Reference in New Issue
Block a user