feat: platform update

This commit is contained in:
ivannoskov
2026-02-09 13:10:08 +03:00
parent f7597270e5
commit 1826483750
15 changed files with 907 additions and 2796 deletions

View File

@@ -1,255 +0,0 @@
"use client";
// ============================================================================
// Create Creative Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } 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 searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const { currentProject, setSelectedProjects, projects } = useWorkspace();
const prefilledProjectId = searchParams.get("project_id");
const [name, setName] = useState("");
const [text, setText] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Auto-select project if prefilled from URL
useEffect(() => {
if (prefilledProjectId && projects.length > 0) {
const project = projects.find(p => p.id === prefilledProjectId);
if (project) {
setSelectedProjects([project]);
}
}
}, [prefilledProjectId, projects, setSelectedProjects]);
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(),
}
);
// Redirect to creatives list with project filter
router.push(`/dashboard/${workspaceId}/creatives?project_id=${currentProject.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

@@ -26,9 +26,10 @@ import {
Grid,
List,
FileText,
Image,
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";
@@ -126,7 +127,7 @@ const getCreativeWord = (count: number): string => {
const getMediaIcon = (mediaType: string) => {
switch (mediaType) {
case "photo":
return Image;
return ImageIcon;
case "video":
return Video;
case "animation":
@@ -236,11 +237,7 @@ export default function CreativesPage() {
);
const handleCreateCreativeClick = () => {
if (currentProject) {
router.push(`/dashboard/${workspaceId}/creatives/new?project_id=${currentProject.id}`);
} else {
setShowCreateDialog(true);
}
setShowCreateDialog(true);
};
const handleOpenBotClick = () => {
@@ -251,12 +248,6 @@ export default function CreativesPage() {
setShowCreateDialog(false);
};
const handleCreateOnPlatformClick = () => {
if (!currentProject) return;
router.push(`/dashboard/${workspaceId}/creatives/new?project_id=${currentProject.id}`);
setShowCreateDialog(false);
};
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text);
};
@@ -502,14 +493,14 @@ export default function CreativesPage() {
size="icon"
className="h-8 w-8"
asChild
>
<Link
href={`/dashboard/${workspaceId}/placements/new?creative_id=${creative.id}`}
title="Создать размещение"
>
<Plus className="h-3.5 w-3.5" />
</Link>
</Button>
<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>
@@ -673,47 +664,45 @@ export default function CreativesPage() {
<DialogHeader>
<DialogTitle>Создание креатива</DialogTitle>
<DialogDescription>
Создание креатива через Telegram бота или на платформе
Креативы создаются через Telegram бота
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Выберите способ создания креатива:
</p>
<div className="space-y-3">
<Button
variant="outline"
className="w-full justify-start"
onClick={handleOpenBotClick}
>
<div className="flex items-center justify-between w-full">
<div className="text-left">
<p className="font-medium">Через Telegram бота</p>
<p className="text-xs text-muted-foreground">
Перешлите сообщение боту
</p>
</div>
</div>
</Button>
{currentProject && (
<Button
variant="outline"
className="w-full justify-start"
onClick={handleCreateOnPlatformClick}
>
<div className="flex items-center justify-between w-full">
<div className="text-left">
<p className="font-medium">На платформе</p>
<p className="text-xs text-muted-foreground">
Для проекта: {currentProject.title}
</p>
</div>
</div>
</Button>
)}
<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>