- {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 ? (
+
+
+
+ ) : (
+
-
-
-
-
-
- {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
+
+ Управление закупками
+
+
*/}
+
-
+
- {/* 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 && (
+
+ )}
+
+
{/* Create Workspace Dialog */}