380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
"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 } 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 canWrite = hasPermission("placements_write");
|
||
|
||
useEffect(() => {
|
||
loadPlacement();
|
||
}, [workspaceId, placementId]);
|
||
|
||
const loadPlacement = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const data = await placementsApi.get(workspaceId, placementId);
|
||
setPlacement(data);
|
||
} 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>
|
||
</>
|
||
);
|
||
}
|