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,79 @@
import { cn } from "@sase/ui";
import { ThumbsDown, ThumbsUp } from "lucide-react";
export type OemVoteChoice = "compatible" | "incompatible";
export interface OemVoteSummary {
compatible: number;
incompatible: number;
myVote: OemVoteChoice | null;
}
export interface CastOemVoteResult {
oemCode: string;
myVote: OemVoteChoice;
counts: { compatible: number; incompatible: number };
pointsAwarded: number;
correct: boolean | null;
isNew: boolean;
}
interface OemVoteButtonsProps {
summary?: OemVoteSummary;
pending?: boolean;
onVote: (vote: OemVoteChoice) => void;
}
// Parça satırındaki topluluk oyu ikilisi: uyumlu (👍) / uyumsuz (👎).
// Satır tıklaması hotspot grubunu seçtiği için tıklamalar satıra taşmaz.
export function OemVoteButtons({ summary, pending, onVote }: OemVoteButtonsProps) {
const myVote = summary?.myVote ?? null;
const options = [
{
vote: "compatible" as const,
icon: ThumbsUp,
count: summary?.compatible ?? 0,
title: "Uyumlu — bu OEM kodu doğru",
activeClass: "text-green-500",
},
{
vote: "incompatible" as const,
icon: ThumbsDown,
count: summary?.incompatible ?? 0,
title: "Uyumsuz — bu OEM kodu hatalı",
activeClass: "text-red-500",
},
];
return (
<span className="inline-flex items-center gap-0.5">
{options.map(({ vote, icon: Icon, count, title, activeClass }) => {
const isActive = myVote === vote;
return (
<button
key={vote}
type="button"
disabled={pending}
title={title}
aria-label={title}
aria-pressed={isActive}
onClick={(e) => {
e.stopPropagation();
onVote(vote);
}}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-1 text-xs transition-colors disabled:opacity-50",
isActive
? cn(activeClass, "font-semibold")
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" fill={isActive ? "currentColor" : "none"} />
<span className="tabular-nums">{count}</span>
</button>
);
})}
</span>
);
}