feat(catalog): OEM uyumluluk oylaması + Parça Uzmanları liderlik tablosu
Parça satırlarına topluluk oyu eklendi: her OEM kodu için uyumlu/uyumsuz ikilisi (👍/👎, sayaçlı). Oylar oem_votes'a (kullanıcı+kod başına tek oy, fikir değişikliği günceller, puan üretmez), ödüller oem_vote_points ledger'ına yazılır: oy +1, kesin çoğunlukla aynı yönde +2 (kodu ilk oylayan her zaman 3 alır); ödüller oy anında kesinleşir, çoğunluk sonra dönse de geri alınmaz. Aynı koda eşzamanlı oylar advisory lock ile sıralanır. /dashboard/uzmanlar: Trophy Gamification UI Kit'ten (ui.trophy.so, MIT) uyarlanan kürsü + sıralama + puan rozetiyle "Parça Uzmanları" liderlik sayfası; adlar KVKK-maskeli (S*** Y***), cevap kullanıcı id sızdırmaz. Not: sidebar nav linki, tr/en i18n anahtarları ve routeTree 09a9487'de gitmişti; bu commit eksik kalan rota/bileşen/API dosyalarını tamamlayarak dev build'ini düzeltir. Migration: 0015_oem_votes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
167
apps/web/src/routes/dashboard/uzmanlar.tsx
Normal file
167
apps/web/src/routes/dashboard/uzmanlar.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { LeaderboardPodium } from "@/components/gamification/leaderboard-podium";
|
||||
import {
|
||||
type LeaderboardRankingItem,
|
||||
LeaderboardRankings,
|
||||
} from "@/components/gamification/leaderboard-rankings";
|
||||
import { PointsBadge } from "@/components/gamification/points-badge";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Card, CardContent, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Medal, Sparkles, ThumbsUp, Trophy, Users } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
name: string;
|
||||
points: number;
|
||||
votes: number;
|
||||
isMe: boolean;
|
||||
}
|
||||
|
||||
interface LeaderboardResponse {
|
||||
entries: LeaderboardEntry[];
|
||||
me: { rank: number | null; points: number; votes: number };
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/dashboard/uzmanlar")({
|
||||
component: ExpertsPage,
|
||||
});
|
||||
|
||||
const SKELETON_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5"] as const;
|
||||
|
||||
// Puanlama kuralları — API'deki computeVoteAward ile birebir aynı anlatım.
|
||||
const SCORING_RULES = [
|
||||
{
|
||||
key: "vote",
|
||||
icon: ThumbsUp,
|
||||
title: "+1 puan",
|
||||
text: "Katalogdaki bir OEM kodunu uyumlu/uyumsuz oylayın.",
|
||||
},
|
||||
{
|
||||
key: "majority",
|
||||
icon: Users,
|
||||
title: "+2 puan",
|
||||
text: "Oyunuz çoğunluk görüşüyle aynı yöndeyse ek puan kazanırsınız.",
|
||||
},
|
||||
{
|
||||
key: "first",
|
||||
icon: Sparkles,
|
||||
title: "3 puan",
|
||||
text: "Bir kodu ilk kez siz değerlendirirseniz tam puan sizindir.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function ExpertsPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["oem-leaderboard"],
|
||||
queryFn: () => api.get<LeaderboardResponse>("/oem-votes/leaderboard"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
capture("experts_leaderboard_viewed");
|
||||
}, []);
|
||||
|
||||
const entries = data?.entries ?? [];
|
||||
const me = data?.me;
|
||||
|
||||
// Liderlik cevabı kullanıcı id'si taşımaz; satır anahtarı sıradan türetilir.
|
||||
const podiumRankings = entries.slice(0, 3).map((e) => ({
|
||||
userId: `rank-${e.rank}`,
|
||||
userName: e.name,
|
||||
rank: e.rank,
|
||||
value: e.points,
|
||||
}));
|
||||
|
||||
const rankingItems: LeaderboardRankingItem[] = entries.map((e) => ({
|
||||
userId: `rank-${e.rank}`,
|
||||
userName: e.name,
|
||||
rank: e.rank,
|
||||
value: e.points,
|
||||
byline: `${e.votes} oy`,
|
||||
isCurrentUser: e.isMe,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Trophy className="size-6 text-amber-400" />
|
||||
Parça Uzmanları
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Katalogdaki OEM kodlarını uyumlu/uyumsuz oylayarak puan kazanın; doğru bilgi tüm parça
|
||||
satıcılarının işini hızlandırır.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Puanlama kuralları */}
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 p-4 sm:grid-cols-3">
|
||||
{SCORING_RULES.map(({ key, icon: Icon, title, text }) => (
|
||||
<div key={key} className="flex items-start gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Icon className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{SKELETON_KEYS.map((k) => (
|
||||
<Skeleton key={k} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<Trophy className="size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm font-medium">Henüz oy kullanılmadı.</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
İlk uzman siz olun: katalogda bir parçanın OEM kodunu değerlendirin, ilk oy 3 puan
|
||||
kazandırır.
|
||||
</p>
|
||||
<Link to="/dashboard/search" className="text-sm font-semibold text-primary underline">
|
||||
Şase sorgula ve oylamaya başla
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Kendi durumum */}
|
||||
{me && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<PointsBadge name="puanınız" total={me.points} />
|
||||
<PointsBadge
|
||||
name="sıralamanız"
|
||||
total={me.rank ?? 0}
|
||||
icon={Medal}
|
||||
formatValue={(v) => (v > 0 ? `#${v}` : "—")}
|
||||
/>
|
||||
<PointsBadge name="oyunuz" total={me.votes} icon={ThumbsUp} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* İlk 3 kürsüsü */}
|
||||
{podiumRankings.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<LeaderboardPodium rankings={podiumRankings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tam sıralama */}
|
||||
<LeaderboardRankings rankings={rankingItems} showPagination={entries.length > 25} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user