Files
tgex-frontend/components/placement-wizard.tsx
2026-02-09 13:10:08 +03:00

413 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";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, ArrowLeft, ArrowRight, Check, Calendar } from "lucide-react";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Calendar as CalendarComponent,
} from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { placementsApi, creativesApi, channelsApi } from "@/lib/api";
import { ChannelInput, type ParsedChannel } from "@/components/channel-input";
import type { Creative } from "@/lib/types/api";
interface PlacementWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
projectId: string;
initialCreativeId?: string | null;
onSuccess?: () => void;
}
export function PlacementWizard({
open,
onOpenChange,
workspaceId,
projectId,
initialCreativeId,
onSuccess,
}: PlacementWizardProps) {
const router = useRouter();
const [step, setStep] = useState(1);
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loading, setLoading] = useState(true);
// Step 1: Creative
const [selectedCreative, setSelectedCreative] = useState<Creative | null>(null);
// Step 2: Channels (parsed from input)
const [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
// Step 3: Details
const [placementDate, setPlacementDate] = useState<Date>(new Date());
const [cost, setCost] = useState("");
const [comment, setComment] = useState("");
const [inviteLinkType, setInviteLinkType] = useState<"approval" | "public">("approval");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
loadData();
} else {
// Reset on close
setStep(1);
setParsedChannels([]);
setCost("");
setComment("");
}
}, [open]);
useEffect(() => {
if (initialCreativeId && creatives.length > 0) {
const creative = creatives.find(c => c.id === initialCreativeId);
if (creative) {
setSelectedCreative(creative);
setStep(2);
}
}
}, [initialCreativeId, creatives]);
const loadData = async () => {
try {
setLoading(true);
const response = await creativesApi.list(workspaceId, { project_id: projectId, size: 100 });
setCreatives(response.items);
} catch (err) {
console.error("Failed to load data:", err);
} finally {
setLoading(false);
}
};
const getValidChannelIds = () => {
return parsedChannels
.filter((c) => c.isValid && c.channel)
.map((c) => c.channel!.id);
};
const handleSubmit = async () => {
if (!selectedCreative) return;
const validChannels = parsedChannels.filter((c) => c.isValid);
const existingChannelIds = validChannels.filter((c) => c.channel).map((c) => c.channel!.id);
const newChannels = validChannels.filter((c) => !c.channel && c.normalized);
try {
setSubmitting(true);
setError(null);
// Create new channels first
const newChannelIds: string[] = [];
if (newChannels.length > 0) {
const channelInputs = newChannels.map((c) => {
if (c.type === "invite_link") {
return { invite_link: `https://t.me/+${c.normalized}` };
}
return { username: c.normalized! };
});
const createResult = await channelsApi.create(channelInputs);
for (const result of createResult.results) {
if ((result.status === "created" || result.status === "updated") && result.channel) {
newChannelIds.push(result.channel.id);
}
}
}
const allChannelIds = [...existingChannelIds, ...newChannelIds];
if (allChannelIds.length === 0) {
setError("Не выбрано ни одного канала");
setSubmitting(false);
return;
}
// Create placements
await placementsApi.createInProject(workspaceId, projectId, {
creative_id: selectedCreative.id,
channels: allChannelIds.map((channelId) => ({
channel_id: channelId,
status: "Без статуса",
comment: undefined,
details: {
placement_at: placementDate.toISOString(),
cost: cost ? { type: "fixed" as const, value: parseFloat(cost) } : undefined,
invite_link_type: inviteLinkType,
},
})),
});
onOpenChange(false);
onSuccess?.();
} catch (err: any) {
setError(err?.detail || "Ошибка создания размещения");
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
onOpenChange(false);
router.push(`/dashboard/${workspaceId}/purchase-plans/${projectId}`);
};
if (!open) return null;
const validChannels = parsedChannels.filter((c) => c.isValid);
const canProceedStep2 = validChannels.length > 0;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Создание размещения</DialogTitle>
<DialogDescription>
Шаг {step} из 3
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Progress */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center gap-2">
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium",
step >= s
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
>
{step > s ? <Check className="h-4 w-4" /> : s}
</div>
{s < 3 && (
<div
className={cn(
"w-12 h-0.5",
step > s ? "bg-primary" : "bg-muted"
)}
/>
)}
</div>
))}
</div>
{/* Step 1: Creative */}
{step === 1 && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Выберите креатив</h3>
<div className="grid gap-3 max-h-96 overflow-y-auto">
{creatives.filter(c => c.status === "active").map((creative) => (
<button
key={creative.id}
type="button"
className={cn(
"text-left p-4 rounded-lg border transition-colors",
selectedCreative?.id === creative.id
? "border-primary bg-primary/5"
: "hover:bg-muted"
)}
onClick={() => setSelectedCreative(creative)}
>
<div className="font-medium">{creative.name}</div>
<div className="text-sm text-muted-foreground line-clamp-2 mt-1">
{creative.text.replace("{invite_link}", "[ссылка]")}
</div>
</button>
))}
</div>
<div className="flex justify-end pt-4">
<Button
onClick={() => setStep(2)}
disabled={!selectedCreative}
>
Далее <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</div>
</div>
)}
{/* Step 2: Channels */}
{step === 2 && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Добавьте каналы</h3>
<p className="text-sm text-muted-foreground">
Введите каналы в любом формате, например:
</p>
<p className="text-xs font-mono">
t.me/username, @username, tgstat.ru/channel/@username, telemetr.me/content/username
</p>
<ChannelInput
value={parsedChannels}
onChange={setParsedChannels}
maxItems={50}
/>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={() => setStep(1)}>
<ArrowLeft className="h-4 w-4 mr-2" /> Назад
</Button>
<Button
onClick={() => setStep(3)}
disabled={!canProceedStep2}
>
Далее <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</div>
</div>
)}
{/* Step 3: Details */}
{step === 3 && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Детали размещения</h3>
{/* Summary of selected channels */}
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm font-medium mb-2">Выбрано каналов: {validChannels.length}</p>
<div className="flex flex-wrap gap-1">
{validChannels.slice(0, 5).map((c, i) => (
<Badge key={i} variant="secondary">
{c.normalized}
</Badge>
))}
{validChannels.length > 5 && (
<Badge variant="secondary">+{validChannels.length - 5} ещё</Badge>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Дата публикации</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!placementDate && "text-muted-foreground"
)}
>
<Calendar className="mr-2 h-4 w-4" />
{placementDate
? format(placementDate, "PPP", { locale: ru })
: "Выберите дату"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CalendarComponent
mode="single"
selected={placementDate}
onSelect={(date) => date && setPlacementDate(date)}
locale={ru}
disabled={(date) => date < new Date()}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Стоимость ()</Label>
<Input
type="number"
placeholder="0"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Тип ссылки</Label>
<div className="flex gap-2">
<Button
type="button"
variant={inviteLinkType === "approval" ? "default" : "outline"}
size="sm"
onClick={() => setInviteLinkType("approval")}
>
Согласование
</Button>
<Button
type="button"
variant={inviteLinkType === "public" ? "default" : "outline"}
size="sm"
onClick={() => setInviteLinkType("public")}
>
Публичная
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<Label>Комментарий</Label>
<Textarea
placeholder="Дополнительная информация..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
</div>
{error && (
<div className="p-3 rounded bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={() => setStep(2)}>
<ArrowLeft className="h-4 w-4 mr-2" /> Назад
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
<>Создать размещение ({validChannels.length})</>
)}
</Button>
</div>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
);
}