Files
tgex-frontend/app/(dashboard)/page.tsx
2025-12-01 08:03:45 +03:00

366 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Dashboard Home Page
// ============================================================================
import React, { useEffect, useState, useRef } from "react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { analyticsApi, channelsApi } from "@/lib/api";
import {
formatCurrency,
formatNumber,
formatMetric,
formatPercent,
} from "@/lib/utils/format";
import {
BarChart3,
TrendingUp,
Users,
ShoppingCart,
Loader2,
ExternalLink,
CheckCircle2,
} from "lucide-react";
import type { SpendingAnalytics } from "@/lib/types/api";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import { BOT_USERNAME } from "@/lib/utils/constants";
export default function DashboardPage() {
const {
selectedChannel,
channels,
isLoading: channelsLoading,
} = useTargetChannel();
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true);
// Для модального окна добавления канала
const [showAddChannelDialog, setShowAddChannelDialog] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [initialChannelCount, setInitialChannelCount] = useState(0);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Показываем модальное окно, если нет каналов
useEffect(() => {
if (!channelsLoading && channels.length === 0) {
setShowAddChannelDialog(true);
setInitialChannelCount(0);
}
}, [channelsLoading, channels]);
// Автоматический polling при открытом окне
useEffect(() => {
if (showAddChannelDialog && !isSuccess && initialChannelCount === 0) {
pollingIntervalRef.current = setInterval(() => {
checkForNewChannels();
}, 1000);
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}
}, [showAddChannelDialog, isSuccess, initialChannelCount]);
// Очистка при размонтировании
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
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);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setTimeout(() => {
setShowAddChannelDialog(false);
setIsSuccess(false);
window.location.reload();
}, 2000);
}
} catch (err) {
console.error("Error polling channels:", err);
}
};
useEffect(() => {
if (!selectedChannel) {
setLoading(false);
return;
}
const loadData = async () => {
try {
setLoading(true);
const data = await analyticsApi.spending({
grouping: "week",
target_channel_id: selectedChannel.id,
});
setSpending(data);
} catch (error) {
console.error("Error loading spending data:", error);
} finally {
setLoading(false);
}
};
loadData();
}, [selectedChannel]);
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
{/* Модальное окно добавления первого канала */}
<Dialog open={showAddChannelDialog} onOpenChange={() => {}}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => 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>
{!selectedChannel ? (
<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>
) : (
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Всего закупов
</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(spending?.total_cost)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(0 || 0, true)} за прошлый месяц
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Подписчиков привлечено
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<div className="text-2xl font-bold">
{formatNumber(spending?.total_subscriptions)}
</div>
<p className="text-xs text-muted-foreground">
{formatNumber(0 || 0, true)} за прошлый месяц
</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>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<div className="text-2xl font-bold">
{formatMetric(spending?.avg_cpf)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(0 || 0, true)} от прошлого месяца
</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>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<div className="text-2xl font-bold">
{formatMetric(spending?.avg_cpm)}
</div>
<p className="text-xs text-muted-foreground">
{formatPercent(0 || 0, true)} от прошлого месяца
</p>
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-1">
<Card>
<CardHeader>
<CardTitle>Быстрый старт</CardTitle>
<CardDescription>Начните с основных действий</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<a
href="/purchases/new"
className="block text-sm text-primary hover:underline"
>
Создать новый закуп
</a>
<a
href="/creatives/new"
className="block text-sm text-primary hover:underline"
>
Создать креатив
</a>
<a
href="/external-channels/import"
className="block text-sm text-primary hover:underline"
>
Импортировать каналы
</a>
<a
href="/analytics"
className="block text-sm text-primary hover:underline"
>
Посмотреть аналитику
</a>
</CardContent>
</Card>
</div>
</div>
)}
</>
);
}