Files
tgex-frontend/app/dashboard/[workspaceId]/placements/[placementId]/page.tsx
2026-02-03 11:14:53 +03:00

409 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";
// ============================================================================
// Placement Detail Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
Loader2,
ArrowLeft,
ExternalLink,
Copy,
Check,
Eye,
Users,
TrendingUp,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { placementsApi, projectsApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { Placement } from "@/lib/types/api";
// ============================================================================
// Helpers
// ============================================================================
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
};
const formatDateTime = (dateString: string | null | undefined) => {
if (!dateString) return "—";
return new Date(dateString).toLocaleString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCurrency = (value: number | null | undefined) => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: number | null | undefined) => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
// ============================================================================
// Component
// ============================================================================
export default function PlacementDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const placementId = params?.placementId as string;
const { hasPermission } = useWorkspace();
const [placement, setPlacement] = useState<Placement | null>(null);
const [loading, setLoading] = useState(true);
const [copiedLink, setCopiedLink] = useState(false);
const [projectId, setProjectId] = useState<string | null>(null);
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadPlacement();
}, [workspaceId, placementId]);
const loadPlacement = async () => {
try {
setLoading(true);
// First, get all projects to find which project this placement belongs to
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
const projects = projectsResponse.items;
// Search for the placement in all projects
let foundProjectId: string | null = null;
let foundPlacement: any = null;
for (const project of projects) {
try {
const placements = await placementsApi.getByProject(workspaceId, project.id);
const placement = placements.find((p) => p.id === placementId);
if (placement) {
foundProjectId = project.id;
foundPlacement = placement;
break;
}
} catch (e) {
// Skip if can't get placements for this project
continue;
}
}
if (foundPlacement) {
setPlacement(foundPlacement);
setProjectId(foundProjectId);
} else {
console.error("Placement not found in any project");
}
} catch (err) {
console.error("Failed to load placement:", err);
} finally {
setLoading(false);
}
};
const handleCopyLink = () => {
if (placement?.invite_link) {
navigator.clipboard.writeText(placement.invite_link);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
}
};
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 (!placement) {
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}/placements`}>
Вернуться к списку
</Link>
</Button>
</div>
</>
);
}
// Calculate metrics
const cost = placement.details?.cost?.value;
const subscriptionsCount = placement.placement_post?.subscriptions_count ?? 0;
const viewsCount = placement.placement_post?.views_count ?? 0;
const cps = cost && subscriptionsCount > 0 ? cost / subscriptionsCount : null;
const cpm = cost && viewsCount > 0 ? (cost / viewsCount) * 1000 : null;
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ label: placement.channel.title },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/placements`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">
{placement.channel.title}
</h1>
<Badge
variant={
placement.status === "Оплачено" ? "default" : "secondary"
}
>
{placement.status === "Оплачено" ? "Активно" : placement.status}
</Badge>
</div>
<p className="text-muted-foreground">
{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}
</p>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(cost)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(subscriptionsCount)}
</div>
<p className="text-xs text-muted-foreground">
CPS: {cps ? formatCurrency(cps) : "—"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(viewsCount)}
</div>
<p className="text-xs text-muted-foreground">
CPM: {cpm ? formatCurrency(cpm) : "—"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Тип ссылки
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{placement.invite_link_type === "approval"
? "С заявками"
: "Публичная"}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Invite Link */}
<Card>
<CardHeader>
<CardTitle>Пригласительная ссылка</CardTitle>
<CardDescription>
{placement.invite_link_type === "approval"
? "С одобрением — бот отслеживает подписчиков"
: "Публичная ссылка"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
{placement.invite_link || "—"}
</code>
<Button
variant="outline"
size="icon"
onClick={handleCopyLink}
disabled={!placement.invite_link}
>
{copiedLink ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
{placement.invite_link && (
<Button variant="outline" size="icon" asChild>
<a
href={placement.invite_link}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</div>
</CardContent>
</Card>
{/* Creative */}
<Card>
<CardHeader>
<CardTitle>Креатив</CardTitle>
<CardDescription>ID: {placement.creative_id}</CardDescription>
</CardHeader>
<CardContent>
{placement.creative_id && (
<Button variant="outline" asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
>
Просмотреть креатив
</Link>
</Button>
)}
</CardContent>
</Card>
</div>
{/* Details */}
<Card>
<CardHeader>
<CardTitle>Детали размещения</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">
Канал размещения
</dt>
<dd className="text-sm">{placement.channel.title}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Дата размещения
</dt>
<dd className="text-sm">{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Дата оплаты
</dt>
<dd className="text-sm">{placement.details?.payment_at ? formatDate(placement.details.payment_at) : "—"}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">
Формат
</dt>
<dd className="text-sm">{placement.details?.format || "—"}</dd>
</div>
{placement.placement_post?.post?.url && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground">
Ссылка на пост
</dt>
<dd className="text-sm">
<a
href={placement.placement_post.post.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{placement.placement_post.post.url}
<ExternalLink className="h-3 w-3" />
</a>
</dd>
</div>
)}
{placement.comment && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground">
Комментарий
</dt>
<dd className="text-sm">{placement.comment}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
</>
);
}