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

415 lines
15 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";
// ============================================================================
// Create Placement Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { Loader2, ArrowLeft, Info, Calendar as CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { placementsApi, creativesApi, channelsApi } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { Creative, Channel, InviteLinkType } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function NewPlacementPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const { currentProject, setSelectedProjects, projects } = useWorkspace();
// Pre-filled from URL params
const prefilledChannelId = searchParams.get("channel_id");
const prefilledCreativeId = searchParams.get("creative_id");
// Form state
const [channelId, setChannelId] = useState(prefilledChannelId || "");
const [creativeId, setCreativeId] = useState(prefilledCreativeId || "");
const [placementDate, setPlacementDate] = useState<Date>(new Date());
const [cost, setCost] = useState("");
const [comment, setComment] = useState("");
const [adPostUrl, setAdPostUrl] = useState("");
const [inviteLinkType, setInviteLinkType] = useState<InviteLinkType>("approval");
// Data
const [channels, setChannels] = useState<Channel[]>([]);
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loadingData, setLoadingData] = useState(true);
// Submit state
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Auto-select project when creative_id is provided
useEffect(() => {
if (!prefilledCreativeId || projects.length === 0) return;
const loadCreativeAndSelectProject = async () => {
try {
const creative = await creativesApi.get(workspaceId, prefilledCreativeId);
const project = projects.find(p => p.id === creative.project_id);
if (project) {
setSelectedProjects([project]);
}
} catch (err) {
console.error("Failed to load creative:", err);
}
};
loadCreativeAndSelectProject();
}, [prefilledCreativeId, projects, workspaceId, setSelectedProjects]);
// Load channels and creatives
const loadData = async () => {
try {
setLoadingData(true);
const [channelsRes, creativesRes] = await Promise.all([
channelsApi.list({ size: 100 }),
currentProject
? creativesApi.list(workspaceId, {
project_id: currentProject.id,
include_archived: false,
size: 100,
})
: Promise.resolve({ items: [] }),
]);
setChannels(channelsRes.items);
setCreatives(creativesRes.items);
} catch (err) {
console.error("Failed to load data:", err);
} finally {
setLoadingData(false);
}
};
useEffect(() => {
loadData();
}, [workspaceId, currentProject?.id]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentProject) {
setError("Выберите проект в верхнем меню");
return;
}
if (!channelId || !creativeId) {
setError("Выберите канал и креатив");
return;
}
try {
setIsSubmitting(true);
setError(null);
const placement = await placementsApi.create(workspaceId, {
project_id: currentProject.id,
placement_channel_id: channelId,
creative_id: creativeId,
placement_date: placementDate.toISOString(),
cost: cost ? parseFloat(cost) : undefined,
comment: comment || undefined,
ad_post_url: adPostUrl || undefined,
invite_link_type: inviteLinkType,
});
// Redirect to placements list with project filter
router.push(`/dashboard/${workspaceId}/placements?project_id=${currentProject.id}`);
} catch (err: any) {
setError(err?.detail || "Не удалось создать размещение");
} finally {
setIsSubmitting(false);
}
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ 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">
Создайте размещение рекламы в Telegram канале
</p>
</div>
</div>
{!currentProject && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Выберите проект в верхнем меню для создания размещения
</AlertDescription>
</Alert>
)}
{loadingData ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Параметры размещения</CardTitle>
<CardDescription>
Укажите канал, креатив и условия размещения
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Channel */}
<div className="space-y-2">
<Label>
Канал для размещения <span className="text-destructive">*</span>
</Label>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger>
<SelectValue placeholder="Выберите канал" />
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
<div className="flex flex-col">
<span>{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">
@{channel.username}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Creative */}
<div className="space-y-2">
<Label>
Креатив <span className="text-destructive">*</span>
</Label>
<Select value={creativeId} onValueChange={setCreativeId}>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
Нет доступных креативов
</div>
) : (
creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{creatives.length === 0 && currentProject && (
<p className="text-xs text-muted-foreground">
Сначала{" "}
<a
href={`/dashboard/${workspaceId}/creatives/new`}
className="text-primary hover:underline"
>
создайте креатив
</a>
</p>
)}
</div>
{/* Placement Date */}
<div className="space-y-2">
<Label>
Дата размещения <span className="text-destructive">*</span>
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!placementDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{placementDate
? format(placementDate, "PPP", { locale: ru })
: "Выберите дату"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={placementDate}
onSelect={(date) => date && setPlacementDate(date)}
locale={ru}
/>
</PopoverContent>
</Popover>
</div>
{/* Cost */}
<div className="space-y-2">
<Label htmlFor="cost">Стоимость ()</Label>
<Input
id="cost"
type="number"
placeholder="0"
value={cost}
onChange={(e) => setCost(e.target.value)}
disabled={isSubmitting}
/>
</div>
{/* Invite Link Type */}
<div className="space-y-2">
<Label>Тип пригласительной ссылки</Label>
<Select
value={inviteLinkType}
onValueChange={(v) => setInviteLinkType(v as InviteLinkType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="approval">
<div className="flex flex-col">
<span>С одобрением (рекомендуется)</span>
<span className="text-xs text-muted-foreground">
Бот одобряет заявки и отслеживает подписчиков
</span>
</div>
</SelectItem>
<SelectItem value="public">
<div className="flex flex-col">
<span>Публичная ссылка</span>
<span className="text-xs text-muted-foreground">
Без отслеживания подписчиков
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Ad Post URL */}
<div className="space-y-2">
<Label htmlFor="adPostUrl">Ссылка на рекламный пост</Label>
<Input
id="adPostUrl"
placeholder="https://t.me/channel/123"
value={adPostUrl}
onChange={(e) => setAdPostUrl(e.target.value)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Ссылка на пост после публикации (можно добавить позже)
</p>
</div>
{/* Comment */}
<div className="space-y-2">
<Label htmlFor="comment">Комментарий</Label>
<Textarea
id="comment"
placeholder="Дополнительная информация..."
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={isSubmitting}
rows={3}
/>
</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
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
Отмена
</Button>
<Button
type="submit"
disabled={
isSubmitting ||
!channelId ||
!creativeId ||
!currentProject
}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
"Создать размещение"
)}
</Button>
</div>
</CardContent>
</Card>
</form>
)}
</div>
</>
);
}