638 lines
25 KiB
TypeScript
638 lines
25 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// Creatives List Page - Enhanced with Statistics
|
||
// ============================================================================
|
||
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { useParams } 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,
|
||
} from "lucide-react";
|
||
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 "креативов";
|
||
};
|
||
|
||
// ============================================================================
|
||
// Component
|
||
// ============================================================================
|
||
|
||
export default function CreativesPage() {
|
||
const params = useParams();
|
||
const workspaceId = params?.workspaceId as string;
|
||
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = 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 {
|
||
await creativesApi.update(workspaceId, creative.id, {});
|
||
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 handleCopyText = (text: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
};
|
||
|
||
// Filter by search
|
||
const filteredCreatives = creativesWithStats.filter(
|
||
(c) =>
|
||
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||
c.text.toLowerCase().includes(search.toLowerCase())
|
||
);
|
||
|
||
// 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: "Креативы" },
|
||
]}
|
||
/>
|
||
|
||
<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={() => setShowCreateDialog(true)}>
|
||
<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>
|
||
<Link
|
||
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||
className="font-medium hover:underline"
|
||
>
|
||
{creative.name}
|
||
</Link>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="max-w-[300px] truncate text-sm text-muted-foreground">
|
||
{creative.text.replace("{invite_link}", "[ссылка]")}
|
||
</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>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon">
|
||
<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/creatives?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>
|
||
</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-2">
|
||
<p className="text-sm text-muted-foreground">
|
||
Чтобы создать креатив:
|
||
</p>
|
||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||
<li>Перейдите в Telegram бота <strong>@{BOT_USERNAME}</strong></li>
|
||
<li>Откройте раздел <strong>Креативы</strong></li>
|
||
<li>Нажмите <strong>Создание</strong></li>
|
||
<li>Перешлите созданный креатив боту</li>
|
||
<li>Креатив автоматически отобразится на платформе</li>
|
||
</ol>
|
||
</div>
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setShowCreateDialog(false)}
|
||
>
|
||
Понятно
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
window.open(`https://t.me/${BOT_USERNAME}`, "_blank");
|
||
setShowCreateDialog(false);
|
||
}}
|
||
>
|
||
Открыть бота
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</TooltipProvider>
|
||
);
|
||
}
|