diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d25e623..9aef4d1 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -17,6 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { AlertCircle, LogIn, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import Image from "next/image"; export default function LoginPage() { const { loginMock, error: authError } = useAuth(); @@ -43,15 +44,19 @@ export default function LoginPage() { const displayError = error || authError; return ( -
+
-
- - TG - +
+ Smartpost
- TGEX + Smartpost Система управления рекламными закупками в Telegram diff --git a/app/dashboard/[workspaceId]/page.tsx b/app/dashboard/[workspaceId]/page.tsx index fd8809e..feef1b6 100644 --- a/app/dashboard/[workspaceId]/page.tsx +++ b/app/dashboard/[workspaceId]/page.tsx @@ -1,16 +1,46 @@ "use client"; // ============================================================================ -// Dashboard Home Page +// Dashboard Overview (Главная) // ============================================================================ -import { useEffect, useState } from "react"; -import { Loader2, ShoppingCart, Users, TrendingUp, BarChart3 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { format, subDays, startOfDay, endOfDay } from "date-fns"; +import { ru } from "date-fns/locale"; +import { + ArrowDown, + ArrowUp, + Calendar as CalendarIcon, + BarChart3, + BarChartHorizontal, + Gauge, + Loader2, + Sparkles, + TrendingUp, + Users, +} from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; +import { DateRange } from "react-day-picker"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { demoAwareAnalyticsApi } from "@/lib/demo"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import type { SpendingAnalytics } from "@/lib/types/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { cn } from "@/lib/utils"; +import type { OverviewAnalytics } from "@/lib/types/api"; // ============================================================================ // Helpers @@ -23,24 +53,94 @@ const formatNumber = (value: number | null | undefined, showSign = false): strin return formatted; }; -const formatCurrency = (value: number | null | undefined): string => { +const formatCurrency = ( + value: number | null | undefined, + digits = 0 +): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", - minimumFractionDigits: 0, - maximumFractionDigits: 0, + minimumFractionDigits: digits, + maximumFractionDigits: digits, }).format(value); }; +const formatDateShort = (value: string): string => { + const [year, month, day] = value.split("-"); + if (!year || !month || !day) return value; + return `${day}.${month}`; +}; + +const DeltaPill: React.FC<{ delta?: number }> = ({ delta }) => { + if (delta === undefined || delta === null) return null; + const isPositive = delta > 0; + const isZero = delta === 0; + const color = isZero + ? "bg-muted text-muted-foreground" + : isPositive + ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200" + : "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-200"; + const Icon = isZero ? Sparkles : isPositive ? ArrowUp : ArrowDown; + const sign = delta > 0 ? "+" : ""; + return ( + + + {sign} + {delta.toFixed(1)}% + + ); +}; + // ============================================================================ // Component // ============================================================================ export default function DashboardPage() { - const { currentWorkspace, currentProject, isLoading: workspaceLoading, projects } = useWorkspace(); - const [spending, setSpending] = useState(null); + const { + currentWorkspace, + isLoading: workspaceLoading, + selectedProjects, + projects, + getProjectFilterId, + } = useWorkspace(); + + const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(true); + const [cpfMode, setCpfMode] = useState<"top" | "worst">("top"); + const [projectPage, setProjectPage] = useState(1); + const pageSize = 5; + const [dateRange, setDateRange] = useState(() => ({ + from: subDays(new Date(), 29), + to: new Date(), + })); + + const dateFromIso = useMemo(() => { + return dateRange?.from + ? startOfDay(dateRange.from).toISOString() + : undefined; + }, [dateRange?.from]); + + const dateToIso = useMemo(() => { + return dateRange?.to ? endOfDay(dateRange.to).toISOString() : undefined; + }, [dateRange?.to]); + + const dateRangeLabel = useMemo(() => { + if (dateRange?.from && dateRange?.to) { + const sameYear = + dateRange.from.getFullYear() === dateRange.to.getFullYear(); + const sameMonth = + sameYear && dateRange.from.getMonth() === dateRange.to.getMonth(); + const fromFormat = sameMonth ? "d MMM" : "d MMM yyyy"; + return `${format(dateRange.from, fromFormat, { locale: ru })} — ${format(dateRange.to, "d MMM yyyy", { locale: ru })}`; + } + if (dateRange?.from) { + return format(dateRange.from, "d MMM yyyy", { locale: ru }); + } + return "За всё время"; + }, [dateRange]); + + const projectFilterId = getProjectFilterId(); useEffect(() => { if (!currentWorkspace) { @@ -51,19 +151,109 @@ export default function DashboardPage() { const loadData = async () => { try { setLoading(true); - const data = await demoAwareAnalyticsApi.spending(currentWorkspace.id, { - project_id: currentProject?.id, + const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, { + project_id: projectFilterId, + date_from: dateFromIso, + date_to: dateToIso, }); - setSpending(data); + setOverview(data); + setProjectPage(1); } catch (err) { - console.error("Failed to load spending:", err); + console.error("Failed to load overview:", err); } finally { setLoading(false); } }; loadData(); - }, [currentWorkspace?.id, currentProject?.id]); + }, [currentWorkspace?.id, projectFilterId, dateFromIso, dateToIso]); + + const stats = useMemo( + () => [ + { + title: "Сумма закупов", + value: overview?.total_cost.value, + delta: overview?.total_cost.delta_percent, + format: (v: number | null | undefined) => formatCurrency(v, 0), + icon: BarChartHorizontal, + }, + { + title: "Общий охват", + value: overview?.total_reach.value, + delta: overview?.total_reach.delta_percent, + format: (v: number | null | undefined) => formatNumber(v), + icon: Users, + }, + { + title: "Всего размещений", + value: overview?.placements_count.value, + delta: overview?.placements_count.delta_percent, + format: (v: number | null | undefined) => formatNumber(v), + icon: Gauge, + }, + { + title: "Кол-во подписок", + value: overview?.subscriptions_count.value, + delta: overview?.subscriptions_count.delta_percent, + format: (v: number | null | undefined) => formatNumber(v), + icon: TrendingUp, + }, + { + title: "Средний CPM", + value: overview?.avg_cpm.value, + delta: overview?.avg_cpm.delta_percent, + format: (v: number | null | undefined) => formatCurrency(v, 2), + icon: BarChart3, + }, + { + title: "Средний CPF", + value: overview?.avg_cpf.value, + delta: overview?.avg_cpf.delta_percent, + format: (v: number | null | undefined) => formatCurrency(v, 2), + icon: Sparkles, + }, + ], + [overview] + ); + + const dailyStats = overview?.daily_stats ?? []; + + const subscriptionsChartData = useMemo( + () => + dailyStats.map((item) => ({ + date: item.date, + dateLabel: formatDateShort(item.date), + value: item.subscriptions_delta, + })), + [dailyStats] + ); + + const costChartData = useMemo( + () => + dailyStats.map((item) => ({ + date: item.date, + dateLabel: formatDateShort(item.date), + value: item.cost, + })), + [dailyStats] + ); + + const cpfChartData = useMemo( + () => + dailyStats.map((item) => ({ + date: item.date, + dateLabel: formatDateShort(item.date), + value: item.cpf, + })), + [dailyStats] + ); + + const projectSpending = overview?.project_spending ?? []; + const totalProjectPages = Math.max(1, Math.ceil(projectSpending.length / pageSize)); + const paginatedProjects = projectSpending.slice( + (projectPage - 1) * pageSize, + projectPage * pageSize + ); if (workspaceLoading) { return ( @@ -92,134 +282,378 @@ export default function DashboardPage() { ); } - // Calculate totals and averages - const totalCost = spending?.total_cost ?? 0; - const totalSubscriptions = spending?.total_subscriptions ?? 0; - const totalViews = spending?.total_views ?? 0; - const avgCps = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null; - const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null; - return ( <>
- {/* Stats Cards */} -
- - - Всего потрачено - +
+
+

Статистика за период

+

{dateRangeLabel}

+
+ + + + + + setDateRange(range)} + /> + + +
+ + {/* Строка 1 — компактные показатели */} +
+ {stats.map((item) => ( + + +
+ + {item.title} + + +
+ +
+ + {loading ? ( + + ) : ( +
{item.format(item.value)}
+ )} +
+
+ ))} +
+ + {/* Строка 2 — три диаграммы */} +
+ + + Динамика подписчиков по дням + {loading ? ( - +
+ +
) : ( -
{formatCurrency(totalCost)}
+ + + + + + + + {subscriptionsChartData.map((entry) => ( + = 0 ? "#22c55e" : "#ef4444"} + /> + ))} + + payload?.[0]?.payload?.date || ""} + formatter={(value: number) => [formatNumber(value, true), "Δ подписок"]} + /> + } + /> + + )}
- - - Подписчиков - + + + Траты по дням + {loading ? ( - +
+ +
) : ( -
{formatNumber(totalSubscriptions)}
+ + + + + formatCurrency(value, 0)} + /> + + payload?.[0]?.payload?.date || ""} + formatter={(value: number) => [formatCurrency(value, 0), "Затраты"]} + /> + } + /> + + )}
- - - Средний CPS - + + + Стоимость подписчика (CPF) + {loading ? ( - +
+ +
) : ( -
{formatCurrency(avgCps)}
- )} -
-
- - - - Средний CPM - - - - {loading ? ( - - ) : ( -
{formatCurrency(avgCpm)}
+ + + + + formatCurrency(value, 0)} + /> + + payload?.[0]?.payload?.date || ""} + formatter={(value: number) => [formatCurrency(value, 2), "CPF"]} + /> + } + /> + + )}
- {/* Quick Actions */} -
- - - Быстрый старт - Начните с основных действий - - - - → Создать размещение - - - → Создать креатив - - - → Посмотреть аналитику - - + {/* Строка 3 — таблицы */} +
+ + setCpfMode(value as "top" | "worst")}> + +
+ Топ / Антитоп по CPF + + + Топ 5 + + + Антитоп 5 + + +
+
+ + {loading ? ( +
+ +
+ ) : ( + <> + + {overview?.top_channels_by_cpf?.length ? ( + + + + Канал + CPF + + + + {overview.top_channels_by_cpf.slice(0, 5).map((row) => ( + + +
{row.title}
+ {row.username && ( + + @{row.username} + + )} +
+ + {formatCurrency(row.cpf, 2)} + +
+ ))} +
+
+ ) : ( +

Нет данных

+ )} +
+ + {overview?.worst_channels_by_cpf?.length ? ( + + + + Канал + CPF + + + + {overview.worst_channels_by_cpf.slice(0, 5).map((row) => ( + + +
{row.title}
+ {row.username && ( + + @{row.username} + + )} +
+ + {formatCurrency(row.cpf, 2)} + +
+ ))} +
+
+ ) : ( +

Нет данных

+ )} +
+ + )} +
+
- - - Проекты - - {projects.length > 0 - ? `${projects.length} активных проектов` - : "Нет активных проектов"} - + + + Сумма закупов по проектам +
+ + + {projectPage}/{totalProjectPages} + + +
- {projects.length > 0 ? ( -
- {projects.slice(0, 5).map((project) => ( -
- {project.title} - {project.username && ( - @{project.username} - )} -
- ))} + {loading ? ( +
+
+ ) : paginatedProjects.length === 0 ? ( +

Нет данных

) : ( -

- Добавьте бота в канал через Telegram для создания проекта -

+ + + + Проект + Сумма + + + + {paginatedProjects.map((row) => ( + + +
{row.project_title}
+ {row.project_username && ( + @{row.project_username} + )} +
+ + {formatCurrency(row.total_cost, 0)} + +
+ ))} +
+
)} @@ -228,4 +662,3 @@ export default function DashboardPage() { ); } - diff --git a/app/dashboard/onboarding/page.tsx b/app/dashboard/onboarding/page.tsx index 50420fc..fe33773 100644 --- a/app/dashboard/onboarding/page.tsx +++ b/app/dashboard/onboarding/page.tsx @@ -55,7 +55,7 @@ export default function OnboardingPage() {
- Добро пожаловать в TGEX! + Добро пожаловать в Smartpost! Создайте свой первый воркспейс для начала работы diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..7822e83 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/layout.tsx b/app/layout.tsx index a2efcbb..5444193 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,7 +14,7 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "TGEX - Управление рекламными закупками", + title: "Smartpost - Управление рекламными закупками", description: "Система для владельцев каналов для управления рекламными закупками в Telegram", }; diff --git a/components/layout/app-sidebar.tsx b/components/layout/app-sidebar.tsx index 444563f..64a8644 100644 --- a/components/layout/app-sidebar.tsx +++ b/components/layout/app-sidebar.tsx @@ -6,27 +6,39 @@ import * as React from "react"; import { useParams } from "next/navigation"; +import Link from "next/link"; import { BarChart3, + BookmarkCheck, Building2, - Check, - ChevronsUpDown, + CalendarClock, + ClipboardCheck, ClipboardList, - ExternalLink, + Filter, Folder, - HelpCircle, + Gauge, + KanbanSquare, LayoutDashboard, + LineChart, + LifeBuoy, + ListChecks, Megaphone, MessageCircle, - Plus, + MonitorPlay, + NotebookPen, + PlayCircle, Settings, ShoppingCart, + Sparkles, Tv, + TrendingUp, BookOpen, + ChevronRight, + X, type LucideIcon, } from "lucide-react"; -import { NavMain } from "@/components/nav-main"; +import { type NavGuideState, NavMain } from "@/components/nav-main"; import { NavUser } from "@/components/nav-user"; import { Sidebar, @@ -36,17 +48,25 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, SidebarRail, SidebarSeparator, SidebarGroup, SidebarGroupContent, + SidebarGroupLabel, + useSidebar, } from "@/components/ui/sidebar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -62,7 +82,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useAuth } from "@/components/auth/auth-provider"; import { useWorkspace } from "@/components/providers/workspace-provider"; -import { buildRoute, BOT_USERNAME } from "@/lib/utils/constants"; +import { buildRoute } from "@/lib/utils/constants"; +import Image from "next/image"; +import { DEMO_WORKSPACE_ID } from "@/lib/demo"; // ============================================================================ // Types @@ -80,35 +102,211 @@ interface NavItem { }[]; } -interface FooterLink { +interface TrainingStep { + id: string; title: string; - url: string; + description: string; + navItemTitle: string; + expandedNavItems?: string[]; + icon?: LucideIcon; + sections?: { + title: string; + description: string; + icon?: LucideIcon; + points?: string[]; + }[]; + points?: string[]; +} + +interface HelpItem { + title: string; + description: string; icon: LucideIcon; + href?: string; + external?: boolean; + action?: () => void; +} + +interface SupportLink { + title: string; + icon: LucideIcon; + href: string; external?: boolean; } -// ============================================================================ -// Footer Links Configuration -// ============================================================================ - -const footerLinks: FooterLink[] = [ +const TRAINING_STEPS: TrainingStep[] = [ { - title: "Поддержка", - url: `https://t.me/${BOT_USERNAME}`, - icon: MessageCircle, - external: true, + id: "dashboard", + title: "Главная панель", + description: + "Это стартовая точка: отсюда видно общее состояние закупок, статус проектов и свежие уведомления. Сюда возвращаемся, чтобы понять, что делать дальше и какие задачи горят.", + navItemTitle: "Главная", + icon: LayoutDashboard, + sections: [ + { + title: "Сводка показателей", + description: + "Карточки расхода, охвата и эффективности обновляются в режиме близи real-time. Это быстрый способ понять, всё ли идёт по плану.", + icon: Gauge, + points: [ + "Подсветка указывает, где есть отклонения от плана или резкие изменения.", + "Нажмите на карточку, чтобы перейти к связанному отчёту или проекту.", + ], + }, + { + title: "Что происходит сейчас", + description: + "Лента событий показывает новые заявки, комментарии и дедлайны. Так команда не пропускает задачи.", + icon: CalendarClock, + points: [ + "Фильтр по типу событий помогает отделить, например, комментарии от обновлений статусов.", + "Кнопка «Создать» рядом ведёт к быстрому созданию проектов и размещений.", + ], + }, + ], }, { - title: "Информационный канал", - url: "https://t.me/tgex_news", - icon: Megaphone, - external: true, + id: "projects", + title: "Проекты", + description: + "Управляйте рекламными активностями по каждому продукту или клиенту и контролируйте статусы задач.", + navItemTitle: "Проекты", + icon: KanbanSquare, + sections: [ + { + title: "Карточки проектов", + description: + "Каждый проект знает свой бюджет, статус и ответственных. Это рабочее пространство конкретной кампании.", + icon: NotebookPen, + points: [ + "Новые проекты создаются через кнопку «Добавить» — укажите клиента, цель и дедлайн.", + "Меняйте статусы (подготовка, в работе, завершён) по мере продвижения кампании.", + ], + }, + { + title: "Фильтры и поиск", + description: + "Фильтруйте проекты по менеджерам или тегам, чтобы быстро найти нужное направление.", + icon: Filter, + points: [ + "Сохраняйте выбранный фильтр как «Избранный», чтобы открывать его в один клик.", + ], + }, + ], }, { - title: "База знаний", - url: "https://docs.tgex.io", - icon: BookOpen, - external: true, + id: "channels", + title: "Каталог каналов", + description: + "Ищите площадки по темам и метрикам, собирайте избранные каналы и сравнивайте их перед закупкой.", + navItemTitle: "Каталог каналов", + icon: BookmarkCheck, + sections: [ + { + title: "Поиск и фильтры", + description: + "Фильтруйте каналы по тематике, охвату и цене. Результаты можно сортировать и группировать.", + icon: Filter, + points: [ + "Подборка фильтров сохраняется автоматически, чтобы вернуться к ней позже.", + ], + }, + { + title: "Работа с подборками", + description: + "Отмечайте интересные площадки, добавляйте их в планы закупов и сравнивайте показатели.", + icon: ListChecks, + points: [ + "Одним кликом отправляйте канал в план или проект, чтобы не собирать всё заново.", + ], + }, + ], + }, + { + id: "purchase-plans", + title: "Планы закупов", + description: + "Это черновики кампаний. Собирайте каналы, фиксируйте ставки и согласовывайте бюджет до запуска.", + navItemTitle: "Планы закупов", + icon: ListChecks, + sections: [ + { + title: "Конструктор кампаний", + description: + "Добавляйте каналы, задавайте даты и ставки. Система сама подсчитает бюджет и прогноз охвата.", + icon: NotebookPen, + points: [ + "Используйте заметки, чтобы фиксировать договорённости с площадками.", + "Приглашайте коллег к плану — они увидят прогресс в реальном времени.", + ], + }, + { + title: "Согласование", + description: + "Когда план готов, его можно перевести в статус «Утверждён» и автоматически развернуть в размещения.", + icon: ClipboardCheck, + points: [ + "История изменений сохранится и поможет восстановить контекст договорённостей.", + ], + }, + ], + }, + { + id: "placements", + title: "Размещения", + description: + "Здесь живут все закупки: от черновиков до опубликованных постов. Следите за статусом и результатами.", + navItemTitle: "Размещения", + icon: ClipboardCheck, + sections: [ + { + title: "Линия жизни закупки", + description: + "Каждое размещение проходит статусы: черновик, согласовано, оплачено, опубликовано. Становится понятно, где застряли процессы.", + icon: CalendarClock, + points: [ + "Обновляйте статусы и дедлайны — таблица подсветит просроченные задачи.", + ], + }, + { + title: "Фактические результаты", + description: + "После выхода поста вносите реальные просмотры, CTR и стоимость — аналитика подхватит данные автоматически.", + icon: Gauge, + points: [ + "Добавляйте скриншоты и ссылки для архива — к ним всегда можно вернуться.", + ], + }, + ], + }, + { + id: "analytics", + title: "Аналитика", + description: + "Сводная аналитика показывает, что работает и куда уходят бюджеты. Не нужно собирать отчёты вручную.", + navItemTitle: "Аналитика", + expandedNavItems: ["Аналитика"], + icon: TrendingUp, + sections: [ + { + title: "Срезы данных", + description: + "Выбирайте отчёт по проектам, каналам или креативам, чтобы увидеть вклад каждой части.", + icon: LineChart, + points: [ + "Сравнивайте периоды и экспортируйте графики для презентаций.", + ], + }, + { + title: "Фильтры и сегменты", + description: + "Фильтр по менеджеру, периоду или типу кампании помогает отвечать на конкретные вопросы бизнеса.", + icon: Filter, + points: [ + "Сохранённые сегменты экономят время: достаточно выбрать нужный набор критериев.", + ], + }, + ], }, ]; @@ -128,97 +326,92 @@ export function AppSidebar({ ...props }: React.ComponentProps) { isAdmin, createWorkspace, } = useWorkspace(); + const { state: sidebarState } = useSidebar(); + const isSidebarCollapsed = sidebarState === "collapsed"; + const expandedLogoWidth = 182; + const expandedLogoHeight = 31; + const expandedLogoDimensions = React.useMemo( + () => ({ + height: expandedLogoHeight, + width: expandedLogoWidth, + minWidth: expandedLogoWidth, + }), + [expandedLogoHeight, expandedLogoWidth] + ); const [showCreateDialog, setShowCreateDialog] = React.useState(false); const [newWorkspaceName, setNewWorkspaceName] = React.useState(""); const [isCreating, setIsCreating] = React.useState(false); + const handleWorkspaceSelect = React.useCallback( + (workspaceId: string) => { + if (currentWorkspace?.id === workspaceId) return; + const workspace = workspaces.find((w) => w.id === workspaceId); + if (workspace) { + setCurrentWorkspace(workspace); + } + }, + [currentWorkspace?.id, workspaces, setCurrentWorkspace] + ); // Build navigation items based on permissions const navItems: NavItem[] = React.useMemo(() => { if (!workspaceId) return []; - const items: NavItem[] = [ - { - title: "Главная", - url: buildRoute.dashboard(workspaceId), - icon: LayoutDashboard, - isActive: true, - }, - ]; + const items: NavItem[] = []; - // Projects - always visible (contains instructions how to add projects via bot) + // 1. Главная + items.push({ + title: "Главная", + url: buildRoute.dashboard(workspaceId), + icon: LayoutDashboard, + isActive: true, + }); + + // 2. Проекты (всегда доступны) items.push({ title: "Проекты", url: buildRoute.projects(workspaceId), icon: Tv, }); - // Channels catalog (available to all) - items.push({ - title: "Каталог каналов", - url: buildRoute.channels(workspaceId), - icon: Building2, - }); - - // Purchase Plans (requires placements_read or admin) - if (hasPermission("placements_read")) { - items.push({ - title: "Планы закупок", - url: buildRoute.purchasePlans(workspaceId), - icon: ClipboardList, - requiredPermission: "placements_read", - }); - } - - // Creatives (requires placements_read or admin) + // 3. Креативы (только с доступом к размещениям) if (hasPermission("placements_read")) { items.push({ title: "Креативы", url: buildRoute.creatives(workspaceId), icon: Folder, requiredPermission: "placements_read", - items: [ - { - title: "Все креативы", - url: buildRoute.creatives(workspaceId), - }, - ...(hasPermission("placements_write") - ? [ - { - title: "Создать", - url: buildRoute.creativeNew(workspaceId), - }, - ] - : []), - ], }); } - // Placements (requires placements_read or admin) + // 4. Каталог каналов (всегда доступен) + items.push({ + title: "Каталог каналов", + url: buildRoute.channels(workspaceId), + icon: Building2, + }); + + // 5. Планы закупов (правка названия) + if (hasPermission("placements_read")) { + items.push({ + title: "Планы закупов", + url: buildRoute.purchasePlans(workspaceId), + icon: ClipboardList, + requiredPermission: "placements_read", + }); + } + + // 6. Размещения if (hasPermission("placements_read")) { items.push({ title: "Размещения", url: buildRoute.placements(workspaceId), icon: ShoppingCart, requiredPermission: "placements_read", - items: [ - { - title: "Все размещения", - url: buildRoute.placements(workspaceId), - }, - ...(hasPermission("placements_write") - ? [ - { - title: "Создать", - url: buildRoute.placementNew(workspaceId), - }, - ] - : []), - ], }); } - // Analytics (requires analytics_read or admin) + // 7. Аналитика — только два подраздела if (hasPermission("analytics_read")) { items.push({ title: "Аналитика", @@ -227,48 +420,178 @@ export function AppSidebar({ ...props }: React.ComponentProps) { requiredPermission: "analytics_read", items: [ { - title: "Обзор", + title: "По проектам", url: buildRoute.analytics(workspaceId), }, { - title: "Расходы", - url: buildRoute.analyticsSpending(workspaceId), - }, - { - title: "Каналы", - url: buildRoute.analyticsChannels(workspaceId), - }, - { - title: "Креативы", + title: "По креативам", url: buildRoute.analyticsCreatives(workspaceId), }, ], }); } - // Settings (admin only) + // 8. Доступы (без подразделов, админ) if (isAdmin) { items.push({ - title: "Настройки", - url: buildRoute.settings(workspaceId), + title: "Доступы", + url: buildRoute.members(workspaceId), icon: Settings, requiredPermission: "admin_full", - items: [ - { - title: "Участники", - url: buildRoute.members(workspaceId), - }, - { - title: "Приглашения", - url: buildRoute.invites(workspaceId), - }, - ], }); } return items; }, [workspaceId, hasPermission, isAdmin]); + const availableTrainingSteps = React.useMemo( + () => + TRAINING_STEPS.filter((step) => + navItems.some((item) => item.title === step.navItemTitle) + ), + [navItems] + ); + const trainingStepsCount = availableTrainingSteps.length; + const [isTrainingOpen, setIsTrainingOpen] = React.useState(false); + const [trainingStepIndex, setTrainingStepIndex] = React.useState(0); + const safeTrainingStepIndex = + trainingStepsCount > 0 + ? Math.min(trainingStepIndex, trainingStepsCount - 1) + : 0; + const currentTrainingStep = + trainingStepsCount > 0 + ? availableTrainingSteps[safeTrainingStepIndex] + : undefined; + + React.useEffect(() => { + if (trainingStepsCount === 0) { + setIsTrainingOpen(false); + setTrainingStepIndex(0); + return; + } + + if (trainingStepIndex > trainingStepsCount - 1) { + setTrainingStepIndex(0); + } + }, [trainingStepIndex, trainingStepsCount]); + + const openTraining = React.useCallback(() => { + if (!trainingStepsCount) return; + setTrainingStepIndex(0); + setIsTrainingOpen(true); + }, [trainingStepsCount]); + + const closeTraining = React.useCallback(() => { + setIsTrainingOpen(false); + }, []); + + const goToNextTrainingStep = React.useCallback(() => { + setTrainingStepIndex((prev) => + Math.min(prev + 1, Math.max(trainingStepsCount - 1, 0)) + ); + }, [trainingStepsCount]); + + const goToPrevTrainingStep = React.useCallback(() => { + setTrainingStepIndex((prev) => Math.max(prev - 1, 0)); + }, []); + + const navGuideState = React.useMemo(() => { + if (!isTrainingOpen || !currentTrainingStep) return undefined; + return { + highlightedItem: currentTrainingStep.navItemTitle, + expandedItems: currentTrainingStep.expandedNavItems, + }; + }, [isTrainingOpen, currentTrainingStep]); + const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep); + + const supportLinks = React.useMemo( + () => [ + { + title: "Наш канал", + href: "https://t.me/tgex_news", + icon: Megaphone, + external: true, + }, + { + title: "Поддержка", + href: "https://t.me/smartpost_help_bot", + icon: MessageCircle, + external: true, + }, + { + title: "База знаний", + href: "https://docs.smartpost.ru", + icon: BookOpen, + external: true, + }, + ], + [] + ); + + const extraHelpItems = React.useMemo( + () => [ + { + title: "Демо дашборд", + description: "Посмотрите пример рабочего пространства со всеми разделами.", + href: buildRoute.dashboard(DEMO_WORKSPACE_ID), + icon: Sparkles, + }, + { + title: "Открыть обучение", + description: "Запустите интерактивный тур по основным разделам.", + action: openTraining, + icon: MonitorPlay, + }, + ], + [openTraining] + ); + + const renderExtraHelpItem = React.useCallback((item: HelpItem) => { + const content = ( + <> + +

{item.title}

+ + ); + + if (item.action) { + return ( + + ); + } + + if (item.href) { + if (item.external) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); + } + + return ( +
{content}
+ ); + }, []); + const handleCreateWorkspace = async () => { if (!newWorkspaceName.trim()) return; @@ -289,98 +612,149 @@ export function AppSidebar({ ...props }: React.ComponentProps) { <> - {/* Workspace Switcher */} - - - + {isSidebarCollapsed ? ( +
+ Smartpost +
+ ) : ( +
-
- -
-
- - {currentWorkspace?.name || "TGEX"} - - - {workspaces.length} воркспейс{workspaces.length === 1 ? "" : workspaces.length < 5 ? "а" : "ов"} - -
- - - - - - Воркспейсы - - {workspaces.map((workspace) => ( - setCurrentWorkspace(workspace)} - className="gap-2 p-2" - > -
- -
- {workspace.name} - {currentWorkspace?.id === workspace.id && ( - - )} -
- ))} - - setShowCreateDialog(true)} - className="gap-2 p-2" - > -
- -
- - Создать воркспейс - -
-
- + Smartpost + Smartpost +
+ )} + {/*
+ Smartpost + + Управление закупками + +
*/} +
- + - {/* Footer Links */} - + - {footerLinks.map((link) => ( + {supportLinks.map((link) => ( - - - - {link.title} - {link.external && ( - - )} - + + {link.external ? ( + + + {link.title} + + ) : ( + + + {link.title} + + )} ))} + + {isSidebarCollapsed ? ( + + + + +
+ + Помощь +
+ +
+
+ + {extraHelpItems.map((item) => ( + + {renderExtraHelpItem(item)} + + ))} + +
+
+ ) : ( + + + + +
+ + Помощь +
+ +
+
+ + + {extraHelpItems.map((item) => ( + + + {renderExtraHelpItem(item)} + + + ))} + + +
+
+ )}
@@ -399,12 +773,31 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ? `https://ui-avatars.com/api/?name=${user.username}` : `https://ui-avatars.com/api/?name=User`, }} + workspaceSwitcher={{ + workspaces, + currentWorkspaceId: currentWorkspace?.id, + onSelect: (workspace) => handleWorkspaceSelect(workspace.id), + onCreate: () => setShowCreateDialog(true), + }} /> )}
+ {isTrainingVisible && ( +
+ {workspaceSwitcher && ( + <> + + + Воркспейсы + + {workspaceSwitcher.workspaces.length > 0 ? ( + workspaceSwitcher.workspaces.map((workspace) => ( + workspaceSwitcher.onSelect(workspace)} + className="gap-2 p-2 text-sm" + > +
+ +
+ {workspace.name} + {workspaceSwitcher.currentWorkspaceId === workspace.id && ( + + )} +
+ )) + ) : ( +
+ Нет доступных воркспейсов +
+ )} + {workspaceSwitcher.onCreate && ( + <> + + +
+ +
+ + Создать воркспейс + +
+ + )} + + )} diff --git a/lib/api/analytics.ts b/lib/api/analytics.ts index d08301c..c2b5833 100644 --- a/lib/api/analytics.ts +++ b/lib/api/analytics.ts @@ -11,6 +11,8 @@ import type { AnalyticsQueryParams, SpendingAnalyticsQueryParams, PaginatedResponse, + OverviewAnalytics, + OverviewAnalyticsQueryParams, } from "@/lib/types/api"; export const analyticsApi = { @@ -49,4 +51,13 @@ export const analyticsApi = { `/workspaces/${workspaceId}/analytics/spending`, { params } ), + + /** + * Overview analytics for dashboard + */ + overview: (workspaceId: string, params?: OverviewAnalyticsQueryParams) => + api.get( + `/workspaces/${workspaceId}/analytics/overview`, + { params } + ), }; diff --git a/lib/demo/api.ts b/lib/demo/api.ts index 630c117..51efa35 100644 --- a/lib/demo/api.ts +++ b/lib/demo/api.ts @@ -14,6 +14,7 @@ import type { SpendingAnalytics, CreativeAnalyticsItem, ChannelAnalyticsItem, + OverviewAnalytics, } from "@/lib/types/api"; import { demoWorkspace, @@ -26,6 +27,7 @@ import { demoSpendingAnalytics, demoCreativeAnalytics, demoChannelAnalytics, + demoOverviewAnalytics, } from "./mock-data"; import { DEMO_USER } from "./constants"; @@ -191,6 +193,13 @@ export const demoApi = { } return Promise.resolve(paginate(items)); }, + overview: (_params?: { + project_id?: string; + date_from?: string; + date_to?: string; + }): Promise => { + return Promise.resolve(demoOverviewAnalytics); + }, }, }; diff --git a/lib/demo/demo-aware-api.ts b/lib/demo/demo-aware-api.ts index 3a79280..61d4283 100644 --- a/lib/demo/demo-aware-api.ts +++ b/lib/demo/demo-aware-api.ts @@ -15,6 +15,7 @@ import { demoProjects, demoWorkspace, demoMember, + demoOverviewAnalytics, } from "./mock-data"; import type { PaginatedResponse, @@ -29,6 +30,7 @@ import type { Workspace, WorkspaceMember, DateGrouping, + OverviewAnalytics, } from "@/lib/types/api"; import { workspacesApi, @@ -305,5 +307,14 @@ export const demoAwareAnalyticsApi = { } return analyticsApi.channels(workspaceId, params); }, + overview: async ( + workspaceId: string, + params?: { project_id?: string; date_from?: string; date_to?: string } + ): Promise => { + if (isDemoWorkspace(workspaceId)) { + return demoOverviewAnalytics; + } + return analyticsApi.overview(workspaceId, params); + }, }; diff --git a/lib/demo/mock-data.ts b/lib/demo/mock-data.ts index c961796..c99cc01 100644 --- a/lib/demo/mock-data.ts +++ b/lib/demo/mock-data.ts @@ -14,6 +14,7 @@ import type { SpendingAnalytics, CreativeAnalyticsItem, ChannelAnalyticsItem, + OverviewAnalytics, } from "@/lib/types/api"; import { DEMO_WORKSPACE_ID, DEMO_USER } from "./constants"; @@ -581,3 +582,125 @@ export const demoChannelAnalytics: ChannelAnalyticsItem[] = [ }, ]; +export const demoOverviewAnalytics: OverviewAnalytics = { + total_cost: { value: 523400.5, delta_percent: 12.4 }, + total_reach: { value: 380000, delta_percent: -8.1 }, + placements_count: { value: 74, delta_percent: 5.7 }, + subscriptions_count: { value: 14250, delta_percent: 18.2 }, + avg_cpm: { value: 1376.06, delta_percent: 2.9 }, + avg_cpf: { value: 36.74, delta_percent: -5.4 }, + daily_stats: [ + { date: "2024-06-01", cost: 18450, subscriptions: 420, subscriptions_delta: -15, cpf: 43.93 }, + { date: "2024-06-02", cost: 21700, subscriptions: 505, subscriptions_delta: 85, cpf: 42.97 }, + { date: "2024-06-03", cost: 15800, subscriptions: 380, subscriptions_delta: -125, cpf: 41.58 }, + { date: "2024-06-04", cost: 26500, subscriptions: 640, subscriptions_delta: 260, cpf: 41.41 }, + { date: "2024-06-05", cost: 30200, subscriptions: 715, subscriptions_delta: 75, cpf: 42.24 }, + { date: "2024-06-06", cost: 18800, subscriptions: 455, subscriptions_delta: -260, cpf: 41.32 }, + { date: "2024-06-07", cost: 22400, subscriptions: 520, subscriptions_delta: 65, cpf: 43.08 }, + ], + top_channels_by_cpf: [ + { + channel_id: "chan-0005-0000-0000-000000000005", + title: "Финансовая грамотность", + username: "finance_edu", + cpf: 18.7, + total_cost: 44200, + subscriptions: 2365, + }, + { + channel_id: "chan-0003-0000-0000-000000000003", + title: "Программисты", + username: "programmers_hub", + cpf: 21.8, + total_cost: 38900, + subscriptions: 1785, + }, + { + channel_id: "chan-0001-0000-0000-000000000001", + title: "Технологии Будущего", + username: "tech_future", + cpf: 23.4, + total_cost: 35500, + subscriptions: 1517, + }, + { + channel_id: "chan-0002-0000-0000-000000000002", + title: "Инвестиции Pro", + username: "invest_pro", + cpf: 24.6, + total_cost: 33100, + subscriptions: 1346, + }, + { + channel_id: "chan-0006-0000-0000-000000000006", + title: "Startup Daily", + username: "startup_daily", + cpf: 25.2, + total_cost: 28800, + subscriptions: 1143, + }, + ], + worst_channels_by_cpf: [ + { + channel_id: "chan-0010-0000-0000-000000000010", + title: "Crypto Buzz", + username: "cryptobuzz", + cpf: 84.5, + total_cost: 9900, + subscriptions: 117, + }, + { + channel_id: "chan-0009-0000-0000-000000000009", + title: "Ads Unlimited", + username: "ads_unlimited", + cpf: 72.3, + total_cost: 8200, + subscriptions: 113, + }, + { + channel_id: "chan-0008-0000-0000-000000000008", + title: "News Mix", + username: "newsmix", + cpf: 66.2, + total_cost: 11400, + subscriptions: 172, + }, + { + channel_id: "chan-0007-0000-0000-000000000007", + title: "Random Talks", + username: "randomtalks", + cpf: 61.9, + total_cost: 13400, + subscriptions: 216, + }, + { + channel_id: "chan-0004-0000-0000-000000000004", + title: "Маркетинг Pro", + username: "marketing_pro", + cpf: 58.4, + total_cost: 12700, + subscriptions: 217, + }, + ], + project_spending: [ + { + project_id: "proj-0001-0000-0000-000000000001", + project_title: "Крипто Новости", + project_username: "crypto_news_demo", + total_cost: 244000, + }, + { + project_id: "proj-0002-0000-0000-000000000002", + project_title: "IT Вакансии", + project_username: "it_jobs_demo", + total_cost: 182400, + }, + { + project_id: "proj-0003-0000-0000-000000000003", + project_title: "Стартапы и Бизнес", + project_username: "startups_demo", + total_cost: 96900, + }, + ], +}; + diff --git a/lib/types/api.ts b/lib/types/api.ts index f55b934..7c7afcd 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -354,6 +354,55 @@ export interface SpendingAnalyticsQueryParams { grouping?: DateGrouping; } +// Overview Analytics +export interface OverviewMetric { + value: number; + delta_percent: number; +} + +export interface OverviewDailyStat { + date: string; // YYYY-MM-DD + cost: number; + subscriptions: number; + subscriptions_delta: number; + cpf: number; +} + +export interface OverviewChannelCpf { + channel_id: string; + title: string; + username: string | null; + cpf: number; + total_cost: number; + subscriptions: number; +} + +export interface OverviewProjectSpending { + project_id: string; + project_title: string; + project_username: string | null; + total_cost: number; +} + +export interface OverviewAnalytics { + total_cost: OverviewMetric; + total_reach: OverviewMetric; + placements_count: OverviewMetric; + subscriptions_count: OverviewMetric; + avg_cpm: OverviewMetric; + avg_cpf: OverviewMetric; + daily_stats: OverviewDailyStat[]; + top_channels_by_cpf: OverviewChannelCpf[]; + worst_channels_by_cpf: OverviewChannelCpf[]; + project_spending: OverviewProjectSpending[]; +} + +export interface OverviewAnalyticsQueryParams { + project_id?: string; + date_from?: string; + date_to?: string; +} + // ---------------------------------------------------------------------------- // Common Types // ---------------------------------------------------------------------------- diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/logo.webp b/public/logo.webp new file mode 100644 index 0000000..78d8428 Binary files /dev/null and b/public/logo.webp differ diff --git a/public/logo_full_dark.webp b/public/logo_full_dark.webp new file mode 100644 index 0000000..826c96d Binary files /dev/null and b/public/logo_full_dark.webp differ diff --git a/public/logo_full_white.webp b/public/logo_full_white.webp new file mode 100644 index 0000000..79a1590 Binary files /dev/null and b/public/logo_full_white.webp differ diff --git a/public/logo_full_white_2.png b/public/logo_full_white_2.png new file mode 100644 index 0000000..65da075 Binary files /dev/null and b/public/logo_full_white_2.png differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file