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

View File

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

View File

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

View File

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

View File

@@ -49,8 +49,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
export default function ExternalChannelsPage() { export default function ExternalChannelsPage() {
const { selectedChannel } = useTargetChannel();
const [channels, setChannels] = useState<ExternalChannel[]>([]); const [channels, setChannels] = useState<ExternalChannel[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -68,23 +70,20 @@ export default function ExternalChannelsPage() {
}); });
useEffect(() => { useEffect(() => {
if (selectedChannel) {
loadChannels(); loadChannels();
}, []); } else {
setLoading(false);
}
}, [selectedChannel]);
const loadChannels = async () => { const loadChannels = async () => {
if (!selectedChannel) return;
try { try {
setLoading(true); setLoading(true);
// Сначала загружаем target channels setError(null);
const targetChannelsRes = await channelsApi.list(); const response = await externalChannelsApi.list(selectedChannel.id);
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);
setChannels(response.external_channels); setChannels(response.external_channels);
} catch (err: any) { } catch (err: any) {
const errorMsg = const errorMsg =

View File

@@ -27,26 +27,53 @@ import {
ShoppingCart, ShoppingCart,
Loader2, Loader2,
} from "lucide-react"; } 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() { 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); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const loadOverview = async () => { if (!selectedChannel) {
setLoading(false);
return;
}
const loadData = async () => {
try { try {
const data = await analyticsApi.overview(); setLoading(true);
setOverview(data); const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (error) { } catch (error) {
console.error("Error loading overview:", error); console.error("Error loading spending data:", error);
} finally { } finally {
setLoading(false); 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 ( return (
<> <>
@@ -66,10 +93,10 @@ export default function DashboardPage() {
) : ( ) : (
<> <>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatNumber(overview?.totalPurchases)} {formatNumber(spending?.total_cost)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatNumber(overview?.purchasesChange || 0, true)} за {formatNumber(0 || 0, true)} за
прошлый месяц прошлый месяц
</p> </p>
</> </>
@@ -90,10 +117,10 @@ export default function DashboardPage() {
) : ( ) : (
<> <>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatNumber(overview?.totalFollowers)} {formatNumber(spending?.total_subscriptions)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatNumber(overview?.followersChange || 0, true)} за {formatNumber(0 || 0, true)} за
прошлый месяц прошлый месяц
</p> </p>
</> </>
@@ -112,10 +139,10 @@ export default function DashboardPage() {
) : ( ) : (
<> <>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatMetric(overview?.averageCpf)} {formatMetric(spending?.avg_cpf)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpfChange || 0, true)} от прошлого {formatPercent(0 || 0, true)} от прошлого
месяца месяца
</p> </p>
</> </>
@@ -134,10 +161,10 @@ export default function DashboardPage() {
) : ( ) : (
<> <>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatMetric(overview?.averageCpm)} {formatMetric(spending?.avg_cpm)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatPercent(overview?.cpmChange || 0, true)} от прошлого {formatPercent(0 || 0, true)} от прошлого
месяца месяца
</p> </p>
</> </>

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { TargetChannelSelector } from "@/components/target-channel-selector";
import React from "react"; import React from "react";
interface BreadcrumbItem { interface BreadcrumbItem {
@@ -53,7 +54,8 @@ export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
)} )}
<div className="ml-auto"> <div className="ml-auto flex items-center gap-2">
<TargetChannelSelector />
<ThemeToggle /> <ThemeToggle />
</div> </div>
</header> </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 { api } from "./client";
import type { import type {
AnalyticsOverview, SpendingAnalytics,
CostsReport, PlacementsAnalytics,
AnalyticsChannelData, CreativesAnalytics,
AnalyticsCreativeData, ExternalChannelsAnalytics,
DateGrouping,
} from "@/lib/types/api"; } from "@/lib/types/api";
export const analyticsApi = { export const analyticsApi = {
/** /**
* Get overview analytics * Get spending analytics
*/ */
overview: (params?: { target_channel_id?: string }) => spending: (params?: {
api.get<AnalyticsOverview>("/analytics/overview", { params }),
/**
* Get costs report
*/
costs: (params: {
period?: "day" | "week" | "month";
target_channel_id?: string; 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 }) => placements: (params?: { target_channel_id?: string }) =>
api.get<{ data: AnalyticsChannelData[] }>("/analytics/channels", { api.get<PlacementsAnalytics>("/analytics/placements", { params }),
params,
}),
/** /**
* Get creatives analytics * Get creatives analytics
*/ */
creatives: (params?: { target_channel_id?: string }) => 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, params,
}), }),
}; };

View File

@@ -205,17 +205,17 @@ const routeMockRequest = async (
} }
// Analytics // Analytics
if (endpoint === "/analytics/overview") { if (endpoint === "/analytics/spending") {
return mockApiHandlers.getAnalyticsOverview(params); return mockApiHandlers.getSpendingAnalytics(params);
} }
if (endpoint === "/analytics/costs") { if (endpoint === "/analytics/placements") {
return mockApiHandlers.getAnalyticsCosts(params); return mockApiHandlers.getPlacementsAnalytics(params);
}
if (endpoint === "/analytics/channels") {
return mockApiHandlers.getAnalyticsChannels(params);
} }
if (endpoint === "/analytics/creatives") { if (endpoint === "/analytics/creatives") {
return mockApiHandlers.getAnalyticsCreatives(params); return mockApiHandlers.getCreativesAnalytics(params);
}
if (endpoint === "/analytics/external-channels") {
return mockApiHandlers.getExternalChannelsAnalytics(params);
} }
throw { 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, mockPlacements,
mockSubscriptions, mockSubscriptions,
mockViewsHistory, mockViewsHistory,
mockAnalyticsOverview,
mockCostsReportByDay,
mockCostsReportByWeek,
mockCostsReportByMonth,
mockAnalyticsChannelsData,
mockAnalyticsCreativesData,
} from "./index"; } from "./index";
import type { import type {
@@ -481,50 +475,99 @@ export const mockApiHandlers = {
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// Analytics // Analytics
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
async getAnalyticsOverview(params?: { async getSpendingAnalytics(params?: {
target_channel_id?: string; target_channel_id?: string;
date_from?: string; date_from?: string;
date_to?: string; date_to?: string;
}) { grouping?: "day" | "week" | "month" | "quarter" | "year";
await delay();
return mockAnalyticsOverview;
},
async getAnalyticsCosts(params?: {
period?: "day" | "week" | "month";
target_channel_id?: string;
}) {
await delay();
const period = params?.period || "week";
if (period === "day") {
return mockCostsReportByDay;
} else if (period === "month") {
return mockCostsReportByMonth;
}
return mockCostsReportByWeek;
},
async getAnalyticsChannels(params?: {
target_channel_id?: string;
date_from?: string;
date_to?: string;
type?: "external" | "target";
}) { }) {
await delay(); await delay();
return { 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?: { async getPlacementsAnalytics(params?: { target_channel_id?: string }) {
target_channel_id?: string;
date_from?: string;
date_to?: string;
}) {
await delay(); await delay();
return { 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/external-channels";
export * from "./data/creatives"; export * from "./data/creatives";
export * from "./data/placements"; export * from "./data/placements";
export * from "./data/analytics";

View File

@@ -238,79 +238,79 @@ export interface ViewsSnapshot {
// Analytics // Analytics
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
export interface AnalyticsOverview { export type DateGrouping = "day" | "week" | "month" | "quarter" | "year";
totalPurchases: number;
purchasesChange: number;
totalFollowers: number;
followersChange: number;
averageCpf: number;
cpfChange: number;
averageCpm: number;
cpmChange: number;
topChannelsByCpf: {
channel_id: string;
channel_name: string;
total_purchases: number;
avg_cpf: number;
}[];
topCreativesByCpf: {
creative_id: string;
creative_name: string;
total_subscriptions: number;
avg_cpf: number;
}[];
}
export type GroupByPeriod = "day" | "week" | "month" | "quarter" | "year"; // Spending Analytics
export interface SpendingDataPoint {
export interface AnalyticsCostsDataPoint { period: string;
period: string; // ISO 8601 date (start of period)
cost: number; cost: number;
purchases_count: number;
subscriptions: number; subscriptions: number;
views: number;
cpf: number | null;
cpm: number | null;
} }
export interface AnalyticsCostsResponse { export interface SpendingAnalytics {
data: AnalyticsCostsDataPoint[];
}
export interface CostsReport {
totalCost: number;
averageCost: number;
periods: {
date: string;
total_cost: number;
purchases_count: number;
avg_cost: number;
}[];
}
export interface AnalyticsChannelData {
channel_id: string;
channel_name: string;
purchases_count: number;
total_cost: number; total_cost: number;
total_subscriptions: number; total_subscriptions: number;
avg_cpf: number; total_views: number;
avg_cpm: number; avg_cpf: number | null;
avg_cpm: number | null;
chart_data: SpendingDataPoint[];
} }
export interface AnalyticsChannelsResponse { // Placements Analytics
data: AnalyticsChannelData[]; export interface PlacementAnalytics {
} id: string;
target_channel_id: string;
export interface AnalyticsCreativeData { target_channel_title: string;
external_channel_id: string;
external_channel_title: string;
creative_id: string; creative_id: string;
creative_name: string; creative_name: string;
purchases_count: number; placement_date: string;
total_cost: number; cost: number | null;
total_subscriptions: number; subscriptions_count: number;
avg_cpf: number; views_count: number | null;
conversion_rate: number; cpf: number | null;
cpm: number | null;
} }
export interface AnalyticsCreativesResponse { export interface PlacementsAnalytics {
data: AnalyticsCreativeData[]; 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; 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", ACCESS_TOKEN: "tgex_access_token",
REFRESH_TOKEN: "tgex_refresh_token", REFRESH_TOKEN: "tgex_refresh_token",
USER: "tgex_user", USER: "tgex_user",
SELECTED_TARGET_CHANNEL: "tgex_selected_target_channel",
} as const; } as const;
// API Endpoints // API Endpoints