feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -0,0 +1,366 @@
"use client";
// ============================================================================
// Creative Detail Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ArrowLeft,
Pencil,
Archive,
Trash2,
Copy,
Check,
ExternalLink,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { creativesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { Creative } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function CreativeDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const creativeId = params?.creativeId as string;
const { hasPermission } = useWorkspace();
const [creative, setCreative] = useState<Creative | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "true");
const [isSaving, setIsSaving] = useState(false);
const [copied, setCopied] = useState(false);
// Edit form state
const [name, setName] = useState("");
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadCreative();
}, [workspaceId, creativeId]);
const loadCreative = async () => {
try {
setLoading(true);
const data = await creativesApi.get(workspaceId, creativeId);
setCreative(data);
setName(data.name);
setText(data.text);
} catch (err) {
console.error("Failed to load creative:", err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!name.trim() || !text.trim()) {
setError("Заполните все поля");
return;
}
if (!text.includes("{invite_link}")) {
setError("Текст должен содержать {invite_link}");
return;
}
try {
setIsSaving(true);
setError(null);
const updated = await creativesApi.update(workspaceId, creativeId, {
name: name.trim(),
text: text.trim(),
});
setCreative(updated);
setIsEditing(false);
} catch (err: any) {
setError(err?.detail || "Не удалось сохранить");
} finally {
setIsSaving(false);
}
};
const handleArchive = async () => {
if (!creative) return;
try {
// Toggle archive status by updating via delete endpoint (soft delete)
await creativesApi.delete(workspaceId, creativeId);
router.push(`/dashboard/${workspaceId}/creatives`);
} catch (err) {
console.error("Failed to archive:", err);
}
};
const handleCopy = () => {
if (creative) {
navigator.clipboard.writeText(creative.text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCancel = () => {
if (creative) {
setName(creative.name);
setText(creative.text);
}
setIsEditing(false);
setError(null);
};
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
if (!creative) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
<div className="flex flex-col items-center justify-center py-12">
<h2 className="text-xl font-semibold mb-2">Креатив не найден</h2>
<Button asChild>
<Link href={`/dashboard/${workspaceId}/creatives`}>
Вернуться к списку
</Link>
</Button>
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
{ label: creative.name },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-3xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/creatives`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">
{creative.name}
</h1>
<Badge
variant={
creative.status === "active" ? "default" : "secondary"
}
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</div>
<p className="text-muted-foreground">
{creative.placements_count} размещений
</p>
</div>
</div>
{canWrite && !isEditing && (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setIsEditing(true)}>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">
<Archive className="h-4 w-4 mr-2" />
В архив
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Архивировать креатив?</AlertDialogTitle>
<AlertDialogDescription>
Креатив будет перемещён в архив. Существующие размещения
останутся без изменений.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleArchive}>
Архивировать
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
{isEditing ? (
<Card>
<CardHeader>
<CardTitle>Редактирование</CardTitle>
<CardDescription>
Изменения не повлияют на существующие размещения
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Название</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="text">Текст креатива</Label>
<Textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isSaving}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Не забудьте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>
</p>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSaving}
>
Отмена
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Текст креатива</CardTitle>
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Скопировано
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Копировать
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
<pre className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-lg">
{creative.text.replace(
"{invite_link}",
"https://t.me/+AbCdEfGhIjK"
)}
</pre>
<p className="text-xs text-muted-foreground mt-2">
<code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
заменяется уникальной ссылкой при создании размещения
</p>
</CardContent>
</Card>
)}
{/* Quick Actions */}
{canWrite && creative.status === "active" && (
<Card>
<CardHeader>
<CardTitle>Действия</CardTitle>
</CardHeader>
<CardContent>
<Button asChild>
<Link
href={`/dashboard/${workspaceId}/placements/new?creative_id=${creative.id}`}
>
<ExternalLink className="h-4 w-4 mr-2" />
Создать размещение с этим креативом
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
// ============================================================================
// Create Creative Page
// ============================================================================
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2, ArrowLeft, Info } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { creativesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
// ============================================================================
// Component
// ============================================================================
export default function NewCreativePage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { currentProject } = useWorkspace();
const [name, setName] = useState("");
const [text, setText] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !text.trim()) {
setError("Заполните все обязательные поля");
return;
}
if (!text.includes("{invite_link}")) {
setError("Текст должен содержать {invite_link} для вставки ссылки");
return;
}
if (!currentProject) {
setError("Выберите проект в верхнем меню");
return;
}
try {
setIsSubmitting(true);
setError(null);
const creative = await creativesApi.create(
workspaceId,
currentProject.id,
{
name: name.trim(),
text: text.trim(),
}
);
router.push(`/dashboard/${workspaceId}/creatives/${creative.id}`);
} catch (err: any) {
setError(err?.detail || "Не удалось создать креатив");
} finally {
setIsSubmitting(false);
}
};
const insertInviteLink = () => {
if (!text.includes("{invite_link}")) {
setText((prev) => prev + "{invite_link}");
}
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
{ label: "Новый креатив" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">Новый креатив</h1>
<p className="text-muted-foreground">
Создайте рекламный материал для размещений
</p>
</div>
</div>
{!currentProject && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Выберите проект в верхнем меню для создания креатива
</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Информация о креативе</CardTitle>
<CardDescription>
Креатив это текст рекламного сообщения с пригласительной ссылкой
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
Название <span className="text-destructive">*</span>
</Label>
<Input
id="name"
placeholder="Например: Летняя акция 2025"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Внутреннее название для удобства поиска
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="text">
Текст креатива <span className="text-destructive">*</span>
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={insertInviteLink}
disabled={text.includes("{invite_link}")}
>
Вставить {"{invite_link}"}
</Button>
</div>
<Textarea
id="text"
placeholder={`Пример:
🔥 Подпишись на наш канал!
Здесь ты найдёшь:
• Полезный контент
• Эксклюзивные материалы
• Актуальные новости
👉 {invite_link}`}
value={text}
onChange={(e) => setText(e.target.value)}
disabled={isSubmitting}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Используйте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
в том месте, где должна быть пригласительная ссылка
</p>
</div>
{text.includes("{invite_link}") && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<span className="font-medium">Предпросмотр:</span>
<pre className="mt-2 whitespace-pre-wrap text-sm">
{text.replace(
"{invite_link}",
"https://t.me/+AbCdEfGhIjK"
)}
</pre>
</AlertDescription>
</Alert>
)}
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="flex gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
Отмена
</Button>
<Button
type="submit"
disabled={
isSubmitting ||
!name.trim() ||
!text.trim() ||
!currentProject
}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
"Создать креатив"
)}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</>
);
}

View File

@@ -0,0 +1,569 @@
"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 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);
};
// ============================================================================
// 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 [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} креативов {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 asChild>
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Link>
</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>
<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 asChild>
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Link>
</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>
</TooltipProvider>
);
}