505 lines
19 KiB
TypeScript
505 lines
19 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|
||
|