Files
tgex-frontend/app/dashboard/[workspaceId]/creatives/page.tsx
2026-02-09 13:10:08 +03:00

712 lines
28 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";
// ============================================================================
// 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 { 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<Creative[]>([]);
const [analyticsData, setAnalyticsData] = useState<CreativeAnalyticsItem[]>([]);
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");
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadCreatives();
}, [workspaceId, projectFilterId, includeArchived]);
useEffect(() => {
if (creatives.length > 0) {
loadAnalytics();
}
}, [creatives, workspaceId, projectFilterId]);
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) {
console.error("Failed to load creatives:", err);
} finally {
setLoading(false);
}
};
const loadAnalytics = async () => {
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) => {
try {
const newStatus = creative.status === "active" ? "archived" : "active";
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
loadCreatives();
} catch (err) {
console.error("Failed to archive creative:", err);
}
};
const handleDelete = async (creativeId: string) => {
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
try {
await creativesApi.delete(workspaceId, creativeId);
loadCreatives();
} catch (err) {
console.error("Failed to delete creative:", err);
}
};
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) => 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: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0),
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
};
}, [filteredCreatives]);
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Креативы</h1>
<p className="text-muted-foreground">
{filteredCreatives.length} {getCreativeWord(filteredCreatives.length)} {formatCurrency(totals.cost)} {" "}
{formatNumber(totals.subscriptions)} подписчиков
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
</div>
{canWrite && (
<Button onClick={handleCreateCreativeClick}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск по названию или тексту..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<ProjectSelector />
<div className="flex items-center gap-2">
<Checkbox
id="include-archived"
checked={includeArchived}
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
/>
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
Показать архивные
</Label>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredCreatives.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет креативов</h3>
<p className="text-muted-foreground text-center mb-4">
{search
? "Ничего не найдено по вашему запросу"
: "Создайте первый креатив для начала работы"}
</p>
{canWrite && !search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Button>
)}
</CardContent>
</Card>
) : viewMode === "table" ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Текст</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1">
Размещений
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Количество размещений с этим креативом
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Потрачено
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Сумма всех размещений с этим креативом
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Сред. CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Subscription средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell>
<div className="flex items-start gap-3">
{creative.media_items && creative.media_items.length > 0 && (
<div className="flex gap-0.5 mt-0.5">
{creative.media_items.slice(0, 2).map((media: { media_type: string }, idx: number) => {
const MediaIcon = getMediaIcon(media.media_type);
return (
<MediaIcon key={idx} className="h-3.5 w-3.5 text-muted-foreground" />
);
})}
{creative.media_items.length > 2 && (
<span className="text-xs text-muted-foreground">+{creative.media_items.length - 2}</span>
)}
</div>
)}
<div className="min-w-0">
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="font-medium hover:underline block"
>
{creative.name}
</Link>
</div>
</div>
</TableCell>
<TableCell>
<div className="max-w-[350px] text-sm text-muted-foreground">
<div className="line-clamp-2">
{creative.text.replace("{invite_link}", "[ссылка]")}
</div>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{creative.analytics?.placements_count ?? creative.placements_count}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.total_cost)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatNumber(creative.analytics?.total_subscriptions)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
)}
</TableCell>
<TableCell>
<Badge
variant={creative.status === "active" ? "default" : "secondary"}
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{canWrite && creative.status === "active" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
title="Создать размещение"
>
<Plus className="h-3.5 w-3.5" />
</Link>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
>
<Eye className="h-4 w-4 mr-2" />
Просмотр
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4 mr-2" />
Аналитика
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
<Copy className="h-4 w-4 mr-2" />
Копировать текст
</DropdownMenuItem>
{canWrite && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArchive(creative)}>
<Archive className="h-4 w-4 mr-2" />
{creative.status === "active" ? "В архив" : "Восстановить"}
</DropdownMenuItem>
{creative.placements_count === 0 && (
<DropdownMenuItem
onClick={() => handleDelete(creative.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCreatives.map((creative) => (
<Card key={creative.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="hover:underline"
>
{creative.name}
</Link>
</CardTitle>
<CardDescription className="line-clamp-2 mt-1">
{creative.text.replace("{invite_link}", "[ссылка]")}
</CardDescription>
</div>
<Badge
variant={creative.status === "active" ? "default" : "secondary"}
className="ml-2 shrink-0"
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats */}
{creative.analytics && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(creative.analytics.total_cost)}
</div>
<div className="text-xs text-muted-foreground">Потрачено</div>
</div>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_views)}
</div>
<div className="text-xs text-muted-foreground">Просмотров</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(creative.analytics.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">Сред. CPS</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
<Eye className="h-4 w-4 mr-1" />
Подробнее
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Dialog для создания креатива */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Создание креатива</DialogTitle>
<DialogDescription>
Креативы создаются через Telegram бота
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">Перешлите пост боту</p>
<p className="text-xs text-muted-foreground mt-1">
Перешлите готовый пост с медиа и оформлением. Кнопки можно будет добавить через бота.
</p>
<p className="text-xs text-muted-foreground mt-1">
В тексте должна быть одна invite-ссылка формата: <code className="text-xs">https://t.me/+xxx</code>
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">Бот создаст креатив</p>
<p className="text-xs text-muted-foreground mt-1">
При создании размещения ссылка автоматически заменится на уникальную для отслеживания
</p>
</div>
</div>
</div>
<Button
variant="default"
className="w-full"
onClick={handleOpenBotClick}
disabled={!currentProject}
>
<Image src="/icons/telegram.svg" alt="Telegram" width={20} height={20} className="w-5 h-5 mr-2" />
Перейти в бота
</Button>
{!currentProject && (
<p className="text-xs text-muted-foreground text-center">
Выберите проект в верхнем меню
</p>
)}
</div>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}