Files
tgex-frontend/app/dashboard/[workspaceId]/creatives/[creativeId]/page.tsx
2026-02-02 11:17:50 +03:00

505 lines
19 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";
// ============================================================================
// 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,
Copy,
Check,
Eye,
LayoutList,
Plus,
BarChart3,
FileText,
Image,
Video,
} 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { TelegramPostPreview } from "@/components/telegram-post-preview";
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);
const [viewMode, setViewMode] = useState<"preview" | "data">("preview");
const [name, setName] = useState("");
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadCreative();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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) {
const errorMessage = err && typeof err === 'object' && 'detail' in err ? (err as { detail?: string }).detail ?? "Не удалось сохранить" : "Не удалось сохранить";
setError(errorMessage);
} finally {
setIsSaving(false);
}
};
const handleArchive = async () => {
if (!creative) return;
try {
await creativesApi.update(workspaceId, creativeId, { status: "archived" });
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>
</>
);
}
const isActive = creative.status === "active";
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">
<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={isActive ? "default" : "secondary"}>
{isActive ? "Активен" : "Архив"}
</Badge>
</div>
<p className="text-muted-foreground">
{creative.placements_count} размещений
</p>
</div>
</div>
{canWrite && !isEditing && (
<div className="flex gap-2">
{isActive && (
<Button
asChild
variant="outline"
>
<Link href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?creative_id=${creative.id}`}>
<Plus className="h-4 w-4 mr-2" />
Создать размещение
</Link>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Дополнительно
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}`}>
<LayoutList 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>
</DropdownMenuContent>
</DropdownMenu>
<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>
<div className="max-w-3xl mx-auto space-y-4">
{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>
) : (
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "preview" | "data")}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="preview" className="gap-2">
<Eye className="h-4 w-4" />
Preview
</TabsTrigger>
<TabsTrigger value="data" className="gap-2">
<LayoutList className="h-4 w-4" />
Данные
</TabsTrigger>
</TabsList>
<TabsContent value="preview" className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Предпросмотр</h3>
<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>
<TelegramPostPreview
text={creative.text}
mediaItems={creative.media_items}
buttons={creative.buttons}
/>
</TabsContent>
<TabsContent value="data" className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Данные креатива</h3>
<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>
<Card>
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground text-sm">Название</Label>
<p className="font-medium">{creative.name}</p>
</div>
<div>
<Label className="text-muted-foreground text-sm">Статус</Label>
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "Активен" : "Архив"}
</Badge>
</div>
<div>
<Label className="text-muted-foreground text-sm">Размещений</Label>
<p className="font-medium">{creative.placements_count}</p>
</div>
<div>
<Label className="text-muted-foreground text-sm">Проект</Label>
<p className="font-medium">{creative.project_channel_title}</p>
</div>
<div>
<Label className="text-muted-foreground text-sm">Создан</Label>
<p className="font-medium">
{new Date(creative.created_at).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
})}
</p>
</div>
</div>
{creative.media_items && creative.media_items.length > 0 && (
<div>
<Label className="text-muted-foreground text-sm mb-3 block">Медиа ({creative.media_items.length})</Label>
<div className="grid grid-cols-4 gap-3">
{creative.media_items.map((media, index) => (
<div key={index} className="border rounded-lg p-3 space-y-2">
<div className="w-8 h-8 bg-muted rounded-md flex items-center justify-center">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
{media.media_type === "photo" && <Image className="h-4 w-4 text-muted-foreground" />}
{media.media_type === "video" && <Video className="h-4 w-4 text-muted-foreground" />}
{media.media_type === "animation" && <FileText className="h-4 w-4 text-muted-foreground" />}
</div>
<div>
<p className="text-xs font-medium capitalize">{media.media_type}</p>
<p className="text-[10px] text-muted-foreground font-mono">
{media.media_file_id.slice(0, 10)}...
</p>
</div>
</div>
))}
</div>
</div>
)}
{creative.buttons && creative.buttons.length > 0 && (
<div>
<Label className="text-muted-foreground text-sm mb-3 block">Кнопки ({creative.buttons.length})</Label>
<div className="space-y-2">
{creative.buttons.map((button, index) => (
<div key={index} className="border rounded-lg p-3 flex items-center justify-between">
<div>
<p className="font-medium text-sm">{button.text}</p>
<p className="text-xs text-muted-foreground font-mono">
{button.url}
</p>
</div>
</div>
))}
</div>
</div>
)}
<div>
<Label className="text-muted-foreground text-sm mb-3 block">Текст</Label>
<div className="border rounded-lg p-4 bg-muted">
<pre className="text-sm whitespace-pre-wrap font-mono">
{creative.text}
</pre>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
</div>
</div>
</>
);
}