randomayzer/src/app/page.tsx

268 lines
12 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 { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import {
Gift,
PlusCircle,
Sparkles,
CheckCircle2,
Clock,
Users,
ShieldCheck,
ArrowRight,
RefreshCw,
LogIn,
} from 'lucide-react';
import { GiveawaySummary } from '@/lib/repository/giveaway-repository';
export default function DashboardPage() {
const [giveaways, setGiveaways] = useState<GiveawaySummary[]>([]);
const [loading, setLoading] = useState(true);
const [isUnauthenticated, setIsUnauthenticated] = useState(false);
const fetchGiveaways = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/giveaways');
if (res.status === 401) {
setIsUnauthenticated(true);
setGiveaways([]);
return;
}
setIsUnauthenticated(false);
const data = await res.json();
if (data.giveaways) {
setGiveaways(data.giveaways);
}
} catch (err) {
console.error('Failed to load giveaways:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let ignore = false;
fetch('/api/giveaways')
.then(res => {
if (res.status === 401) {
if (!ignore) {
setIsUnauthenticated(true);
setGiveaways([]);
}
return null;
}
return res.json();
})
.then(data => {
if (!ignore && data && data.giveaways) {
setIsUnauthenticated(false);
setGiveaways(data.giveaways);
}
})
.catch(err => {
console.error('Failed to load giveaways:', err);
})
.finally(() => {
if (!ignore) {
setLoading(false);
}
});
return () => {
ignore = true;
};
}, []);
const completedCount = giveaways.filter(g => g.status === 'DRAWN' || g.status === 'PUBLISHED').length;
const totalEligible = giveaways.reduce((acc, g) => acc + (g.eligibleParticipantsCount || 0), 0);
return (
<div className="space-y-8">
{/* Hero Welcome Banner */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-blue-900/40 via-indigo-900/30 to-slate-900 border border-blue-500/20 p-8 shadow-xl">
<div className="relative z-10 max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/20 border border-blue-400/30 text-blue-300 text-xs font-semibold mb-4">
<Sparkles className="w-3.5 h-3.5" />
Честные розыгрыши ВКонтакте
</div>
<h1 className="text-3xl font-extrabold tracking-tight text-white sm:text-4xl mb-3">
Честный рандомайзер с доказуемым результатом
</h1>
<p className="text-slate-300 text-sm sm:text-base mb-6 leading-relaxed">
Выбирайте победителей по лайкам и комментариям.
Каждый розыгрыш фиксируется неизменяемым слепком (Snapshot) и криптографическим auditHash (HMAC-SHA256).
</p>
<div className="flex flex-wrap items-center gap-4">
<Link
href="/giveaways/new"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-medium shadow-lg shadow-blue-600/30 transition-all active:scale-95"
>
<PlusCircle className="w-5 h-5" />
Создать новый розыгрыш
</Link>
</div>
</div>
<div className="absolute right-6 top-1/2 -translate-y-1/2 hidden lg:block opacity-15 pointer-events-none">
<Gift className="w-64 h-64 text-blue-400" />
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Всего кампаний</span>
<Gift className="w-4 h-4 text-blue-400" />
</div>
<div className="text-2xl font-bold text-white">{giveaways.length}</div>
</div>
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Проведено розыгрышей</span>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="text-2xl font-bold text-emerald-400">{completedCount}</div>
</div>
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Участников в розыгрышах</span>
<Users className="w-4 h-4 text-indigo-400" />
</div>
<div className="text-2xl font-bold text-indigo-400">{totalEligible}</div>
</div>
</div>
{/* List of Giveaways */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-white">Список розыгрышей</h2>
<p className="text-xs text-slate-400">История созданных и завершенных конкурсов</p>
</div>
<button
onClick={fetchGiveaways}
disabled={loading}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
title="Обновить"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading ? (
<div className="py-12 text-center text-slate-400 text-sm">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-500" />
Загрузка списка...
</div>
) : isUnauthenticated ? (
<div className="py-12 text-center border border-dashed border-blue-500/30 rounded-xl bg-blue-950/20 px-4">
<LogIn className="w-10 h-10 text-blue-400 mx-auto mb-3" />
<p className="text-sm text-slate-200 font-medium mb-1">Требуется авторизация</p>
<p className="text-xs text-slate-400 mb-5 max-w-sm mx-auto">
Войдите через VK ID, чтобы управлять своими конкурсами и просматривать историю розыгрышей.
</p>
<a
href="/api/auth/vk/start?redirectTarget=/"
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold bg-[#0077ff] hover:bg-[#0066dd] text-white rounded-lg transition-all active:scale-95 shadow-md shadow-blue-600/30"
>
<LogIn className="w-3.5 h-3.5" />
Войти через VK ID
</a>
</div>
) : giveaways.length === 0 ? (
<div className="py-12 text-center border border-dashed border-slate-800 rounded-xl bg-slate-950/40">
<Gift className="w-10 h-10 text-slate-400 mx-auto mb-3" />
<p className="text-sm text-slate-300 font-medium mb-1">Пока нет созданных розыгрышей</p>
<p className="text-xs text-slate-400 mb-4 max-w-sm mx-auto">
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и зафиксировать результат
</p>
<Link
href="/giveaways/new"
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
<PlusCircle className="w-3.5 h-3.5" />
Создать первый розыгрыш
</Link>
</div>
) : (
<div className="space-y-3">
{giveaways.map((gw) => (
<div
key={gw.id}
className="group flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-xl bg-slate-950/60 border border-slate-800/80 hover:border-blue-500/50 transition-all gap-4"
>
<div className="flex items-start gap-3.5 min-w-0">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 text-blue-400 font-bold text-xs mt-0.5">
VK
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
{gw.title || 'Розыгрыш по записи VK'}
</h3>
<p className="text-xs text-slate-400 truncate mt-0.5 max-w-md">
{gw.sourceUrl}
</p>
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-400">
<span>Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')}</span>
<span></span>
<span>Лайков: {gw.postLikesCount}</span>
<span></span>
<span>Комментов: {gw.postCommentsCount}</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 self-end sm:self-center">
{gw.status === 'DRAWN' || gw.status === 'PUBLISHED' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-3 h-3" />
{gw.status}
</span>
) : gw.status === 'SNAPSHOT_LOCKED' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
<Clock className="w-3 h-3" />
Слепок зафиксирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<Clock className="w-3 h-3" />
{gw.status}
</span>
)}
<Link
href={`/giveaways/${gw.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-white transition-colors"
>
Подробнее
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
</div>
))}
</div>
)}
</div>
{/* Provably Fair Info Box */}
<div className="rounded-xl bg-slate-900/40 border border-slate-800 p-6 flex flex-col md:flex-row items-start md:items-center gap-4">
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 shrink-0">
<ShieldCheck className="w-8 h-8" />
</div>
<div>
<h3 className="text-sm font-semibold text-white mb-1">
Как гарантируется честность результатов?
</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Перед жеребьевкой список участников фиксируется в неизменяемый слепок и хешируется по стандарту SHA-256.
Победитель определяется алгоритмом HMAC_SHA256_FY_V1 с rejection sampling. Любой зритель может воспроизвести результат и проверить неизменность выборки.
</p>
</div>
</div>
</div>
);
}