feat: add header channel selector & adding channel modal menu

This commit is contained in:
ivannoskov
2025-12-01 05:57:28 +03:00
parent d4672ea32b
commit d2cc1c39b3
22 changed files with 3473 additions and 892 deletions

2609
api.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,11 +21,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { analyticsApi, channelsApi } from "@/lib/api";
import { analyticsApi } from "@/lib/api";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import { Loader2, AlertCircle, DollarSign } from "lucide-react";
import type { CostsReport } from "@/lib/types/api";
import type { TargetChannel } from "@/lib/types/api";
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
LineChart,
Line,
@@ -40,32 +40,30 @@ import {
} from "recharts";
export default function AnalyticsCostsPage() {
const [report, setReport] = useState<CostsReport | null>(null);
const [channels, setChannels] = useState<TargetChannel[]>([]);
const { selectedChannel } = useTargetChannel();
const [report, setReport] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [period, setPeriod] = useState<"day" | "week" | "month">("week");
const [targetChannelId, setTargetChannelId] = useState<string | undefined>(
undefined
);
const [grouping, setGrouping] = useState<DateGrouping>("week");
useEffect(() => {
loadData();
}, [period, targetChannelId]);
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [grouping, selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const [reportData, channelsData] = await Promise.all([
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
channels.length > 0
? Promise.resolve({ target_channels: channels })
: channelsApi.list(),
]);
const reportData = await analyticsApi.spending({
grouping,
target_channel_id: selectedChannel.id,
});
setReport(reportData);
if (channels.length === 0) {
setChannels(channelsData.target_channels);
}
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки аналитики");
} finally {
@@ -106,47 +104,25 @@ export default function AnalyticsCostsPage() {
<CardTitle>Фильтры</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">
Период группировки
</label>
<Select
value={period}
onValueChange={(value: any) => setPeriod(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Целевой канал</label>
<Select
value={targetChannelId || "all"}
onValueChange={(value) =>
setTargetChannelId(value === "all" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Период группировки
</label>
<Select
value={grouping}
onValueChange={(value: DateGrouping) => setGrouping(value)}
>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">По дням</SelectItem>
<SelectItem value="week">По неделям</SelectItem>
<SelectItem value="month">По месяцам</SelectItem>
<SelectItem value="quarter">По кварталам</SelectItem>
<SelectItem value="year">По годам</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
@@ -159,7 +135,7 @@ export default function AnalyticsCostsPage() {
</Card>
) : report ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
@@ -169,7 +145,7 @@ export default function AnalyticsCostsPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(report.totalCost)}
{formatCurrency(report.total_cost)}
</div>
<p className="text-xs text-muted-foreground mt-1">
За выбранный период
@@ -180,15 +156,15 @@ export default function AnalyticsCostsPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Средние затраты
Подписчики
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(report.averageCost)}
{formatNumber(report.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground mt-1">
В среднем за период
Всего привлечено
</p>
</CardContent>
</Card>
@@ -196,15 +172,31 @@ export default function AnalyticsCostsPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Периодов
Просмотры
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(report.periods.length)}
{formatNumber(report.total_views)}
</div>
<p className="text-xs text-muted-foreground mt-1">
С активностью
Всего просмотров
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Средний CPF
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{report.avg_cpf ? formatCurrency(report.avg_cpf) : "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">
В среднем за период
</p>
</CardContent>
</Card>
@@ -217,13 +209,13 @@ export default function AnalyticsCostsPage() {
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={report.periods}>
<BarChart data={report.chart_data}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="date"
dataKey="period"
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
/>
@@ -245,7 +237,7 @@ export default function AnalyticsCostsPage() {
/>
<Legend />
<Bar
dataKey="total_cost"
dataKey="cost"
name="Затраты"
fill="hsl(var(--primary))"
radius={[8, 8, 0, 0]}
@@ -262,23 +254,23 @@ export default function AnalyticsCostsPage() {
</CardHeader>
<CardContent>
<div className="space-y-3">
{report.periods.map((period) => (
{report.chart_data.map((period) => (
<div
key={period.date}
key={period.period}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div>
<p className="font-medium">{period.date}</p>
<p className="font-medium">{period.period}</p>
<p className="text-sm text-muted-foreground">
{formatNumber(period.purchases_count)} закупов
{formatNumber(period.subscriptions)} подписок {formatNumber(period.views)} просмотров
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold">
{formatCurrency(period.total_cost)}
{formatCurrency(period.cost)}
</p>
<p className="text-sm text-muted-foreground">
ср. {formatNumber(period.avg_cost)}
{period.cpf ? `CPF: ${formatCurrency(period.cpf)}` : "—"}
</p>
</div>
</div>

View File

@@ -32,7 +32,8 @@ import {
ShoppingCart,
DollarSign,
} from "lucide-react";
import type { AnalyticsOverview } from "@/lib/types/api";
import type { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
LineChart,
Line,
@@ -47,19 +48,29 @@ import {
} from "recharts";
export default function AnalyticsPage() {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const { selectedChannel } = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadOverview();
}, []);
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
const loadOverview = async () => {
try {
setLoading(true);
const data = await analyticsApi.overview();
setOverview(data);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки аналитики");
} finally {
@@ -83,7 +94,7 @@ export default function AnalyticsPage() {
);
}
if (error || !overview) {
if (error || !spending) {
return (
<>
<DashboardHeader
@@ -125,34 +136,17 @@ export default function AnalyticsPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего закупов
Всего затрат
</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(overview.totalPurchases)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.purchasesChange >= 0 ? (
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.purchasesChange >= 0
? "text-green-600"
: "text-red-600"
}
>
{overview.purchasesChange >= 0 ? "+" : ""}
{formatNumber(overview.purchasesChange)}
</span>
<span className="text-muted-foreground ml-1">
за прошлый месяц
</span>
{formatCurrency(spending.total_cost)}
</div>
<p className="text-xs text-muted-foreground mt-1">
За выбранный период
</p>
</CardContent>
</Card>
@@ -165,28 +159,11 @@ export default function AnalyticsPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(overview.totalFollowers)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.followersChange >= 0 ? (
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.followersChange >= 0
? "text-green-600"
: "text-red-600"
}
>
{overview.followersChange >= 0 ? "+" : ""}
{formatNumber(overview.followersChange)}
</span>
<span className="text-muted-foreground ml-1">
за прошлый месяц
</span>
{formatNumber(spending.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Всего привлечено
</p>
</CardContent>
</Card>
@@ -197,151 +174,68 @@ export default function AnalyticsPage() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(overview.averageCpf)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.cpfChange <= 0 ? (
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.cpfChange <= 0 ? "text-green-600" : "text-red-600"
}
>
{formatPercent(overview.cpfChange, true)}
</span>
<span className="text-muted-foreground ml-1">
от прошлого месяца
</span>
{spending.avg_cpf ? `${formatMetric(spending.avg_cpf)}` : "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Стоимость подписки
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMetric(overview.averageCpm)}
</div>
<div className="flex items-center text-xs mt-1">
{overview.cpmChange <= 0 ? (
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
) : (
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
)}
<span
className={
overview.cpmChange <= 0 ? "text-green-600" : "text-red-600"
}
>
{formatPercent(overview.cpmChange, true)}
</span>
<span className="text-muted-foreground ml-1">
от прошлого месяца
</span>
{spending.avg_cpm ? `${formatMetric(spending.avg_cpm)}` : "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">
За 1000 просмотров
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Топ каналов по CPF</CardTitle>
<CardDescription>
Самые эффективные внешние каналы
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overview.topChannelsByCpf?.length > 0 ? (
overview.topChannelsByCpf.slice(0, 5).map((item, index) => (
<div
key={item.channel_id}
className="flex items-center gap-3"
>
<Badge
variant="outline"
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.channel_name}
</p>
<p className="text-xs text-muted-foreground">
{formatNumber(item.total_purchases)} закупов
</p>
</div>
<div className="text-right">
<p className="font-medium">
{formatMetric(item.avg_cpf)}
</p>
<p className="text-xs text-muted-foreground">CPF</p>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-4">
Нет данных
</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Динамика затрат</CardTitle>
<CardDescription>Расходы по периодам</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={spending.chart_data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="period"
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
/>
<YAxis
className="text-xs"
tick={{ fill: "hsl(var(--muted-foreground))" }}
tickFormatter={(value) => `${formatNumber(value)}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
}}
/>
<Line
type="monotone"
dataKey="cost"
stroke="hsl(var(--primary))"
strokeWidth={2}
name="Затраты"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Топ креативов</CardTitle>
<CardDescription>
Самые эффективные рекламные креативы
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overview.topCreativesByCpf?.length > 0 ? (
overview.topCreativesByCpf.slice(0, 5).map((item, index) => (
<div
key={item.creative_id}
className="flex items-center gap-3"
>
<Badge
variant="outline"
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
>
{index + 1}
</Badge>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">
{item.creative_name}
</p>
<p className="text-xs text-muted-foreground">
{formatNumber(item.total_subscriptions)} подписчиков
</p>
</div>
<div className="text-right">
<p className="font-medium">
{formatMetric(item.avg_cpf)}
</p>
<p className="text-xs text-muted-foreground">CPF</p>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-4">
Нет данных
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</>
);

View File

@@ -29,32 +29,19 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { creativesApi, channelsApi } from "@/lib/api";
import { AlertCircle, Loader2, ArrowLeft, Info } from "lucide-react";
import type { TargetChannel } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreateCreativePage() {
const router = useRouter();
const [channels, setChannels] = useState<TargetChannel[]>([]);
const { selectedChannel } = useTargetChannel();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: "",
text: "",
target_channel_id: "",
});
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
const response = await channelsApi.list();
setChannels(response.target_channels.filter((c) => c.is_active));
} catch (err) {
console.error("Error loading channels:", err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -75,14 +62,17 @@ export default function CreateCreativePage() {
return;
}
if (!formData.target_channel_id) {
setError("Выберите целевой канал");
if (!selectedChannel) {
setError("Выберите целевой канал в верхнем меню");
return;
}
try {
setIsLoading(true);
await creativesApi.create(formData);
await creativesApi.create({
...formData,
target_channel_id: selectedChannel.id,
});
router.push("/creatives");
} catch (err: any) {
setError(err?.error?.message || "Ошибка создания креатива");
@@ -151,26 +141,14 @@ export default function CreateCreativePage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="target_channel_id">Целевой канал *</Label>
<Select
value={formData.target_channel_id}
onValueChange={(value) =>
setFormData({ ...formData, target_channel_id: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedChannel && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Креатив будет создан для канала: <strong>{selectedChannel.title}</strong>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>

View File

@@ -36,8 +36,10 @@ import {
} from "lucide-react";
import type { Creative } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreativesPage() {
const { selectedChannel } = useTargetChannel();
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -46,13 +48,21 @@ export default function CreativesPage() {
);
useEffect(() => {
loadCreatives();
}, []);
if (selectedChannel) {
loadCreatives();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadCreatives = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const response = await creativesApi.list();
const response = await creativesApi.list({
target_channel_id: selectedChannel.id,
});
setCreatives(response.creatives);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки креативов");

View File

@@ -49,8 +49,10 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function ExternalChannelsPage() {
const { selectedChannel } = useTargetChannel();
const [channels, setChannels] = useState<ExternalChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -68,23 +70,20 @@ export default function ExternalChannelsPage() {
});
useEffect(() => {
loadChannels();
}, []);
if (selectedChannel) {
loadChannels();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadChannels = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
// Сначала загружаем target channels
const targetChannelsRes = await channelsApi.list();
if (targetChannelsRes.target_channels.length === 0) {
setError("Добавьте сначала целевой канал");
setLoading(false);
return;
}
// Загружаем external channels для первого target channel
const firstTargetChannel = targetChannelsRes.target_channels[0];
const response = await externalChannelsApi.list(firstTargetChannel.id);
setError(null);
const response = await externalChannelsApi.list(selectedChannel.id);
setChannels(response.external_channels);
} catch (err: any) {
const errorMsg =

View File

@@ -27,26 +27,53 @@ import {
ShoppingCart,
Loader2,
} from "lucide-react";
import type { AnalyticsOverview } from "@/lib/types/api";
import type { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function DashboardPage() {
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const { selectedChannel } = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadOverview = async () => {
if (!selectedChannel) {
setLoading(false);
return;
}
const loadData = async () => {
try {
const data = await analyticsApi.overview();
setOverview(data);
setLoading(true);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (error) {
console.error("Error loading overview:", error);
console.error("Error loading spending data:", error);
} finally {
setLoading(false);
}
};
loadOverview();
}, []);
loadData();
}, [selectedChannel]);
if (!selectedChannel) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Выберите целевой канал</h2>
<p className="text-muted-foreground">
Для начала работы выберите целевой канал в верхнем меню
</p>
</div>
</div>
</>
);
}
return (
<>
@@ -66,10 +93,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(overview?.totalPurchases)}
{formatNumber(spending?.total_cost)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(overview?.purchasesChange || 0, true)} за
{formatNumber(0 || 0, true)} за
прошлый месяц
</p>
</>
@@ -90,10 +117,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(overview?.totalFollowers)}
{formatNumber(spending?.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(overview?.followersChange || 0, true)} за
{formatNumber(0 || 0, true)} за
прошлый месяц
</p>
</>
@@ -112,10 +139,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatMetric(overview?.averageCpf)}
{formatMetric(spending?.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpfChange || 0, true)} от прошлого
{formatPercent(0 || 0, true)} от прошлого
месяца
</p>
</>
@@ -134,10 +161,10 @@ export default function DashboardPage() {
) : (
<>
<div className="text-2xl font-bold">
{formatMetric(overview?.averageCpm)}
{formatMetric(spending?.avg_cpm)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpmChange || 0, true)} от прошлого
{formatPercent(0 || 0, true)} от прошлого
месяца
</p>
</>

View File

@@ -42,11 +42,12 @@ import {
Copy,
Check,
} from "lucide-react";
import type { TargetChannel, ExternalChannel, Creative } from "@/lib/types/api";
import type { ExternalChannel, Creative } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function CreatePurchasePage() {
const router = useRouter();
const [channels, setChannels] = useState<TargetChannel[]>([]);
const { selectedChannel } = useTargetChannel();
const [externalChannels, setExternalChannels] = useState<ExternalChannel[]>(
[]
);
@@ -57,7 +58,6 @@ export default function CreatePurchasePage() {
const [copied, setCopied] = useState(false);
const [formData, setFormData] = useState({
target_channel_id: "",
external_channel_id: "",
creative_id: "",
placement_date: "",
@@ -68,41 +68,28 @@ export default function CreatePurchasePage() {
});
useEffect(() => {
loadData();
}, []);
if (selectedChannel) {
loadData();
}
}, [selectedChannel]);
const loadData = async () => {
try {
const channelsRes = await channelsApi.list();
setChannels(channelsRes.target_channels.filter((c) => c.is_active));
if (!selectedChannel) return;
// Загружаем внешние каналы для первого доступного target channel
if (channelsRes.target_channels.length > 0) {
const firstTargetChannel = channelsRes.target_channels[0];
const [externalChannelsRes, creativesRes] = await Promise.all([
externalChannelsApi.list(firstTargetChannel.id),
creativesApi.list(),
]);
setExternalChannels(externalChannelsRes.external_channels);
setCreatives(
creativesRes.creatives.filter((c) => c.status === "active")
);
} else {
setCreatives([]);
setExternalChannels([]);
}
try {
const [externalChannelsRes, creativesRes] = await Promise.all([
externalChannelsApi.list(selectedChannel.id),
creativesApi.list({ target_channel_id: selectedChannel.id }),
]);
setExternalChannels(externalChannelsRes.external_channels);
setCreatives(
creativesRes.creatives.filter((c) => c.status === "active")
);
} catch (err) {
console.error("Error loading data:", err);
}
};
// Фильтр креативов по выбранному целевому каналу
const filteredCreatives = formData.target_channel_id
? creatives.filter(
(c) => c.target_channel_id === formData.target_channel_id
)
: creatives;
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
const handleSubmit = async (e: React.FormEvent) => {
@@ -110,8 +97,8 @@ export default function CreatePurchasePage() {
setError(null);
// Validation
if (!formData.target_channel_id) {
setError("Выберите целевой канал");
if (!selectedChannel) {
setError("Выберите целевой канал в верхнем меню");
return;
}
@@ -138,7 +125,7 @@ export default function CreatePurchasePage() {
try {
setIsLoading(true);
const result = await purchasesApi.create({
target_channel_id: formData.target_channel_id,
target_channel_id: selectedChannel.id,
external_channel_id: formData.external_channel_id,
creative_id: formData.creative_id,
placement_date: formData.placement_date,
@@ -253,7 +240,6 @@ export default function CreatePurchasePage() {
onClick={() => {
setInviteLink("");
setFormData({
target_channel_id: "",
external_channel_id: "",
creative_id: "",
placement_date: "",
@@ -315,42 +301,29 @@ export default function CreatePurchasePage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="target_channel_id">Целевой канал *</Label>
<Select
value={formData.target_channel_id}
onValueChange={(value) =>
setFormData({
...formData,
target_channel_id: value,
creative_id: "", // Сбросить креатив при смене канала
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedChannel && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Размещение будет создано для канала: <strong>{selectedChannel.title}</strong>
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="external_channel_id">Внешний канал *</Label>
<Select
value={formData.external_channel_id}
onValueChange={(value) =>
setFormData({ ...formData, external_channel_id: value })
setFormData({
...formData,
external_channel_id: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
<SelectValue placeholder="Выберите внешний канал" />
</SelectTrigger>
<SelectContent>
{externalChannels.map((channel) => (
@@ -361,34 +334,27 @@ export default function CreatePurchasePage() {
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="creative_id">Креатив *</Label>
<Select
value={formData.creative_id}
onValueChange={(value) =>
setFormData({ ...formData, creative_id: value })
}
disabled={!formData.target_channel_id}
<div className="space-y-2">
<Label htmlFor="creative_id">Креатив *</Label>
<Select
value={formData.creative_id}
onValueChange={(value) =>
setFormData({ ...formData, creative_id: value })
}
>
<SelectTrigger>
<SelectValue
placeholder={
formData.target_channel_id
? "Выберите креатив"
: "Сначала выберите целевой канал"
}
/>
</SelectTrigger>
<SelectContent>
{filteredCreatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>

View File

@@ -52,31 +52,35 @@ import {
Clock,
Archive,
} from "lucide-react";
import type { Purchase, TargetChannel } from "@/lib/types/api";
import type { Purchase } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function PurchasesPage() {
const { selectedChannel } = useTargetChannel();
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterChannel, setFilterChannel] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
useEffect(() => {
loadData();
}, []);
if (selectedChannel) {
loadData();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadData = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
const [purchasesRes, channelsRes] = await Promise.all([
purchasesApi.list(),
channelsApi.list(),
]);
const purchasesRes = await purchasesApi.list({
target_channel_id: selectedChannel.id,
});
setPurchases(purchasesRes.placements);
setChannels(channelsRes.target_channels);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки данных");
} finally {
@@ -90,15 +94,8 @@ export default function PurchasesPage() {
purchase.external_channel_title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.target_channel_title
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase());
// Фильтр по каналу
const matchesChannel =
filterChannel === "all" || purchase.target_channel_id === filterChannel;
// Фильтр по статусу
let matchesStatus = true;
if (filterStatus === "archived") {
@@ -107,7 +104,7 @@ export default function PurchasesPage() {
matchesStatus = purchase.status === "active";
}
return matchesSearch && matchesChannel && matchesStatus;
return matchesSearch && matchesStatus;
});
// Статистика
@@ -215,20 +212,6 @@ export default function PurchasesPage() {
/>
</div>
<Select value={filterChannel} onValueChange={setFilterChannel}>
<SelectTrigger>
<SelectValue placeholder="Целевой канал" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger>
<SelectValue placeholder="Статус" />
@@ -264,16 +247,12 @@ export default function PurchasesPage() {
<div className="text-center py-12">
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
{searchQuery || filterStatus !== "all"
? "Закупы не найдены"
: "Нет закупов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery ||
filterChannel !== "all" ||
filterStatus !== "all"
{searchQuery || filterStatus !== "all"
? "Попробуйте изменить фильтры"
: "Создайте первый закуп"}
</p>

View File

@@ -7,6 +7,7 @@
import React from "react";
import { AuthProvider } from "@/components/auth/auth-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { TargetChannelProvider } from "@/components/providers/target-channel-provider";
interface ProvidersProps {
children: React.ReactNode;
@@ -20,7 +21,9 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
enableSystem
disableTransitionOnChange
>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
<TargetChannelProvider>{children}</TargetChannelProvider>
</AuthProvider>
</ThemeProvider>
);
};

View File

@@ -11,7 +11,6 @@ import {
LayoutDashboard,
Megaphone,
ShoppingCart,
Target,
TrendingUp,
Settings2,
ExternalLink,
@@ -36,17 +35,6 @@ const data = {
icon: LayoutDashboard,
isActive: true,
},
{
title: "Целевые каналы",
url: "/channels",
icon: Target,
items: [
{
title: "Все каналы",
url: "/channels",
},
],
},
{
title: "Внешние каналы",
url: "/external-channels",

View File

@@ -15,6 +15,7 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ThemeToggle } from "@/components/theme-toggle";
import { TargetChannelSelector } from "@/components/target-channel-selector";
import React from "react";
interface BreadcrumbItem {
@@ -53,7 +54,8 @@ export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
</BreadcrumbList>
</Breadcrumb>
)}
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
<TargetChannelSelector />
<ThemeToggle />
</div>
</header>

View File

@@ -0,0 +1,6 @@
// ============================================================================
// Providers Export
// ============================================================================
export { TargetChannelProvider, useTargetChannel } from "./target-channel-provider";

View File

@@ -0,0 +1,101 @@
"use client";
// ============================================================================
// Target Channel Provider
// ============================================================================
import React, { createContext, useContext, useState, useEffect } from "react";
import { channelsApi } from "@/lib/api";
import type { TargetChannel } from "@/lib/types/api";
interface TargetChannelContextType {
channels: TargetChannel[];
selectedChannel: TargetChannel | null;
setSelectedChannel: (channel: TargetChannel | null) => void;
isLoading: boolean;
error: string | null;
refreshChannels: () => Promise<void>;
}
const TargetChannelContext = createContext<TargetChannelContextType | undefined>(
undefined
);
export const useTargetChannel = () => {
const context = useContext(TargetChannelContext);
if (!context) {
throw new Error(
"useTargetChannel must be used within TargetChannelProvider"
);
}
return context;
};
export const TargetChannelProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [selectedChannel, setSelectedChannelState] = useState<TargetChannel | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadChannels = async () => {
try {
setIsLoading(true);
setError(null);
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter((c) => c.is_active);
setChannels(activeChannels);
// Загружаем сохраненный канал из localStorage
const savedChannelId = localStorage.getItem("selected_channel_id");
if (savedChannelId && activeChannels.length > 0) {
const savedChannel = activeChannels.find((c) => c.id === savedChannelId);
if (savedChannel) {
setSelectedChannelState(savedChannel);
} else {
// Если сохраненный канал не найден, выбираем первый
setSelectedChannelState(activeChannels[0]);
localStorage.setItem("selected_channel_id", activeChannels[0].id);
}
} else if (activeChannels.length > 0) {
// Если нет сохраненного канала, выбираем первый
setSelectedChannelState(activeChannels[0]);
localStorage.setItem("selected_channel_id", activeChannels[0].id);
}
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setIsLoading(false);
}
};
const setSelectedChannel = (channel: TargetChannel | null) => {
setSelectedChannelState(channel);
if (channel) {
localStorage.setItem("selected_channel_id", channel.id);
} else {
localStorage.removeItem("selected_channel_id");
}
};
useEffect(() => {
loadChannels();
}, []);
return (
<TargetChannelContext.Provider
value={{
channels,
selectedChannel,
setSelectedChannel,
isLoading,
error,
refreshChannels: loadChannels,
}}
>
{children}
</TargetChannelContext.Provider>
);
};

View File

@@ -0,0 +1,279 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
Loader2,
AlertCircle,
Plus,
ExternalLink,
CheckCircle2,
ArrowUpRightIcon,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { BOT_USERNAME } from "@/lib/utils/constants";
import { channelsApi } from "@/lib/api";
export const TargetChannelSelector: React.FC = () => {
const { channels, selectedChannel, setSelectedChannel, isLoading, error } =
useTargetChannel();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [initialChannelCount, setInitialChannelCount] = useState(0);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Очистка интервала при размонтировании
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
// Автоматический запуск polling при открытии диалога
useEffect(() => {
if (isAddDialogOpen && !isSuccess && initialChannelCount > 0) {
// Polling каждую секунду
pollingIntervalRef.current = setInterval(() => {
checkForNewChannels();
}, 1000);
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}
}, [isAddDialogOpen, isSuccess, initialChannelCount]);
const checkForNewChannels = async () => {
try {
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter(
(c) => c.is_active
);
if (activeChannels.length > initialChannelCount) {
// Новый канал добавлен!
setIsSuccess(true);
// Останавливаем polling
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
// Закрываем диалог через 2 секунды
setTimeout(() => {
setIsAddDialogOpen(false);
setIsSuccess(false);
// Обновляем список каналов через провайдер
window.location.reload();
}, 2000);
}
} catch (err) {
console.error("Error polling channels:", err);
}
};
const handleAddChannel = () => {
setInitialChannelCount(channels.length);
setIsSuccess(false);
setIsAddDialogOpen(true);
};
const handleDialogClose = () => {
if (!isSuccess) {
// Не даем закрыть диалог во время ожидания
return;
}
setIsAddDialogOpen(false);
setIsSuccess(false);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка каналов...
</div>
);
}
if (error) {
return (
<Alert variant="destructive" className="p-2 h-auto">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">Ошибка: {error}</AlertDescription>
</Alert>
);
}
if (channels.length === 0) {
return (
<Button variant="outline" size="sm" onClick={handleAddChannel}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
);
}
return (
<>
<Select
value={selectedChannel?.id || "add-new"}
onValueChange={(value) => {
if (value === "add-new") {
handleAddChannel();
} else {
const channel = channels.find((c) => c.id === value);
if (channel) {
setSelectedChannel(channel);
}
}
}}
>
<SelectTrigger className="w-[260px] h-auto min-h-[52px] py-2 rounded-lg">
<SelectValue placeholder="Выберите канал">
{selectedChannel && (
<div className="flex flex-col items-start gap-0.5">
<span className="font-semibold text-base">
{selectedChannel.title}
</span>
{selectedChannel.username && (
<span className="text-xs text-muted-foreground -mt-1">
@{selectedChannel.username}
</span>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem
key={channel.id}
value={channel.id}
className="cursor-pointer py-3"
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex flex-col items-start flex-1 min-w-0 gap-0.5">
<span className="font-semibold text-base truncate w-full">
{channel.title}
</span>
{channel.username && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
@{channel.username}
</span>
)}
</div>
</div>
</SelectItem>
))}
<SelectItem
value="add-new"
className="cursor-pointer border-t mt-1 py-3"
>
<div className="flex items-center gap-2 text-primary">
<Plus className="h-4 w-4" />
<span className="font-medium">Добавить канал</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Dialog open={isAddDialogOpen} onOpenChange={handleDialogClose}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => !isSuccess && e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Добавить целевой канал</DialogTitle>
<DialogDescription>
Следуйте инструкциям ниже, чтобы подключить новый канал
</DialogDescription>
</DialogHeader>
{isSuccess ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="rounded-full bg-green-100 dark:bg-green-900 p-3">
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold">
Канал успешно добавлен!
</h3>
<p className="text-sm text-muted-foreground mt-1">
Страница обновится автоматически...
</p>
</div>
</div>
) : (
<div className="space-y-4">
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">Шаги для добавления канала:</p>
<ol className="list-decimal list-inside space-y-1.5 text-sm">
<li>Откройте свой Telegram канал</li>
<li>Перейдите в настройки канала Администраторы</li>
<li>
Добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
как администратора
</li>
<li>
Предоставьте боту права:{" "}
<span className="font-medium">
Публикация сообщений, Удаление сообщений
</span>
</li>
</ol>
</AlertDescription>
</Alert>
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground text-center">
Ждем добавления бота...
<br />
<span className="text-xs">
Как только вы добавите бота, канал автоматически появится
</span>
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -4,40 +4,41 @@
import { api } from "./client";
import type {
AnalyticsOverview,
CostsReport,
AnalyticsChannelData,
AnalyticsCreativeData,
SpendingAnalytics,
PlacementsAnalytics,
CreativesAnalytics,
ExternalChannelsAnalytics,
DateGrouping,
} from "@/lib/types/api";
export const analyticsApi = {
/**
* Get overview analytics
* Get spending analytics
*/
overview: (params?: { target_channel_id?: string }) =>
api.get<AnalyticsOverview>("/analytics/overview", { params }),
/**
* Get costs report
*/
costs: (params: {
period?: "day" | "week" | "month";
spending: (params?: {
target_channel_id?: string;
}) => api.get<CostsReport>("/analytics/costs", { params }),
date_from?: string;
date_to?: string;
grouping?: DateGrouping;
}) => api.get<SpendingAnalytics>("/analytics/spending", { params }),
/**
* Get channels analytics
* Get placements analytics
*/
channels: (params?: { target_channel_id?: string }) =>
api.get<{ data: AnalyticsChannelData[] }>("/analytics/channels", {
params,
}),
placements: (params?: { target_channel_id?: string }) =>
api.get<PlacementsAnalytics>("/analytics/placements", { params }),
/**
* Get creatives analytics
*/
creatives: (params?: { target_channel_id?: string }) =>
api.get<{ data: AnalyticsCreativeData[] }>("/analytics/creatives", {
api.get<CreativesAnalytics>("/analytics/creatives", { params }),
/**
* Get external channels analytics
*/
externalChannels: (params?: { target_channel_id?: string }) =>
api.get<ExternalChannelsAnalytics>("/analytics/external-channels", {
params,
}),
};

View File

@@ -205,17 +205,17 @@ const routeMockRequest = async (
}
// Analytics
if (endpoint === "/analytics/overview") {
return mockApiHandlers.getAnalyticsOverview(params);
if (endpoint === "/analytics/spending") {
return mockApiHandlers.getSpendingAnalytics(params);
}
if (endpoint === "/analytics/costs") {
return mockApiHandlers.getAnalyticsCosts(params);
}
if (endpoint === "/analytics/channels") {
return mockApiHandlers.getAnalyticsChannels(params);
if (endpoint === "/analytics/placements") {
return mockApiHandlers.getPlacementsAnalytics(params);
}
if (endpoint === "/analytics/creatives") {
return mockApiHandlers.getAnalyticsCreatives(params);
return mockApiHandlers.getCreativesAnalytics(params);
}
if (endpoint === "/analytics/external-channels") {
return mockApiHandlers.getExternalChannelsAnalytics(params);
}
throw {

View File

@@ -1,277 +0,0 @@
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

@@ -11,12 +11,6 @@ import {
mockPlacements,
mockSubscriptions,
mockViewsHistory,
mockAnalyticsOverview,
mockCostsReportByDay,
mockCostsReportByWeek,
mockCostsReportByMonth,
mockAnalyticsChannelsData,
mockAnalyticsCreativesData,
} from "./index";
import type {
@@ -481,50 +475,99 @@ export const mockApiHandlers = {
// --------------------------------------------------------------------------
// Analytics
// --------------------------------------------------------------------------
async getAnalyticsOverview(params?: {
async getSpendingAnalytics(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";
grouping?: "day" | "week" | "month" | "quarter" | "year";
}) {
await delay();
return {
data: mockAnalyticsChannelsData,
total_cost: 45000,
total_subscriptions: 850,
total_views: 125000,
avg_cpf: 52.94,
avg_cpm: 360,
chart_data: [
{
period: "2024-03-01",
cost: 15000,
subscriptions: 280,
views: 42000,
cpf: 53.57,
cpm: 357.14,
},
{
period: "2024-03-08",
cost: 18000,
subscriptions: 340,
views: 51000,
cpf: 52.94,
cpm: 352.94,
},
{
period: "2024-03-15",
cost: 12000,
subscriptions: 230,
views: 32000,
cpf: 52.17,
cpm: 375,
},
],
};
},
async getAnalyticsCreatives(params?: {
target_channel_id?: string;
date_from?: string;
date_to?: string;
}) {
async getPlacementsAnalytics(params?: { target_channel_id?: string }) {
await delay();
return {
data: mockAnalyticsCreativesData,
placements: mockPlacements.map((p) => ({
id: p.id,
target_channel_id: p.target_channel_id,
target_channel_title: p.target_channel_title,
external_channel_id: p.external_channel_id,
external_channel_title: p.external_channel_title,
creative_id: p.creative_id,
creative_name: p.creative_name,
placement_date: p.placement_date,
cost: p.cost,
subscriptions_count: p.subscriptions_count,
views_count: p.views_count,
cpf: p.cost && p.subscriptions_count > 0 ? p.cost / p.subscriptions_count : null,
cpm: p.cost && p.views_count ? (p.cost / p.views_count) * 1000 : null,
})),
};
},
async getCreativesAnalytics(params?: { target_channel_id?: string }) {
await delay();
return {
creatives: mockCreatives.map((c) => ({
id: c.id,
name: c.name,
placements_count: c.placements_count,
total_cost: 15000,
total_subscriptions: 280,
total_views: 42000,
avg_cpf: 53.57,
avg_cpm: 357.14,
})),
};
},
async getExternalChannelsAnalytics(params?: { target_channel_id?: string }) {
await delay();
return {
external_channels: mockExternalChannels.map((ec) => ({
id: ec.id,
title: ec.title,
username: ec.username,
placements_count: 3,
total_cost: 12000,
total_subscriptions: 210,
total_views: 28000,
avg_cpf: 57.14,
avg_cpm: 428.57,
})),
};
},
};

View File

@@ -7,4 +7,3 @@ export * from "./data/channels";
export * from "./data/external-channels";
export * from "./data/creatives";
export * from "./data/placements";
export * from "./data/analytics";

View File

@@ -238,79 +238,79 @@ export interface ViewsSnapshot {
// 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 DateGrouping = "day" | "week" | "month" | "quarter" | "year";
export type GroupByPeriod = "day" | "week" | "month" | "quarter" | "year";
export interface AnalyticsCostsDataPoint {
period: string; // ISO 8601 date (start of period)
// Spending Analytics
export interface SpendingDataPoint {
period: string;
cost: number;
purchases_count: number;
subscriptions: number;
views: number;
cpf: number | null;
cpm: number | null;
}
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;
export interface SpendingAnalytics {
total_cost: number;
total_subscriptions: number;
avg_cpf: number;
avg_cpm: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
chart_data: SpendingDataPoint[];
}
export interface AnalyticsChannelsResponse {
data: AnalyticsChannelData[];
}
export interface AnalyticsCreativeData {
// Placements Analytics
export interface PlacementAnalytics {
id: string;
target_channel_id: string;
target_channel_title: string;
external_channel_id: string;
external_channel_title: string;
creative_id: string;
creative_name: string;
purchases_count: number;
total_cost: number;
total_subscriptions: number;
avg_cpf: number;
conversion_rate: number;
placement_date: string;
cost: number | null;
subscriptions_count: number;
views_count: number | null;
cpf: number | null;
cpm: number | null;
}
export interface AnalyticsCreativesResponse {
data: AnalyticsCreativeData[];
export interface PlacementsAnalytics {
placements: PlacementAnalytics[];
}
// Creatives Analytics
export interface CreativeAnalytics {
id: string;
name: string;
placements_count: number;
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
}
export interface CreativesAnalytics {
creatives: CreativeAnalytics[];
}
// External Channels Analytics
export interface ExternalChannelAnalytics {
id: string;
title: string;
username: string | null;
placements_count: number;
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
}
export interface ExternalChannelsAnalytics {
external_channels: ExternalChannelAnalytics[];
}
// ----------------------------------------------------------------------------
@@ -369,22 +369,3 @@ export interface AnalyticsOverviewQueryParams {
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;
}

View File

@@ -14,6 +14,7 @@ export const STORAGE_KEYS = {
ACCESS_TOKEN: "tgex_access_token",
REFRESH_TOKEN: "tgex_refresh_token",
USER: "tgex_user",
SELECTED_TARGET_CHANNEL: "tgex_selected_target_channel",
} as const;
// API Endpoints