Files
tgex-frontend/app/dashboard/[workspaceId]/placements/new/page.tsx
2025-12-29 10:12:13 +03:00

394 lines
14 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 } = useWorkspace();
// Pre-filled from URL params
const prefilledCreativeId = searchParams.get("creative_id");
const prefilledChannelId = searchParams.get("channel_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);
useEffect(() => {
loadData();
}, [workspaceId, currentProject?.id]);
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);
}
};
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,
});
router.push(`/dashboard/${workspaceId}/placements/${placement.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>
</>
);
}