"use client"; // ============================================================================ // Creatives List Page - Enhanced with Statistics // ============================================================================ import { useEffect, useState, useMemo } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import { Loader2, Search, Plus, Folder, MoreHorizontal, Archive, Pencil, Trash2, Eye, Copy, TrendingUp, Users, ShoppingCart, BarChart3, Info, Grid, List, FileText, Image as ImageIcon, Video, } from "lucide-react"; import Image from "next/image"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { creativesApi } from "@/lib/api"; import { demoAwareCreativesApi, demoAwareAnalyticsApi } from "@/lib/demo"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { toast } from "sonner"; import { handleApiError } from "@/lib/utils/error-handler"; import { BOT_USERNAME } from "@/lib/utils/constants"; import { ProjectSelector } from "@/components/project-selector"; import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api"; // ============================================================================ // Types // ============================================================================ interface CreativeWithStats extends Creative { analytics?: CreativeAnalyticsItem; } // ============================================================================ // Helpers // ============================================================================ const formatNumber = (value: number | null | undefined): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU").format(value); }; const formatCurrency = (value: number | null | undefined): string => { if (value === null || value === undefined) return "—"; return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0, }).format(value); }; const getCreativeWord = (count: number): string => { const mod10 = count % 10; const mod100 = count % 100; if (mod100 >= 11 && mod100 <= 14) { return "креативов"; } if (mod10 === 1) { return "креатив"; } if (mod10 >= 2 && mod10 <= 4) { return "креатива"; } return "креативов"; }; const getMediaIcon = (mediaType: string) => { switch (mediaType) { case "photo": return ImageIcon; case "video": return Video; case "animation": return FileText; default: return FileText; } }; // ============================================================================ // Component // ============================================================================ export default function CreativesPage() { const params = useParams(); const router = useRouter(); const workspaceId = params?.workspaceId as string; const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission, currentProject } = useWorkspace(); const projectFilterId = getProjectFilterId(); const [creatives, setCreatives] = useState([]); const [analyticsData, setAnalyticsData] = useState([]); const [loading, setLoading] = useState(true); const [loadingAnalytics, setLoadingAnalytics] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false); const [search, setSearch] = useState(""); const [includeArchived, setIncludeArchived] = useState(false); const [viewMode, setViewMode] = useState<"table" | "grid">("table"); // Permission checks const canReadCreatives = hasPermission("creatives_read"); const canWriteCreatives = hasPermission("creatives_write"); const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives"); const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives"); useEffect(() => { loadCreatives(); }, [workspaceId, projectFilterId, includeArchived]); useEffect(() => { if (creatives.length > 0 && canViewAnalytics) { loadAnalytics(); } }, [creatives, workspaceId, projectFilterId, canViewAnalytics]); const loadCreatives = async () => { try { setLoading(true); const response = await demoAwareCreativesApi.list(workspaceId, { project_id: projectFilterId, include_archived: includeArchived, size: 100, }); setCreatives(response.items); } catch (err) { handleApiError(err); } finally { setLoading(false); } }; const loadAnalytics = async () => { if (!canViewAnalytics) { setAnalyticsData([]); return; } try { setLoadingAnalytics(true); const response = await demoAwareAnalyticsApi.creatives(workspaceId, { project_id: projectFilterId, size: 100, }); setAnalyticsData(response.items); } catch (err) { console.error("Failed to load analytics:", err); } finally { setLoadingAnalytics(false); } }; // Merge creatives with analytics const creativesWithStats: CreativeWithStats[] = useMemo(() => { return creatives.map((creative) => ({ ...creative, analytics: analyticsData.find((a) => a.id === creative.id), })); }, [creatives, analyticsData]); const handleArchive = async (creative: Creative) => { await toast.promise( (async () => { const newStatus = creative.status === "active" ? "archived" : "active"; await creativesApi.update(workspaceId, creative.id, { status: newStatus }); await loadCreatives(); })(), { loading: creative.status === "active" ? "Архивирование..." : "Восстановление...", success: () => { return creative.status === "active" ? `Креатив «${creative.name}» архивирован` : `Креатив «${creative.name}» восстановлен`; }, error: (err) => { handleApiError(err); return "Не удалось изменить статус"; }, } ); }; const handleDelete = async (creativeId: string, creativeName: string) => { await toast.promise( (async () => { await creativesApi.delete(workspaceId, creativeId); await loadCreatives(); })(), { loading: "Удаление креатива...", success: () => `Креатив «${creativeName}» удалён`, error: (err) => { handleApiError(err); return "Не удалось удалить креатив"; }, } ); }; const filteredCreatives = creativesWithStats.filter( (c) => c.name.toLowerCase().includes(search.toLowerCase()) || c.text.toLowerCase().includes(search.toLowerCase()) ); const handleCreateCreativeClick = () => { setShowCreateDialog(true); }; const handleOpenBotClick = () => { const botUrl = currentProject ? `https://t.me/${BOT_USERNAME}?start=project_${currentProject.id}_createcreative` : `https://t.me/${BOT_USERNAME}`; window.open(botUrl, "_blank"); setShowCreateDialog(false); }; const handleCopyText = (text: string) => { navigator.clipboard.writeText(text); }; // Totals const totals = useMemo(() => { const data = filteredCreatives.filter((c) => canViewAnalytics && c.analytics); return { placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0), cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0), subscriptions: canViewSubscriptions ? data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0) : 0, views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0), }; }, [filteredCreatives, canViewAnalytics, canViewSubscriptions]); return (

Креативы

{filteredCreatives.length} {getCreativeWord(filteredCreatives.length)} • {formatCurrency(totals.cost)} •{" "} {formatNumber(totals.subscriptions)} подписчиков

{canWriteCreatives && ( )}
setSearch(e.target.value)} className="pl-10" />
setIncludeArchived(checked === true)} />
{loading ? (
) : filteredCreatives.length === 0 ? (

Нет креативов

{search ? "Ничего не найдено по вашему запросу" : "Создайте первый креатив для начала работы"}

{canWriteCreatives && !search && ( )}
) : viewMode === "table" ? ( Название Текст
Размещений Количество размещений с этим креативом
Потрачено Сумма всех размещений с этим креативом
{canViewSubscriptions && (
Подписчики Привлеченные подписчики
)}
Сред. CPS Cost Per Subscription — средняя стоимость подписчика
Статус
{filteredCreatives.map((creative) => (
{creative.media_items && creative.media_items.length > 0 && (
{creative.media_items.slice(0, 2).map((media: { media_type: string }, idx: number) => { const MediaIcon = getMediaIcon(media.media_type); return ( ); })} {creative.media_items.length > 2 && ( +{creative.media_items.length - 2} )}
)}
{creative.name}
{creative.text.replace("{invite_link}", "[ссылка]")}
{canViewAnalytics ? creative.analytics?.placements_count ?? creative.placements_count : creative.placements_count} {canViewAnalytics ? ( loadingAnalytics ? ( ) : ( formatCurrency(creative.analytics?.total_cost) ) ) : ( "—" )} {canViewSubscriptions && ( {canViewAnalytics ? ( loadingAnalytics ? ( ) : ( formatNumber(creative.analytics?.total_subscriptions) ) ) : ( "—" )} )} {canViewAnalytics ? ( loadingAnalytics ? ( ) : ( formatCurrency(creative.analytics?.avg_cpf) ) ) : ( "—" )} {creative.status === "active" ? "Активен" : "Архив"}
{canWriteCreatives && creative.status === "active" && ( )} Просмотр {canViewAnalytics && ( Аналитика )} handleCopyText(creative.text)}> Копировать текст {canWriteCreatives && ( <> Редактировать handleArchive(creative)}> {creative.status === "active" ? "В архив" : "Восстановить"} {creative.placements_count === 0 && ( handleDelete(creative.id, creative.name)} className="text-destructive focus:text-destructive" > Удалить )} )}
))}
) : ( // Grid View
{filteredCreatives.map((creative) => (
{creative.name} {creative.text.replace("{invite_link}", "[ссылка]")}
{creative.status === "active" ? "Активен" : "Архив"}
{/* Stats */} {canViewAnalytics && creative.analytics && (
{formatCurrency(creative.analytics.total_cost)}
Потрачено
{canViewSubscriptions && (
{formatNumber(creative.analytics.total_subscriptions)}
Подписчиков
)}
{formatNumber(creative.analytics.total_views)}
Просмотров
{formatCurrency(creative.analytics.avg_cpf)}
Сред. CPS
)} {/* Actions */}
))}
)}
{/* Dialog для создания креатива */} Создание креатива Креативы создаются через Telegram бота

Перешлите пост боту

Перешлите готовый пост с медиа и оформлением. Кнопки можно будет добавить через бота.

В тексте должна быть одна invite-ссылка формата: https://t.me/+xxx

Бот создаст креатив

При создании размещения ссылка автоматически заменится на уникальную для отслеживания

{!currentProject && (

Выберите проект в верхнем меню

)}
); }