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:
2026-06-12 00:00:51 +03:00
parent 09a9487564
commit 8fddd09087
17 changed files with 1170 additions and 2 deletions

View 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>
);
}